diff --git a/backend/README.md b/backend/README.md
index 266b5b4d4..d373c6763 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -7,7 +7,7 @@ BACKEND_URL=http://localhost:8001
FRONTEND_URL=http://localhost
FORMS_URL=http://localhost/forms
ENVIRONMENT=Development
-ENABLE_LOGGING=yes
+ENABLE_LOGGING=no
DJANGO_DEBUG=yes
DJANGO_SETTINGS_MODULE=src.settings
DJANGO_SECRET_KEY=django_secret_django_secret_django_secret
diff --git a/backend/src/accounts/locale/de/LC_MESSAGES/django.po b/backend/src/accounts/locale/de/LC_MESSAGES/django.po
index 9fb728ee2..0413c5321 100644
--- a/backend/src/accounts/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/accounts/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/accounts/locale/django.pot b/backend/src/accounts/locale/django.pot
index 4657a4b74..921217f3c 100644
--- a/backend/src/accounts/locale/django.pot
+++ b/backend/src/accounts/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/accounts/locale/es/LC_MESSAGES/django.po b/backend/src/accounts/locale/es/LC_MESSAGES/django.po
index e640ce431..447b7de73 100644
--- a/backend/src/accounts/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/accounts/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/accounts/locale/fr/LC_MESSAGES/django.po b/backend/src/accounts/locale/fr/LC_MESSAGES/django.po
index 556491adf..73f62c3e6 100644
--- a/backend/src/accounts/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/accounts/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/accounts/locale/ru/LC_MESSAGES/django.po b/backend/src/accounts/locale/ru/LC_MESSAGES/django.po
index 35bd71ce7..35eeee381 100644
--- a/backend/src/accounts/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/accounts/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/accounts/migrations/0144_auto_20260609_1910.py b/backend/src/accounts/migrations/0144_auto_20260609_1910.py
new file mode 100644
index 000000000..8ea04d53a
--- /dev/null
+++ b/backend/src/accounts/migrations/0144_auto_20260609_1910.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2026-06-09 19:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0143_user_manager'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='notification',
+ name='type',
+ field=models.CharField(choices=[('system', 'system'), ('comment', 'new comment'), ('mention', 'mention'), ('urgent', 'urgent'), ('not_urgent', 'not urgent'), ('overdue_task', 'overdue task'), ('reminder_task', 'reminder task'), ('snooze_workflow', 'snooze workflow'), ('resume_workflow', 'resume workflow'), ('due_date_changed', 'due date changed'), ('reaction', 'reaction'), ('complete_task', 'complete task'), ('complete_workflow', 'complete workflow')], max_length=24),
+ ),
+ ]
diff --git a/backend/src/analysis/locale/de/LC_MESSAGES/django.po b/backend/src/analysis/locale/de/LC_MESSAGES/django.po
index 9b63a7091..35bf8d285 100644
--- a/backend/src/analysis/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/analysis/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/analysis/locale/django.pot b/backend/src/analysis/locale/django.pot
index eeb19e806..1bdd920fa 100644
--- a/backend/src/analysis/locale/django.pot
+++ b/backend/src/analysis/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/analysis/locale/es/LC_MESSAGES/django.po b/backend/src/analysis/locale/es/LC_MESSAGES/django.po
index 5ebe07253..7adce183c 100644
--- a/backend/src/analysis/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/analysis/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/analysis/locale/fr/LC_MESSAGES/django.po b/backend/src/analysis/locale/fr/LC_MESSAGES/django.po
index 2aabe93bf..dc7c6db57 100644
--- a/backend/src/analysis/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/analysis/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/analysis/locale/ru/LC_MESSAGES/django.po b/backend/src/analysis/locale/ru/LC_MESSAGES/django.po
index 757ec541a..709bc4714 100644
--- a/backend/src/analysis/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/analysis/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/authentication/locale/de/LC_MESSAGES/django.po b/backend/src/authentication/locale/de/LC_MESSAGES/django.po
index 682e52fd2..f5f9f4b29 100644
--- a/backend/src/authentication/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/authentication/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/authentication/locale/django.pot b/backend/src/authentication/locale/django.pot
index 9481e9e33..7cdad1d8f 100644
--- a/backend/src/authentication/locale/django.pot
+++ b/backend/src/authentication/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/authentication/locale/es/LC_MESSAGES/django.po b/backend/src/authentication/locale/es/LC_MESSAGES/django.po
index c5c58cc66..2ba8fb14d 100644
--- a/backend/src/authentication/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/authentication/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/authentication/locale/fr/LC_MESSAGES/django.po b/backend/src/authentication/locale/fr/LC_MESSAGES/django.po
index 201874267..10d5532e1 100644
--- a/backend/src/authentication/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/authentication/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/authentication/locale/ru/LC_MESSAGES/django.po b/backend/src/authentication/locale/ru/LC_MESSAGES/django.po
index ac9ec56d8..8b60b665a 100644
--- a/backend/src/authentication/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/authentication/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/datasets/locale/de/LC_MESSAGES/django.po b/backend/src/datasets/locale/de/LC_MESSAGES/django.po
index aeca49712..b60de04a7 100644
--- a/backend/src/datasets/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/datasets/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/datasets/locale/django.pot b/backend/src/datasets/locale/django.pot
index 165a6a29e..678d4f8d4 100644
--- a/backend/src/datasets/locale/django.pot
+++ b/backend/src/datasets/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/datasets/locale/es/LC_MESSAGES/django.po b/backend/src/datasets/locale/es/LC_MESSAGES/django.po
index 5cfb6c4ca..b45093fcc 100644
--- a/backend/src/datasets/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/datasets/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/datasets/locale/fr/LC_MESSAGES/django.po b/backend/src/datasets/locale/fr/LC_MESSAGES/django.po
index 59ad970de..6b9f97f2b 100644
--- a/backend/src/datasets/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/datasets/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/datasets/locale/ru/LC_MESSAGES/django.po b/backend/src/datasets/locale/ru/LC_MESSAGES/django.po
index 0f8ab8686..51a6f015f 100644
--- a/backend/src/datasets/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/datasets/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/datasets/services/dataset.py b/backend/src/datasets/services/dataset.py
index 155134819..42ab54ea6 100644
--- a/backend/src/datasets/services/dataset.py
+++ b/backend/src/datasets/services/dataset.py
@@ -1,4 +1,4 @@
-from typing import Any, Dict, List, Optional
+from typing import Dict, List, Optional
from django.contrib.auth import get_user_model
from django.db import IntegrityError
@@ -37,7 +37,7 @@ def _create_instance(
def _create_related(
self,
- items: Optional[List[Dict[str, Any]]] = None,
+ items: Optional[List[Dict]] = None,
**kwargs,
):
if items:
@@ -82,7 +82,7 @@ def delete(self) -> None:
def create_items(
self,
- items_data: List[Dict[str, Any]],
+ items_data: List[Dict],
):
service = DataSetItemService(
user=self.user,
@@ -97,7 +97,7 @@ def create_items(
def update_items(
self,
- items_data: List[Dict[str, Any]],
+ items_data: List[Dict],
):
""" All dataset items will be updated """
diff --git a/backend/src/datasets/services/dataset_item.py b/backend/src/datasets/services/dataset_item.py
index d7be50054..a0c595457 100644
--- a/backend/src/datasets/services/dataset_item.py
+++ b/backend/src/datasets/services/dataset_item.py
@@ -34,9 +34,6 @@ def _create_instance(
) from ex
return self.instance
- def _create_related(self, **kwargs):
- pass
-
def partial_update(
self,
force_save: bool = True,
@@ -54,6 +51,3 @@ def partial_update(
message=MSG_DS_0002(value=self.instance.value),
) from ex
return self.instance
-
- def delete(self) -> None:
- self.instance.delete()
diff --git a/backend/src/generics/base/service.py b/backend/src/generics/base/service.py
index 1e2b1bf64..9df725b1a 100644
--- a/backend/src/generics/base/service.py
+++ b/backend/src/generics/base/service.py
@@ -3,7 +3,6 @@
from django.contrib.auth import get_user_model
from django.db import transaction
-from django.db.models import Model
from src.authentication.enums import AuthTokenType
@@ -18,19 +17,19 @@ def __init__(
instance=None,
is_superuser: bool = False,
auth_type: AuthTokenType.LITERALS = AuthTokenType.USER,
+ account=None,
):
if user:
self.user = user
self.account = user.account
else:
self.user = None
- self.account = None
+ self.account = account
self.is_superuser = is_superuser
self.auth_type = auth_type
self.instance = instance
self.update_fields = set()
- @abstractmethod
def _create_related(
self,
**kwargs,
@@ -54,7 +53,7 @@ def _create_actions(
def create(
self,
**kwargs,
- ) -> Model:
+ ):
with transaction.atomic():
self._create_instance(**kwargs)
self._create_related(**kwargs)
@@ -70,7 +69,7 @@ def partial_update(
self,
force_save=False,
**update_kwargs,
- ) -> Model:
+ ):
self.update_fields.update(update_kwargs.keys())
for field_name, value in update_kwargs.items():
@@ -78,3 +77,6 @@ def partial_update(
if force_save:
self.save()
return self.instance
+
+ def delete(self) -> None:
+ self.instance.delete()
diff --git a/backend/src/generics/fields.py b/backend/src/generics/fields.py
index 8cfdeba98..cf4698ef4 100644
--- a/backend/src/generics/fields.py
+++ b/backend/src/generics/fields.py
@@ -11,6 +11,7 @@
UserDateFormat,
)
from src.accounts.models import Account
+from src.processes.models.templates.template import Template
from src.generics.messages import (
MSG_GE_0002,
MSG_GE_0007,
@@ -18,7 +19,7 @@
)
-class AccountPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
+class AccountQstMixin:
def _get_account(self) -> Account:
account = self.context.get('account')
@@ -28,14 +29,31 @@ def _get_account(self) -> Account:
account = request.user.account
if not account:
raise Exception(
- 'Account not provided for AccountPrimaryKeyRelatedField',
+ 'Account not provided for AccountQstMixin',
)
return account
+
+class TemplateQstMixin:
+
+ def _get_template(self) -> Template:
+ template = self.context.get('template')
+ if not template:
+ raise Exception(
+ 'Template not provided for TemplateQstMixin',
+ )
+ return template
+
+
+class AccountPrimaryKeyRelatedField(
+ AccountQstMixin,
+ serializers.PrimaryKeyRelatedField,
+):
+
def get_queryset(self):
queryset = super().get_queryset()
account = self._get_account()
- if account is None or queryset is None:
+ if queryset is None:
raise Exception(MSG_GE_0002)
return queryset.filter(account=account)
@@ -53,6 +71,30 @@ def to_internal_value(self, data):
return super().to_internal_value(data)
+class RelatedApiNameField(
+ AccountQstMixin,
+ TemplateQstMixin,
+ serializers.SlugRelatedField,
+):
+
+ def __init__(self, **kwargs):
+ super().__init__(slug_field='api_name', **kwargs)
+
+ def get_queryset(self):
+ queryset = super().get_queryset()
+ account = self._get_account()
+ template = self._get_template()
+ if queryset is None:
+ raise Exception(MSG_GE_0002)
+ return queryset.filter(account=account, template=template)
+
+ def to_internal_value(self, data):
+
+ """Convert api_name -> to object before saving """
+
+ return super().to_internal_value(data)
+
+
class AnyField(serializers.Field):
def to_internal_value(self, data):
@@ -65,15 +107,28 @@ def to_representation(self, value):
class RelatedListField(serializers.ListField):
def to_representation(self, objects):
- """
- List of objects -> List of objects ids.
- """
+
+ """ List of objects -> List of objects ids """
+
return [
self.child.to_representation(item.id)
for item in objects.all()
]
+class RelatedApiNameListField(serializers.ListField):
+
+ child = serializers.CharField()
+
+ def to_representation(self, data):
+
+ """ List of objects -> List of objects api_name's """
+
+ if hasattr(data, 'all'):
+ return [field.api_name for field in data.all()]
+ return [field.api_name for field in data if hasattr(field, 'api_name')]
+
+
class CommaSeparatedListField(serializers.ListField):
def get_value(self, dictionary):
diff --git a/backend/src/generics/locale/de/LC_MESSAGES/django.po b/backend/src/generics/locale/de/LC_MESSAGES/django.po
index 45c4e10d0..5a19815f6 100644
--- a/backend/src/generics/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/generics/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/generics/locale/django.pot b/backend/src/generics/locale/django.pot
index 21d3d25b6..8193dada3 100644
--- a/backend/src/generics/locale/django.pot
+++ b/backend/src/generics/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/generics/locale/es/LC_MESSAGES/django.po b/backend/src/generics/locale/es/LC_MESSAGES/django.po
index f33c9415e..f652bf3e6 100644
--- a/backend/src/generics/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/generics/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/generics/locale/fr/LC_MESSAGES/django.po b/backend/src/generics/locale/fr/LC_MESSAGES/django.po
index 5d9b9561e..8e4bdcd67 100644
--- a/backend/src/generics/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/generics/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/generics/locale/ru/LC_MESSAGES/django.po b/backend/src/generics/locale/ru/LC_MESSAGES/django.po
index 110122ee2..ede626116 100644
--- a/backend/src/generics/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/generics/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/generics/messages.py b/backend/src/generics/messages.py
index c5df9e56d..94ad656a9 100644
--- a/backend/src/generics/messages.py
+++ b/backend/src/generics/messages.py
@@ -7,7 +7,7 @@
choice=choice,
)
# Translators: AccountPrimaryKeyRelatedField incorrect init
-MSG_GE_0002 = _('Account or queryset not provided')
+MSG_GE_0002 = _('Queryset not provided')
MSG_GE_0003 = _('Value should be a list of integers.')
MSG_GE_0004 = _(
'The "raise_validation_error" method should be used only '
diff --git a/backend/src/logs/service.py b/backend/src/logs/service.py
index f87b66860..4bb012064 100644
--- a/backend/src/logs/service.py
+++ b/backend/src/logs/service.py
@@ -56,9 +56,6 @@ def _create_instance(
contractor=contractor,
)
- def _create_related(self, **kwargs):
- pass
-
def api_request(
self,
user: UserModel,
diff --git a/backend/src/notifications/locale/de/LC_MESSAGES/django.po b/backend/src/notifications/locale/de/LC_MESSAGES/django.po
index a0bc29df4..45aa68234 100644
--- a/backend/src/notifications/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/notifications/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/notifications/locale/django.pot b/backend/src/notifications/locale/django.pot
index ed2b87766..0e1206c75 100644
--- a/backend/src/notifications/locale/django.pot
+++ b/backend/src/notifications/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/notifications/locale/es/LC_MESSAGES/django.po b/backend/src/notifications/locale/es/LC_MESSAGES/django.po
index 11337f83e..eca05325b 100644
--- a/backend/src/notifications/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/notifications/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/notifications/locale/fr/LC_MESSAGES/django.po b/backend/src/notifications/locale/fr/LC_MESSAGES/django.po
index 75169984f..568ddc69b 100644
--- a/backend/src/notifications/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/notifications/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/notifications/locale/ru/LC_MESSAGES/django.po b/backend/src/notifications/locale/ru/LC_MESSAGES/django.po
index 2030a0fe3..68ab2152a 100644
--- a/backend/src/notifications/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/notifications/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/payment/locale/de/LC_MESSAGES/django.po b/backend/src/payment/locale/de/LC_MESSAGES/django.po
index 1ccc0a8cb..4cec99bd7 100644
--- a/backend/src/payment/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/payment/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/payment/locale/django.pot b/backend/src/payment/locale/django.pot
index 5b69697c7..4807fa176 100644
--- a/backend/src/payment/locale/django.pot
+++ b/backend/src/payment/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/payment/locale/es/LC_MESSAGES/django.po b/backend/src/payment/locale/es/LC_MESSAGES/django.po
index 17b2e0dd8..36bc09ed7 100644
--- a/backend/src/payment/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/payment/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/payment/locale/fr/LC_MESSAGES/django.po b/backend/src/payment/locale/fr/LC_MESSAGES/django.po
index f7084c777..c7a525fbd 100644
--- a/backend/src/payment/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/payment/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/payment/locale/ru/LC_MESSAGES/django.po b/backend/src/payment/locale/ru/LC_MESSAGES/django.po
index 14d5f75eb..9898dbcce 100644
--- a/backend/src/payment/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/payment/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/processes/enums.py b/backend/src/processes/enums.py
index 1fb287b03..0121e45bd 100644
--- a/backend/src/processes/enums.py
+++ b/backend/src/processes/enums.py
@@ -729,4 +729,42 @@ class SystemVariable:
WORKFLOW_NAME_VARS = {DATE, TEMPLATE_NAME, WORKFLOW_ID, WORKFLOW_STARTER}
- TASK_VARS = {WORKFLOW_STARTER}
+ # Allowed in task name / description without matching a FieldTemplate
+ # (same as workflow name template — date, template-name,
+ # workflow-id, starter).
+ TASK_VARS = WORKFLOW_NAME_VARS
+
+
+class LabelPosition:
+
+ TOP = 'top'
+ LEFT = 'left'
+
+ CHOICES = (
+ (TOP, 'Top'),
+ (LEFT, 'Left'),
+ )
+ LITERALS = Literal[TOP, LEFT]
+
+
+class FieldSetLayout:
+
+ HORIZONTAL = 'horizontal'
+ VERTICAL = 'vertical'
+
+ CHOICES = (
+ (HORIZONTAL, 'Horizontal'),
+ (VERTICAL, 'Vertical'),
+ )
+ LITERALS = Literal[HORIZONTAL, VERTICAL]
+
+
+class FieldSetRuleType:
+
+ SUM_EQUAL = 'sum_equal'
+
+ CHOICES = (
+ (SUM_EQUAL, 'The sum is equal'),
+ )
+
+ LITERALS = Literal[SUM_EQUAL]
diff --git a/backend/src/processes/filters.py b/backend/src/processes/filters.py
index c03dce6bb..f7695d3d8 100644
--- a/backend/src/processes/filters.py
+++ b/backend/src/processes/filters.py
@@ -14,8 +14,8 @@
TsQuerySearchFilter,
)
from src.processes.enums import TaskStatus, WorkflowStatus
+from src.processes.models.templates.fieldset import FieldsetTemplate
from src.processes.models.templates.system_template import SystemTemplate
-from src.processes.models.templates.template import Template
from src.processes.models.workflows.event import WorkflowEvent
@@ -28,22 +28,6 @@ def filter(self, qs, value):
return super().filter(qs, ordering)
-class TemplateFilter(FilterSet):
- ordering = TemplateOrderingFilter(
- fields=(
- ('name', 'name'),
- ('date_created', 'date'),
- ),
- )
-
- class Meta:
- model = Template
- fields = (
- 'ordering',
- 'is_active',
- )
-
-
class WorkflowDurationFilter(FilterSet):
date_from = IsoDateTimeFilter(method='filter_date_from')
date_to = IsoDateTimeFilter(method='filter_date_to')
@@ -150,3 +134,20 @@ class Meta:
search = TsQuerySearchFilter(
field_name='search_content',
)
+
+
+class FieldSetFilter(FilterSet):
+
+ ordering = DefaultOrderingFilter(
+ fields=(
+ ('name', 'name'),
+ ('date_created', 'date'),
+ ),
+ default=('-date_created',),
+ )
+
+ class Meta:
+ model = FieldsetTemplate
+ fields = (
+ 'ordering',
+ )
diff --git a/backend/src/processes/locale/de/LC_MESSAGES/django.po b/backend/src/processes/locale/de/LC_MESSAGES/django.po
index eb72e42a7..ddcad0cd6 100644
--- a/backend/src/processes/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/processes/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -17,6 +17,21 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
+msgid "Cannot delete a fieldset template that is used in templates."
+msgstr ""
+
+#, python-brace-format
+msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"."
+msgstr ""
+
+msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"."
+msgstr ""
+
+#, fuzzy
+#| msgid "The value must be a number."
+msgid "Rule \"sum_equal\" value must be a number."
+msgstr "Der Wert muss eine Zahl sein."
+
msgid "You can't pass \"snooze\" for the first task."
msgstr "Sie können \"snooze\" nicht für die erste Aufgabe übergeben."
diff --git a/backend/src/processes/locale/django.pot b/backend/src/processes/locale/django.pot
index b9d0b1505..aa9169865 100644
--- a/backend/src/processes/locale/django.pot
+++ b/backend/src/processes/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -17,6 +17,19 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
+msgid "Cannot delete a fieldset template that is used in templates."
+msgstr ""
+
+#, python-brace-format
+msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"."
+msgstr ""
+
+msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"."
+msgstr ""
+
+msgid "Rule \"sum_equal\" value must be a number."
+msgstr ""
+
msgid "You can't pass \"snooze\" for the first task."
msgstr ""
diff --git a/backend/src/processes/locale/es/LC_MESSAGES/django.po b/backend/src/processes/locale/es/LC_MESSAGES/django.po
index 726c47fcd..f53992a8c 100644
--- a/backend/src/processes/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/processes/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -17,6 +17,21 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
+msgid "Cannot delete a fieldset template that is used in templates."
+msgstr ""
+
+#, python-brace-format
+msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"."
+msgstr ""
+
+msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"."
+msgstr ""
+
+#, fuzzy
+#| msgid "The value must be a number."
+msgid "Rule \"sum_equal\" value must be a number."
+msgstr "El valor debe ser un número."
+
msgid "You can't pass \"snooze\" for the first task."
msgstr "No puedes pasar \"snooze\" para la primera tarea."
diff --git a/backend/src/processes/locale/fr/LC_MESSAGES/django.po b/backend/src/processes/locale/fr/LC_MESSAGES/django.po
index 7e4886d43..ec0e9249f 100644
--- a/backend/src/processes/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/processes/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -17,6 +17,21 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
+msgid "Cannot delete a fieldset template that is used in templates."
+msgstr ""
+
+#, python-brace-format
+msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"."
+msgstr ""
+
+msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"."
+msgstr ""
+
+#, fuzzy
+#| msgid "The value must be a number."
+msgid "Rule \"sum_equal\" value must be a number."
+msgstr "La valeur doit être un nombre."
+
msgid "You can't pass \"snooze\" for the first task."
msgstr "Vous ne pouvez pas passer \"snooze\" pour la première tâche."
diff --git a/backend/src/processes/locale/ru/LC_MESSAGES/django.po b/backend/src/processes/locale/ru/LC_MESSAGES/django.po
index 82f5b7df0..21e752c90 100644
--- a/backend/src/processes/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/processes/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -17,6 +17,21 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
+msgid "Cannot delete a fieldset template that is used in templates."
+msgstr ""
+
+#, python-brace-format
+msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"."
+msgstr ""
+
+msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"."
+msgstr ""
+
+#, fuzzy
+#| msgid "The value must be a number."
+msgid "Rule \"sum_equal\" value must be a number."
+msgstr "Значение должно быть числом."
+
msgid "You can't pass \"snooze\" for the first task."
msgstr "Нельзя передать «snooze» для первой задачи."
diff --git a/backend/src/processes/management/commands/migrate_fieldsets.py b/backend/src/processes/management/commands/migrate_fieldsets.py
new file mode 100644
index 000000000..0e5d1617c
--- /dev/null
+++ b/backend/src/processes/management/commands/migrate_fieldsets.py
@@ -0,0 +1,184 @@
+# ruff: noqa: T201
+import copy
+
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from src.processes.models.templates.fieldset import FieldsetTemplate
+from src.processes.models.templates.template import TemplateDraft
+from src.processes.services.fieldsets.fieldset import FieldSetTemplateService
+
+
+class Command(BaseCommand):
+
+ def update_draft_fieldset(
+ self,
+ template_draft: TemplateDraft,
+ fieldset_data: dict,
+ fieldset_api_name: str,
+ ):
+ draft = template_draft.draft
+ if not isinstance(draft, dict):
+ return
+
+ print('--- update template draft')
+ updated = False
+ # Update matching fieldsets in kickoff
+ kickoff = draft.get('kickoff')
+ new_kickoff_fieldsets = []
+ if isinstance(kickoff, dict):
+ kickoff_fieldsets = kickoff.get('fieldsets') or []
+ for i in range(len(kickoff_fieldsets)):
+ api_name = kickoff_fieldsets[i].get('api_name')
+ if api_name == fieldset_api_name:
+ if not updated:
+ new_kickoff_fieldsets.append(fieldset_data)
+ print('---|--- new kickoff fieldset')
+ updated = True
+ else:
+ print('---|--- duplicate kickoff fieldset - removed')
+ else:
+ new_kickoff_fieldsets.append(kickoff_fieldsets[i])
+ kickoff['fieldsets'] = new_kickoff_fieldsets
+
+ # Update matching fieldsets in tasks
+ for task in draft.get('tasks') or []:
+ new_task_fieldsets = []
+ task_fieldsets = task.get('fieldsets') or []
+ for i in range(len(task_fieldsets)):
+ api_name = task_fieldsets[i].get('api_name')
+ if api_name == fieldset_api_name:
+ if not updated:
+ task_fieldsets.append(fieldset_data)
+ print(
+ f'---|--- new task fieldset: '
+ f'task: "{task["name"]}"',
+ )
+ updated = True
+ else:
+ print('---|--- duplicate kickoff fieldset - removed')
+ del task_fieldsets[i]
+ else:
+ new_task_fieldsets.append(task_fieldsets[i])
+ task['fieldsets'] = new_kickoff_fieldsets
+ template_draft.save(update_fields=('draft',))
+
+ def handle(self, *args, **options):
+ old_fieldsets = (
+ FieldsetTemplate.objects
+ .filter(is_shared=True)
+ .exclude(template_id=None)
+ .order_by('account_id')
+ )
+ with transaction.atomic():
+ for old_shared_fieldset in old_fieldsets:
+ print(
+ f'\nProcessed old shared fieldset: '
+ f'{old_shared_fieldset.name} ({old_shared_fieldset.id})',
+ )
+ template = old_shared_fieldset.template
+ if template.is_active:
+ template_draft = None
+ print('--- template is active')
+ else:
+ template_draft = template.draft
+ print('--- template not active')
+
+ # Ensure the original is marked as shared
+ old_shared_fieldset.template_id = None
+
+ # Build the serialized representation of the shared fieldset
+ old_shared_fieldset_data = FieldSetTemplateService.to_json(
+ old_shared_fieldset,
+ )
+ template_fieldset_data = copy.deepcopy(
+ old_shared_fieldset_data,
+ )
+
+ old_shared_fieldset_data.pop('id')
+ old_shared_fieldset_data.pop('api_name')
+ old_shared_fieldset_data.pop('order')
+ # Recreate shared fieldset with new api_names
+ new_shared_fieldset_data = (
+ FieldSetTemplateService._replace_api_names(
+ old_shared_fieldset_data,
+ )
+ )
+ old_shared_fieldset.fields.all().delete()
+ old_shared_fieldset.rules.all().delete()
+ user = old_shared_fieldset.account.get_owner()
+
+ shared_service = FieldSetTemplateService(user=user)
+ new_shared_fieldset = shared_service.create_shared_fieldset(
+ **new_shared_fieldset_data,
+ )
+ template_fieldset_data['shared_fieldset_id'] = (
+ new_shared_fieldset.id
+ )
+ template_fieldset_data.pop('order', None)
+ print(f'--- new shared fieldset id: {new_shared_fieldset.id}')
+
+ # update drafts
+ if template_draft:
+ self.update_draft_fieldset(
+ template_draft=template_draft,
+ fieldset_data=template_fieldset_data,
+ fieldset_api_name=old_shared_fieldset.api_name,
+ )
+
+ updated = False
+ # update kickoff fieldsets
+ kickoff_links = (
+ old_shared_fieldset.kickoffs
+ .through.objects.filter(
+ fieldset=old_shared_fieldset,
+ is_deleted=False,
+ )
+ )
+ for link in kickoff_links:
+ if not updated:
+ service = FieldSetTemplateService(user=user)
+ new_template_fieldset = service.create(
+ **template_fieldset_data,
+ is_shared=False,
+ order=link.order,
+ kickoff_id=link.kickoff_id,
+ template_id=link.kickoff.template_id,
+ )
+ updated = True
+ print(
+ f'--- new kickoff fieldset: '
+ f'{new_template_fieldset.id}. '
+ f'template: {link.kickoff.template.name} '
+ f'({link.kickoff.template_id})',
+ )
+ else:
+ print('--- duplicate kickoff fieldset - removed')
+ link.delete()
+
+ # update tasks fieldsets
+ task_links = old_shared_fieldset.tasks.through.objects.filter(
+ fieldset=old_shared_fieldset,
+ is_deleted=False,
+ )
+
+ for link in task_links:
+ if not updated:
+ service = FieldSetTemplateService(user=user)
+ new_template_fieldset = service.create(
+ **template_fieldset_data,
+ is_shared=False,
+ order=link.order,
+ task_id=link.task_id,
+ template_id=link.task.template_id,
+ )
+ updated = True
+ print(
+ f'--- new task fieldset: '
+ f'{new_template_fieldset.id}. '
+ f'task: "{link.task.name}" '
+ f'({link.task.id})',
+ )
+ else:
+ print('--- duplicate task fieldset - removed')
+ link.delete()
+ old_shared_fieldset.delete()
diff --git a/backend/src/processes/management/commands/migrate_presets.py b/backend/src/processes/management/commands/migrate_presets.py
deleted file mode 100644
index 61fce766b..000000000
--- a/backend/src/processes/management/commands/migrate_presets.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from django.core.management.base import BaseCommand
-from django.db import transaction
-
-from src.processes.models.templates.preset import TemplatePresetField
-
-
-class Command(BaseCommand):
-
- help = 'Set more unique api_names for system columns.'
-
- def handle(self, *args, **options):
- with transaction.atomic():
- (
- TemplatePresetField.objects
- .filter(api_name='workflow')
- .update(api_name='system-column-workflow')
- )
- (
- TemplatePresetField.objects
- .filter(api_name='starter')
- .update(api_name='system-column-starter')
- )
- (
- TemplatePresetField.objects
- .filter(api_name='progress')
- .update(api_name='system-column-progress')
- )
- (
- TemplatePresetField.objects
- .filter(api_name='step')
- .update(api_name='system-column-step')
- )
- (
- TemplatePresetField.objects
- .filter(api_name='performer')
- .update(api_name='system-column-performer')
- )
- (
- TemplatePresetField.objects
- .filter(api_name='template')
- .update(api_name='system-column-template')
- )
diff --git a/backend/src/processes/messages/fieldset.py b/backend/src/processes/messages/fieldset.py
new file mode 100644
index 000000000..04280a8b2
--- /dev/null
+++ b/backend/src/processes/messages/fieldset.py
@@ -0,0 +1,40 @@
+from django.utils.text import format_lazy
+from django.utils.translation import ugettext_lazy as _
+
+MSG_FS_0001 = _(
+ 'Cannot delete a fieldset template that is used in templates.',
+)
+MSG_FS_0002 = lambda value: format_lazy(
+ _('The sum of the fields in this field set must equal "{value}".'),
+ value=value,
+)
+MSG_FS_0003 = _(
+ 'Rule "Sum equal" requires all fields to be of type "number".',
+)
+MSG_FS_0004 = _('Rule "Sum equal" value must be a number.')
+MSG_FS_0005 = lambda rule, field: format_lazy(
+ _('field "{field}" not found in rule "{rule}".'),
+ field=field,
+ rule=rule,
+)
+MSG_FS_0006 = _(
+ 'The task with the specified "api_name" was not found in the template',
+)
+MSG_FS_0007 = _(
+ 'Either "task" or "kickoff" must be provided to create a fieldset.',
+)
+MSG_FS_0008 = _(
+ 'Shared fieldset template with the specified id was not found.',
+)
+MSG_FS_0009 = _(
+ 'Cannot change a fieldset template that is used in templates.',
+)
+MSG_FS_0010 = _('shared_fieldset_id is required for a template fieldset.')
+MSG_FS_0011 = _('template_id is required for a template fieldset.')
+MSG_FS_0012 = lambda values: format_lazy(
+ _(
+ 'The sum of the fields in this field set must equal one of '
+ 'the following values: "{values}".',
+ ),
+ values=values,
+)
diff --git a/backend/src/processes/migrations/0254_add_fieldsets.py b/backend/src/processes/migrations/0254_add_fieldsets.py
new file mode 100644
index 000000000..e865e2436
--- /dev/null
+++ b/backend/src/processes/migrations/0254_add_fieldsets.py
@@ -0,0 +1,158 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import src.generics.mixins.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('processes', '0253_add_completed_or_skipped_predicate'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FieldSet',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('api_name', models.CharField(max_length=200)),
+ ('label_position', models.CharField(choices=[('top', 'Top'), ('left', 'Left')], default='top', max_length=20)),
+ ('name', models.TextField(max_length=1000)),
+ ('description', models.TextField(blank=True, default='')),
+ ('layout', models.CharField(choices=[('horizontal', 'Horizontal'), ('vertical', 'Vertical')], default='vertical', max_length=200)),
+ ('order', models.IntegerField(default=0)),
+ ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')),
+ ('kickoff', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.KickoffValue')),
+ ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Task')),
+ ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Workflow')),
+ ],
+ options={
+ 'ordering': ['-id'],
+ },
+ bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='FieldSetRule',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('api_name', models.CharField(max_length=200)),
+ ('type', models.CharField(choices=[('sum_equal', 'The sum is equal')], max_length=50)),
+ ('value', models.TextField(blank=True, null=True)),
+ ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')),
+ ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='processes.FieldSet')),
+ ],
+ options={
+ 'ordering': ['-id'],
+ },
+ bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='FieldsetTemplate',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('api_name', models.CharField(max_length=200)),
+ ('label_position', models.CharField(choices=[('top', 'Top'), ('left', 'Left')], default='top', max_length=20)),
+ ('name', models.TextField(max_length=1000)),
+ ('description', models.TextField(blank=True, default='')),
+ ('layout', models.CharField(choices=[('horizontal', 'Horizontal'), ('vertical', 'Vertical')], default='vertical', max_length=200)),
+ ('date_created', models.DateTimeField(auto_now_add=True)),
+ ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')),
+ ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Template')),
+ ],
+ options={
+ 'ordering': ['-id'],
+ },
+ bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='FieldsetTemplateRule',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('api_name', models.CharField(max_length=200)),
+ ('type', models.CharField(choices=[('sum_equal', 'The sum is equal')], max_length=50)),
+ ('value', models.TextField(blank=True, null=True)),
+ ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')),
+ ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='processes.FieldsetTemplate')),
+ ],
+ options={
+ 'ordering': ['-id'],
+ },
+ bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='FieldsetTemplateTaskTemplate',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('order', models.IntegerField(default=0)),
+ ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.FieldsetTemplate')),
+ ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.TaskTemplate')),
+ ],
+ options={
+ 'db_table': 'processes_fieldsettemplate_tasktemplate',
+ 'ordering': ['order'],
+ },
+ bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='FieldsetTemplateKickoff',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True,
+ serialize=False, verbose_name='ID')),
+ ('is_deleted', models.BooleanField(default=False)),
+ ('order', models.IntegerField(default=0)),
+ ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.FieldsetTemplate')),
+ ('kickoff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.Kickoff')),
+ ],
+ options={
+ 'db_table': 'processes_fieldsettemplate_kickoff',
+ 'ordering': ['order'],
+ },
+ bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model),
+ ),
+ migrations.AddField(
+ model_name='fieldsettemplate',
+ name='kickoffs',
+ field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateKickoff', to='processes.Kickoff'),
+ ),
+ migrations.AddConstraint(
+ model_name='fieldsettemplate',
+ constraint=models.UniqueConstraint(
+ condition=models.Q(is_deleted=False),
+ fields=('template', 'api_name'),
+ name='fieldsettemplate_api_name_template_unique'),
+ ),
+ migrations.AddField(
+ model_name='fieldsettemplate',
+ name='tasks',
+ field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateTaskTemplate', to='processes.TaskTemplate'),
+ ),
+ migrations.AlterField(
+ model_name='fieldtemplate',
+ name='template',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='processes.Template'),
+ ),
+ migrations.AddField(
+ model_name='fieldtemplate',
+ name='fieldset',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='processes.FieldsetTemplate'),
+ ),
+ migrations.AddField(
+ model_name='fieldtemplate',
+ name='rules',
+ field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldsetTemplateRule'),
+ ),
+ migrations.AddField(
+ model_name='taskfield',
+ name='fieldset',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='processes.FieldSet'),
+ ),
+ migrations.AddField(
+ model_name='taskfield',
+ name='rules',
+ field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldSetRule'),
+ ),
+ ]
diff --git a/backend/src/processes/migrations/0255_add_shared_fieldsets.py b/backend/src/processes/migrations/0255_add_shared_fieldsets.py
new file mode 100644
index 000000000..becdeef08
--- /dev/null
+++ b/backend/src/processes/migrations/0255_add_shared_fieldsets.py
@@ -0,0 +1,89 @@
+# Generated by Django 2.2 on 2026-07-01 12:59
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('processes', '0254_add_fieldsets'),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name='fieldsettemplate',
+ name='fieldsettemplate_api_name_template_unique',
+ ),
+ migrations.RunSQL("""
+ ALTER TABLE processes_fieldsettemplate
+ DROP CONSTRAINT IF EXISTS fieldsettemplate_template_api_name_unique;
+ """),
+ migrations.RunSQL("""
+ DROP INDEX IF EXISTS fieldsettemplate_template_api_name_unique;
+ """),
+ migrations.AddField(
+ model_name='fieldset',
+ name='title',
+ field=models.TextField(blank=True, default=''),
+ ),
+ migrations.AddField(
+ model_name='fieldsettemplate',
+ name='is_shared',
+ field=models.BooleanField(default=True),
+ ),
+ migrations.AddField(
+ model_name='fieldsettemplate',
+ name='kickoff',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Kickoff'),
+ ),
+ migrations.AddField(
+ model_name='fieldsettemplate',
+ name='order',
+ field=models.IntegerField(default=0),
+ ),
+ migrations.AddField(
+ model_name='fieldsettemplate',
+ name='shared_fieldset',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_fieldsets', to='processes.FieldsetTemplate'),
+ ),
+ migrations.AddField(
+ model_name='fieldsettemplate',
+ name='task',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.TaskTemplate'),
+ ),
+ migrations.AddField(
+ model_name='fieldsettemplate',
+ name='title',
+ field=models.TextField(blank=True, default=''),
+ ),
+ migrations.AlterField(
+ model_name='fieldsettemplate',
+ name='kickoffs',
+ field=models.ManyToManyField(blank=True, related_name='old_fieldsets', through='processes.FieldsetTemplateKickoff', to='processes.Kickoff'),
+ ),
+ migrations.AlterField(
+ model_name='fieldsettemplate',
+ name='tasks',
+ field=models.ManyToManyField(blank=True, related_name='old_fieldsets', through='processes.FieldsetTemplateTaskTemplate', to='processes.TaskTemplate'),
+ ),
+ migrations.AlterField(
+ model_name='fieldsettemplate',
+ name='template',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Template'),
+ ),
+ migrations.AlterField(
+ model_name='fieldtemplateselection',
+ name='template',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='selections', to='processes.Template'),
+ ),
+ migrations.AddConstraint(
+ model_name='fieldsettemplate',
+ constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('api_name', 'template', 'is_shared'), name='fieldsettemplate_template_api_name_is_shared_unique'),
+ ),
+ migrations.AddConstraint(
+ model_name='fieldsettemplaterule',
+ constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('api_name', 'fieldset'), name='fieldsettemplate_api_name_template_unique'),
+ ),
+ ]
diff --git a/backend/src/processes/models/mixins.py b/backend/src/processes/models/mixins.py
index 3d091ae93..f993b8220 100644
--- a/backend/src/processes/models/mixins.py
+++ b/backend/src/processes/models/mixins.py
@@ -7,13 +7,16 @@
from django.db import models
from django.db.models.query import QuerySet
-from src.accounts.models import UserGroup, AccountBaseMixin
+from src.accounts.models import UserGroup
from src.processes.enums import (
ConditionAction,
FieldType,
+ LabelPosition,
+ FieldSetLayout,
PerformerType,
PredicateOperator,
PredicateType,
+ FieldSetRuleType,
)
from src.datasets.models import Dataset
@@ -181,7 +184,7 @@ class Meta:
)
-class FieldMixin(AccountBaseMixin):
+class FieldMixin(models.Model):
class Meta:
abstract = True
@@ -326,3 +329,43 @@ class Meta:
abstract = True
api_name = models.CharField(max_length=200)
+
+
+class FieldMetaMixin(models.Model):
+
+ class Meta:
+ abstract = True
+
+ label_position = models.CharField(
+ max_length=20,
+ choices=LabelPosition.CHOICES,
+ default=LabelPosition.TOP,
+ )
+
+
+class BaseFieldSetMixin(FieldMetaMixin):
+
+ class Meta:
+ abstract = True
+
+ name = models.TextField(max_length=1000)
+ title = models.TextField(blank=True, default='')
+ order = models.IntegerField(default=0)
+ description = models.TextField(blank=True, default='')
+ layout = models.CharField(
+ max_length=200,
+ choices=FieldSetLayout.CHOICES,
+ default=FieldSetLayout.VERTICAL,
+ )
+
+
+class BaseFieldSetRuleMixin(models.Model):
+
+ class Meta:
+ abstract = True
+
+ type = models.CharField(
+ max_length=50,
+ choices=FieldSetRuleType.CHOICES,
+ )
+ value = models.TextField(blank=True, null=True)
diff --git a/backend/src/processes/models/templates/fields.py b/backend/src/processes/models/templates/fields.py
index bed5f0ae7..3c36b8f03 100644
--- a/backend/src/processes/models/templates/fields.py
+++ b/backend/src/processes/models/templates/fields.py
@@ -1,6 +1,7 @@
from django.db import models
from django.db.models import Q, UniqueConstraint
+from src.accounts.models import AccountBaseMixin
from src.generics.managers import BaseSoftDeleteManager
from src.processes.models.base import BaseApiNameModel
from src.processes.models.mixins import FieldMixin
@@ -15,6 +16,7 @@
class FieldTemplate(
BaseApiNameModel,
+ AccountBaseMixin,
FieldMixin,
):
@@ -33,6 +35,8 @@ class Meta:
template = models.ForeignKey(
Template,
on_delete=models.CASCADE,
+ null=True,
+ blank=True,
related_name='fields',
)
kickoff = models.ForeignKey(
@@ -47,6 +51,18 @@ class Meta:
null=True,
related_name='fields',
)
+ fieldset = models.ForeignKey(
+ 'processes.FieldsetTemplate',
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name='fields',
+ )
+ rules = models.ManyToManyField(
+ 'processes.FieldsetTemplateRule',
+ blank=True,
+ related_name='fields',
+ )
date_created = models.DateTimeField(auto_now_add=True)
default = models.TextField(blank=True)
@@ -83,6 +99,8 @@ class Meta:
template = models.ForeignKey(
Template,
on_delete=models.CASCADE,
+ null=True,
+ blank=True,
related_name='selections',
)
field_template = models.ForeignKey(
diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py
new file mode 100644
index 000000000..8149881db
--- /dev/null
+++ b/backend/src/processes/models/templates/fieldset.py
@@ -0,0 +1,176 @@
+from django.db import models
+from django.db.models import Q, UniqueConstraint
+
+from src.accounts.models import AccountBaseMixin
+from src.generics.managers import BaseSoftDeleteManager
+from src.generics.models import SoftDeleteModel
+from src.processes.models.base import BaseApiNameModel
+from src.processes.models.mixins import (
+ BaseFieldSetMixin,
+ BaseFieldSetRuleMixin,
+)
+from src.processes.models.templates.kickoff import Kickoff
+from src.processes.models.templates.template import Template
+from src.processes.models.templates.task import TaskTemplate
+from src.processes.querysets import (
+ FieldsetTemplateQuerySet,
+ FieldsetTemplateRuleQuerySet,
+)
+
+
+class FieldsetTemplate(
+ BaseApiNameModel,
+ BaseFieldSetMixin,
+ AccountBaseMixin,
+):
+
+ class Meta:
+ ordering = ['-id']
+ constraints = [
+ UniqueConstraint(
+ fields=['api_name', 'template', 'is_shared'],
+ condition=Q(is_deleted=False),
+ name='fieldsettemplate_template_api_name_is_shared_unique',
+ ),
+ ]
+
+ api_name_prefix = 'fieldset'
+
+ date_created = models.DateTimeField(
+ auto_now_add=True,
+ )
+ is_shared = models.BooleanField(default=True)
+ shared_fieldset = models.ForeignKey(
+ 'FieldsetTemplate',
+ on_delete=models.SET_NULL,
+ related_name='child_fieldsets',
+ null=True,
+ blank=True,
+ )
+ template = models.ForeignKey(
+ Template,
+ on_delete=models.CASCADE,
+ related_name='fieldsets',
+ null=True,
+ blank=True,
+ )
+ task = models.ForeignKey(
+ TaskTemplate,
+ on_delete=models.CASCADE,
+ related_name='fieldsets',
+ null=True,
+ blank=True,
+ )
+ kickoff = models.ForeignKey(
+ Kickoff,
+ on_delete=models.CASCADE,
+ related_name='fieldsets',
+ null=True,
+ blank=True,
+ )
+
+ # TODO Deprecated
+ tasks = models.ManyToManyField(
+ TaskTemplate,
+ through='FieldsetTemplateTaskTemplate',
+ related_name='old_fieldsets',
+ blank=True,
+ )
+ # TODO Deprecated
+ kickoffs = models.ManyToManyField(
+ Kickoff,
+ through='FieldsetTemplateKickoff',
+ related_name='old_fieldsets',
+ blank=True,
+ )
+
+ objects = BaseSoftDeleteManager.from_queryset(
+ FieldsetTemplateQuerySet,
+ )()
+
+ def __str__(self):
+ return self.name
+
+
+class FieldsetTemplateRule(
+ BaseApiNameModel,
+ BaseFieldSetRuleMixin,
+ AccountBaseMixin,
+):
+
+ class Meta:
+ ordering = ['-id']
+ constraints = [
+ UniqueConstraint(
+ fields=['api_name', 'fieldset'],
+ condition=Q(is_deleted=False),
+ name='fieldsettemplate_api_name_template_unique',
+ ),
+ ]
+
+ api_name_prefix = 'fieldsetrule'
+
+ fieldset = models.ForeignKey(
+ FieldsetTemplate,
+ on_delete=models.CASCADE,
+ related_name='rules',
+ )
+
+ objects = BaseSoftDeleteManager.from_queryset(
+ FieldsetTemplateRuleQuerySet,
+ )()
+
+ def __str__(self):
+ return self.name
+
+
+class FieldsetTemplateTaskTemplate(SoftDeleteModel):
+
+ # TODO Deprecated
+
+ class Meta:
+ ordering = ['order']
+ db_table = 'processes_fieldsettemplate_tasktemplate'
+
+ fieldset = models.ForeignKey(
+ 'FieldsetTemplate',
+ on_delete=models.CASCADE,
+ )
+ task = models.ForeignKey(
+ TaskTemplate,
+ on_delete=models.CASCADE,
+ )
+ order = models.IntegerField(default=0)
+
+ def __str__(self):
+ return (
+ f'Fieldset: {self.fieldset_id} - '
+ f'Task: {self.task_template_id} - '
+ f'Order: {self.order}'
+ )
+
+
+class FieldsetTemplateKickoff(SoftDeleteModel):
+
+ # TODO Deprecated
+
+ class Meta:
+ ordering = ['order']
+ db_table = 'processes_fieldsettemplate_kickoff'
+
+ fieldset = models.ForeignKey(
+ 'FieldsetTemplate',
+ on_delete=models.CASCADE,
+ )
+ kickoff = models.ForeignKey(
+ Kickoff,
+ on_delete=models.CASCADE,
+ )
+ order = models.IntegerField(default=0)
+
+ def __str__(self):
+ return (
+ f'Fieldset: {self.fieldset_id} - '
+ f'Kickoff: {self.kickoff_id} - '
+ f'Order: {self.order}'
+ )
diff --git a/backend/src/processes/models/templates/template.py b/backend/src/processes/models/templates/template.py
index 2acc7ad53..12bba56d1 100644
--- a/backend/src/processes/models/templates/template.py
+++ b/backend/src/processes/models/templates/template.py
@@ -8,6 +8,7 @@
ObjectDoesNotExist,
)
from django.db import models, transaction
+from django.db.models import Q
from src.accounts.models import (
AccountBaseMixin,
@@ -146,16 +147,17 @@ def get_kickoff_output_fields(
""" Return the output fields from kickoff """
- try:
- result = self.kickoff.get().fields.all()
- if fields_filter_kwargs:
- result = result.filter(**fields_filter_kwargs)
-
- except ObjectDoesNotExist:
- from src.processes.models.templates\
- .fields import FieldTemplate
- result = FieldTemplate.objects.none()
- return result
+ from src.processes.models.templates.fields import FieldTemplate
+ kickoff = self.kickoff.get()
+ qst = FieldTemplate.objects.filter(
+ Q(
+ Q(kickoff_id=kickoff.id) |
+ Q(fieldset__kickoff__id=kickoff.id),
+ ),
+ )
+ if fields_filter_kwargs:
+ qst = qst.filter(**fields_filter_kwargs)
+ return qst
def get_tasks_output_fields(
self,
@@ -164,26 +166,39 @@ def get_tasks_output_fields(
fields_filter_kwargs: Optional[Dict] = None,
) -> FieldTemplateQuerySet:
- """ Return the output fields from tasks """
+ """Return output FieldTemplates for this template's tasks,
+ with optional task/field-level filtering and exclusion.
- from src.processes.models.templates \
- .fields import FieldTemplate
+ Fields are collected from two sources:
+ - directly attached to tasks (task FK)
+ - indirectly via fieldsets linked to tasks through M2M
+ """
- if tasks_filter_kwargs is None:
- tasks_filter_kwargs = {
- 'task__template_id': self.id,
- 'task__account_id': self.account_id,
- }
- else:
- tasks_filter_kwargs['task__template_id'] = self.id
- tasks_filter_kwargs['task__account_id'] = self.account_id
- qst = FieldTemplate.objects.filter(**tasks_filter_kwargs)
+ from src.processes.models.templates.fields import FieldTemplate
+
+ tasks_filter_kwargs = tasks_filter_kwargs or {}
+ tasks_exclude_kwargs = tasks_exclude_kwargs or {}
+ fields_filter_kwargs = fields_filter_kwargs or {}
+
+ tasks_filter = {'task__template_id': self.id, **tasks_filter_kwargs}
+ fieldset_filter = {'fieldset__task__template_id': self.id}
+ for key, value in tasks_filter_kwargs.items():
+ fieldset_filter[f'fieldset__{key}'] = value
+ tasks_q = Q(**tasks_filter)
+ fieldset_q = Q(**fieldset_filter)
+
+ if tasks_exclude_kwargs:
+ fieldset_exclude_kwargs = {}
+ for key, value in tasks_exclude_kwargs.items():
+ fieldset_exclude_kwargs[f'fieldset__{key}'] = value
+ tasks_q = Q(tasks_q, ~Q(**tasks_exclude_kwargs))
+ fieldset_q = Q(fieldset_q, ~Q(**fieldset_exclude_kwargs))
+
+ qst = FieldTemplate.objects.filter(tasks_q | fieldset_q)
if fields_filter_kwargs:
qst = qst.filter(**fields_filter_kwargs)
- if tasks_exclude_kwargs:
- qst = qst.exclude(**tasks_exclude_kwargs)
return qst
def get_tasks(self, performer_id: int):
diff --git a/backend/src/processes/models/workflows/fields.py b/backend/src/processes/models/workflows/fields.py
index dca7e13ba..45ee46fdc 100644
--- a/backend/src/processes/models/workflows/fields.py
+++ b/backend/src/processes/models/workflows/fields.py
@@ -1,6 +1,7 @@
from django.contrib.auth import get_user_model
from django.db import models
+from src.accounts.models import AccountBaseMixin
from src.generics.managers import BaseSoftDeleteManager
from src.generics.models import SoftDeleteModel
from src.processes.models.mixins import (
@@ -9,6 +10,7 @@
)
from src.processes.models.workflows.kickoff import KickoffValue
from src.processes.models.workflows.task import Task
+from src.processes.models.workflows.fieldset import FieldSet
from src.processes.models.workflows.workflow import Workflow
from src.processes.querysets import (
FieldSelectionQuerySet,
@@ -20,6 +22,7 @@
class TaskField(
SoftDeleteModel,
+ AccountBaseMixin,
FieldMixin,
ApiNameMixin,
):
@@ -55,6 +58,18 @@ class Meta:
related_name='output',
null=True,
)
+ fieldset = models.ForeignKey(
+ FieldSet,
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name='fields',
+ )
+ rules = models.ManyToManyField(
+ 'processes.FieldsetRule',
+ blank=True,
+ related_name='fields',
+ )
workflow = models.ForeignKey(
Workflow,
on_delete=models.CASCADE,
diff --git a/backend/src/processes/models/workflows/fieldset.py b/backend/src/processes/models/workflows/fieldset.py
new file mode 100644
index 000000000..86e08842e
--- /dev/null
+++ b/backend/src/processes/models/workflows/fieldset.py
@@ -0,0 +1,63 @@
+from django.db import models
+
+from src.accounts.models import AccountBaseMixin
+from src.generics.managers import BaseSoftDeleteManager
+from src.processes.models.base import BaseApiNameModel
+from src.processes.models.mixins import (
+ BaseFieldSetMixin,
+ BaseFieldSetRuleMixin,
+)
+from src.processes.models.workflows.kickoff import KickoffValue
+from src.processes.models.workflows.task import Task
+from src.processes.models.workflows.workflow import Workflow
+from src.processes.querysets import FieldSetQuerySet, FieldSetRuleQuerySet
+
+
+class FieldSet(
+ BaseApiNameModel,
+ BaseFieldSetMixin,
+ AccountBaseMixin,
+):
+
+ class Meta:
+ ordering = ['-id']
+
+ workflow = models.ForeignKey(
+ Workflow,
+ on_delete=models.CASCADE,
+ related_name='fieldsets',
+ )
+ kickoff = models.ForeignKey(
+ KickoffValue,
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name='fieldsets',
+ )
+ task = models.ForeignKey(
+ Task,
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name='fieldsets',
+ )
+
+ objects = BaseSoftDeleteManager.from_queryset(FieldSetQuerySet)()
+
+
+class FieldSetRule(
+ BaseApiNameModel,
+ BaseFieldSetRuleMixin,
+ AccountBaseMixin,
+):
+
+ class Meta:
+ ordering = ['-id']
+
+ fieldset = models.ForeignKey(
+ FieldSet,
+ on_delete=models.CASCADE,
+ related_name='rules',
+ )
+
+ objects = BaseSoftDeleteManager.from_queryset(FieldSetRuleQuerySet)()
diff --git a/backend/src/processes/models/workflows/workflow.py b/backend/src/processes/models/workflows/workflow.py
index b961c774f..80eaf046b 100644
--- a/backend/src/processes/models/workflows/workflow.py
+++ b/backend/src/processes/models/workflows/workflow.py
@@ -2,8 +2,8 @@
from typing import Dict, Optional
from django.contrib.auth import get_user_model
-from django.core.exceptions import ObjectDoesNotExist
from django.db import models
+from django.db.models import Q
from django.utils import timezone
from src.accounts.models import AccountBaseMixin
@@ -126,15 +126,17 @@ def get_kickoff_output_fields(
""" Return the output fields from kickoff """
- try:
- result = self.kickoff.get().output.all()
- if fields_filter_kwargs:
- result = result.filter(**fields_filter_kwargs)
- except ObjectDoesNotExist:
- from src.processes.models.workflows \
- .fields import TaskField
- result = TaskField.objects.none()
- return result
+ from src.processes.models.workflows.fields import TaskField
+ kickoff = self.kickoff.get()
+ qst = TaskField.objects.filter(
+ Q(
+ Q(kickoff_id=kickoff.id) |
+ Q(fieldset__kickoff_id=kickoff.id),
+ ),
+ )
+ if fields_filter_kwargs:
+ qst = qst.filter(**fields_filter_kwargs)
+ return qst
def get_tasks_output_fields(
self,
@@ -145,24 +147,33 @@ def get_tasks_output_fields(
""" Return the output fields from tasks """
- from src.processes.models.workflows \
- .fields import TaskField
+ from src.processes.models.workflows.fields import TaskField
+
+ tasks_filter_kwargs = tasks_filter_kwargs or {}
+ tasks_exclude_kwargs = tasks_exclude_kwargs or {}
+ fields_filter_kwargs = fields_filter_kwargs or {}
- if tasks_filter_kwargs is None:
- tasks_filter_kwargs = {
- 'task__workflow_id': self.id,
- 'task__account_id': self.account_id,
+ tasks_filter = {'task__workflow_id': self.id, **tasks_filter_kwargs}
+ fieldset_filter = {
+ f'fieldset__{key}': value
+ for key, value in tasks_filter.items()
+ }
+ tasks_q = Q(**tasks_filter)
+ fieldset_q = Q(**fieldset_filter)
+
+ if tasks_exclude_kwargs:
+ fieldset_exclude_kwargs = {
+ f'fieldset__{key}': value
+ for key, value in tasks_exclude_kwargs.items()
}
- else:
- tasks_filter_kwargs['task__workflow_id'] = self.id
- tasks_filter_kwargs['task__account_id'] = self.account_id
- qst = TaskField.objects.filter(**tasks_filter_kwargs)
+ tasks_q = Q(tasks_q, ~Q(**tasks_exclude_kwargs))
+ fieldset_q = Q(fieldset_q, ~Q(**fieldset_exclude_kwargs))
+
+ qst = TaskField.objects.filter(tasks_q | fieldset_q)
if fields_filter_kwargs:
qst = qst.filter(**fields_filter_kwargs)
- if tasks_exclude_kwargs:
- qst = qst.exclude(**tasks_exclude_kwargs)
return qst
@property
diff --git a/backend/src/processes/querysets.py b/backend/src/processes/querysets.py
index f3aaa3013..19ac77abe 100644
--- a/backend/src/processes/querysets.py
+++ b/backend/src/processes/querysets.py
@@ -293,6 +293,7 @@ def raw_list_query(
lookup='kickoff',
queryset=(
Kickoff.objects.all().prefetch_related(
+ 'fieldsets',
Prefetch(
lookup='fields',
queryset=(
@@ -364,6 +365,7 @@ def raw_list_by_owners_query(
lookup='kickoff',
queryset=(
Kickoff.objects.all().prefetch_related(
+ 'fieldsets',
Prefetch(
lookup='fields',
queryset=(
@@ -443,9 +445,11 @@ def raw_export_query(
'tasks__conditions__rules',
'tasks__conditions__rules__predicates',
'tasks__raw_performers',
+ 'tasks__fieldsets',
'kickoff',
'kickoff__fields',
'kickoff__fields__selections',
+ 'kickoff__fieldsets',
)
)
@@ -686,7 +690,12 @@ def raw_list_query(
queryset=(
TaskField.objects
.filter(api_name__in=fields)
- .order_by('kickoff_id', 'task__number', '-order')
+ .order_by(
+ 'kickoff_id',
+ 'task__number',
+ 'fieldset_id',
+ '-order',
+ )
),
),
)
@@ -1275,3 +1284,24 @@ def by_user(self, user, template_id):
class SearchContentQuerySet(AccountBaseQuerySet):
pass
+
+
+class FieldsetTemplateQuerySet(AccountBaseQuerySet):
+
+ def shared(self):
+ return self.filter(is_shared=True)
+
+
+class FieldsetTemplateRuleQuerySet(AccountBaseQuerySet):
+
+ pass
+
+
+class FieldSetQuerySet(AccountBaseQuerySet):
+
+ pass
+
+
+class FieldSetRuleQuerySet(AccountBaseQuerySet):
+
+ pass
diff --git a/backend/src/processes/serializers/templates/field.py b/backend/src/processes/serializers/templates/field.py
index 7b92a8321..48ea10dff 100644
--- a/backend/src/processes/serializers/templates/field.py
+++ b/backend/src/processes/serializers/templates/field.py
@@ -73,16 +73,16 @@ class Meta:
model = FieldTemplate
api_primary_field = 'api_name'
fields = (
- 'type',
'name',
'description',
+ 'type',
'is_required',
'is_hidden',
- 'selections',
- 'order',
'api_name',
+ 'selections',
'default',
'dataset',
+ 'order',
)
create_or_update_fields = {
'type',
@@ -215,31 +215,19 @@ def update(
return instance
-class FieldTemplateShortViewSerializer(ModelSerializer):
- class Meta:
- model = FieldTemplate
- fields = (
- 'name',
- 'type',
- 'order',
- 'description',
- 'is_hidden',
- 'api_name',
- )
-
-
class FieldTemplateListSerializer(ModelSerializer):
class Meta:
model = FieldTemplate
fields = (
'name',
+ 'description',
'type',
'is_required',
'is_hidden',
- 'description',
'api_name',
'selections',
+ 'default',
'dataset',
'order',
)
diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py
new file mode 100644
index 000000000..b50a57c81
--- /dev/null
+++ b/backend/src/processes/serializers/templates/fieldset.py
@@ -0,0 +1,119 @@
+from django.core.validators import MinValueValidator
+from rest_framework.fields import CharField, IntegerField
+from rest_framework.serializers import ModelSerializer
+from src.generics.fields import (
+ RelatedApiNameListField, AccountPrimaryKeyRelatedField,
+)
+from src.generics.mixins.serializers import CustomValidationErrorMixin
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+ FieldsetTemplateRule,
+)
+from src.processes.serializers.templates.field import (
+ FieldTemplateSerializer,
+)
+
+
+class FieldsetTemplateRuleSerializer(
+ CustomValidationErrorMixin,
+ ModelSerializer,
+):
+
+ class Meta:
+ model = FieldsetTemplateRule
+ fields = (
+ 'type',
+ 'value',
+ 'api_name',
+ 'fields',
+ )
+
+ api_name = CharField(required=False, max_length=200)
+ fields = RelatedApiNameListField(
+ required=False,
+ allow_empty=True,
+ default=list,
+ )
+
+
+class FieldsetTemplateSerializer(
+ ModelSerializer,
+ CustomValidationErrorMixin,
+):
+
+ class Meta:
+ model = FieldsetTemplate
+ fields = (
+ 'title',
+ 'order',
+ 'description',
+ 'api_name',
+ 'shared_fieldset_id',
+ 'name',
+ 'label_position',
+ 'layout',
+ 'rules',
+ 'fields',
+ )
+
+ read_only_fields = (
+ 'name',
+ 'label_position',
+ 'layout',
+ 'rules',
+ 'fields',
+ )
+
+ shared_fieldset_id = AccountPrimaryKeyRelatedField(
+ queryset=FieldsetTemplate.objects.all(),
+ required=True,
+ )
+ api_name = CharField(required=False, max_length=200)
+ rules = FieldsetTemplateRuleSerializer(
+ many=True,
+ required=False,
+ default=list,
+ )
+ fields = FieldTemplateSerializer(
+ many=True,
+ required=False,
+ default=list,
+ )
+ order = IntegerField(
+ required=False,
+ default=0,
+ validators=[MinValueValidator(0)],
+ )
+
+
+class SharedFieldsetTemplateSerializer(
+ CustomValidationErrorMixin,
+ ModelSerializer,
+):
+
+ class Meta:
+ model = FieldsetTemplate
+ fields = (
+ 'id',
+ 'title',
+ 'order',
+ 'description',
+ 'api_name',
+ 'name',
+ 'label_position',
+ 'layout',
+ 'rules',
+ 'fields',
+ )
+
+ rules = FieldsetTemplateRuleSerializer(
+ many=True,
+ required=False,
+ default=list,
+ )
+ fields = FieldTemplateSerializer(
+ many=True,
+ required=False,
+ default=list,
+ )
+ api_name = CharField(required=False, max_length=200)
diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py
index 668e10b0f..36ffa3115 100644
--- a/backend/src/processes/serializers/templates/kickoff.py
+++ b/backend/src/processes/serializers/templates/kickoff.py
@@ -3,7 +3,6 @@
from rest_framework.serializers import (
ModelSerializer,
)
-
from src.generics.mixins.serializers import (
AdditionalValidationMixin,
CustomValidationErrorMixin,
@@ -12,11 +11,14 @@
from src.processes.serializers.templates.field import (
FieldTemplateListSerializer,
FieldTemplateSerializer,
- FieldTemplateShortViewSerializer,
+)
+from src.processes.serializers.templates.fieldset import (
+ FieldsetTemplateSerializer,
)
from src.processes.serializers.templates.mixins import (
CreateOrUpdateInstanceMixin,
CreateOrUpdateRelatedMixin,
+ FieldsetMixin,
)
@@ -25,6 +27,7 @@ class KickoffSerializer(
CreateOrUpdateRelatedMixin,
CustomValidationErrorMixin,
AdditionalValidationMixin,
+ FieldsetMixin,
ModelSerializer,
):
@@ -32,34 +35,50 @@ class Meta:
model = Kickoff
fields = (
'fields',
+ 'fieldsets',
)
create_or_update_fields = {
'account',
'template',
}
- fields = FieldTemplateSerializer(many=True, required=False)
+ fields = FieldTemplateSerializer(many=True, required=False, default=list)
+ fieldsets = FieldsetTemplateSerializer(
+ many=True,
+ required=False,
+ allow_empty=True,
+ )
- def to_representation(self, data: Dict[str, Any]):
- data = super().to_representation(data)
- if data.get('fields') is None:
- data['fields'] = []
- return data
+ def to_representation(self, instance):
+ # TODO Delete when the Template <-> Kickoff relation becomes o2o
+ from django.db import models # noqa : PLC0415
+ if isinstance(instance, models.Manager):
+ instance = instance.first()
+ if instance is None:
+ return {'fields': [], 'fieldsets': []}
+ return super().to_representation(instance)
def create(self, validated_data: Dict[str, Any]):
self.additional_validate(validated_data)
+ template = self.context['template']
instance = self.create_or_update_instance(
validated_data={
- 'template': self.context['template'],
+ 'template': template,
'account': self.context.get('account'),
**validated_data,
},
)
+ self.create_or_update_fieldsets(
+ fieldsets_data=validated_data.pop('fieldsets', []),
+ template=template,
+ kickoff=instance,
+ user=self.context['user'],
+ )
self.create_or_update_related(
data=validated_data.get('fields'),
ancestors_data={
'kickoff': instance,
- 'template': self.context['template'],
+ 'template': template,
},
slz_cls=FieldTemplateSerializer,
slz_context={
@@ -75,19 +94,26 @@ def update(
validated_data: Dict[str, Any],
):
self.additional_validate(validated_data)
+ template = self.context['template']
instance = self.create_or_update_instance(
instance=instance,
validated_data={
- 'template': self.context['template'],
+ 'template': template,
'account': self.context.get('account'),
**validated_data,
},
)
+ self.create_or_update_fieldsets(
+ fieldsets_data=validated_data.pop('fieldsets', []),
+ template=template,
+ kickoff=instance,
+ user=self.context['user'],
+ )
self.create_or_update_related(
data=validated_data.get('fields'),
ancestors_data={
'kickoff': instance.id,
- 'template': self.context['template'],
+ 'template': template,
},
slz_cls=FieldTemplateSerializer,
slz_context={
@@ -98,26 +124,21 @@ def update(
return instance
-class KickoffOnlyFieldsSerializer(ModelSerializer):
- class Meta:
- model = Kickoff
- fields = (
- 'fields',
- )
-
- fields = FieldTemplateShortViewSerializer(
- many=True,
- required=False,
- read_only=True,
- )
-
-
class KickoffListSerializer(ModelSerializer):
class Meta:
model = Kickoff
fields = (
'fields',
+ 'fieldsets',
)
fields = FieldTemplateListSerializer(many=True)
+ fieldsets = FieldsetTemplateSerializer(many=True)
+
+ def to_representation(self, instance):
+ # TODO Delete when the Template <-> Kickoff relation becomes o2o
+ from django.db import models # noqa : PLC0415
+ if isinstance(instance, models.Manager):
+ instance = instance.first()
+ return super().to_representation(instance)
diff --git a/backend/src/processes/serializers/templates/mixins.py b/backend/src/processes/serializers/templates/mixins.py
index 95e81d6a4..494abe626 100644
--- a/backend/src/processes/serializers/templates/mixins.py
+++ b/backend/src/processes/serializers/templates/mixins.py
@@ -3,9 +3,17 @@
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
+from django.core.exceptions import ObjectDoesNotExist
from rest_framework.serializers import Serializer
from src.processes.messages.template import MSG_PT_0041
+from src.processes.models.templates.kickoff import Kickoff
+from src.processes.models.templates.task import TaskTemplate
+from src.processes.models.templates.template import Template
+from src.processes.models.templates.fieldset import FieldsetTemplate
+from src.processes.services.fieldsets.fieldset import (
+ FieldSetTemplateService,
+)
from src.utils.validation import raise_validation_error
UserModel = get_user_model()
@@ -240,3 +248,93 @@ def additional_validate_api_name(
new_api_name=value,
),
)
+
+
+class FieldsetMixin:
+
+ @staticmethod
+ def create_or_update_fieldsets(
+ fieldsets_data: List[Dict],
+ template: Template,
+ user: UserModel,
+ task: Optional[TaskTemplate] = None,
+ kickoff: Optional[Kickoff] = None,
+ ):
+ instance = task or kickoff
+ existing_fieldsets = {f.api_name: f for f in instance.fieldsets.all()}
+ fieldsets_api_names = set()
+ for fieldset_data in fieldsets_data:
+ fieldset_api_name = fieldset_data.get('api_name')
+ if fieldset_api_name and fieldset_api_name in existing_fieldsets:
+ fieldset = existing_fieldsets[fieldset_api_name]
+ update_kwargs = {}
+ if fieldset.order != fieldset_data['order']:
+ update_kwargs['order'] = fieldset_data['order']
+ if fieldset.title != fieldset_data['title']:
+ update_kwargs['title'] = fieldset_data['title']
+ if fieldset.description != fieldset_data['description']:
+ update_kwargs['description'] = fieldset_data['description']
+
+ if update_kwargs:
+ service = FieldSetTemplateService(
+ instance=fieldset,
+ user=user,
+ )
+ service.partial_update_instance(
+ order=fieldset_data['order'],
+ title=fieldset_data.get('title'),
+ description=fieldset_data.get('description'),
+ )
+ fieldsets_api_names.add(fieldset.api_name)
+ else:
+ shared_fieldset = fieldset_data['shared_fieldset_id']
+ service = FieldSetTemplateService(user=user)
+ fieldset = service.create_from_shared(
+ shared_fieldset_data=FieldSetTemplateService.to_json(
+ shared_fieldset,
+ ),
+ shared_fieldset_id=shared_fieldset.id,
+ template_id=template.id,
+ task_id=task.id if task else None,
+ kickoff_id=kickoff.id if kickoff else None,
+ order=fieldset_data['order'],
+ api_name=fieldset_data.get('api_name'),
+ title=fieldset_data.get('title'),
+ description=fieldset_data.get('description'),
+ )
+ fieldsets_api_names.add(fieldset.api_name)
+ instance.fieldsets.exclude(api_name__in=fieldsets_api_names).delete()
+
+ def get_draft_fieldsets(self, fieldsets_data: Any):
+ result = []
+ if isinstance(fieldsets_data, list):
+ for fieldset_data in fieldsets_data:
+ if fieldset_data.get('fields'):
+ result.append(fieldset_data)
+ # Fieldset already done
+ continue
+ try:
+ shared_fieldset_id = int(fieldset_data.get(
+ 'shared_fieldset_id',
+ ))
+ shared_fieldset = FieldsetTemplate.objects.get(
+ id=shared_fieldset_id,
+ is_shared=True,
+ )
+ except (TypeError, ValueError, ObjectDoesNotExist):
+ # Remove invalid or not existent fieldset
+ continue
+ order = fieldset_data.get('order', 0)
+ service = FieldSetTemplateService()
+ new_fieldset_data = service.get_new_fieldset_data(
+ shared_fieldset_data=FieldSetTemplateService.to_json(
+ shared_fieldset,
+ ),
+ api_name=fieldset_data.get('api_name'),
+ title=fieldset_data.get('title'),
+ description=fieldset_data.get('description'),
+ )
+ new_fieldset_data['order'] = order
+ new_fieldset_data['shared_fieldset_id'] = shared_fieldset_id
+ result.append(new_fieldset_data)
+ return result
diff --git a/backend/src/processes/serializers/templates/public/kickoff.py b/backend/src/processes/serializers/templates/public/kickoff.py
index d7fd10d6f..9a529bf7e 100644
--- a/backend/src/processes/serializers/templates/public/kickoff.py
+++ b/backend/src/processes/serializers/templates/public/kickoff.py
@@ -4,11 +4,12 @@
CharField,
ModelSerializer,
)
-
from src.processes.models.templates.kickoff import Kickoff
from src.processes.serializers.templates.field import (
PublicFieldTemplateSerializer,
)
+from src.processes.serializers.templates.fieldset import \
+ FieldsetTemplateSerializer
class PublicKickoffSerializer(ModelSerializer):
@@ -18,10 +19,12 @@ class Meta:
fields = (
'description',
'fields',
+ 'fieldsets',
)
description = CharField(allow_blank=True, default='')
fields = PublicFieldTemplateSerializer(many=True, required=False)
+ fieldsets = FieldsetTemplateSerializer(many=True, required=False)
def to_representation(self, data: Dict[str, Any]):
data = super().to_representation(data)
@@ -29,4 +32,6 @@ def to_representation(self, data: Dict[str, Any]):
data['description'] = ''
if data.get('fields') is None:
data['fields'] = []
+ if data.get('fieldsets') is None:
+ data['fieldsets'] = []
return data
diff --git a/backend/src/processes/serializers/templates/public/template.py b/backend/src/processes/serializers/templates/public/template.py
index 732a06a82..78b1dece0 100644
--- a/backend/src/processes/serializers/templates/public/template.py
+++ b/backend/src/processes/serializers/templates/public/template.py
@@ -4,6 +4,7 @@
from rest_framework.serializers import (
CharField,
ModelSerializer,
+ SerializerMethodField,
)
from src.processes.models.templates.template import Template
@@ -25,19 +26,21 @@ class Meta:
)
description = CharField(allow_blank=True, default='')
- kickoff = PublicKickoffSerializer(required=False)
+ kickoff = SerializerMethodField()
+
+ def get_kickoff(self, instance: Template):
+ # PublicTemplateSerializer cannot return a single Kickoff
+ # object because the Template related with Kickoff by
+ # foreign key instead of one to one relation.
+ # Getting the object manually:
+ kickoff_slz = PublicKickoffSerializer(
+ instance=instance.kickoff_instance,
+ )
+ return kickoff_slz.data
def to_representation(self, data: Dict[str, Any]):
data = super().to_representation(data)
if data.get('description') is None:
data['description'] = ''
-
- # PublicTemplateSerializer cannot return a single Kickoff object
- # because the Template related with Kickoff by foreign key
- # instead of one to one relation. Getting the object manually:
- kickoff_slz = PublicKickoffSerializer(
- instance=self.instance.kickoff_instance,
- )
- data['kickoff'] = kickoff_slz.data
return data
diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py
index 2e74cca8a..f54014632 100644
--- a/backend/src/processes/serializers/templates/task.py
+++ b/backend/src/processes/serializers/templates/task.py
@@ -32,12 +32,15 @@
)
from src.processes.serializers.templates.field import (
FieldTemplateSerializer,
- FieldTemplateShortViewSerializer,
+)
+from src.processes.serializers.templates.fieldset import (
+ FieldsetTemplateSerializer,
)
from src.processes.serializers.templates.mixins import (
CreateOrUpdateInstanceMixin,
CreateOrUpdateRelatedMixin,
CustomValidationApiNameMixin,
+ FieldsetMixin,
)
from src.processes.serializers.templates.raw_due_date import (
RawDueDateTemplateSerializer,
@@ -59,6 +62,7 @@ class TaskTemplateSerializer(
CustomValidationErrorMixin,
AdditionalValidationMixin,
CustomValidationApiNameMixin,
+ FieldsetMixin,
ModelSerializer,
):
@@ -74,6 +78,7 @@ class Meta:
'skip_for_starter',
'delay',
'fields',
+ 'fieldsets',
'conditions',
'api_name',
'raw_performers',
@@ -101,6 +106,11 @@ class Meta:
number = IntegerField()
api_name = CharField(max_length=200, required=False)
fields = FieldTemplateSerializer(many=True, required=False)
+ fieldsets = FieldsetTemplateSerializer(
+ many=True,
+ required=False,
+ allow_empty=True,
+ )
checklists = ChecklistTemplateSerializer(many=True, required=False)
conditions = ConditionTemplateSerializer(many=True, required=False)
raw_performers = RawPerformerSerializer(
@@ -416,15 +426,12 @@ def create(self, validated_data: Dict[str, Any]):
},
)
template = self.context['template']
- if template.is_active and validated_data.get('raw_due_date'):
- AnalyticService.templates_task_due_date_created(
- user=self.context['user'],
- template=template,
- task=instance,
- is_superuser=self.context['is_superuser'],
- auth_type=self.context['auth_type'],
- )
-
+ self.create_or_update_fieldsets(
+ fieldsets_data=validated_data.pop('fieldsets', []),
+ template=template,
+ task=instance,
+ user=self.context['user'],
+ )
self.create_or_update_related(
data=validated_data.get('fields'),
ancestors_data={
@@ -486,8 +493,9 @@ def create(self, validated_data: Dict[str, Any]):
},
)
+ raw_due_date_data = validated_data.get('raw_due_date')
self.create_or_update_related_one(
- data=validated_data.get('raw_due_date'),
+ data=raw_due_date_data,
ancestors_data={
'task': instance,
'template': self.context['template'],
@@ -498,6 +506,14 @@ def create(self, validated_data: Dict[str, Any]):
'task': instance,
},
)
+ if template.is_active and raw_due_date_data:
+ AnalyticService.templates_task_due_date_created(
+ user=self.context['user'],
+ template=template,
+ task=instance,
+ is_superuser=self.context['is_superuser'],
+ auth_type=self.context['auth_type'],
+ )
return instance
def update(
@@ -515,6 +531,7 @@ def update(
and not hasattr(self.instance, 'raw_due_date')
and validated_data.get('raw_due_date')
)
+ validated_data.pop('fieldsettemplatetasktemplate_set', None)
instance = self.create_or_update_instance(
instance=instance,
validated_data={
@@ -525,6 +542,12 @@ def update(
**validated_data,
},
)
+ self.create_or_update_fieldsets(
+ fieldsets_data=validated_data.pop('fieldsets', []),
+ template=template,
+ task=instance,
+ user=self.context['user'],
+ )
if raw_due_date_created:
AnalyticService.templates_task_due_date_created(
user=self.context['user'],
@@ -613,30 +636,11 @@ class TemplateStepNameSerializer(ModelSerializer):
class Meta:
model = TaskTemplate
fields = (
- 'id', # Deprecated
- 'name',
- 'number',
- 'api_name',
- )
-
-
-class TemplateTaskOnlyFieldsSerializer(ModelSerializer):
- class Meta:
- model = TaskTemplate
- fields = (
- 'id', # Deprecated
'name',
'number',
'api_name',
- 'fields',
)
- fields = FieldTemplateShortViewSerializer(
- many=True,
- required=False,
- read_only=True,
- )
-
class TaskTemplatePrivilegesSerializer(ModelSerializer):
class Meta:
diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py
index befe0f3c0..1ec4bb3b4 100644
--- a/backend/src/processes/serializers/templates/template.py
+++ b/backend/src/processes/serializers/templates/template.py
@@ -37,7 +37,8 @@
SystemVariable,
TemplateOrdering,
TemplateType,
- WorkflowApiStatus, TaskStatus,
+ WorkflowApiStatus,
+ TaskStatus,
)
from src.processes.messages import template as messages
from src.processes.models.templates.kickoff import Kickoff
@@ -48,12 +49,11 @@
)
from src.processes.serializers.templates.kickoff import (
KickoffListSerializer,
- KickoffOnlyFieldsSerializer,
KickoffSerializer,
)
from src.processes.serializers.templates.mixins import (
CreateOrUpdateInstanceMixin,
- CreateOrUpdateRelatedMixin,
+ CreateOrUpdateRelatedMixin, FieldsetMixin,
)
from src.processes.serializers.templates.owner import (
TemplateOwnerSerializer,
@@ -62,7 +62,6 @@
ShortTaskSerializer,
TaskTemplatePrivilegesSerializer,
TaskTemplateSerializer,
- TemplateTaskOnlyFieldsSerializer,
)
from src.processes.services.templates.integrations import (
TemplateIntegrationsService,
@@ -90,6 +89,7 @@ class TemplateSerializer(
AdditionalValidationMixin,
CreateOrUpdateInstanceMixin,
CreateOrUpdateRelatedMixin,
+ FieldsetMixin,
ModelSerializer,
):
"""
@@ -157,6 +157,23 @@ class Meta:
public_success_url = CharField(allow_null=True, required=False)
date_updated_tsp = TimeStampField(read_only=True, source='date_updated')
+ def _get_formatted_fields_data(self, fields_data: list):
+ result = []
+ for field in fields_data:
+ try:
+ api_name = field.get('api_name')
+ name = field.get('name')
+ is_required = field.get('is_required', False)
+ if api_name and name:
+ result.append({
+ 'name': name,
+ 'api_name': api_name,
+ 'is_required': is_required,
+ })
+ except (TypeError, AttributeError):
+ continue
+ return result
+
def _get_raw_fields_from_kickoff(self, data: Dict[str, Any]) -> List[dict]:
""" Return format:
@@ -170,26 +187,16 @@ def _get_raw_fields_from_kickoff(self, data: Dict[str, Any]) -> List[dict]:
"""
result = []
try:
- fields = data['kickoff']['fields']
+ kickoff_data = data['kickoff']
except KeyError:
- pass
- else:
- try:
- for field in fields:
- try:
- api_name = field.get('api_name')
- name = field.get('name')
- is_required = field.get('is_required', False)
- if api_name and name:
- result.append({
- 'name': name,
- 'api_name': api_name,
- 'is_required': is_required,
- })
- except TypeError:
- continue
- except TypeError:
- pass
+ return result
+
+ fields_data = kickoff_data.get('fields') or []
+ result = self._get_formatted_fields_data(fields_data)
+ fieldsets_data = kickoff_data.get('fieldsets') or []
+ for fieldset in fieldsets_data:
+ fields_data = fieldset.get('fields') or []
+ result.extend(self._get_formatted_fields_data(fields_data))
return result
def _get_template_performers_ids(self, data: Dict[str, Any]) -> Set[int]:
@@ -454,8 +461,14 @@ def _get_normalized_kickoff_draft(
) -> dict:
if isinstance(data, dict):
data['fields'] = data.get('fields', [])
+ data['fieldsets'] = self.get_draft_fieldsets(
+ data.get('fieldsets'),
+ )
else:
- data = {'fields': []}
+ data = {
+ 'fields': [],
+ 'fieldsets': [],
+ }
return data
def save_as_draft(self) -> Template:
@@ -482,6 +495,10 @@ def save_as_draft(self) -> Template:
else:
task['parents'] = []
task['ancestors'] = []
+ task['fieldsets'] = self.get_draft_fieldsets(
+ task.get('fieldsets'),
+ )
+
if not self.instance:
self.instance = Template.objects.create(
account=user.account,
@@ -623,6 +640,7 @@ def create(self, validated_data: Dict[str, Any]) -> Template:
parents_by_tasks = get_tasks_parents(validated_data['tasks'])
tasks_api_names = set(parents_by_tasks.keys())
ancestors_by_tasks = get_tasks_ancestors(parents_by_tasks)
+
self.create_or_update_related(
data=validated_data['tasks'],
ancestors_data={
@@ -847,43 +865,6 @@ def get_is_editable(self, instance: Template) -> bool:
).exists()
-class TemplateOnlyFieldsSerializer(ModelSerializer):
- class Meta:
- model = Template
- fields = (
- 'id',
- 'kickoff',
- 'tasks',
- )
-
- kickoff = KickoffOnlyFieldsSerializer(required=False, read_only=True)
- tasks = TemplateTaskOnlyFieldsSerializer(
- many=True,
- required=False,
- read_only=True,
- )
-
- def to_representation(self, data: Dict[str, Any]):
-
- data = super().to_representation(data)
- if data.get('tasks') is None:
- data['tasks'] = []
-
- # TemplateSerializer cannot return a single Kickoff object
- # because the Template related with Kickoff by foreign key
- # instead of one to one relation. Getting the object manually:
- kickoff_slz = KickoffOnlyFieldsSerializer(
- instance=self.instance.kickoff_instance,
- )
- data['kickoff'] = kickoff_slz.data
- return data
-
- def get_response_data(self) -> Dict[str, Any]:
- if self.instance.is_active:
- return self.data
- return self.instance.get_draft()
-
-
class TemplateTitlesByWorkflowsSerializer(
CustomValidationErrorMixin,
Serializer,
@@ -1040,3 +1021,22 @@ class TemplateTitlesSerializer(Serializer):
id = IntegerField(read_only=True)
name = CharField(read_only=True)
count = IntegerField(read_only=True)
+
+
+class FieldsetTemplateFilterSerializer(
+ CustomValidationErrorMixin,
+ AdditionalValidationMixin,
+ Serializer,
+):
+
+ ordering = ChoiceField(
+ required=False,
+ choices=(
+ ('name', 'name'),
+ ('-name', '-name'),
+ ('date', 'date'),
+ ('-date', '-date'),
+ ),
+ )
+ limit = IntegerField(min_value=0, required=False)
+ offset = IntegerField(min_value=0, required=False)
diff --git a/backend/src/processes/serializers/templates/template_fields.py b/backend/src/processes/serializers/templates/template_fields.py
new file mode 100644
index 000000000..1d6d36de1
--- /dev/null
+++ b/backend/src/processes/serializers/templates/template_fields.py
@@ -0,0 +1,137 @@
+from typing import Any, Dict
+
+from rest_framework.serializers import (
+ ModelSerializer,
+)
+from src.processes.models.templates.fields import FieldTemplate
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+)
+from src.processes.models.templates.kickoff import Kickoff
+from src.processes.models.templates.template import Template
+from src.processes.models.templates.task import TaskTemplate
+
+
+# All serializers for the GET /templates/id/fields API
+
+
+class FieldTemplateOnlyFieldsSerializer(ModelSerializer):
+
+ class Meta:
+ model = FieldTemplate
+ fields = (
+ 'name',
+ 'type',
+ 'order',
+ 'description',
+ 'is_hidden',
+ 'api_name',
+ )
+
+
+class FieldsetTemplateOnlyFieldsSerializer(ModelSerializer):
+
+ class Meta:
+ model = FieldsetTemplate
+ fields = (
+ 'order',
+ 'name',
+ 'title',
+ 'description',
+ 'fields',
+ 'api_name',
+ 'label_position',
+ 'layout',
+ )
+
+ fields = FieldTemplateOnlyFieldsSerializer(many=True)
+
+
+class KickoffOnlyFieldsSerializer(ModelSerializer):
+
+ class Meta:
+ model = Kickoff
+ fields = (
+ 'fields',
+ 'fieldsets',
+ )
+
+ fields = FieldTemplateOnlyFieldsSerializer(
+ many=True,
+ default=[],
+ read_only=True,
+ )
+ fieldsets = FieldsetTemplateOnlyFieldsSerializer(
+ many=True,
+ default=[],
+ read_only=True,
+ )
+
+ def to_representation(self, instance):
+ # TODO Delete when the Template <-> Kickoff relation becomes o2o
+ from django.db import models # noqa : PLC0415
+ if isinstance(instance, models.Manager):
+ instance = instance.first()
+ return super().to_representation(instance)
+
+
+class TemplateTaskOnlyFieldsSerializer(ModelSerializer):
+
+ class Meta:
+ model = TaskTemplate
+ fields = (
+ 'name',
+ 'number',
+ 'api_name',
+ 'fields',
+ 'fieldsets',
+ )
+
+ fields = FieldTemplateOnlyFieldsSerializer(
+ many=True,
+ default=[],
+ read_only=True,
+ )
+ fieldsets = FieldsetTemplateOnlyFieldsSerializer(
+ many=True,
+ default=[],
+ read_only=True,
+ )
+
+
+class TemplateOnlyFieldsSerializer(ModelSerializer):
+
+ class Meta:
+ model = Template
+ fields = (
+ 'id',
+ 'kickoff',
+ 'tasks',
+ )
+
+ kickoff = KickoffOnlyFieldsSerializer(required=False, read_only=True)
+ tasks = TemplateTaskOnlyFieldsSerializer(
+ many=True,
+ required=False,
+ read_only=True,
+ )
+
+ def to_representation(self, data: Dict[str, Any]):
+
+ data = super().to_representation(data)
+ if data.get('tasks') is None:
+ data['tasks'] = []
+
+ # TemplateSerializer cannot return a single Kickoff object
+ # because the Template related with Kickoff by foreign key
+ # instead of one to one relation. Getting the object manually:
+ kickoff_slz = KickoffOnlyFieldsSerializer(
+ instance=self.instance.kickoff_instance,
+ )
+ data['kickoff'] = kickoff_slz.data
+ return data
+
+ def get_response_data(self) -> Dict[str, Any]:
+ if self.instance.is_active:
+ return self.data
+ return self.instance.get_draft()
diff --git a/backend/src/processes/serializers/workflows/events.py b/backend/src/processes/serializers/workflows/events.py
index 6ef99cd2f..fe568358e 100644
--- a/backend/src/processes/serializers/workflows/events.py
+++ b/backend/src/processes/serializers/workflows/events.py
@@ -11,6 +11,7 @@
from src.processes.serializers.workflows.field import (
TaskFieldEventSerializer,
)
+from src.processes.serializers.workflows.fieldset import FieldSetSerializer
from src.processes.serializers.workflows.task_performer import (
TaskUserGroupPerformerSerializer,
)
@@ -68,6 +69,7 @@ class Meta:
'performers',
'due_date_tsp',
'output',
+ 'fieldsets',
'sub_workflow',
)
@@ -76,6 +78,7 @@ class Meta:
source='exclude_directly_deleted_taskperformer_set',
)
output = serializers.SerializerMethodField()
+ fieldsets = serializers.SerializerMethodField()
due_date_tsp = TimeStampField(source='due_date')
sub_workflow = serializers.SerializerMethodField()
@@ -87,6 +90,17 @@ def get_output(self, instance):
).data
return None
+ def get_fieldsets(self, instance):
+ if (
+ self.context['event_type'] == WorkflowEventType.TASK_COMPLETE
+ and instance.fieldsets.exists()
+ ):
+ return FieldSetSerializer(
+ instance=instance.fieldsets.all(),
+ many=True,
+ ).data
+ return None
+
def get_sub_workflow(self, instance):
if self.context['event_type'] == WorkflowEventType.SUB_WORKFLOW_RUN:
return SubWorkflowEventSerializer(
diff --git a/backend/src/processes/serializers/workflows/field.py b/backend/src/processes/serializers/workflows/field.py
index ccc87865d..5a841ed18 100644
--- a/backend/src/processes/serializers/workflows/field.py
+++ b/backend/src/processes/serializers/workflows/field.py
@@ -83,6 +83,7 @@ class Meta:
'clear_value',
'user_id',
'group_id',
+ 'fieldset_id',
)
diff --git a/backend/src/processes/serializers/workflows/fieldset.py b/backend/src/processes/serializers/workflows/fieldset.py
new file mode 100644
index 000000000..e8a4de857
--- /dev/null
+++ b/backend/src/processes/serializers/workflows/fieldset.py
@@ -0,0 +1,23 @@
+from rest_framework import serializers
+
+from src.processes.models.workflows.fieldset import FieldSet
+from src.processes.serializers.workflows.field import TaskFieldSerializer
+
+
+class FieldSetSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = FieldSet
+ fields = (
+ 'id',
+ 'api_name',
+ 'name',
+ 'title',
+ 'description',
+ 'order',
+ 'label_position',
+ 'layout',
+ 'fields',
+ )
+
+ fields = TaskFieldSerializer(many=True)
diff --git a/backend/src/processes/serializers/workflows/kickoff_value.py b/backend/src/processes/serializers/workflows/kickoff_value.py
index b9a1ffe69..6eea83236 100644
--- a/backend/src/processes/serializers/workflows/kickoff_value.py
+++ b/backend/src/processes/serializers/workflows/kickoff_value.py
@@ -1,31 +1,44 @@
from typing import Any, Dict
-
+from django.db.models import Q
from rest_framework import serializers
from src.generics.serializers import CustomValidationErrorMixin
+from src.processes.models.templates.fieldset import FieldsetTemplate
from src.processes.models.templates.kickoff import Kickoff
+from src.processes.models.workflows.fieldset import FieldSet
+from src.processes.models.workflows.fields import TaskField
from src.processes.models.workflows.kickoff import KickoffValue
from src.processes.models.workflows.workflow import Workflow
from src.processes.serializers.workflows.field import (
TaskFieldSerializer,
)
+from src.processes.serializers.workflows.fieldset import (
+ FieldSetSerializer,
+)
+from src.processes.services.exceptions import FieldsetServiceException
from src.processes.services.tasks.exceptions import (
TaskFieldException,
)
from src.processes.services.tasks.task import (
TaskFieldService,
)
+from src.processes.services.workflows.fieldsets.fieldset import (
+ FieldSetService,
+)
+
from src.services.markdown import MarkdownService
class KickoffValueInfoSerializer(serializers.ModelSerializer):
output = TaskFieldSerializer(many=True)
+ fieldsets = FieldSetSerializer(many=True)
class Meta:
model = KickoffValue
fields = (
'id',
'output',
+ 'fieldsets',
)
@@ -69,20 +82,44 @@ def create(self, validated_data: Dict[str, Any]):
account_id=validated_data['account_id'],
clear_description=clear_description,
)
- for field_template in kickoff.fields.all():
- service = TaskFieldService(user=self.context['user'])
- try:
+ workflow = validated_data['workflow']
+ fieldset_templates = (
+ FieldsetTemplate.objects
+ .filter(kickoff=kickoff)
+ .prefetch_related('rules', 'fields')
+ .order_by('order')
+ )
+ try:
+ for fieldset_template in fieldset_templates:
+ service = FieldSetService(user=self.context['user'])
+ service.create(
+ instance_template=fieldset_template,
+ account_id=workflow.account_id,
+ workflow=workflow,
+ kickoff=instance,
+ fields_data=fields_data,
+ )
+ try:
+ service.validate_rules()
+ except FieldsetServiceException as ex:
+ self.raise_validation_error(message=ex.message)
+ for field_template in kickoff.fields.filter(fieldset__isnull=True):
+ service = TaskFieldService(user=self.context['user'])
service.create(
instance_template=field_template,
- workflow_id=validated_data['workflow'].id,
+ workflow_id=workflow.id,
kickoff_id=instance.id,
value=fields_data.get(field_template.api_name),
)
- except TaskFieldException as ex:
- self.raise_validation_error(
- message=ex.message,
- api_name=field_template.api_name,
- )
+ except TaskFieldException as ex:
+ self.raise_validation_error(
+ message=ex.message,
+ api_name=ex.api_name,
+ )
+ except FieldsetServiceException as ex:
+ self.raise_validation_error(
+ message=ex.message,
+ )
return instance
def update(
@@ -90,24 +127,43 @@ def update(
instance: KickoffValue,
validated_data: Dict[str, Any],
):
- fields_data: dict = validated_data.pop('fields_data', {})
+ fields_values: dict = validated_data.pop('fields_data', {})
instance = super().update(instance, validated_data)
- if fields_data:
- for task_field in self.instance.output.filter(
- api_name__in=fields_data.keys(),
- ):
+ if fields_values:
+ fields = (
+ TaskField.objects
+ .filter(
+ Q(fieldset__kickoff=instance) | Q(kickoff=instance),
+ api_name__in=fields_values,
+ )
+ )
+ fieldsets_ids = set()
+ for field in fields:
+ if field.fieldset_id:
+ fieldsets_ids.add(field.fieldset_id)
service = TaskFieldService(
user=self.context['user'],
- instance=task_field,
+ instance=field,
)
try:
service.partial_update(
- value=fields_data[task_field.api_name],
+ value=fields_values[field.api_name],
force_save=True,
)
except TaskFieldException as ex:
self.raise_validation_error(
message=ex.message,
- api_name=task_field.api_name,
+ api_name=field.api_name,
)
+ if fieldsets_ids:
+ fieldsets = FieldSet.objects.filter(id__in=fieldsets_ids)
+ try:
+ for fieldset in fieldsets:
+ service = FieldSetService(
+ user=self.context['user'],
+ instance=fieldset,
+ )
+ service.validate_rules()
+ except FieldsetServiceException as ex:
+ self.raise_validation_error(message=ex.message)
return instance
diff --git a/backend/src/processes/serializers/workflows/task.py b/backend/src/processes/serializers/workflows/task.py
index 7aa6333d4..1e5299aad 100644
--- a/backend/src/processes/serializers/workflows/task.py
+++ b/backend/src/processes/serializers/workflows/task.py
@@ -26,6 +26,9 @@
from src.processes.serializers.workflows.field import (
TaskFieldSerializer,
)
+from src.processes.serializers.workflows.fieldset import (
+ FieldSetSerializer,
+)
from src.processes.serializers.workflows.task_performer import (
get_performers_for_task,
)
@@ -113,6 +116,7 @@ class Meta:
'status',
'revert_tasks',
'is_read_only_viewer',
+ 'fieldsets',
)
date_started_tsp = TimeStampField(source='date_started')
@@ -131,6 +135,7 @@ class Meta:
sub_workflows = serializers.SerializerMethodField()
revert_tasks = TaskShortSerializer(many=True, source='get_revert_tasks')
is_read_only_viewer = serializers.SerializerMethodField()
+ fieldsets = FieldSetSerializer(many=True)
def get_performers(self, instance) -> List[Dict[str, Any]]:
return get_performers_for_task(instance)
diff --git a/backend/src/processes/serializers/workflows/workflow.py b/backend/src/processes/serializers/workflows/workflow.py
index 218d96724..5a4ba5948 100644
--- a/backend/src/processes/serializers/workflows/workflow.py
+++ b/backend/src/processes/serializers/workflows/workflow.py
@@ -399,23 +399,32 @@ class Meta:
is_read_only_viewer = serializers.SerializerMethodField()
def get_kickoff(self, instance: Workflow):
+ field_prefetches = [
+ Prefetch(
+ lookup='selections',
+ queryset=FieldSelection.objects.order_by('id'),
+ to_attr='selections_values',
+ ),
+ Prefetch(
+ 'dataset__items',
+ queryset=DatasetItem.objects.order_by('order'),
+ to_attr='dataset_values',
+ ),
+ ]
kickoff = (
KickoffValue.objects
.filter(workflow=self.instance)
.prefetch_related(
Prefetch(
lookup='output',
- queryset=TaskField.objects.all().prefetch_related(
- Prefetch(
- lookup='selections',
- queryset=FieldSelection.objects.order_by('id'),
- to_attr='selections_values',
- ),
- Prefetch(
- 'dataset__items',
- queryset=DatasetItem.objects.order_by('order'),
- to_attr='dataset_values',
- ),
+ queryset=TaskField.objects.filter(
+ fieldset__isnull=True,
+ ).prefetch_related(*field_prefetches),
+ ),
+ Prefetch(
+ lookup='fieldsets__fields',
+ queryset=TaskField.objects.prefetch_related(
+ *field_prefetches,
),
),
).first()
diff --git a/backend/src/processes/services/events.py b/backend/src/processes/services/events.py
index 1eabf8cb0..013d845d1 100644
--- a/backend/src/processes/services/events.py
+++ b/backend/src/processes/services/events.py
@@ -604,9 +604,6 @@ def task_delegation_event(
class CommentService(BaseModelService):
- def _create_related(self, **kwargs):
- pass
-
def _create_instance(self, **kwargs):
pass
diff --git a/backend/src/processes/services/exceptions.py b/backend/src/processes/services/exceptions.py
index e8d0000a8..b495b9980 100644
--- a/backend/src/processes/services/exceptions.py
+++ b/backend/src/processes/services/exceptions.py
@@ -1,4 +1,5 @@
from src.generics.exceptions import BaseServiceException
+from src.processes.messages import fieldset as fs_messages
from src.processes.messages import template as pt_messages
from src.processes.messages import workflow as pw_messages
@@ -196,3 +197,72 @@ class TemplatePresetServiceException(BaseServiceException):
class CommentedNotTask(CommentServiceException):
default_message = pw_messages.MSG_PW_0077
+
+
+class FieldsetTemplateRuleServiceException(BaseServiceException):
+
+ pass
+
+
+class FieldsetTemplateRuleSumMaxFieldsNotNumber(
+ FieldsetTemplateRuleServiceException,
+):
+
+ default_message = fs_messages.MSG_FS_0003
+
+
+class FieldsetTemplateRuleSumMaxInvalidValue(
+ FieldsetTemplateRuleServiceException,
+):
+
+ default_message = fs_messages.MSG_FS_0004
+
+
+class FieldsetTemplateServiceException(BaseServiceException):
+
+ pass
+
+
+class FieldsetTemplateSharedIdMissing(FieldsetTemplateServiceException):
+
+ default_message = fs_messages.MSG_FS_0010
+
+
+class FieldsetTemplateTemplateIdMissing(FieldsetTemplateServiceException):
+
+ default_message = fs_messages.MSG_FS_0011
+
+
+class FieldsetTemplateInUseException(FieldsetTemplateServiceException):
+
+ default_message = fs_messages.MSG_FS_0001
+
+
+class FieldsetTemplateInUseException2(FieldsetTemplateServiceException):
+
+ default_message = fs_messages.MSG_FS_0009
+
+
+class FieldTemplateServiceException(BaseServiceException):
+
+ pass
+
+
+class FieldTemplateSelectionsRequired(FieldTemplateServiceException):
+
+ default_message = pt_messages.MSG_PT_0005
+
+
+class FieldTemplateUserMustBeRequired(FieldTemplateServiceException):
+
+ default_message = pt_messages.MSG_PT_0006
+
+
+class FieldsetServiceException(BaseServiceException):
+
+ pass
+
+
+class SharedFieldsetNotFoundException(FieldsetServiceException):
+
+ default_message = fs_messages.MSG_FS_0008
diff --git a/backend/src/processes/services/fieldsets/__init__.py b/backend/src/processes/services/fieldsets/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/src/processes/services/fieldsets/fieldset.py b/backend/src/processes/services/fieldsets/fieldset.py
new file mode 100644
index 000000000..ec0306e05
--- /dev/null
+++ b/backend/src/processes/services/fieldsets/fieldset.py
@@ -0,0 +1,361 @@
+# ruff: noqa: PLC0415
+from copy import deepcopy
+from typing import Dict, List, Optional
+from django.contrib.auth import get_user_model
+from django.db import transaction
+
+from src.generics.base.service import BaseModelService
+from src.processes.enums import LabelPosition, FieldSetLayout
+from src.processes.models.templates.fields import FieldTemplate
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+ FieldsetTemplateRule,
+)
+from src.processes.services.exceptions import (
+ FieldsetTemplateInUseException,
+ FieldsetTemplateInUseException2,
+ FieldsetTemplateSharedIdMissing,
+ FieldsetTemplateTemplateIdMissing,
+)
+from src.processes.services.templates.field_template import (
+ FieldTemplateService,
+)
+from src.processes.services.fieldsets.fieldset_rule import (
+ FieldsetTemplateRuleService,
+)
+from src.processes.utils.common import create_api_name
+
+
+UserModel = get_user_model()
+
+
+class FieldSetTemplateService(BaseModelService):
+
+ def _create_instance(
+ self,
+ name: str,
+ is_shared: bool,
+ template_id: Optional[int] = None,
+ order: int = 0,
+ title: str = '',
+ description: str = '',
+ kickoff_id: Optional[int] = None,
+ task_id: Optional[int] = None,
+ shared_fieldset_id: Optional[int] = None,
+ api_name: Optional[str] = None,
+ label_position: LabelPosition.LITERALS = LabelPosition.TOP,
+ layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL,
+ **kwargs,
+ ):
+
+ if not is_shared:
+ if not template_id:
+ raise FieldsetTemplateTemplateIdMissing
+ if not shared_fieldset_id:
+ raise FieldsetTemplateSharedIdMissing
+
+ create_kwargs = {
+ 'template_id': template_id,
+ 'account': self.account,
+ 'order': order,
+ 'name': name,
+ 'title': title,
+ 'description': description,
+ 'label_position': label_position,
+ 'layout': layout,
+ 'kickoff_id': kickoff_id,
+ 'task_id': task_id,
+ 'shared_fieldset_id': shared_fieldset_id,
+ 'is_shared': is_shared,
+ }
+ if api_name:
+ create_kwargs['api_name'] = api_name
+ self.instance = FieldsetTemplate.objects.create(**create_kwargs)
+ return self.instance
+
+ def create_shared_fieldset(
+ self,
+ name: str,
+ title: str = '',
+ description: str = '',
+ api_name: Optional[str] = None,
+ label_position: LabelPosition.LITERALS = LabelPosition.TOP,
+ layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL,
+ **kwargs,
+ ):
+
+ """ Creates a shared FieldSetTemplate
+ that is not linked to a template. """
+
+ return super().create(
+ name=name,
+ title=title,
+ description=description,
+ api_name=api_name,
+ label_position=label_position,
+ layout=layout,
+ is_shared=True,
+ **kwargs,
+ )
+
+ def create_from_shared(
+ self,
+ shared_fieldset_data: dict,
+ shared_fieldset_id: int,
+ template_id: int,
+ order: int = 0,
+ kickoff_id: Optional[int] = None,
+ task_id: Optional[int] = None,
+ api_name: Optional[str] = None,
+ title: Optional[str] = None,
+ description: Optional[str] = None,
+ ) -> FieldsetTemplate:
+
+ fieldset_data = self.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ api_name=api_name,
+ title=title,
+ description=description,
+ )
+
+ return self.create(
+ **fieldset_data,
+ is_shared=False,
+ shared_fieldset_id=shared_fieldset_id,
+ order=order,
+ kickoff_id=kickoff_id,
+ task_id=task_id,
+ template_id=template_id,
+ )
+
+ def _create_related(
+ self,
+ rules: Optional[List[Dict]] = None,
+ fields: Optional[List[Dict]] = None,
+ **kwargs,
+ ):
+ if fields:
+ self._create_fields(fields_data=fields)
+ if rules:
+ self.create_rules(rules_data=rules)
+
+ def _create_fields(
+ self,
+ fields_data: List[Dict],
+ ):
+ for field_data in fields_data:
+ service = FieldTemplateService(
+ user=self.user,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ account=self.account,
+ )
+ service.create(
+ fieldset_id=self.instance.id,
+ template_id=self.instance.template_id,
+ **field_data,
+ )
+
+ def _update_fields(
+ self,
+ fields_data: List[Dict],
+ ):
+ """ All fieldset fields will be updated """
+
+ existing_fields = {
+ field.api_name: field
+ for field in self.instance.fields.all()
+ }
+ fields_api_names = set()
+ for field_data in fields_data:
+ field_api_name = field_data.pop('api_name', None)
+ if field_api_name and field_api_name in existing_fields:
+ service = FieldTemplateService(
+ user=self.user,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ instance=existing_fields[field_api_name],
+ )
+ service.partial_update(force_save=True, **field_data)
+ fields_api_names.add(field_api_name)
+ else:
+ service = FieldTemplateService(
+ user=self.user,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ )
+ field = service.create(
+ fieldset_id=self.instance.id,
+ template_id=self.instance.template_id,
+ **field_data,
+ )
+ fields_api_names.add(field.api_name)
+
+ self.instance.fields.exclude(api_name__in=fields_api_names).delete()
+
+ def _validate_rules(self):
+ for rule in self.instance.rules.all():
+ service = FieldsetTemplateRuleService(
+ user=self.user,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ instance=rule,
+ )
+ service._validate()
+
+ def partial_update(
+ self,
+ **update_kwargs,
+ ) -> FieldsetTemplate:
+
+ if self.instance.is_shared and self.instance.child_fieldsets.exists():
+ raise FieldsetTemplateInUseException2
+
+ rules_data = update_kwargs.pop('rules', None)
+ fields_data = update_kwargs.pop('fields', None)
+ with transaction.atomic():
+ if update_kwargs:
+ self.instance = super().partial_update(
+ force_save=True,
+ **update_kwargs,
+ )
+
+ if fields_data is not None:
+ self._update_fields(fields_data=fields_data)
+ if rules_data is not None:
+ self.update_rules(rules_data=rules_data)
+ self._validate_rules()
+ return self.instance
+
+ def partial_update_instance(
+ self,
+ **update_kwargs,
+ ) -> FieldsetTemplate:
+
+ with transaction.atomic():
+ if update_kwargs:
+ self.instance = super().partial_update(
+ force_save=True,
+ **update_kwargs,
+ )
+ return self.instance
+
+ def delete(self) -> None:
+ if self.instance.is_shared and self.instance.child_fieldsets.exists():
+ raise FieldsetTemplateInUseException
+ if self.instance.kickoff_id or self.instance.task_id:
+ raise FieldsetTemplateInUseException
+ self.instance.delete()
+
+ @staticmethod
+ def _replace_api_names(shared_fieldset_data: dict) -> dict:
+
+ fieldset_data = deepcopy(shared_fieldset_data)
+ fieldset_data['api_name'] = create_api_name(
+ FieldsetTemplate.api_name_prefix,
+ )
+ fields_map: Dict[str, str] = {}
+ updated_fields_data = []
+ for field_data in fieldset_data.get('fields', []):
+ new_api_name = create_api_name(
+ FieldTemplate.api_name_prefix,
+ )
+ fields_map[field_data['api_name']] = new_api_name
+ field_data['api_name'] = new_api_name
+ updated_fields_data.append(field_data)
+ fieldset_data['fields'] = updated_fields_data
+
+ updated_rules_data = []
+ for rule_data in fieldset_data.get('rules', []):
+ rule_data['api_name'] = create_api_name(
+ FieldsetTemplateRule.api_name_prefix,
+ )
+ rule_data['fields'] = [
+ fields_map[old_api_name]
+ for old_api_name in rule_data.get('fields', [])
+ ]
+ updated_rules_data.append(rule_data)
+ fieldset_data['rules'] = updated_rules_data
+ return fieldset_data
+
+ def get_new_fieldset_data(
+ self,
+ shared_fieldset_data: dict,
+ api_name: Optional[str] = None,
+ title: Optional[str] = None,
+ description: Optional[str] = None,
+ ) -> dict:
+
+ fieldset_data = self._replace_api_names(shared_fieldset_data)
+ if api_name:
+ fieldset_data['api_name'] = api_name
+ if title:
+ fieldset_data['title'] = title
+ if description:
+ fieldset_data['description'] = description
+ fieldset_data.pop('order', None)
+ return fieldset_data
+
+ def create_rules(
+ self,
+ rules_data: List[Dict],
+ ):
+ service = FieldsetTemplateRuleService(
+ user=self.user,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ )
+ for rule_data in rules_data:
+ service.create(
+ fieldset_id=self.instance.id,
+ **rule_data,
+ )
+
+ def update_rules(
+ self,
+ rules_data: List[Dict],
+ ):
+ """ All dataset items will be updated """
+
+ existing_rules = {
+ rule.api_name: rule for rule in self.instance.rules.all()
+ }
+ rule_api_names = set()
+ for rule_data in rules_data:
+ rule_api_name = rule_data.pop('api_name', None)
+ if rule_api_name and rule_api_name in existing_rules:
+ service = FieldsetTemplateRuleService(
+ user=self.user,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ instance=existing_rules[rule_api_name],
+ )
+ service.partial_update(**rule_data)
+ rule_api_names.add(rule_api_name)
+ else:
+ service = FieldsetTemplateRuleService(
+ user=self.user,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ )
+ rule = service.create(
+ fieldset_id=self.instance.id,
+ **rule_data,
+ )
+ rule_api_names.add(rule.api_name)
+
+ self.instance.rules.exclude(api_name__in=rule_api_names).delete()
+
+ @staticmethod
+ def to_json(fieldset: FieldsetTemplate) -> dict:
+ if fieldset.is_shared:
+ from src.processes.serializers.templates.fieldset import (
+ SharedFieldsetTemplateSerializer,
+ )
+ slz_cls = SharedFieldsetTemplateSerializer
+ else:
+ from src.processes.serializers.templates.fieldset import (
+ FieldsetTemplateSerializer,
+ )
+ slz_cls = FieldsetTemplateSerializer
+ return dict(slz_cls(fieldset).data)
diff --git a/backend/src/processes/services/fieldsets/fieldset_rule.py b/backend/src/processes/services/fieldsets/fieldset_rule.py
new file mode 100644
index 000000000..3c5975af0
--- /dev/null
+++ b/backend/src/processes/services/fieldsets/fieldset_rule.py
@@ -0,0 +1,123 @@
+from typing import List, Optional
+from django.contrib.auth import get_user_model
+from django.db import transaction
+from src.generics.base.service import BaseModelService
+from src.processes.enums import FieldSetRuleType, FieldType
+from src.processes.models.templates.fields import FieldTemplate
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplateRule,
+)
+from src.processes.services.exceptions import (
+ FieldsetTemplateRuleSumMaxFieldsNotNumber,
+ FieldsetTemplateRuleSumMaxInvalidValue,
+ FieldsetTemplateRuleServiceException,
+)
+from src.processes.messages.fieldset import MSG_FS_0005
+
+
+UserModel = get_user_model()
+
+
+class FieldsetTemplateRuleService(BaseModelService):
+
+ def _validate_sum_equal(self, **kwargs) -> float:
+
+ value = self.instance.value
+ if not value:
+ raise FieldsetTemplateRuleSumMaxInvalidValue
+ try:
+ result = float(value)
+ except (ValueError, TypeError) as ex:
+ raise FieldsetTemplateRuleSumMaxInvalidValue from ex
+
+ if self.instance.fields.exclude(type=FieldType.NUMBER).exists():
+ raise FieldsetTemplateRuleSumMaxFieldsNotNumber
+ return result
+
+ def _validate(self, **kwargs):
+
+ """ Call after objects save """
+
+ validator = getattr(self, f'_validate_{self.instance.type}', None)
+ validator(**kwargs)
+
+ def _create_instance(
+ self,
+ type: FieldSetRuleType.LITERALS, # noqa: A002
+ value: Optional[str] = None,
+ fieldset_id: Optional[int] = None,
+ api_name: Optional[str] = None,
+ **kwargs,
+ ):
+ create_kwargs = {
+ 'account': self.account,
+ 'type': type,
+ 'value': value,
+ 'fieldset_id': fieldset_id,
+ }
+ if api_name:
+ create_kwargs['api_name'] = api_name
+ self.instance = FieldsetTemplateRule.objects.create(**create_kwargs)
+ return self.instance
+
+ def _create_related(
+ self,
+ **kwargs,
+ ):
+ fields = kwargs.pop('fields', None)
+ if fields is not None:
+ self._set_fields(fields)
+
+ def _get_valid_fields(
+ self,
+ fields_api_names: List[str],
+ **kwargs,
+ ) -> List[FieldTemplate]:
+
+ rule_type = kwargs.get('type') or self.instance.type
+ available_fields = list(
+ FieldTemplate.objects
+ .filter(
+ fieldset_id=self.instance.fieldset_id,
+ api_name__in=fields_api_names,
+ ),
+ )
+ fields_api_names = set(fields_api_names)
+ available_api_names = {field.api_name for field in available_fields}
+ failed_api_names = fields_api_names - available_api_names
+ if failed_api_names:
+ raise FieldsetTemplateRuleServiceException(
+ message=MSG_FS_0005(
+ rule=rule_type,
+ field=failed_api_names.pop(),
+ ),
+ )
+ return available_fields
+
+ def _set_fields(self, fields_api_names: List[str], **kwargs):
+ if fields_api_names:
+ fields = self._get_valid_fields(fields_api_names, **kwargs)
+ self.instance.fields.set(fields)
+ else:
+ self.instance.fields.clear()
+
+ def create(
+ self,
+ **kwargs,
+ ) -> FieldsetTemplateRule:
+
+ with transaction.atomic():
+ self._create_instance(**kwargs)
+ self._create_related(**kwargs)
+ self._create_actions(**kwargs)
+ self._validate(**kwargs)
+ return self.instance
+
+ def partial_update(self, **update_kwargs) -> FieldsetTemplateRule:
+ fields = update_kwargs.pop('fields', None)
+ with transaction.atomic():
+ result = super().partial_update(**update_kwargs, force_save=True)
+ if fields is not None:
+ self._set_fields(fields)
+ self._validate(**update_kwargs)
+ return result
diff --git a/backend/src/processes/services/tasks/field.py b/backend/src/processes/services/tasks/field.py
index 0f787ecac..2d5a95221 100644
--- a/backend/src/processes/services/tasks/field.py
+++ b/backend/src/processes/services/tasks/field.py
@@ -15,6 +15,7 @@
FieldTemplate,
)
from src.processes.models.workflows.attachment import FileAttachment
+from src.processes.models.workflows.fieldset import FieldSetRule
from src.processes.models.workflows.fields import TaskField
from src.processes.services.base import BaseWorkflowService
from src.processes.services.tasks.exceptions import TaskFieldException
@@ -287,6 +288,7 @@ def _create_instance(
self.instance = TaskField(
kickoff_id=kwargs.get('kickoff_id'),
task_id=kwargs.get('task_id'),
+ fieldset_id=kwargs.get('fieldset_id'),
type=instance_template.type,
is_required=instance_template.is_required,
is_hidden=instance_template.is_hidden,
@@ -320,6 +322,8 @@ def _create_related(
self._link_new_attachments(raw_value)
elif self.instance.type in FieldType.TYPES_WITH_SELECTIONS:
self._create_selections(instance_template)
+ if instance_template.rules.all().exists():
+ self._link_rules(instance_template, **kwargs)
def _link_new_attachments(
self,
@@ -354,6 +358,22 @@ def _create_selections(
field_id=self.instance.id,
)
+ def _link_rules(
+ self,
+ instance_template: FieldTemplate,
+ **kwargs,
+ ):
+
+ rule_api_names = set(
+ instance_template.rules.values_list('api_name', flat=True),
+ )
+ rules = FieldSetRule.objects.filter(
+ account=self.account,
+ fieldset_id=kwargs['fieldset_id'],
+ api_name__in=rule_api_names,
+ )
+ self.instance.rules.set(rules)
+
def _remove_unused_attachments(
self,
value: Optional[str],
diff --git a/backend/src/processes/services/tasks/task.py b/backend/src/processes/services/tasks/task.py
index 74f09b732..a68c8260f 100644
--- a/backend/src/processes/services/tasks/task.py
+++ b/backend/src/processes/services/tasks/task.py
@@ -13,6 +13,7 @@
from src.processes.models.templates.checklist import (
ChecklistTemplateSelection,
)
+from src.processes.models.templates.fieldset import FieldsetTemplate
from src.processes.models.templates.task import TaskTemplate
from src.processes.models.workflows.conditions import (
Condition,
@@ -42,6 +43,9 @@
from src.processes.services.tasks.mixins import (
ConditionMixin,
)
+from src.processes.services.workflows.fieldsets.fieldset import (
+ FieldSetService,
+)
from src.processes.utils.common import (
insert_fields_values_to_text,
)
@@ -101,6 +105,7 @@ def _create_related(
redefined_performer=kwargs.get('redefined_performer'),
)
self.create_fields_from_template(instance_template)
+ self.create_fieldsets_from_template(instance_template)
self.create_conditions_from_template(instance_template)
self.create_checklists_from_template(instance_template)
self.create_raw_due_date_from_template(instance_template)
@@ -200,8 +205,14 @@ def create_conditions_from_template(
self.create_rules(conditions, conditions_tree)
def create_fields_from_template(self, instance_template: TaskTemplate):
-
- for field_template in instance_template.fields.all():
+ active_fieldset_ids = (
+ FieldsetTemplate.objects
+ .filter(task=instance_template)
+ .values_list('id', flat=True)
+ )
+ for field_template in instance_template.fields.exclude(
+ fieldset__in=active_fieldset_ids,
+ ):
service = TaskFieldService(user=self.user)
service.create(
instance_template=field_template,
@@ -210,6 +221,27 @@ def create_fields_from_template(self, instance_template: TaskTemplate):
skip_value=True,
)
+ def create_fieldsets_from_template(
+ self,
+ instance_template: TaskTemplate,
+ ):
+ fieldsets = (
+ FieldsetTemplate.objects
+ .filter(task=instance_template)
+ .prefetch_related('rules', 'fields')
+ .order_by('order')
+ )
+ for fieldset in fieldsets:
+ service = FieldSetService(user=self.user)
+ service.create(
+ instance_template=fieldset,
+ account_id=self.instance.workflow.account_id,
+ workflow=self.instance.workflow,
+ task=self.instance,
+ order=fieldset.order,
+ skip_value=True,
+ )
+
def create_checklists_from_template(self, instance_template: TaskTemplate):
for checklist_template in instance_template.checklists.all():
checklist_service = ChecklistService(
diff --git a/backend/src/processes/services/tasks/task_version.py b/backend/src/processes/services/tasks/task_version.py
index 81b20b850..038f1fe32 100644
--- a/backend/src/processes/services/tasks/task_version.py
+++ b/backend/src/processes/services/tasks/task_version.py
@@ -17,6 +17,10 @@
Predicate,
Rule,
)
+from src.processes.models.workflows.fieldset import (
+ FieldSet,
+ FieldSetRule,
+)
from src.processes.models.workflows.fields import (
FieldSelection,
TaskField,
@@ -54,6 +58,27 @@ class TaskUpdateVersionService(
# TODO Very bad code. Needs to be refactored
+ def _update_field_selections(
+ self,
+ field: TaskField,
+ field_data: Dict,
+ ) -> None:
+
+ if field_data.get('selections'):
+ selection_ids = set()
+ for selection_data in field_data['selections']:
+ selection, __ = (
+ FieldSelection.objects.update_or_create(
+ field=field,
+ api_name=selection_data['api_name'],
+ defaults={
+ 'value': selection_data['value'],
+ },
+ )
+ )
+ selection_ids.add(selection.id)
+ field.selections.exclude(id__in=selection_ids).delete()
+
def _update_fields(
self,
data: Optional[List[Dict]] = None,
@@ -64,22 +89,9 @@ def _update_fields(
field_ids = []
if data:
for field_data in data:
- field, _ = self._update_field(field_data)
+ field, _ = self._update_field(field_data, fieldset=None)
field_ids.append(field.id)
- if field_data.get('selections'):
- selection_ids = set()
- for selection_data in field_data['selections']:
- selection, __ = (
- FieldSelection.objects.update_or_create(
- field=field,
- api_name=selection_data['api_name'],
- defaults={
- 'value': selection_data['value'],
- },
- )
- )
- selection_ids.add(selection.id)
- field.selections.exclude(id__in=selection_ids).delete()
+ self._update_field_selections(field, field_data)
self.instance.output.exclude(id__in=field_ids).delete()
def _update_delay(self, new_duration: Optional[str] = None):
@@ -153,26 +165,115 @@ def _update_conditions(
conditions = Condition.objects.bulk_create(conditions)
self.create_rules(conditions, conditions_tree)
- def _update_field(self, template: Dict):
+ def _update_field(
+ self,
+ field_data: Dict,
+ fieldset: Optional[FieldSet] = None,
+ ):
# TODO Move to TaskFieldService
return TaskField.objects.update_or_create(
task=self.instance,
- api_name=template['api_name'],
+ api_name=field_data['api_name'],
+ fieldset=fieldset,
defaults={
- 'name': template['name'],
- 'description': template['description'],
- 'type': template['type'],
- 'is_required': template['is_required'],
- 'is_hidden': template['is_hidden'],
- 'order': template['order'],
+ 'name': field_data['name'],
+ 'description': field_data['description'],
+ 'type': field_data['type'],
+ 'is_required': field_data['is_required'],
+ 'is_hidden': field_data['is_hidden'],
+ 'order': field_data['order'],
'workflow': self.instance.workflow,
'account': self.instance.account,
- 'dataset_id': template['dataset_id'],
+ 'dataset_id': field_data['dataset_id'],
},
)
+ def _update_fieldset_rules(
+ self,
+ fieldset: FieldSet,
+ rules_data: Optional[List[Dict]],
+ ) -> None:
+
+ rule_ids = []
+ rules_data = rules_data or []
+ for rule_data in rules_data:
+ rule, _ = FieldSetRule.objects.update_or_create(
+ fieldset=fieldset,
+ api_name=rule_data['api_name'],
+ defaults={
+ 'account_id': fieldset.account_id,
+ 'type': rule_data['type'],
+ 'value': rule_data.get('value'),
+ },
+ )
+ rule_ids.append(rule.id)
+ fieldset.rules.exclude(id__in=rule_ids).delete()
+
+ def _update_field_rules(
+ self,
+ field: TaskField,
+ field_data: Dict,
+ fieldset: FieldSet,
+ ) -> None:
+
+ rules = field_data.get('rules', [])
+ if rules:
+ rules_api_names = [e['api_name'] for e in rules]
+ rules = FieldSetRule.objects.filter(
+ fieldset=fieldset,
+ api_name__in=rules_api_names,
+ )
+ field.rules.set(rules)
+ else:
+ field.rules.clear()
+
+ def _update_fieldset_fields(
+ self,
+ fieldset: FieldSet,
+ fields_data: Optional[List[Dict]],
+ ) -> None:
+
+ field_ids = []
+ fields_data = fields_data or []
+ for field_data in fields_data:
+ field, _ = self._update_field(field_data, fieldset=fieldset)
+ field_ids.append(field.id)
+ self._update_field_selections(field, field_data)
+ self._update_field_rules(field, field_data, fieldset)
+ fieldset.fields.exclude(id__in=field_ids).delete()
+
+ def _update_fieldsets(self, data: Optional[List]) -> None:
+
+ fieldset_api_names = set()
+ for fieldset_data in data or []:
+ fieldset, _ = FieldSet.objects.update_or_create(
+ workflow=self.instance.workflow,
+ task=self.instance,
+ api_name=fieldset_data['api_name'],
+ defaults={
+ 'account_id': self.instance.account_id,
+ 'name': fieldset_data['name'],
+ 'description': fieldset_data['description'],
+ 'order': fieldset_data['order'],
+ 'label_position': fieldset_data['label_position'],
+ 'layout': fieldset_data['layout'],
+ },
+ )
+ self._update_fieldset_rules(
+ fieldset=fieldset,
+ rules_data=fieldset_data.get('rules'),
+ )
+ self._update_fieldset_fields(
+ fieldset=fieldset,
+ fields_data=fieldset_data.get('fields'),
+ )
+ fieldset_api_names.add(fieldset.api_name)
+ FieldSet.objects.filter(
+ task=self.instance,
+ ).exclude(api_name__in=fieldset_api_names).delete()
+
def _update_checklists(
self,
version: int,
@@ -406,6 +507,7 @@ def update_from_version(
'is_urgent': bool,
'require_completion_by_all': bool,
'fields': list,
+ 'fieldsets': list,
'checklists': list,
'conditions': list,
'raw_due_date: dict,
@@ -424,6 +526,7 @@ def update_from_version(
fields_values=tasks_fields_values,
)
self._update_fields(data=data.get('fields'))
+ self._update_fieldsets(data=data.get('fieldsets'))
self._update_conditions(data=data.get('conditions'))
self._update_checklists(
data=data.get('checklists'),
diff --git a/backend/src/processes/services/templates/field_template.py b/backend/src/processes/services/templates/field_template.py
new file mode 100644
index 000000000..133c9bef7
--- /dev/null
+++ b/backend/src/processes/services/templates/field_template.py
@@ -0,0 +1,103 @@
+from typing import Optional
+
+from django.db.models import Model
+
+from src.generics.base.service import BaseModelService
+from src.processes.enums import FieldType
+from src.processes.models.templates.fields import FieldTemplate
+from src.processes.services.exceptions import (
+ FieldTemplateSelectionsRequired,
+ FieldTemplateUserMustBeRequired,
+)
+from src.processes.services.templates.field_template_selection import (
+ FieldTemplateSelectionService,
+)
+
+
+class FieldTemplateService(BaseModelService):
+
+ def _validate(self, **kwargs):
+ field_type = kwargs.get('type')
+
+ if (
+ field_type in FieldType.TYPES_WITH_SELECTIONS
+ and not (kwargs.get('selections') or kwargs.get('dataset'))
+ ):
+ raise FieldTemplateSelectionsRequired
+
+ if field_type == FieldType.USER and kwargs.get('is_required') is False:
+ raise FieldTemplateUserMustBeRequired
+
+ def create(self, **kwargs) -> Model:
+ self._validate(**kwargs)
+ return super().create(**kwargs)
+
+ def partial_update(self, **update_kwargs) -> Model:
+ self._validate(**update_kwargs)
+ selections_data = update_kwargs.pop('selections', None)
+ result = super().partial_update(**update_kwargs)
+ if selections_data is not None:
+ self.instance.selections.all().delete()
+ self.create_selections(selections_data=selections_data)
+ return result
+
+ def _create_instance(
+ self,
+ name: str,
+ type: str, # noqa: A002
+ order: int = 0,
+ description: str = '',
+ is_required: bool = False,
+ is_hidden: bool = False,
+ default: str = '',
+ template_id: Optional[int] = None,
+ kickoff_id: Optional[int] = None,
+ task_id: Optional[int] = None,
+ fieldset_id: Optional[int] = None,
+ dataset=None,
+ dataset_id: Optional[int] = None,
+ api_name: Optional[str] = None,
+ **kwargs,
+ ):
+ if dataset is not None and dataset_id is None:
+ dataset_id = dataset.pk if hasattr(dataset, 'pk') else dataset
+ params = {
+ 'account': self.account,
+ 'name': name,
+ 'type': type,
+ 'order': order,
+ 'description': description,
+ 'is_required': is_required,
+ 'is_hidden': is_hidden,
+ 'default': default,
+ 'template_id': template_id,
+ 'kickoff_id': kickoff_id,
+ 'task_id': task_id,
+ 'fieldset_id': fieldset_id,
+ 'dataset_id': dataset_id,
+ }
+ if api_name:
+ params['api_name'] = api_name
+ self.instance = FieldTemplate.objects.create(**params)
+ return self.instance
+
+ def _create_related(
+ self,
+ selections: Optional[list] = None,
+ **kwargs,
+ ):
+ if selections:
+ self.create_selections(selections_data=selections)
+
+ def create_selections(self, selections_data: list):
+ service = FieldTemplateSelectionService(
+ user=self.user,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ )
+ for selection_data in selections_data:
+ service.create(
+ field_template_id=self.instance.id,
+ template_id=self.instance.template_id,
+ **selection_data,
+ )
diff --git a/backend/src/processes/services/templates/field_template_selection.py b/backend/src/processes/services/templates/field_template_selection.py
new file mode 100644
index 000000000..f2f12866b
--- /dev/null
+++ b/backend/src/processes/services/templates/field_template_selection.py
@@ -0,0 +1,25 @@
+from typing import Optional
+
+from src.generics.base.service import BaseModelService
+from src.processes.models.templates.fields import FieldTemplateSelection
+
+
+class FieldTemplateSelectionService(BaseModelService):
+
+ def _create_instance(
+ self,
+ value: str,
+ field_template_id: int,
+ template_id: Optional[int] = None,
+ api_name: Optional[str] = None,
+ **kwargs,
+ ):
+ params = {
+ 'value': value,
+ 'field_template_id': field_template_id,
+ 'template_id': template_id,
+ }
+ if api_name:
+ params['api_name'] = api_name
+ self.instance = FieldTemplateSelection.objects.create(**params)
+ return self.instance
diff --git a/backend/src/processes/services/templates/preset.py b/backend/src/processes/services/templates/preset.py
index 2f28a6b18..3c0ecf7eb 100644
--- a/backend/src/processes/services/templates/preset.py
+++ b/backend/src/processes/services/templates/preset.py
@@ -65,9 +65,6 @@ def set_default(self) -> TemplatePreset:
self.partial_update(is_default=True, force_save=True)
return self.instance
- def delete(self) -> None:
- self.instance.delete()
-
def _reset_default_presets(self) -> None:
queryset = (
TemplatePreset.objects
diff --git a/backend/src/processes/services/templates/template.py b/backend/src/processes/services/templates/template.py
index 7c8f0d594..f165ec6d2 100644
--- a/backend/src/processes/services/templates/template.py
+++ b/backend/src/processes/services/templates/template.py
@@ -68,6 +68,7 @@ def fill_template_data(self, initial_data: dict) -> dict:
'kickoff': {
'description': initial_kickoff_data.get('description', ''),
'fields': initial_kickoff_data.get('fields', []),
+ 'fieldsets': initial_kickoff_data.get('fieldsets', []),
},
'tasks': deepcopy(initial_tasks_data),
}
diff --git a/backend/src/processes/services/versioning/schemas.py b/backend/src/processes/services/versioning/schemas.py
index 91440da70..f78e18eb2 100644
--- a/backend/src/processes/services/versioning/schemas.py
+++ b/backend/src/processes/services/versioning/schemas.py
@@ -9,6 +9,10 @@
PredicateTemplate,
RuleTemplate,
)
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+ FieldsetTemplateRule,
+)
from src.processes.models.templates.fields import (
FieldTemplate,
FieldTemplateSelection,
@@ -32,6 +36,17 @@ class Meta:
)
+class FieldsetTemplateRuleSchemaV1(serializers.ModelSerializer):
+
+ class Meta:
+ model = FieldsetTemplateRule
+ fields = (
+ 'api_name',
+ 'type',
+ 'value',
+ )
+
+
class FieldSchemaV1(serializers.ModelSerializer):
class Meta:
@@ -48,6 +63,7 @@ class Meta:
'default',
'selections',
'dataset_id',
+ 'rules',
)
selections = SelectionSchemaV1(
@@ -56,6 +72,35 @@ class Meta:
allow_empty=True,
required=False,
)
+ rules = FieldsetTemplateRuleSchemaV1(
+ many=True,
+ allow_null=True,
+ allow_empty=True,
+ )
+
+
+class FieldSetSchemaV1(serializers.ModelSerializer):
+
+ class Meta:
+ model = FieldsetTemplate
+ fields = (
+ 'name',
+ 'title',
+ 'description',
+ 'order',
+ 'api_name',
+ 'label_position',
+ 'layout',
+ 'fields',
+ 'rules',
+ )
+
+ fields = FieldSchemaV1(many=True, allow_null=True, allow_empty=True)
+ rules = FieldsetTemplateRuleSchemaV1(
+ many=True,
+ allow_null=True,
+ allow_empty=True,
+ )
class KickoffSchemaV1(serializers.ModelSerializer):
@@ -64,9 +109,16 @@ class Meta:
model = Kickoff
fields = (
'fields',
+ 'fieldsets',
)
fields = FieldSchemaV1(many=True, allow_null=True, allow_empty=True)
+ fieldsets = FieldSetSchemaV1(
+ many=True,
+ allow_null=True,
+ allow_empty=True,
+ required=False,
+ )
class TemplateOwnerSchemaV1(serializers.ModelSerializer):
@@ -194,6 +246,7 @@ class Meta:
'require_completion_by_all',
'skip_for_starter',
'fields',
+ 'fieldsets',
'delay',
'conditions',
'raw_performers',
@@ -204,6 +257,12 @@ class Meta:
)
fields = FieldSchemaV1(many=True, allow_null=True, allow_empty=True)
+ fieldsets = FieldSetSchemaV1(
+ many=True,
+ allow_null=True,
+ allow_empty=True,
+ required=False,
+ )
conditions = ConditionSchemaV1(
many=True,
allow_null=True,
diff --git a/backend/src/processes/services/workflow_action.py b/backend/src/processes/services/workflow_action.py
index 4778291c9..0a10ed131 100644
--- a/backend/src/processes/services/workflow_action.py
+++ b/backend/src/processes/services/workflow_action.py
@@ -27,11 +27,13 @@
WorkflowStatus,
)
from src.processes.messages import workflow as messages
+from src.processes.models.workflows.fieldset import FieldSet
from src.processes.models.workflows.task import (
Delay,
Task,
TaskPerformer,
)
+from src.processes.models.workflows.fields import TaskField
from src.processes.models.workflows.workflow import Workflow
from src.processes.services import exceptions
from src.processes.services.condition_check.service import (
@@ -44,6 +46,7 @@
TaskFieldService,
)
from src.processes.services.tasks.task import TaskService
+from src.processes.services.workflows.fieldsets.fieldset import FieldSetService
from src.processes.tasks.webhooks import (
send_task_completed_webhook,
send_task_returned_webhook,
@@ -855,15 +858,38 @@ def complete_task_for_user(
fields_values = fields_values or {}
with transaction.atomic():
- for task_field in task.output.all():
+ fields = (
+ TaskField.objects
+ .filter(
+ Q(fieldset__task=task) | Q(task=task),
+ api_name__in=fields_values,
+ )
+ )
+ fieldsets_ids = set()
+ for field in fields:
+ if field.fieldset_id:
+ fieldsets_ids.add(field.fieldset_id)
service = TaskFieldService(
user=self.user,
- instance=task_field,
+ instance=field,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
)
service.partial_update(
- value=fields_values.get(task_field.api_name),
+ value=fields_values[field.api_name],
force_save=True,
)
+ if fieldsets_ids:
+ fieldsets = FieldSet.objects.filter(id__in=fieldsets_ids)
+ for fieldset in fieldsets:
+ service = FieldSetService(
+ user=self.user,
+ instance=fieldset,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ )
+ service.validate_rules()
+
if task_performer:
if self._task_can_be_completed(task):
self.complete_task(task=task, by_user=True)
diff --git a/backend/src/processes/services/workflows/fieldsets/__init__.py b/backend/src/processes/services/workflows/fieldsets/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset.py b/backend/src/processes/services/workflows/fieldsets/fieldset.py
new file mode 100644
index 000000000..39943f1c5
--- /dev/null
+++ b/backend/src/processes/services/workflows/fieldsets/fieldset.py
@@ -0,0 +1,111 @@
+from itertools import groupby
+from typing import List, Optional, Dict
+from django.contrib.auth import get_user_model
+
+from src.generics.base.service import BaseModelService
+from src.processes.messages.fieldset import MSG_FS_0007, MSG_FS_0012
+from src.processes.models.templates.fieldset import FieldsetTemplate
+from src.processes.models.workflows.fieldset import FieldSet
+from src.processes.services.exceptions import FieldsetServiceException
+from src.processes.services.tasks.field import TaskFieldService
+from src.processes.services.workflows.fieldsets.fieldset_rule import (
+ FieldSetRuleService,
+)
+
+UserModel = get_user_model()
+
+
+class FieldSetService(BaseModelService):
+
+ def _create_instance(
+ self,
+ instance_template: FieldsetTemplate,
+ **kwargs,
+ ):
+ task = kwargs.get('task')
+ kickoff = kwargs.get('kickoff')
+ if not (task or kickoff):
+ raise FieldsetServiceException(
+ message=MSG_FS_0007,
+ )
+
+ self.instance = FieldSet.objects.create(
+ account=self.account,
+ workflow=kwargs['workflow'],
+ kickoff=kickoff,
+ task=task,
+ api_name=instance_template.api_name,
+ name=instance_template.name,
+ title=instance_template.title,
+ description=instance_template.description,
+ order=instance_template.order,
+ label_position=instance_template.label_position,
+ layout=instance_template.layout,
+ )
+
+ def _create_fields(
+ self,
+ instance_template: FieldsetTemplate,
+ fields_data: Optional[List[Dict]] = None,
+ skip_value: bool = False,
+ **kwargs,
+ ):
+ fields_data = fields_data or {}
+ for field_template in instance_template.fields.all():
+ field_service = TaskFieldService(
+ user=self.user,
+ is_superuser=self.is_superuser,
+ auth_type=self.auth_type,
+ )
+ field_service.create(
+ instance_template=field_template,
+ workflow_id=self.instance.workflow_id,
+ fieldset_id=self.instance.id,
+ skip_value=skip_value,
+ value=fields_data.get(field_template.api_name, ''),
+ )
+
+ def _create_rules(self, instance_template, **kwargs):
+ for rule_template in instance_template.rules.filter(is_deleted=False):
+ service = FieldSetRuleService(user=self.user)
+ service.create(
+ instance_template=rule_template,
+ fieldset=self.instance,
+ skip_validation=kwargs.get('skip_value'),
+ )
+
+ def _create_related(self, instance_template, **kwargs):
+ self._create_rules(instance_template, **kwargs)
+ self._create_fields(instance_template, **kwargs)
+
+ def validate_rules(self) -> bool:
+ rules = list(self.instance.rules.order_by('type').all())
+ for _, group in groupby(rules, key=lambda r: r.type):
+ group_rules = list(group)
+ if len(group_rules) == 1:
+ # Single rule of this type — standard validation
+ service = FieldSetRuleService(
+ user=self.user,
+ instance=group_rules[0],
+ )
+ service.validate()
+ else:
+ # Multiple rules of the same type — OR logic:
+ # validation passes if at least one rule succeeds
+ ex_counter = 0
+ for rule in group_rules:
+ try:
+ service = FieldSetRuleService(
+ user=self.user,
+ instance=rule,
+ )
+ service.validate()
+ except FieldsetServiceException:
+ ex_counter += 1
+ if len(group_rules) == ex_counter:
+ values = ', '.join(
+ str(rule.value) for rule in group_rules
+ )
+ raise FieldsetServiceException(
+ message=MSG_FS_0012(values),
+ )
diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py
new file mode 100644
index 000000000..4540da22f
--- /dev/null
+++ b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py
@@ -0,0 +1,62 @@
+from django.contrib.auth import get_user_model
+from django.db import transaction
+from src.processes.messages.fieldset import MSG_FS_0002
+from src.processes.models.workflows.fieldset import FieldSetRule
+from src.processes.services.base import BaseModelService
+from src.processes.services.exceptions import FieldsetServiceException
+
+
+UserModel = get_user_model()
+
+
+class FieldSetRuleService(BaseModelService):
+
+ NULL_VALUES = (None, '', [])
+
+ def _validate_sum_equal(self, **kwargs):
+
+ total = 0
+ values_exists = False
+ for field in self.instance.fields.all():
+ if field.value in self.NULL_VALUES:
+ if field.is_required:
+ values_exists = True
+ else:
+ total += float(field.value)
+ values_exists = True
+ if values_exists and total != float(self.instance.value):
+ raise FieldsetServiceException(
+ message=MSG_FS_0002(self.instance.value),
+ )
+ return True
+
+ def validate(self, **kwargs):
+
+ """ Call after objects save """
+
+ validator = getattr(self, f'_validate_{self.instance.type}')
+ validator(**kwargs)
+
+ def _create_instance(self, instance_template, **kwargs):
+ self.instance = FieldSetRule.objects.create(
+ account=self.account,
+ fieldset=kwargs['fieldset'],
+ api_name=instance_template.api_name,
+ type=instance_template.type,
+ value=instance_template.value,
+ )
+
+ def create(self, **kwargs) -> FieldSetRule:
+ with transaction.atomic():
+ self._create_instance(**kwargs)
+ self._create_related(**kwargs)
+ self._create_actions(**kwargs)
+ if kwargs.get('skip_validation') is False:
+ self.validate(**kwargs)
+ return self.instance
+
+ def partial_update(self, **update_kwargs) -> FieldSetRule:
+ with transaction.atomic():
+ result = super().partial_update(**update_kwargs)
+ self.validate(**update_kwargs)
+ return result
diff --git a/backend/src/processes/services/workflows/kickoff_version.py b/backend/src/processes/services/workflows/kickoff_version.py
index 9fa54b4ff..6bb093882 100644
--- a/backend/src/processes/services/workflows/kickoff_version.py
+++ b/backend/src/processes/services/workflows/kickoff_version.py
@@ -1,5 +1,9 @@
-from typing import Dict, List
+from typing import Dict, List, Optional
+from src.processes.models.workflows.fieldset import (
+ FieldSet,
+ FieldSetRule,
+)
from src.processes.models.workflows.fields import (
FieldSelection,
TaskField,
@@ -11,13 +15,19 @@
class KickoffUpdateVersionService(BaseUpdateVersionService):
- def _update_field(self, template: dict):
+ def _update_field(
+ self,
+ template: dict,
+ *,
+ fieldset: Optional[FieldSet] = None,
+ ):
# TODO Move to TaskFieldService
return TaskField.objects.update_or_create(
kickoff=self.instance,
api_name=template['api_name'],
+ fieldset=fieldset,
defaults={
'name': template['name'],
'description': template['description'],
@@ -31,6 +41,25 @@ def _update_field(self, template: dict):
},
)
+ def _update_field_selections(
+ self,
+ field: TaskField,
+ field_data: Dict,
+ ) -> None:
+
+ if field_data.get('selections'):
+ selection_ids = set()
+ for selection_data in field_data['selections']:
+ selection, __ = FieldSelection.objects.update_or_create(
+ field=field,
+ api_name=selection_data['api_name'],
+ defaults={
+ 'value': selection_data['value'],
+ },
+ )
+ selection_ids.add(selection.id)
+ field.selections.exclude(id__in=selection_ids).delete()
+
def _update_fields(
self,
data: List[Dict],
@@ -40,21 +69,101 @@ def _update_fields(
field_ids = []
for field_data in data:
- field, _ = self._update_field(field_data)
+ field, _ = self._update_field(field_data, fieldset=None)
field_ids.append(field.id)
- if field_data.get('selections'):
- selection_ids = set()
- for selection_data in field_data['selections']:
- selection, __ = FieldSelection.objects.update_or_create(
- field=field,
- api_name=selection_data['api_name'],
- defaults={
- 'value': selection_data['value'],
- },
- )
- selection_ids.add(selection.id)
- field.selections.exclude(id__in=selection_ids).delete()
- self.instance.output.exclude(id__in=field_ids).delete()
+ self._update_field_selections(field, field_data)
+ self.instance.output.filter(
+ fieldset__isnull=True,
+ ).exclude(id__in=field_ids).delete()
+
+ def _update_fieldset_rules(
+ self,
+ fieldset: FieldSet,
+ rules_data: Optional[List[Dict]] = None,
+ ) -> None:
+
+ rule_ids = []
+ rules_data = rules_data or []
+ for rule_data in rules_data:
+ rule, _ = FieldSetRule.objects.update_or_create(
+ fieldset=fieldset,
+ api_name=rule_data['api_name'],
+ defaults={
+ 'account_id': fieldset.account_id,
+ 'type': rule_data['type'],
+ 'value': rule_data.get('value'),
+ },
+ )
+ rule_ids.append(rule.id)
+ fieldset.rules.exclude(id__in=rule_ids).delete()
+
+ def _update_field_rules(
+ self,
+ field: TaskField,
+ field_data: Dict,
+ fieldset: FieldSet,
+ ) -> None:
+
+ rules = field_data.get('rules', [])
+ if rules:
+ rules_api_names = [e['api_name'] for e in rules]
+ rules = FieldSetRule.objects.filter(
+ fieldset=fieldset,
+ api_name__in=rules_api_names,
+ )
+ field.rules.set(rules)
+ else:
+ field.rules.clear()
+
+ def _update_fieldset_fields(
+ self,
+ fieldset: FieldSet,
+ fields_data: Optional[List[Dict]],
+ ) -> None:
+
+ field_ids = []
+ fields_data = fields_data or []
+ for field_data in fields_data:
+ field, _ = self._update_field(field_data, fieldset=fieldset)
+ field_ids.append(field.id)
+ self._update_field_selections(field, field_data)
+ self._update_field_rules(field, field_data, fieldset)
+ TaskField.objects.filter(
+ kickoff=self.instance,
+ fieldset=fieldset,
+ ).exclude(id__in=field_ids).delete()
+
+ def _update_fieldsets(self, data: Optional[List]) -> None:
+
+ fs_api_names = set()
+ for fs_data in data or []:
+ order = fs_data['kickoff_links'][0]['order']
+ fieldset, _ = FieldSet.objects.update_or_create(
+ workflow=self.instance.workflow,
+ kickoff=self.instance,
+ api_name=fs_data['api_name'],
+ defaults={
+ 'account_id': self.instance.account_id,
+ 'name': fs_data['name'],
+ 'description': fs_data['description'],
+ 'order': order,
+ 'label_position': fs_data['label_position'],
+ 'layout': fs_data['layout'],
+ },
+ )
+ self._update_fieldset_rules(
+ fieldset=fieldset,
+ rules_data=fs_data.get('rules'),
+ )
+ self._update_fieldset_fields(
+ fieldset=fieldset,
+ fields_data=fs_data.get('fields'),
+ )
+ fs_api_names.add(fs_data['api_name'])
+ FieldSet.objects.filter(
+ kickoff=self.instance,
+ is_deleted=False,
+ ).exclude(api_name__in=fs_api_names).delete()
def update_from_version(
self,
@@ -64,9 +173,12 @@ def update_from_version(
"""
data = {
'description': str,
- 'fields': list
+ 'fields': list,
+ 'fieldsets': list,
}
"""
if data.get('fields'):
self._update_fields(data=data['fields'])
+ if data.get('fieldsets') is not None:
+ self._update_fieldsets(data=data['fieldsets'])
diff --git a/backend/src/processes/tests/fixtures.py b/backend/src/processes/tests/fixtures.py
index fbd35696b..9fbaa85b4 100644
--- a/backend/src/processes/tests/fixtures.py
+++ b/backend/src/processes/tests/fixtures.py
@@ -25,7 +25,9 @@
from src.payment.enums import BillingPeriod
from src.processes.enums import (
ConditionAction,
+ FieldSetLayout,
FieldType,
+ LabelPosition,
OwnerRole,
OwnerType,
PerformerType,
@@ -34,7 +36,7 @@
TaskStatus,
TemplateType,
WorkflowEventType,
- WorkflowStatus,
+ WorkflowStatus, FieldSetRuleType,
)
from src.processes.models.templates.checklist import (
ChecklistTemplate,
@@ -45,6 +47,11 @@
PredicateTemplate,
RuleTemplate,
)
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+ FieldsetTemplateRule,
+)
+from src.processes.models.templates.fields import FieldTemplate
from src.processes.models.templates.kickoff import Kickoff
from src.processes.models.templates.owner import TemplateOwner
from src.processes.models.templates.preset import (
@@ -64,6 +71,7 @@
from src.processes.models.workflows.fields import (
TaskField,
)
+from src.processes.models.workflows.fieldset import FieldSet, FieldSetRule
from src.processes.models.workflows.kickoff import KickoffValue
from src.processes.models.workflows.task import Task
from src.processes.models.workflows.workflow import Workflow
@@ -819,3 +827,169 @@ def create_test_dataset(
order=i,
)
return dataset
+
+
+def create_test_shared_fieldset(
+ account: Account,
+ name: str = 'Test Fieldset',
+ title: str = '',
+ description: str = '',
+ order: int = 0,
+ label_position: LabelPosition.LITERALS = LabelPosition.TOP,
+ layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL,
+ rule_type: Optional[FieldSetRuleType.LITERALS] = None,
+ rule_value: Optional[str] = None,
+ api_name: Optional[str] = None,
+) -> FieldsetTemplate:
+
+ """Creating fieldset templates."""
+
+ fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ name=name,
+ title=title,
+ description=description,
+ order=order,
+ label_position=label_position,
+ layout=layout,
+ api_name=api_name,
+ is_shared=True,
+ )
+ if rule_type:
+ FieldsetTemplateRule.objects.create(
+ fieldset=fieldset,
+ account=account,
+ api_name=f'{fieldset.api_name}-rule-1',
+ type=rule_type,
+ value=rule_value,
+ )
+ if rule_type == FieldSetRuleType.SUM_EQUAL:
+ field_type = FieldType.NUMBER
+ else:
+ field_type = FieldType.STRING
+
+ FieldTemplate.objects.create(
+ name='Fieldset field',
+ type=field_type,
+ fieldset=fieldset,
+ order=1,
+ api_name=f'{fieldset.api_name}-field-1',
+ account=account,
+ )
+ return fieldset
+
+
+def create_test_fieldset_template(
+ account: Account,
+ template: Optional[Template] = None,
+ kickoff: Optional[Kickoff] = None,
+ task: Optional[TaskTemplate] = None,
+ title: Optional[str] = None,
+ description: Optional[str] = None,
+ order: int = 0,
+ api_name: Optional[str] = None,
+ shared_fieldset: Optional[FieldsetTemplate] = None,
+ rule_type: Optional[FieldSetRuleType.LITERALS] = None,
+ rule_value: Optional[str] = None,
+) -> FieldsetTemplate:
+
+ """Creating fieldset templates."""
+
+ if shared_fieldset is None:
+ shared_fieldset = create_test_shared_fieldset(
+ account=account,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ )
+
+ fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ template=template,
+ name=shared_fieldset.name,
+ title=title or shared_fieldset.title,
+ description=description or shared_fieldset.description,
+ order=order,
+ label_position=shared_fieldset.label_position,
+ layout=shared_fieldset.layout,
+ api_name=api_name,
+ task=task,
+ kickoff=kickoff,
+ is_shared=False,
+ shared_fieldset=shared_fieldset,
+ )
+ for shared_rule in shared_fieldset.rules.all():
+ FieldsetTemplateRule.objects.create(
+ fieldset=fieldset,
+ account=account,
+ api_name=f'{fieldset.api_name}-rule-1',
+ type=shared_rule.type,
+ value=shared_rule.value,
+ )
+
+ for shared_field in shared_fieldset.fields.all():
+ FieldTemplate.objects.create(
+ name=shared_field.name,
+ type=shared_field.type,
+ fieldset=fieldset,
+ template=template,
+ order=shared_field.order,
+ api_name=f'{fieldset.api_name}-field-1',
+ account=account,
+ )
+ return fieldset
+
+
+def create_test_fieldset(
+ workflow: Workflow,
+ task: Optional[Task] = None,
+ kickoff: Optional[KickoffValue] = None,
+ name: str = 'Test Fieldset',
+ description: str = '',
+ order: int = 0,
+ label_position: LabelPosition.LITERALS = LabelPosition.TOP,
+ layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL,
+ rule_type: Optional[FieldSetRuleType.LITERALS] = None,
+ rule_value: Optional[str] = None,
+ api_name: Optional[str] = None,
+) -> FieldSet:
+
+ """Creating a workflow FieldSet with one TaskField."""
+
+ fieldset = FieldSet.objects.create(
+ account=workflow.account,
+ workflow=workflow,
+ kickoff=kickoff,
+ task=task,
+ name=name,
+ description=description,
+ order=order,
+ label_position=label_position,
+ layout=layout,
+ api_name=api_name,
+ )
+ if rule_type:
+ FieldSetRule.objects.create(
+ fieldset=fieldset,
+ account=workflow.account,
+ api_name=f'{fieldset.api_name}-rule-1',
+ type=rule_type,
+ value=rule_value,
+ )
+ if rule_type == FieldSetRuleType.SUM_EQUAL:
+ field_type = FieldType.NUMBER
+ field_value = '10'
+ else:
+ field_type = FieldType.STRING
+ field_value = 'Some value'
+ TaskField.objects.create(
+ account=workflow.account,
+ workflow=workflow,
+ fieldset=fieldset,
+ task=task,
+ name='Fieldset field',
+ type=field_type,
+ order=1,
+ api_name=f'{fieldset.api_name}-field-1',
+ value=field_value,
+ )
+ return fieldset
diff --git a/backend/src/processes/tests/test_models/test_workflow.py b/backend/src/processes/tests/test_models/test_workflow.py
index c7ca2d804..5189d09cd 100644
--- a/backend/src/processes/tests/test_models/test_workflow.py
+++ b/backend/src/processes/tests/test_models/test_workflow.py
@@ -1,106 +1,488 @@
import pytest
from django.contrib.auth import get_user_model
-
+from src.processes.enums import FieldType
from src.processes.models.workflows.task import Task
from src.processes.models.workflows.workflow import Workflow
+from src.processes.models.workflows.fields import TaskField
+from src.processes.models.workflows.fieldset import FieldSet
from src.processes.tests.fixtures import (
- create_test_user,
+ create_test_account,
+ create_test_owner,
create_test_workflow,
)
+pytestmark = pytest.mark.django_db
UserModel = get_user_model()
pytestmark = pytest.mark.django_db
-class TestWorkflow:
- @pytest.fixture
- def workflow_sql(self):
- return """
- SELECT
- id,
- is_deleted,
- template_id
- FROM processes_workflow
- WHERE id = %(workflow_id)s
- """
-
- @pytest.fixture
- def task_sql(self):
- return """
- SELECT
- id,
- is_deleted
- FROM processes_task
- WHERE workflow_id = %(workflow_id)s
- """
-
- def test_delete(
- self,
+@pytest.fixture
+def workflow_sql():
+ return """
+ SELECT
+ id,
+ is_deleted,
+ template_id
+ FROM processes_workflow
+ WHERE id = %(workflow_id)s
+ """
+
+
+@pytest.fixture
+def task_sql():
+ return """
+ SELECT
+ id,
+ is_deleted
+ FROM processes_task
+ WHERE workflow_id = %(workflow_id)s
+ """
+
+
+def test_delete(workflow_sql, task_sql):
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user)
+
+ # act
+ workflow.delete()
+
+ # assert
+ assert Workflow.objects.raw(
workflow_sql,
+ {'workflow_id': workflow.id},
+ )[0].is_deleted is True
+ task_list = Task.objects.raw(
task_sql,
- ):
- # arrange
- user = create_test_user()
- workflow = create_test_workflow(user)
-
- # act
- workflow.delete()
-
- # assert
- assert Workflow.objects.raw(
- workflow_sql,
- {'workflow_id': workflow.id},
- )[0].is_deleted is True
- task_list = Task.objects.raw(
- task_sql,
- {'workflow_id': workflow.id},
- )
- assert task_list[0].is_deleted is True
- assert task_list[1].is_deleted is True
- assert task_list[2].is_deleted is True
-
- def test_get_kickoff_fields_values__ok(self, mocker):
- # arrange
- user = create_test_user()
- workflow = create_test_workflow(user=user)
- field_mock = mocker.Mock(
- api_name='field-template',
- markdown_value='test',
- )
- kickoff_output_fields_mock = mocker.patch(
- 'src.processes.models.workflows.workflow.Workflow.'
- 'get_kickoff_output_fields',
- return_value=[field_mock],
- )
-
- # act
- workflow.get_kickoff_fields_values()
-
- # assert
- kickoff_output_fields_mock.assert_called_once()
-
- def test_get_fields_markdown_values__workflow_starter__ok(self):
- # arrange
- user = create_test_user()
- workflow = create_test_workflow(user=user)
-
- # act
- fields_values = workflow.get_fields_markdown_values()
-
- # assert
- assert 'workflow-starter' in fields_values
- assert fields_values['workflow-starter'] == user.name
-
- def test_get_kickoff_fields_markdown_values__workflow_starter__ok(self):
- # arrange
- user = create_test_user()
- workflow = create_test_workflow(user=user)
-
- # act
- fields_values = workflow.get_kickoff_fields_markdown_values()
-
- # assert
- assert 'workflow-starter' in fields_values
- assert fields_values['workflow-starter'] == user.name
+ {'workflow_id': workflow.id},
+ )
+ assert task_list[0].is_deleted is True
+ assert task_list[1].is_deleted is True
+ assert task_list[2].is_deleted is True
+
+
+def test_get_kickoff_fields_values__ok(mocker):
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user)
+ field_mock = mocker.Mock(
+ api_name='field-template',
+ markdown_value='test',
+ )
+ kickoff_output_fields_mock = mocker.patch(
+ 'src.processes.models.workflows.workflow.Workflow.'
+ 'get_kickoff_output_fields',
+ return_value=[field_mock],
+ )
+
+ # act
+ workflow.get_kickoff_fields_values()
+
+ # assert
+ kickoff_output_fields_mock.assert_called_once()
+
+
+def test_get_fields_markdown_values__workflow_starter__ok():
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user)
+
+ # act
+ fields_values = workflow.get_fields_markdown_values()
+
+ # assert
+ assert 'workflow-starter' in fields_values
+ assert fields_values['workflow-starter'] == user.name
+
+
+def test_get_kickoff_fields_markdown_values__workflow_starter__ok():
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user)
+
+ # act
+ fields_values = workflow.get_kickoff_fields_markdown_values()
+
+ # assert
+ assert 'workflow-starter' in fields_values
+ assert fields_values['workflow-starter'] == user.name
+
+
+def test_get_kickoff_output_fields__field_and_fieldset__ok():
+
+ """Call with default params returns kickoff and fieldset fields."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ kickoff = workflow.kickoff.get()
+ field_1 = TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ )
+ fieldset_1 = FieldSet.objects.create(
+ name='Fieldset 1',
+ api_name='fieldset-1',
+ workflow=workflow,
+ kickoff=kickoff,
+ account=account,
+ )
+ field_2 = TaskField.objects.create(
+ name='Field 2',
+ type=FieldType.STRING,
+ api_name='field-2',
+ fieldset=fieldset_1,
+ workflow=workflow,
+ account=account,
+ )
+
+ # act
+ result = workflow.get_kickoff_output_fields()
+
+ # assert
+ assert result.count() == 2
+ assert field_1 in result
+ assert field_2 in result
+
+
+def test_get_kickoff_output_fields__only_field__ok():
+
+ """Returns only direct kickoff fields when no fieldsets exist."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ kickoff = workflow.kickoff.get()
+ field_1 = TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ )
+ task = workflow.tasks.get(number=1)
+ fieldset_1 = FieldSet.objects.create(
+ name='Fieldset 1',
+ api_name='fieldset-1',
+ workflow=workflow,
+ task=task,
+ account=account,
+ )
+ TaskField.objects.create(
+ name='Field 2',
+ type=FieldType.STRING,
+ api_name='field-2',
+ fieldset=fieldset_1,
+ workflow=workflow,
+ account=account,
+ )
+
+ # act
+ result = workflow.get_kickoff_output_fields()
+
+ # assert
+ assert result.count() == 1
+ assert result[0] == field_1
+
+
+def test_get_kickoff_output_fields__only_fieldsets__ok():
+
+ """Returns only fieldset fields when no direct kickoff fields exist."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ kickoff = workflow.kickoff.get()
+ fieldset_1 = FieldSet.objects.create(
+ name='Fieldset 1',
+ api_name='fieldset-1',
+ workflow=workflow,
+ kickoff=kickoff,
+ account=account,
+ )
+ field_1 = TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ fieldset=fieldset_1,
+ workflow=workflow,
+ account=account,
+ )
+ task = workflow.tasks.get(number=1)
+ TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ task=task,
+ workflow=workflow,
+ account=account,
+ )
+
+ # act
+ result = workflow.get_kickoff_output_fields()
+
+ # assert
+ assert result.count() == 1
+ assert result[0] == field_1
+
+
+def test_get_kickoff_output_fields__fields_filter__ok():
+
+ """Call with fields_filter_kwargs applies additional filter."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ kickoff = workflow.kickoff.get()
+ field_1 = TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ )
+ TaskField.objects.create(
+ name='Field 2',
+ type=FieldType.TEXT,
+ api_name='field-2',
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ )
+
+ # act
+ result = workflow.get_kickoff_output_fields(
+ fields_filter_kwargs={'type': FieldType.STRING},
+ )
+
+ # assert
+ assert result.count() == 1
+ assert result[0] == field_1
+
+
+def test_get_tasks_output_fields__field_and_fieldset__ok():
+
+ """Call with default params returns task and fieldset fields."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ task_1 = workflow.tasks.get(number=1)
+ field_1 = TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ task=task_1,
+ workflow=workflow,
+ account=account,
+ )
+ fieldset_1 = FieldSet.objects.create(
+ name='Fieldset 1',
+ api_name='fieldset-1',
+ workflow=workflow,
+ task=task_1,
+ account=account,
+ )
+ field_2 = TaskField.objects.create(
+ name='Field 2',
+ type=FieldType.STRING,
+ api_name='field-2',
+ fieldset=fieldset_1,
+ workflow=workflow,
+ account=account,
+ )
+
+ # act
+ result = workflow.get_tasks_output_fields()
+
+ # assert
+ assert result.count() == 2
+ assert field_1 in result
+ assert field_2 in result
+
+
+def test_get_tasks_output_fields__only_fields__ok():
+
+ """Returns only direct task fields when no fieldsets exist."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ task_1 = workflow.tasks.get(number=1)
+ field_1 = TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ task=task_1,
+ workflow=workflow,
+ account=account,
+ )
+ fieldset_1 = FieldSet.objects.create(
+ name='Fieldset 1',
+ api_name='fieldset-1',
+ workflow=workflow,
+ kickoff=workflow.kickoff_instance,
+ account=account,
+ )
+ TaskField.objects.create(
+ name='Field 2',
+ type=FieldType.STRING,
+ api_name='field-2',
+ fieldset=fieldset_1,
+ workflow=workflow,
+ account=account,
+ )
+
+ # act
+ result = workflow.get_tasks_output_fields()
+
+ # assert
+ assert result.count() == 1
+ assert result[0] == field_1
+
+
+def test_get_tasks_output_fields__only_fieldsets__ok():
+
+ """Returns only fieldset fields when no direct task fields exist."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ task_1 = workflow.tasks.get(number=1)
+ fieldset_1 = FieldSet.objects.create(
+ name='Fieldset 1',
+ api_name='fieldset-1',
+ workflow=workflow,
+ task=task_1,
+ account=account,
+ )
+ field_1 = TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ fieldset=fieldset_1,
+ workflow=workflow,
+ account=account,
+ )
+ TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ kickoff=workflow.kickoff_instance,
+ workflow=workflow,
+ account=account,
+ )
+
+ # act
+ result = workflow.get_tasks_output_fields()
+
+ # assert
+ assert result.count() == 1
+ assert result[0] == field_1
+
+
+def test_get_tasks_output_fields__exclude_kwargs__ok():
+
+ """Call with tasks_exclude_kwargs excludes matching tasks and fieldsets."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ task_1 = workflow.tasks.get(number=1)
+ task_2 = workflow.tasks.get(number=2)
+ field_1 = TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ task=task_1,
+ workflow=workflow,
+ account=account,
+ )
+ TaskField.objects.create(
+ name='Field 2',
+ type=FieldType.STRING,
+ api_name='field-2',
+ task=task_2,
+ workflow=workflow,
+ account=account,
+ )
+ fieldset_1 = FieldSet.objects.create(
+ name='Fieldset 1',
+ api_name='fieldset-1',
+ workflow=workflow,
+ task=task_2,
+ account=account,
+ )
+ TaskField.objects.create(
+ name='Field 3',
+ type=FieldType.STRING,
+ api_name='field-3',
+ fieldset=fieldset_1,
+ workflow=workflow,
+ account=account,
+ )
+
+ # act
+ result = workflow.get_tasks_output_fields(
+ tasks_exclude_kwargs={'task__number': 2},
+ )
+
+ # assert
+ assert result.count() == 1
+ assert result[0] == field_1
+
+
+def test_get_tasks_output_fields__fields_filter__ok():
+
+ """Call with fields_filter_kwargs applies additional filter."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ task_1 = workflow.tasks.get(number=1)
+ field_1 = TaskField.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name='field-1',
+ task=task_1,
+ workflow=workflow,
+ account=account,
+ )
+ TaskField.objects.create(
+ name='Field 2',
+ type=FieldType.TEXT,
+ api_name='field-2',
+ task=task_1,
+ workflow=workflow,
+ account=account,
+ )
+
+ # act
+ result = workflow.get_tasks_output_fields(
+ fields_filter_kwargs={'type': FieldType.STRING},
+ )
+
+ # assert
+ assert result.count() == 1
+ assert result[0] == field_1
diff --git a/backend/src/processes/tests/test_services/test_fieldsets/__init__.py b/backend/src/processes/tests/test_services/test_fieldsets/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_rule_service.py b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_rule_service.py
new file mode 100644
index 000000000..7f60472f4
--- /dev/null
+++ b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_rule_service.py
@@ -0,0 +1,894 @@
+import pytest
+from src.authentication.enums import AuthTokenType
+from src.processes.enums import (
+ FieldSetRuleType,
+ FieldType,
+)
+from src.processes.messages import fieldset as fs_messages
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+ FieldsetTemplateRule,
+)
+from src.processes.models.templates.fields import FieldTemplate
+from src.processes.services.exceptions import (
+ FieldsetTemplateRuleServiceException,
+ FieldsetTemplateRuleSumMaxFieldsNotNumber,
+ FieldsetTemplateRuleSumMaxInvalidValue,
+)
+from src.processes.services.fieldsets.fieldset_rule import (
+ FieldsetTemplateRuleService,
+)
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_owner,
+ create_test_template,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+def test__create_instance__default_params__ok():
+
+ """
+ Call with default parameters
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+
+ # act
+ result = service._create_instance(
+ type=FieldSetRuleType.SUM_EQUAL,
+ fieldset_id=fieldset.id,
+ )
+
+ # assert
+ assert service.instance is not None
+ assert result is service.instance
+ assert service.instance.type == FieldSetRuleType.SUM_EQUAL
+ assert service.instance.value is None
+ assert service.instance.fieldset_id == fieldset.id
+ assert service.instance.account_id == account.id
+ assert service.instance.api_name
+
+
+def test__create_instance__all_params__ok():
+
+ """
+ Call with all parameters
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ value = '100'
+ api_name = 'rule-1'
+ rule_type = FieldSetRuleType.SUM_EQUAL
+
+ # act
+ service._create_instance(
+ type=rule_type,
+ value=value,
+ api_name=api_name,
+ fieldset_id=fieldset.id,
+ )
+
+ # assert
+ assert service.instance is not None
+ assert service.instance.type == rule_type
+ assert service.instance.value == value
+ assert service.instance.fieldset_id == fieldset.id
+ assert service.instance.account_id == account.id
+ assert service.instance.api_name == api_name
+
+
+def test__validate_sum_equal__valid__ok():
+
+ """
+ Value from kwargs, valid number, all NUMBER fields → ok
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Number field',
+ type=FieldType.NUMBER,
+ api_name='num',
+ order=1,
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100.5',
+ )
+ rule.fields.add(field)
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ result = service._validate_sum_equal()
+
+ # assert
+ assert result == 100.5
+
+
+def test__validate_sum_equal__empty_value__raise_exception():
+
+ """
+ Value is empty → raises SumMaxInvalidValue
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value=None,
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ with pytest.raises(FieldsetTemplateRuleSumMaxInvalidValue) as ex:
+ service._validate_sum_equal()
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0004
+
+
+def test__validate_sum_equal__non_numeric__raise_exception():
+
+ """
+ Value is non-numeric → raises SumMaxInvalidValue
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='abc',
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ with pytest.raises(FieldsetTemplateRuleSumMaxInvalidValue) as ex:
+ service._validate_sum_equal()
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0004
+
+
+@pytest.mark.parametrize(
+ 'field_type',
+ (
+ FieldType.STRING,
+ FieldType.TEXT,
+ FieldType.RADIO,
+ FieldType.CHECKBOX,
+ FieldType.DATE,
+ FieldType.URL,
+ FieldType.DROPDOWN,
+ FieldType.FILE,
+ FieldType.USER,
+ ),
+)
+def test__validate_sum_equal__non_number_type__raise_exception(field_type):
+
+ """
+ Non-NUMBER field exists → raises SumMaxFieldsNotNumber
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='String field',
+ type=field_type,
+ api_name='str_field',
+ order=1,
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ rule.fields.add(field)
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ with pytest.raises(FieldsetTemplateRuleSumMaxFieldsNotNumber) as ex:
+ service._validate_sum_equal()
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0003
+
+
+def test__validate__call_method_by_type__ok(mocker):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+ validate_sum_equal_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService._validate_sum_equal',
+ )
+ kwargs = {'type': FieldSetRuleType.SUM_EQUAL}
+
+ # act
+ service._validate(**kwargs)
+
+ # assert
+ validate_sum_equal_mock.assert_called_once_with(**kwargs)
+
+
+def test_get_valid_fields__all_found__ok():
+
+ """
+ All fields found → returns list
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field_1_api_name = 'field_1'
+ field1 = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.STRING,
+ api_name=field_1_api_name,
+ order=1,
+ )
+ field_2_api_name = 'field_2'
+ field2 = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ api_name=field_2_api_name,
+ order=2,
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ service.instance = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ )
+
+ # act
+ result = service._get_valid_fields([field_1_api_name, field_2_api_name])
+
+ # assert
+ assert len(result) == 2
+ assert field1 in result
+ assert field2 in result
+
+
+def test_get_valid_fields__type_from_kwargs__ok():
+
+ """
+ rule_type from kwargs
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field_api_name = 'field_1'
+ field = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Number field',
+ type=FieldType.NUMBER,
+ api_name=field_api_name,
+ order=1,
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ service.instance = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ )
+
+ # act
+ result = service._get_valid_fields(
+ fields_api_names=[field_api_name],
+ type=FieldSetRuleType.SUM_EQUAL,
+ )
+
+ # assert
+ assert result == [field]
+
+
+def test_get_valid_fields__type_from_instance__ok():
+
+ """
+ rule_type from instance
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field_api_name = 'field_1'
+ field = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Number field',
+ type=FieldType.NUMBER,
+ api_name=field_api_name,
+ order=1,
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ service.instance = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ )
+
+ # act
+ result = service._get_valid_fields(
+ fields_api_names=[field_api_name],
+ )
+
+ # assert
+ assert result == [field]
+
+
+def test_get_valid_fields__one_failed__raise_exception():
+
+ """
+ failed_api_names has 1 element → raises exception
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Number field',
+ type=FieldType.NUMBER,
+ api_name='num',
+ order=1,
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ service.instance = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ )
+
+ # act
+ with pytest.raises(FieldsetTemplateRuleServiceException) as ex:
+ service._get_valid_fields(['missing'])
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0005(
+ rule=FieldSetRuleType.SUM_EQUAL,
+ field='missing',
+ )
+
+
+def test_get_valid_fields__two_failed__raise_exception():
+
+ """
+ failed_api_names has 2 elements → raises exception
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ service.instance = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ )
+
+ # act
+ with pytest.raises(FieldsetTemplateRuleServiceException) as ex:
+ service._get_valid_fields(['missing1', 'missing2'])
+
+ # assert
+ assert ex.value.message in {
+ fs_messages.MSG_FS_0005(
+ rule=FieldSetRuleType.SUM_EQUAL,
+ field='missing1',
+ ), fs_messages.MSG_FS_0005(
+ rule=FieldSetRuleType.SUM_EQUAL,
+ field='missing2',
+ ),
+ }
+
+
+def test_set_fields__fields_provided__set_fields(mocker):
+
+ """
+ Non-empty list → fields are set
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field_api_name = 'num'
+ field = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Number field',
+ type=FieldType.NUMBER,
+ api_name=field_api_name,
+ order=1,
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ )
+ service.instance = rule
+ get_valid_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.FieldsetTemplateRuleService'
+ '._get_valid_fields',
+ return_value=[field],
+ )
+
+ # act
+ service._set_fields([field_api_name])
+
+ # assert
+ get_valid_fields_mock.assert_called_once_with(['num'])
+ assert list(rule.fields.all()) == [field]
+
+
+def test_set_fields__fields_not_provided__clear_fields(mocker):
+
+ """
+ Empty list → fields are cleared
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Number field',
+ type=FieldType.NUMBER,
+ api_name='num',
+ order=1,
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ )
+ rule.fields.add(field)
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ service.instance = rule
+ get_valid_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.FieldsetTemplateRuleService'
+ '._get_valid_fields',
+ )
+
+ # act
+ service._set_fields([])
+
+ # assert
+ get_valid_fields_mock.assert_not_called()
+ assert rule.fields.count() == 0
+
+
+def test_create_related__fields_provided__ok(mocker):
+
+ """
+ fields present in kwargs
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ set_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.FieldsetTemplateRuleService'
+ '._set_fields',
+ )
+ fields = ['num']
+
+ # act
+ service._create_related(fields=fields)
+
+ # assert
+ set_fields_mock.assert_called_once_with(fields)
+
+
+def test_create_related__fields_provided_empty_list__ok(mocker):
+
+ """
+ fields not in kwargs → no-op
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ set_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.FieldsetTemplateRuleService'
+ '._set_fields',
+ )
+
+ # act
+ service._create_related(fields=[])
+
+ # assert
+ set_fields_mock.assert_called_once_with([])
+
+
+def test_create_related__fields_not_provided__skip(mocker):
+
+ """
+ fields not in kwargs → no-op
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ set_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.FieldsetTemplateRuleService'
+ '._set_fields',
+ )
+
+ # act
+ service._create_related()
+
+ # assert
+ set_fields_mock.assert_not_called()
+
+
+def test_create__valid_data__ok(mocker):
+
+ """
+ Call with valid data → returns instance
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ service.instance = rule
+ create_instance_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService._create_instance',
+ )
+ create_related_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService._create_related',
+ )
+ create_actions_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService._create_actions',
+ )
+ validate_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService._validate',
+ )
+
+ kwargs = {
+ 'fields': ['num'],
+ 'value': '100',
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'fieldset_id': fieldset.id,
+ }
+
+ # act
+ result = service.create(**kwargs)
+
+ # assert
+ assert result == rule
+ create_instance_mock.assert_called_once_with(**kwargs)
+ create_related_mock.assert_called_once_with(**kwargs)
+ create_actions_mock.assert_called_once_with(**kwargs)
+ validate_mock.assert_called_once_with(**kwargs)
+
+
+def test_partial_update__with_fields__ok(mocker):
+
+ """
+ fields in update_kwargs → branch taken
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+ set_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.FieldsetTemplateRuleService'
+ '._set_fields',
+ )
+ validate_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.FieldsetTemplateRuleService'
+ '._validate',
+ )
+ value = '200'
+ fields = ['num']
+
+ # act
+ result = service.partial_update(value=value, fields=fields)
+
+ # assert
+ set_fields_mock.assert_called_once_with(fields)
+ validate_mock.assert_called_once_with(value=value)
+ assert result == rule
+ rule.refresh_from_db()
+ assert rule.value == value
+
+
+def test_partial_update__without_fields__ok(mocker):
+
+ """
+ fields not in update_kwargs → branch skipped
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ service = FieldsetTemplateRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+ set_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.FieldsetTemplateRuleService'
+ '._set_fields',
+ )
+ validate_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.FieldsetTemplateRuleService'
+ '._validate',
+ )
+ super_partial_mock = mocker.patch(
+ 'src.processes.services.fieldsets'
+ '.fieldset_rule.BaseModelService'
+ '.partial_update',
+ return_value=rule,
+ )
+ value = '200'
+
+ # act
+ result = service.partial_update(value=value)
+
+ # assert
+ super_partial_mock.assert_called_once_with(value=value, force_save=True)
+ set_fields_mock.assert_not_called()
+ validate_mock.assert_called_once_with(value=value)
+ assert result == rule
diff --git a/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_service.py b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_service.py
new file mode 100644
index 000000000..bcb4dee7a
--- /dev/null
+++ b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_service.py
@@ -0,0 +1,2104 @@
+import pytest
+from src.authentication.enums import AuthTokenType
+from src.processes.enums import (
+ FieldSetLayout,
+ FieldSetRuleType,
+ FieldType,
+ LabelPosition,
+)
+from src.processes.messages import fieldset as fs_messages
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+ FieldsetTemplateRule,
+)
+from src.processes.models.templates.fields import FieldTemplate
+from src.processes.services.exceptions import (
+ FieldsetTemplateInUseException,
+ FieldsetTemplateSharedIdMissing,
+ FieldsetTemplateTemplateIdMissing,
+)
+from src.processes.services.templates.field_template import (
+ FieldTemplateService,
+)
+from src.processes.services.fieldsets.fieldset import (
+ FieldSetTemplateService,
+)
+from src.processes.services.fieldsets.fieldset_rule import (
+ FieldsetTemplateRuleService,
+)
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_owner,
+ create_test_template,
+ create_test_fieldset_template,
+ create_test_shared_fieldset,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+def test__create_instance__default_params__ok():
+
+ """
+ Call with default parameters
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ shared_fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ name='Shared FS',
+ is_shared=True,
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ name = 'Test fieldset'
+
+ # act
+ service._create_instance(
+ name=name,
+ template_id=template.id,
+ is_shared=False,
+ shared_fieldset_id=shared_fieldset.id,
+ )
+
+ # assert
+ assert service.instance is not None
+ assert service.instance.name == name
+ assert service.instance.api_name
+ assert service.instance.template_id == template.id
+ assert service.instance.shared_fieldset_id == shared_fieldset.id
+ assert service.instance.account_id == account.id
+ assert service.instance.description == ''
+ assert service.instance.label_position == LabelPosition.TOP
+ assert service.instance.layout == FieldSetLayout.VERTICAL
+
+
+def test__create_instance__all_params__ok():
+
+ """
+ Call with all parameters
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ shared_fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ name='Shared FS',
+ is_shared=True,
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ name = 'Test fieldset'
+ description = 'Test description'
+ label_position = LabelPosition.LEFT
+ layout = FieldSetLayout.HORIZONTAL
+ api_name = 'fs-1'
+
+ # act
+ service._create_instance(
+ name=name,
+ template_id=template.id,
+ is_shared=False,
+ shared_fieldset_id=shared_fieldset.id,
+ description=description,
+ label_position=label_position,
+ layout=layout,
+ api_name=api_name,
+ )
+
+ # assert
+ assert service.instance.name == name
+ assert service.instance.template_id == template.id
+ assert service.instance.shared_fieldset_id == shared_fieldset.id
+ assert service.instance.description == description
+ assert service.instance.label_position == label_position
+ assert service.instance.layout == layout
+ assert service.instance.api_name == api_name
+
+
+def test__create_instance__shared_fieldset__ok():
+
+ """
+ Call with default parameters
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ name = 'Test fieldset'
+
+ # act
+ service._create_instance(
+ name=name,
+ is_shared=True,
+ )
+
+ # assert
+ assert service.instance is not None
+ assert service.instance.name == name
+ assert service.instance.is_shared is True
+ assert service.instance.template_id is None
+ assert service.instance.title == ''
+ assert service.instance.description == ''
+ assert service.instance.kickoff_id is None
+ assert service.instance.task_id is None
+ assert service.instance.shared_fieldset_id is None
+ assert service.instance.api_name
+ assert service.instance.account_id == account.id
+ assert service.instance.label_position == LabelPosition.TOP
+ assert service.instance.layout == FieldSetLayout.VERTICAL
+
+
+def test__create_instance__is_shared_api_name_provided__ok():
+
+ """
+ is_shared=True, api_name provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ name = 'Shared fieldset'
+ api_name = 'fs-custom-1'
+
+ # act
+ service._create_instance(
+ name=name,
+ is_shared=True,
+ api_name=api_name,
+ )
+
+ # assert
+ assert service.instance.api_name == api_name
+ assert service.instance.is_shared is True
+ assert service.instance.template_id is None
+
+
+def test__create_instance__not_shared_no_template_id__raise_exception():
+
+ """
+ is_shared=False, template_id missing
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ shared_fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ name='Shared FS',
+ is_shared=True,
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+
+ # act
+ with pytest.raises(FieldsetTemplateTemplateIdMissing) as ex:
+ service._create_instance(
+ name='Fieldset',
+ is_shared=False,
+ shared_fieldset_id=shared_fieldset.id,
+ )
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0011
+
+
+def test__create_instance__not_shared_no_shared_fieldset_id__raise_exception():
+
+ """
+ is_shared=False, shared_fieldset_id missing
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+
+ # act
+ with pytest.raises(FieldsetTemplateSharedIdMissing) as ex:
+ service._create_instance(
+ name='Fieldset',
+ is_shared=False,
+ template_id=template.id,
+ )
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0010
+
+
+def test__create_fields__with_data__ok(mocker):
+
+ """
+ Call with fields data
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ fields_data = [
+ {'name': 'Field 1', 'type': 'string', 'order': 1},
+ {'name': 'Field 2', 'type': 'number', 'order': 2},
+ ]
+
+ # mock
+ field_template_service_init_mock = mocker.patch.object(
+ FieldTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_template_service_create_mock = mocker.patch(
+ 'src.processes.services.templates.field_template.'
+ 'FieldTemplateService.create',
+ )
+
+ # act
+ service._create_fields(fields_data=fields_data)
+
+ # assert
+ assert field_template_service_init_mock.call_count == 2
+ assert field_template_service_create_mock.call_count == 2
+ field_template_service_create_mock.assert_has_calls(
+ [
+ mocker.call(
+ fieldset_id=fieldset.id,
+ template_id=template.id,
+ name='Field 1',
+ type='string',
+ order=1,
+ ),
+ mocker.call(
+ fieldset_id=fieldset.id,
+ template_id=template.id,
+ name='Field 2',
+ type='number',
+ order=2,
+ ),
+ ],
+ any_order=True,
+ )
+
+
+def test_create_rules__with_data__ok(mocker):
+
+ """
+ Call with rules data
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ rules_data = [
+ {'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'},
+ ]
+
+ # mock
+ fieldset_template_rule_service_init_mock = mocker.patch.object(
+ FieldsetTemplateRuleService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_template_rule_service_create_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService.create',
+ )
+
+ # act
+ service.create_rules(rules_data=rules_data)
+
+ # assert
+ fieldset_template_rule_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_template_rule_service_create_mock.assert_called_once_with(
+ fieldset_id=fieldset.id,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+
+
+def test__create_related__default_params__ok(mocker):
+
+ """
+ Call with default parameters (no rules, no fields)
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+
+ # mock
+ create_rules_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.create_rules',
+ )
+ create_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._create_fields',
+ )
+
+ # act
+ service._create_related()
+
+ # assert
+ create_rules_mock.assert_not_called()
+ create_fields_mock.assert_not_called()
+
+
+def test__create_related__rules_provided__ok(mocker):
+
+ """
+ Rules provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ rules = [{'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'}]
+
+ # mock
+ create_rules_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.create_rules',
+ )
+ create_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._create_fields',
+ )
+
+ # act
+ service._create_related(rules=rules)
+
+ # assert
+ create_rules_mock.assert_called_once_with(rules_data=rules)
+ create_fields_mock.assert_not_called()
+
+
+def test__create_related__fields_provided__ok(mocker):
+
+ """
+ Fields provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fields = [{'name': 'Field 1', 'type': 'string', 'order': 1}]
+
+ # mock
+ create_rules_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.create_rules',
+ )
+ create_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._create_fields',
+ )
+
+ # act
+ service._create_related(fields=fields)
+
+ # assert
+ create_rules_mock.assert_not_called()
+ create_fields_mock.assert_called_once_with(fields_data=fields)
+
+
+def test__create_related__both_provided__ok(mocker):
+
+ """
+ Both rules and fields provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ rules = [{'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'}]
+ fields = [{'name': 'Field 1', 'type': 'string', 'order': 1}]
+
+ # mock
+ create_rules_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.create_rules',
+ )
+ create_fields_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._create_fields',
+ )
+
+ # act
+ service._create_related(rules=rules, fields=fields)
+
+ # assert
+ create_rules_mock.assert_called_once_with(rules_data=rules)
+ create_fields_mock.assert_called_once_with(fields_data=fields)
+
+
+def test__update_fields__existing_field__ok(mocker):
+
+ """
+ Update existing field
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field_1 = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Field 1',
+ type='string',
+ order=1,
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ fields_data = [{'api_name': field_1.api_name, 'name': 'Updated Field 1'}]
+
+ # mock
+ field_template_service_init_mock = mocker.patch.object(
+ FieldTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_template_service_partial_update_mock = mocker.patch(
+ 'src.processes.services.templates.field_template.'
+ 'FieldTemplateService.partial_update',
+ )
+ field_template_service_create_mock = mocker.patch(
+ 'src.processes.services.templates.field_template.'
+ 'FieldTemplateService.create',
+ )
+
+ # act
+ service._update_fields(fields_data=fields_data)
+
+ # assert
+ field_template_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=field_1,
+ )
+ field_template_service_partial_update_mock.assert_called_once_with(
+ name='Updated Field 1',
+ force_save=True,
+ )
+ field_template_service_create_mock.assert_not_called()
+
+
+def test__update_fields__new_field__ok(mocker):
+
+ """
+ Create new field
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=template.kickoff_instance,
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ fields_data = [
+ {'name': 'New Field', 'type': 'number', 'order': 1},
+ ]
+
+ # mock
+ create_return = mocker.Mock()
+ create_return.api_name = 'field_api_name'
+ field_template_service_init_mock = mocker.patch.object(
+ FieldTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_template_service_create_mock = mocker.patch(
+ 'src.processes.services.templates.field_template.'
+ 'FieldTemplateService.create',
+ return_value=create_return,
+ )
+ field_template_service_partial_update_mock = mocker.patch(
+ 'src.processes.services.templates.field_template.'
+ 'FieldTemplateService.partial_update',
+ )
+
+ # act
+ service._update_fields(fields_data=fields_data)
+
+ # assert
+ field_template_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ field_template_service_create_mock.assert_called_once_with(
+ fieldset_id=fieldset.id,
+ template_id=template.id,
+ name='New Field',
+ type='number',
+ order=1,
+ )
+ field_template_service_partial_update_mock.assert_not_called()
+
+
+def test__update_fields__orphan_fields__deleted(mocker):
+
+ """
+ Orphan fields deleted
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field_1 = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Field 1',
+ type='string',
+ order=1,
+ )
+ field_2 = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset,
+ name='Field 2',
+ type='string',
+ order=2,
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ fields_data = [{'api_name': field_1.api_name, 'name': 'Updated Field 1'}]
+
+ # mock
+ field_template_service_init_mock = mocker.patch.object(
+ FieldTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_template_service_update_mock = mocker.patch(
+ 'src.processes.services.templates.field_template.'
+ 'FieldTemplateService.partial_update',
+ )
+
+ # act
+ service._update_fields(fields_data=fields_data)
+
+ # assert
+ field_template_service_init_mock.assert_called_once()
+ field_template_service_update_mock.assert_called_once()
+ assert not FieldTemplate.objects.filter(
+ id=field_2.id,
+ ).exists()
+ assert FieldTemplate.objects.filter(id=field_1.id).exists()
+
+
+def test__validate_rules__with_rules__ok(mocker):
+
+ """
+ Call with rules
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule_1 = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # mock
+ fieldset_template_rule_service_init_mock = mocker.patch.object(
+ FieldsetTemplateRuleService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_template_rule_service_validate_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService._validate',
+ )
+
+ # act
+ service._validate_rules()
+
+ # assert
+ fieldset_template_rule_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule_1,
+ )
+ fieldset_template_rule_service_validate_mock.assert_called_once_with()
+
+
+def test_update_rules__existing_rule__ok(mocker):
+
+ """
+ Update existing rule
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule_1 = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ rules_data = [{'api_name': rule_1.api_name, 'value': '200'}]
+
+ # mock
+ fieldset_template_rule_service_init_mock = mocker.patch.object(
+ FieldsetTemplateRuleService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_template_rule_service_partial_update_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService.partial_update',
+ )
+ fieldset_template_rule_service_create_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService.create',
+ )
+
+ # act
+ service.update_rules(rules_data=rules_data)
+
+ # assert
+ fieldset_template_rule_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule_1,
+ )
+ fs_rule_update_mock = (
+ fieldset_template_rule_service_partial_update_mock
+ )
+ fs_rule_update_mock.assert_called_once_with(
+ value='200',
+ )
+ fieldset_template_rule_service_create_mock.assert_not_called()
+
+
+def test_update_rules__new_rule__ok(mocker):
+
+ """
+ Create new rule
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ rules_data = [
+ {'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'},
+ ]
+
+ # mock
+ create_return = mocker.Mock()
+ create_return.api_name = 'new-rule-api'
+ fs_rule_init_mock = mocker.patch.object(
+ FieldsetTemplateRuleService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fs_rule_create_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService.create',
+ return_value=create_return,
+ )
+ fs_rule_update_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService.partial_update',
+ )
+
+ # act
+ service.update_rules(rules_data=rules_data)
+
+ # assert
+ fs_rule_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fs_rule_create_mock.assert_called_once_with(
+ fieldset_id=fieldset.id,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ fs_rule_update_mock.assert_not_called()
+
+
+def test_update_rules__orphan_rules__deleted(mocker):
+
+ """
+ Orphan rules deleted
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule_1 = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ rule_2 = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='200',
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ rules_data = [{'api_name': rule_1.api_name, 'value': '150'}]
+
+ # mock
+ fs_rule_init_mock = mocker.patch.object(
+ FieldsetTemplateRuleService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fs_rule_update_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset_rule.'
+ 'FieldsetTemplateRuleService.partial_update',
+ )
+
+ # act
+ service.update_rules(rules_data=rules_data)
+
+ # assert
+ fs_rule_init_mock.assert_called_once()
+ fs_rule_update_mock.assert_called_once()
+ assert not FieldsetTemplateRule.objects.filter(
+ id=rule_2.id,
+ ).exists()
+ assert FieldsetTemplateRule.objects.filter(
+ id=rule_1.id,
+ ).exists()
+
+
+def test_update_rules__two_rules_same_type_no_api_name__create_new_rule():
+
+ """
+ Two rules with the same type in rules_data:
+ one existing (with api_name) and one new (without api_name).
+ Both rules must be persisted after update_rules completes.
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ field_1 = fieldset.fields.first()
+ field_2 = FieldTemplate.objects.create(
+ account=account,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ api_name='f2',
+ fieldset=fieldset,
+ )
+ existing_rule = fieldset.rules.first()
+ existing_rule.fields.add(field_1)
+ existing_rule.fields.add(field_2)
+
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ rules_data = [
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '1',
+ 'fields': [field_1.api_name, field_2.api_name],
+ 'api_name': existing_rule.api_name,
+ },
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '100',
+ 'fields': [field_1.api_name, field_2.api_name],
+ },
+ ]
+
+ # act
+ service.update_rules(rules_data=rules_data)
+
+ # assert
+ assert fieldset.rules.count() == 2
+ existing_rule.refresh_from_db()
+ assert existing_rule.value == '1'
+ assert existing_rule.type == FieldSetRuleType.SUM_EQUAL
+ assert existing_rule.fields.count() == 2
+ assert existing_rule.fields.get(id=field_1.id)
+ assert existing_rule.fields.get(id=field_2.id)
+
+ new_rule = fieldset.rules.exclude(
+ fieldset=fieldset,
+ api_name=existing_rule.api_name,
+ ).get()
+ assert new_rule.type == FieldSetRuleType.SUM_EQUAL
+ assert new_rule.value == '100'
+ assert new_rule.fields.count() == 2
+ assert new_rule.fields.get(id=field_1.id)
+ assert new_rule.fields.get(id=field_2.id)
+
+
+def test_partial_update_name__ok(mocker):
+
+ """Call `partial_update` with default parameters"""
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ template = create_test_template(user=owner, tasks_count=1)
+ template.tasks.get(number=1)
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ )
+ mock_update_fields = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._update_fields',
+ )
+ mock_update_rules = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.update_rules',
+ )
+ mock_validate_rules = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._validate_rules',
+ )
+ service = FieldSetTemplateService(instance=fieldset, user=owner)
+ data = {"name": 'Updated Name'}
+
+ # act
+ result = service.partial_update(**data)
+
+ # assert
+ assert result.name == data['name']
+ mock_update_fields.assert_not_called()
+ mock_update_rules.assert_not_called()
+ mock_validate_rules.assert_called_once_with()
+
+
+def test_partial_update_fields_ok(mocker):
+ """Verify fields update logic for existing and new fields"""
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ template = create_test_template(user=owner, tasks_count=1)
+ template.tasks.get(number=1)
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ )
+ service = FieldSetTemplateService(user=owner, instance=fieldset)
+
+ mock_update_fields = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._update_fields',
+ )
+ mock_update_rules = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.update_rules',
+ )
+ mock_validate_rules = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._validate_rules',
+ )
+ mock_super_partial_update = mocker.patch(
+ 'src.generics.base.service.'
+ 'BaseModelService.partial_update',
+ )
+ data = {
+ "fields": [
+ {"api_name": "field_1", "value": "val"},
+ ],
+ }
+
+ # act
+ service.partial_update(**data)
+
+ # assert
+ mock_super_partial_update.assert_not_called()
+ mock_update_fields.assert_called_once_with(fields_data=data['fields'])
+ mock_update_rules.assert_not_called()
+ mock_validate_rules.assert_called_once_with()
+
+
+def test_partial_update__rules__ok(mocker):
+ """Verify rules update logic for existing and new rules"""
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ template = create_test_template(user=owner, tasks_count=1)
+ template.tasks.get(number=1)
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ )
+
+ mock_super_partial_update = mocker.patch(
+ 'src.generics.base.service.'
+ 'BaseModelService.partial_update',
+ )
+ mock_update_fields = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._update_fields',
+ )
+ mock_update_rules = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.update_rules',
+ )
+ mock_validate_rules = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._validate_rules',
+ )
+ service = FieldSetTemplateService(user=owner, instance=fieldset)
+ data = {
+ 'rules': [
+ {"api_name": "rule_1", "condition": "eq"},
+ ],
+ }
+
+ # act
+ result = service.partial_update(**data)
+
+ # assert
+ assert result is fieldset
+ mock_super_partial_update.assert_not_called()
+ mock_update_fields.assert_not_called()
+ mock_update_rules.assert_called_once_with(rules_data=data['rules'])
+ mock_validate_rules.assert_called_once_with()
+
+
+def test_delete__not_in_use__ok():
+
+ """
+ Not in use → deleted
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ service.delete()
+
+ # assert
+ assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists()
+
+
+def test_delete__used_by_kickoff_deleted_record__ok():
+
+ """
+ Fieldset previously linked to kickoff but now cleared → deleted
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ kickoff = template.kickoff_instance
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ kickoff=kickoff,
+ )
+ fieldset.kickoff = None
+ fieldset.save(update_fields=['kickoff'])
+ fieldset.refresh_from_db()
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ service.delete()
+
+ # assert
+ assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists()
+
+
+def test_delete__used_by_task_deleted_record__ok():
+
+ """
+ Fieldset previously linked to task but now cleared → deleted
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ task = template.tasks.get(number=1)
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ task=task,
+ )
+ fieldset.task = None
+ fieldset.save(update_fields=['task'])
+ fieldset.refresh_from_db()
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ service.delete()
+
+ # assert
+ assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists()
+
+
+def test_delete__used_by_kickoff__raise_exception():
+
+ """
+ In use by kickoff (kickoff_id is set) → exception
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ kickoff = template.kickoff_instance
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ kickoff=kickoff,
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ with pytest.raises(FieldsetTemplateInUseException) as ex:
+ service.delete()
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0001
+ assert FieldsetTemplate.objects.filter(id=fieldset.id).exists()
+
+
+def test_delete__used_by_task__raise_exception():
+
+ """
+ In use by task → exception
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ task_template = template.tasks.first()
+ fieldset = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ task=task_template,
+ )
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ with pytest.raises(FieldsetTemplateInUseException) as ex:
+ service.delete()
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0001
+ assert FieldsetTemplate.objects.filter(id=fieldset.id).exists()
+
+
+def test_create_shared_fieldset__ok():
+
+ """
+ Default params
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ name = 'My Shared Fieldset'
+
+ # act
+ result = service.create_shared_fieldset(name=name)
+
+ # assert
+ assert result.name == name
+ assert result.is_shared is True
+ assert result.title == ''
+ assert result.description == ''
+ assert result.label_position == LabelPosition.TOP
+ assert result.layout == FieldSetLayout.VERTICAL
+ assert result.api_name
+ assert result.template_id is None
+
+
+def test__create_shared_fieldset__all_params__ok():
+
+ """
+ All params provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ name = 'Custom Fieldset'
+ title = 'Custom Title'
+ description = 'Custom description'
+ api_name = 'fs-custom'
+ label_position = LabelPosition.LEFT
+ layout = FieldSetLayout.HORIZONTAL
+
+ # act
+ result = service.create_shared_fieldset(
+ name=name,
+ title=title,
+ description=description,
+ api_name=api_name,
+ label_position=label_position,
+ layout=layout,
+ )
+
+ # assert
+ assert result.name == name
+ assert result.title == title
+ assert result.description == description
+ assert result.api_name == api_name
+ assert result.label_position == label_position
+ assert result.layout == layout
+ assert result.is_shared is True
+
+
+def test__replace_api_names__fields_and_rules__ok(mocker):
+
+ """
+ Default params, fields and rules present
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ old_field_api = 'old-field-1'
+ shared_fieldset_data = {
+ 'api_name': 'old-fs',
+ 'fields': [{'api_name': old_field_api, 'name': 'F 1'}],
+ 'rules': [{'api_name': 'old-rule-1', 'fields': [old_field_api]}],
+ }
+ new_fs_api = 'new-fs-1'
+ new_field_api = 'new-field-1'
+ new_rule_api = 'new-rule-1'
+ create_api_name_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.create_api_name',
+ side_effect=[new_fs_api, new_field_api, new_rule_api],
+ )
+
+ # act
+ result = service._replace_api_names(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert result['api_name'] == new_fs_api
+ assert result['fields'][0]['api_name'] == new_field_api
+ assert result['rules'][0]['api_name'] == new_rule_api
+ assert result['rules'][0]['fields'][0] == new_field_api
+ assert create_api_name_mock.call_count == 3
+ create_api_name_mock.assert_has_calls(
+ [
+ mocker.call(FieldsetTemplate.api_name_prefix),
+ mocker.call(FieldTemplate.api_name_prefix),
+ mocker.call(FieldsetTemplateRule.api_name_prefix),
+ ],
+ any_order=True,
+ )
+
+
+def test__replace_api_names__no_fields_key__ok(mocker):
+
+ """
+ No fields key
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs'}
+ create_api_name_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.create_api_name',
+ return_value='new-fs-1',
+ )
+
+ # act
+ result = service._replace_api_names(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert result['fields'] == []
+ create_api_name_mock.assert_called_once_with(
+ FieldsetTemplate.api_name_prefix,
+ )
+
+
+def test__replace_api_names__empty_fields__ok(mocker):
+
+ """
+ Fields is empty list
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs', 'fields': []}
+ create_api_name_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.create_api_name',
+ return_value='new-fs-1',
+ )
+
+ # act
+ result = service._replace_api_names(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert result['fields'] == []
+ create_api_name_mock.assert_called_once_with(
+ FieldsetTemplate.api_name_prefix,
+ )
+
+
+def test__replace_api_names__no_rules_key__ok(mocker):
+
+ """
+ No rules key
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs', 'fields': []}
+ create_api_name_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.create_api_name',
+ return_value='new-fs-1',
+ )
+
+ # act
+ result = service._replace_api_names(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert result['rules'] == []
+ create_api_name_mock.assert_called_once_with(
+ FieldsetTemplate.api_name_prefix,
+ )
+
+
+def test__replace_api_names__empty_rules__ok(mocker):
+
+ """
+ Rules is empty list
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs', 'rules': []}
+ create_api_name_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.create_api_name',
+ return_value='new-fs-1',
+ )
+
+ # act
+ result = service._replace_api_names(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert result['rules'] == []
+ create_api_name_mock.assert_called_once_with(
+ FieldsetTemplate.api_name_prefix,
+ )
+
+
+def test__replace_api_names__original_not_mutated__ok(mocker):
+
+ """
+ Original dict not mutated
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ old_field_api = 'old-field-1'
+ shared_fieldset_data = {
+ 'api_name': 'old-fs',
+ 'fields': [{'api_name': old_field_api, 'name': 'F 1'}],
+ 'rules': [],
+ }
+ create_api_name_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.create_api_name',
+ side_effect=['new-fs', 'new-field'],
+ )
+
+ # act
+ service._replace_api_names(shared_fieldset_data=shared_fieldset_data)
+
+ # assert
+ assert shared_fieldset_data['api_name'] == 'old-fs'
+ assert shared_fieldset_data['fields'][0]['api_name'] == old_field_api
+ assert create_api_name_mock.call_count == 2
+
+
+def test__get_new_fieldset_data__default_params__ok(mocker):
+
+ """
+ Default params
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs'}
+ replace_api_names_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._replace_api_names',
+ return_value={'api_name': 'mocked-api', 'order': 3},
+ )
+
+ # act
+ result = service.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert 'order' not in result
+ replace_api_names_mock.assert_called_once_with(shared_fieldset_data)
+
+
+def test__get_new_fieldset_data__api_name_provided__ok(mocker):
+
+ """
+ api_name provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs'}
+ override_api_name = 'custom-api'
+ replace_api_names_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._replace_api_names',
+ return_value={'api_name': 'mocked-api'},
+ )
+
+ # act
+ result = service.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ api_name=override_api_name,
+ )
+
+ # assert
+ assert result['api_name'] == override_api_name
+ replace_api_names_mock.assert_called_once_with(shared_fieldset_data)
+
+
+def test__get_new_fieldset_data__api_name_omitted__ok(mocker):
+
+ """
+ api_name omitted
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs'}
+ mocked_api_name = 'mocked-api'
+ replace_api_names_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._replace_api_names',
+ return_value={'api_name': mocked_api_name},
+ )
+
+ # act
+ result = service.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert result['api_name'] == mocked_api_name
+ replace_api_names_mock.assert_called_once_with(shared_fieldset_data)
+
+
+def test__get_new_fieldset_data__title_provided__ok(mocker):
+
+ """
+ title provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs'}
+ override_title = 'Custom Title'
+ replace_api_names_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._replace_api_names',
+ return_value={'api_name': 'api', 'title': 'Original'},
+ )
+
+ # act
+ result = service.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ title=override_title,
+ )
+
+ # assert
+ assert result['title'] == override_title
+ replace_api_names_mock.assert_called_once_with(shared_fieldset_data)
+
+
+def test__get_new_fieldset_data__title_omitted__ok(mocker):
+
+ """
+ title omitted
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs'}
+ original_title = 'Original Title'
+ replace_api_names_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._replace_api_names',
+ return_value={'api_name': 'api', 'title': original_title},
+ )
+
+ # act
+ result = service.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert result['title'] == original_title
+ replace_api_names_mock.assert_called_once_with(shared_fieldset_data)
+
+
+def test__get_new_fieldset_data__description_provided__ok(mocker):
+
+ """
+ description provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs'}
+ override_description = 'New description'
+ replace_api_names_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._replace_api_names',
+ return_value={'api_name': 'api', 'description': 'Old desc'},
+ )
+
+ # act
+ result = service.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ description=override_description,
+ )
+
+ # assert
+ assert result['description'] == override_description
+ replace_api_names_mock.assert_called_once_with(shared_fieldset_data)
+
+
+def test__get_new_fieldset_data__description_omitted__ok(mocker):
+
+ """
+ description omitted
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs'}
+ original_description = 'Original description'
+ replace_api_names_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._replace_api_names',
+ return_value={'api_name': 'api', 'description': original_description},
+ )
+
+ # act
+ result = service.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert result['description'] == original_description
+ replace_api_names_mock.assert_called_once_with(shared_fieldset_data)
+
+
+def test__get_new_fieldset_data__order_present__removed(mocker):
+
+ """
+ order present in data
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs', 'order': 5}
+ replace_api_names_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._replace_api_names',
+ return_value={'api_name': 'api', 'order': 5},
+ )
+
+ # act
+ result = service.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert 'order' not in result
+ replace_api_names_mock.assert_called_once_with(shared_fieldset_data)
+
+
+def test__get_new_fieldset_data__no_order__ok(mocker):
+
+ """
+ order absent in data
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs'}
+ replace_api_names_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService._replace_api_names',
+ return_value={'api_name': 'api'},
+ )
+
+ # act
+ result = service.get_new_fieldset_data(
+ shared_fieldset_data=shared_fieldset_data,
+ )
+
+ # assert
+ assert 'order' not in result
+ replace_api_names_mock.assert_called_once_with(shared_fieldset_data)
+
+
+def test__create_from_shared__default_params__ok(mocker):
+
+ """
+ Default optional params
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs', 'name': 'Fieldset'}
+ shared_fieldset_id = 42
+ fieldset_data_from_mock = {'api_name': 'new-fs', 'name': 'Fieldset'}
+ get_new_fieldset_data_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.get_new_fieldset_data',
+ return_value=fieldset_data_from_mock,
+ )
+ create_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.create',
+ return_value=mocker.Mock(),
+ )
+
+ # act
+ result = service.create_from_shared(
+ shared_fieldset_data=shared_fieldset_data,
+ shared_fieldset_id=shared_fieldset_id,
+ template_id=template.id,
+ )
+
+ # assert
+ assert result is create_mock.return_value
+ get_new_fieldset_data_mock.assert_called_once_with(
+ shared_fieldset_data=shared_fieldset_data,
+ api_name=None,
+ title=None,
+ description=None,
+ )
+ create_mock.assert_called_once_with(
+ api_name='new-fs',
+ name='Fieldset',
+ is_shared=False,
+ shared_fieldset_id=shared_fieldset_id,
+ order=0,
+ kickoff_id=None,
+ task_id=None,
+ template_id=template.id,
+ )
+
+
+def test__create_from_shared__all_params__ok(mocker):
+
+ """
+ All params provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.get(number=1)
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ shared_fieldset_data = {'api_name': 'old-fs', 'name': 'Fieldset'}
+ shared_fieldset_id = 10
+ api_name = 'custom-api'
+ title = 'Custom Title'
+ description = 'Custom desc'
+ order = 3
+ fieldset_data_from_mock = {'api_name': api_name, 'name': 'Fieldset'}
+ get_new_fieldset_data_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.get_new_fieldset_data',
+ return_value=fieldset_data_from_mock,
+ )
+ create_mock = mocker.patch(
+ 'src.processes.services.fieldsets.fieldset.'
+ 'FieldSetTemplateService.create',
+ return_value=mocker.Mock(),
+ )
+
+ # act
+ result = service.create_from_shared(
+ shared_fieldset_data=shared_fieldset_data,
+ shared_fieldset_id=shared_fieldset_id,
+ template_id=template.id,
+ order=order,
+ kickoff_id=kickoff.id,
+ task_id=task.id,
+ api_name=api_name,
+ title=title,
+ description=description,
+ )
+
+ # assert
+ assert result is create_mock.return_value
+ get_new_fieldset_data_mock.assert_called_once_with(
+ shared_fieldset_data=shared_fieldset_data,
+ api_name=api_name,
+ title=title,
+ description=description,
+ )
+ create_mock.assert_called_once_with(
+ api_name=api_name,
+ name='Fieldset',
+ is_shared=False,
+ shared_fieldset_id=shared_fieldset_id,
+ order=order,
+ kickoff_id=kickoff.id,
+ task_id=task.id,
+ template_id=template.id,
+ )
+
+
+def test__partial_update_instance__no_kwargs__ok():
+
+ """
+ No kwargs
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ )
+ original_name = fieldset.name
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ result = service.partial_update_instance()
+
+ # assert
+ assert result is fieldset
+ fieldset.refresh_from_db()
+ assert fieldset.name == original_name
+
+
+def test__partial_update_instance__kwargs_provided__ok():
+
+ """
+ kwargs provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ )
+ new_name = 'Updated Fieldset Name'
+ service = FieldSetTemplateService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ result = service.partial_update_instance(name=new_name)
+
+ # assert
+ assert result is not None
+ fieldset.refresh_from_db()
+ assert fieldset.name == new_name
+
+
+def test__to_json__is_shared__ok(mocker):
+
+ """
+ is_shared=True
+ """
+
+ # arrange
+ account = create_test_account()
+ fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ name='Shared FS',
+ is_shared=True,
+ )
+ serializer_data = {'id': fieldset.id, 'name': 'Shared FS'}
+ shared_fs_slz_mock = mocker.patch(
+ 'src.processes.serializers.templates.fieldset.'
+ 'SharedFieldsetTemplateSerializer',
+ )
+ shared_fs_slz_mock.return_value.data = serializer_data
+
+ # act
+ result = FieldSetTemplateService.to_json(fieldset=fieldset)
+
+ # assert
+ assert result == serializer_data
+ shared_fs_slz_mock.assert_called_once_with(fieldset)
+
+
+def test__to_json__not_shared__ok(mocker):
+
+ """
+ is_shared=False
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ )
+ serializer_data = {'id': fieldset.id, 'name': fieldset.name}
+ fieldset_template_serializer_mock = mocker.patch(
+ 'src.processes.serializers.templates.fieldset.'
+ 'FieldsetTemplateSerializer',
+ )
+ fieldset_template_serializer_mock.return_value.data = serializer_data
+
+ # act
+ result = FieldSetTemplateService.to_json(fieldset=fieldset)
+
+ # assert
+ assert result == serializer_data
+ fieldset_template_serializer_mock.assert_called_once_with(fieldset)
diff --git a/backend/src/processes/tests/test_services/test_tasks/test_task_service.py b/backend/src/processes/tests/test_services/test_tasks/test_task_service.py
index 206a35ecf..379973062 100644
--- a/backend/src/processes/tests/test_services/test_tasks/test_task_service.py
+++ b/backend/src/processes/tests/test_services/test_tasks/test_task_service.py
@@ -13,11 +13,22 @@
ChecklistTemplate,
ChecklistTemplateSelection,
)
+from src.processes.models.templates.fields import FieldTemplate
+from src.processes.models.templates.raw_due_date import RawDueDateTemplate
from src.processes.models.workflows.fields import TaskField
from src.processes.models.workflows.raw_due_date import RawDueDate
+from src.processes.models.workflows.task import Delay
from src.authentication.enums import AuthTokenType
+from src.processes.services.tasks.checklist import ChecklistService
+from src.processes.services.tasks.field import TaskFieldService
from src.processes.services.tasks.task import TaskService
+from src.processes.services.workflows.fieldsets.fieldset import FieldSetService
from src.processes.tests.fixtures import (
+ create_checklist_template,
+ create_test_account,
+ create_test_admin,
+ create_test_fieldset_template,
+ create_test_owner,
create_test_template,
create_test_user,
create_test_workflow,
@@ -1242,3 +1253,877 @@ def test_insert_fields_values__no_checklists__skip(mocker):
markdown_clear_mock.assert_called_once_with(new_description)
checklist_service_init_mock.assert_not_called()
save_mock.assert_called_once_with()
+
+
+def test_partial_update__default__ok(mocker):
+
+ """
+ Default call
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ markdown_clear_mock = mocker.patch(
+ 'src.processes.services.tasks.task.MarkdownService.clear',
+ )
+ save_mock = mocker.patch(
+ 'src.processes.services.tasks.task.TaskService.save',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.partial_update()
+
+ # assert
+ markdown_clear_mock.assert_not_called()
+ save_mock.assert_not_called()
+
+
+def test_partial_update__with_desc__clear_desc_set(mocker):
+
+ """
+ With description
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ description = 'New description'
+ clear_description = 'New clear description'
+ markdown_clear_mock = mocker.patch(
+ 'src.processes.services.tasks.task.MarkdownService.clear',
+ return_value=clear_description,
+ )
+ save_mock = mocker.patch(
+ 'src.processes.services.tasks.task.TaskService.save',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.partial_update(description=description)
+
+ # assert
+ assert task.description == description
+ assert task.clear_description == clear_description
+ markdown_clear_mock.assert_called_once_with(description)
+ save_mock.assert_not_called()
+
+
+def test_partial_update__no_desc__clear_desc_skip(mocker):
+
+ """
+ Without description
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ original_clear_description = task.clear_description
+ markdown_clear_mock = mocker.patch(
+ 'src.processes.services.tasks.task.MarkdownService.clear',
+ )
+ save_mock = mocker.patch(
+ 'src.processes.services.tasks.task.TaskService.save',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.partial_update(name='New name')
+
+ # assert
+ assert task.name == 'New name'
+ assert task.clear_description == original_clear_description
+ markdown_clear_mock.assert_not_called()
+ save_mock.assert_not_called()
+
+
+def test_partial_update__date_started_first__set(mocker):
+
+ """
+ date_started, first start
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ task.date_first_started = None
+ task.save(update_fields=['date_first_started'])
+ date_started = timezone.now()
+ markdown_clear_mock = mocker.patch(
+ 'src.processes.services.tasks.task.MarkdownService.clear',
+ )
+ save_mock = mocker.patch(
+ 'src.processes.services.tasks.task.TaskService.save',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.partial_update(date_started=date_started)
+
+ # assert
+ assert task.date_first_started == date_started
+ assert 'date_first_started' in service.update_fields
+ markdown_clear_mock.assert_not_called()
+ save_mock.assert_not_called()
+
+
+def test_partial_update__date_started_already__skip(mocker):
+
+ """
+ date_started, already started
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ original_date_first_started = timezone.now() - timedelta(hours=1)
+ task.date_first_started = original_date_first_started
+ task.save(update_fields=['date_first_started'])
+ date_started = timezone.now()
+ markdown_clear_mock = mocker.patch(
+ 'src.processes.services.tasks.task.MarkdownService.clear',
+ )
+ save_mock = mocker.patch(
+ 'src.processes.services.tasks.task.TaskService.save',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.partial_update(date_started=date_started)
+
+ # assert
+ assert task.date_first_started == original_date_first_started
+ assert 'date_first_started' not in service.update_fields
+ markdown_clear_mock.assert_not_called()
+ save_mock.assert_not_called()
+
+
+def test_partial_update__force_save__saved(mocker):
+
+ """
+ force_save True
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ markdown_clear_mock = mocker.patch(
+ 'src.processes.services.tasks.task.MarkdownService.clear',
+ )
+ save_mock = mocker.patch(
+ 'src.processes.services.tasks.task.TaskService.save',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.partial_update(force_save=True, name='Updated')
+
+ # assert
+ assert task.name == 'Updated'
+ markdown_clear_mock.assert_not_called()
+ save_mock.assert_called_once_with()
+
+
+def test_create_raw_performers_from_template__default__ok(mocker):
+
+ """
+ Default call
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ add_raw_performer_mock = mocker.patch(
+ 'src.processes.models.workflows.task.Task.add_raw_performer',
+ )
+ update_raw_performers_mock = mocker.patch(
+ 'src.processes.models.workflows.task.Task'
+ '.update_raw_performers_from_task_template',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_raw_performers_from_template(
+ instance_template=template_task,
+ )
+
+ # assert
+ add_raw_performer_mock.assert_not_called()
+ update_raw_performers_mock.assert_called_once_with(template_task)
+
+
+def test_create_raw_performers_from_template__redefined__ok(mocker):
+
+ """
+ With redefined_performer
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ redefined_performer = create_test_admin(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ add_raw_performer_mock = mocker.patch(
+ 'src.processes.models.workflows.task.Task.add_raw_performer',
+ )
+ update_raw_performers_mock = mocker.patch(
+ 'src.processes.models.workflows.task.Task'
+ '.update_raw_performers_from_task_template',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_raw_performers_from_template(
+ instance_template=template_task,
+ redefined_performer=redefined_performer,
+ )
+
+ # assert
+ add_raw_performer_mock.assert_called_once_with(
+ user=redefined_performer,
+ )
+ update_raw_performers_mock.assert_not_called()
+
+
+def test_create_fields_from_template__no_fields__ok(mocker):
+
+ """
+ Default call, no fields
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ task_field_service_init_mock = mocker.patch.object(
+ TaskFieldService,
+ '__init__',
+ return_value=None,
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_fields_from_template(instance_template=template_task)
+
+ # assert
+ task_field_service_init_mock.assert_not_called()
+
+
+def test_create_fields_from_template__outside_fs__ok(mocker):
+
+ """
+ Fields outside fieldsets
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ field_template_1 = FieldTemplate.objects.create(
+ name='Field 1',
+ type=FieldType.STRING,
+ task=template_task,
+ template=template,
+ order=1,
+ api_name='field-1',
+ account=user.account,
+ )
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ task_field_service_init_mock = mocker.patch.object(
+ TaskFieldService,
+ '__init__',
+ return_value=None,
+ )
+ task_field_service_create_mock = mocker.patch(
+ 'src.processes.services.tasks.field.TaskFieldService.create',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_fields_from_template(instance_template=template_task)
+
+ # assert
+ task_field_service_init_mock.assert_called_once_with(user=user)
+ task_field_service_create_mock.assert_called_once_with(
+ instance_template=field_template_1,
+ workflow_id=task.workflow_id,
+ task_id=task.id,
+ skip_value=True,
+ )
+
+
+def test_create_fields_from_template__in_fieldsets__skip(mocker):
+
+ """
+ Fields inside fieldsets
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ task=template_task,
+ )
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ task_field_service_init_mock = mocker.patch.object(
+ TaskFieldService,
+ '__init__',
+ return_value=None,
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_fields_from_template(instance_template=template_task)
+
+ # assert
+ task_field_service_init_mock.assert_not_called()
+
+
+def test_create_fieldsets_from_template__no_fieldsets__ok(mocker):
+
+ """
+ Default call, no fieldsets
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ field_set_service_init_mock = mocker.patch.object(
+ FieldSetService,
+ '__init__',
+ return_value=None,
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_fieldsets_from_template(instance_template=template_task)
+
+ # assert
+ field_set_service_init_mock.assert_not_called()
+
+
+def test_create_fieldsets_from_template__with_fieldsets__ok(mocker):
+
+ """
+ With fieldsets
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ fieldset_template = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ task=template_task,
+ order=5,
+ )
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ field_set_service_init_mock = mocker.patch.object(
+ FieldSetService,
+ '__init__',
+ return_value=None,
+ )
+ field_set_service_create_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset'
+ '.FieldSetService.create',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_fieldsets_from_template(instance_template=template_task)
+
+ # assert
+ field_set_service_init_mock.assert_called_once_with(user=user)
+ field_set_service_create_mock.assert_called_once_with(
+ instance_template=fieldset_template,
+ account_id=task.workflow.account_id,
+ workflow=task.workflow,
+ task=task,
+ order=5,
+ skip_value=True,
+ )
+
+
+def test_create_conditions_from_template__no_conditions__ok(mocker):
+
+ """
+ Default call, no conditions
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+
+ # remove default conditions
+ template_task.conditions.all().delete()
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ create_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.mixins.ConditionMixin.create_rules',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_conditions_from_template(
+ instance_template=template_task,
+ )
+
+ # assert
+ create_rules_mock.assert_not_called()
+
+
+def test_create_conditions_from_template__with_conditions__ok(mocker):
+
+ """
+ With conditions
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=2)
+ template_task = template.tasks.get(number=2)
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=2)
+ create_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.mixins.ConditionMixin.create_rules',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_conditions_from_template(
+ instance_template=template_task,
+ )
+
+ # assert
+ create_rules_mock.assert_called_once_with(
+ mocker.ANY,
+ mocker.ANY,
+ )
+
+
+def test_create_checklists_from_template__no_checklists__ok(mocker):
+
+ """
+ Default call, no checklists
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ checklist_service_init_mock = mocker.patch.object(
+ ChecklistService,
+ '__init__',
+ return_value=None,
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_checklists_from_template(
+ instance_template=template_task,
+ )
+
+ # assert
+ checklist_service_init_mock.assert_not_called()
+
+
+def test_create_checklists_from_template__with_checklists__ok(mocker):
+
+ """
+ With checklists
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ checklist_template_1 = create_checklist_template(
+ task_template=template_task,
+ selections_count=1,
+ )
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ checklist_service_init_mock = mocker.patch.object(
+ ChecklistService,
+ '__init__',
+ return_value=None,
+ )
+ checklist_service_create_mock = mocker.patch(
+ 'src.processes.services.tasks.checklist.ChecklistService.create',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_checklists_from_template(
+ instance_template=template_task,
+ )
+
+ # assert
+ checklist_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=service.is_superuser,
+ auth_type=service.auth_type,
+ )
+ checklist_service_create_mock.assert_called_once_with(
+ instance_template=checklist_template_1,
+ task=task,
+ )
+
+
+def test_create_raw_due_date_from_template__no_raw__ok():
+
+ """
+ Default call, no raw_due_date
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_raw_due_date_from_template(
+ instance_template=template_task,
+ )
+
+ # assert
+ assert not RawDueDate.objects.filter(task=task).exists()
+
+
+def test_create_raw_due_date_from_template__with_raw__ok():
+
+ """
+ With raw_due_date
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ duration = timedelta(hours=2)
+ duration_months = 1
+ RawDueDateTemplate.objects.create(
+ task=template_task,
+ template=template,
+ duration=duration,
+ duration_months=duration_months,
+ rule=DueDateRule.AFTER_WORKFLOW_STARTED,
+ source_id=None,
+ )
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+
+ # remove any raw_due_date created by workflow fixture
+ RawDueDate.objects.filter(task=task).delete()
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_raw_due_date_from_template(
+ instance_template=template_task,
+ )
+
+ # assert
+ raw_due_date = RawDueDate.objects.get(task=task)
+ assert raw_due_date.duration == duration
+ assert raw_due_date.duration_months == duration_months
+ assert raw_due_date.rule == DueDateRule.AFTER_WORKFLOW_STARTED
+ assert raw_due_date.source_id is None
+
+
+def test_get_task_due_date__after_started_same__none():
+
+ """
+ After task started, same, no date
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ task.date_first_started = None
+ task.save(update_fields=['date_first_started'])
+ RawDueDate.objects.create(
+ task=task,
+ duration=timedelta(days=1),
+ rule=DueDateRule.AFTER_TASK_STARTED,
+ source_id=task.api_name,
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ due_date = service.get_task_due_date()
+
+ # assert
+ assert due_date is None
+
+
+def test_get_task_due_date__field_no_value__none():
+
+ """
+ Field rule, no value
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=2)
+ task_1 = workflow.tasks.get(number=1)
+ task_2 = workflow.tasks.get(number=2)
+ field = TaskField.objects.create(
+ task=task_1,
+ name='date',
+ api_name='date-1',
+ type=FieldType.DATE,
+ value='',
+ workflow=workflow,
+ account=user.account,
+ )
+ RawDueDate.objects.create(
+ task=task_2,
+ duration=timedelta(days=1),
+ rule=DueDateRule.AFTER_FIELD,
+ source_id=field.api_name,
+ )
+ service = TaskService(user=user, instance=task_2)
+
+ # act
+ due_date = service.get_task_due_date()
+
+ # assert
+ assert due_date is None
+
+
+def test_create_related__no_delay__ok(mocker):
+
+ """
+ Default call, no delay
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ template_task.delay = None
+ template_task.save(update_fields=['delay'])
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ create_raw_performers_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_raw_performers_from_template',
+ )
+ create_fields_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_fields_from_template',
+ )
+ create_fieldsets_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_fieldsets_from_template',
+ )
+ create_conditions_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_conditions_from_template',
+ )
+ create_checklists_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_checklists_from_template',
+ )
+ create_raw_due_date_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_raw_due_date_from_template',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service._create_related(
+ instance_template=template_task,
+ )
+
+ # assert
+ assert not Delay.objects.filter(task=task).exists()
+ create_raw_performers_mock.assert_called_once_with(
+ instance_template=template_task,
+ redefined_performer=None,
+ )
+ create_fields_mock.assert_called_once_with(template_task)
+ create_fieldsets_mock.assert_called_once_with(template_task)
+ create_conditions_mock.assert_called_once_with(template_task)
+ create_checklists_mock.assert_called_once_with(template_task)
+ create_raw_due_date_mock.assert_called_once_with(template_task)
+
+
+def test_create_related__with_delay__ok(mocker):
+
+ """
+ With delay
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ template_task = template.tasks.get(number=1)
+ delay_value = timedelta(seconds=30)
+ template_task.delay = delay_value
+ template_task.save(update_fields=['delay'])
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+
+ # remove delays created by workflow fixture
+ Delay.objects.filter(task=task).delete()
+ create_raw_performers_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_raw_performers_from_template',
+ )
+ create_fields_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_fields_from_template',
+ )
+ create_fieldsets_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_fieldsets_from_template',
+ )
+ create_conditions_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_conditions_from_template',
+ )
+ create_checklists_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_checklists_from_template',
+ )
+ create_raw_due_date_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.create_raw_due_date_from_template',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service._create_related(
+ instance_template=template_task,
+ )
+
+ # assert
+ delay = Delay.objects.get(task=task)
+ assert delay.duration == delay_value
+ assert delay.workflow == workflow
+ create_raw_performers_mock.assert_called_once_with(
+ instance_template=template_task,
+ redefined_performer=None,
+ )
+ create_fields_mock.assert_called_once_with(template_task)
+ create_fieldsets_mock.assert_called_once_with(template_task)
+ create_conditions_mock.assert_called_once_with(template_task)
+ create_checklists_mock.assert_called_once_with(template_task)
+ create_raw_due_date_mock.assert_called_once_with(template_task)
+
+
+def test_set_due_date_directly__default__ok(mocker):
+
+ """
+ Default call
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ partial_update_mock = mocker.patch(
+ 'src.processes.services.tasks.task'
+ '.TaskService.partial_update',
+ )
+ send_notifications_mock = mocker.patch(
+ 'src.notifications.tasks.send_due_date_changed.delay',
+ )
+ due_date_changed_event_mock = mocker.patch(
+ 'src.processes.services.events.WorkflowEventService'
+ '.due_date_changed_event',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.set_due_date_directly()
+
+ # assert
+ partial_update_mock.assert_called_once_with(
+ due_date=None,
+ force_save=True,
+ )
+ send_notifications_mock.assert_called_once_with(
+ logging=user.account.log_api_requests,
+ author_id=user.id,
+ task_id=task.id,
+ account_id=task.account_id,
+ logo_lg=user.account.logo_lg,
+ )
+ due_date_changed_event_mock.assert_called_once_with(
+ task=task,
+ user=user,
+ )
+
+
+def test_create_fields_from_template__deleted_fieldsets__skip(mocker):
+
+ """
+ Field inside an active fieldset is excluded,
+ field inside a soft-deleted fieldset is created as standalone.
+ """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(user=user, tasks_count=1)
+ template_task = template.tasks.get(number=1)
+ fieldset = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ task=template_task,
+ )
+ fieldset.delete()
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ task_field_service_init_mock = mocker.patch.object(
+ TaskFieldService,
+ '__init__',
+ return_value=None,
+ )
+ task_field_service_create_mock = mocker.patch(
+ 'src.processes.services.tasks.field.TaskFieldService.create',
+ )
+ service = TaskService(user=user, instance=task)
+
+ # act
+ service.create_fields_from_template(instance_template=template_task)
+
+ # assert
+ task_field_service_init_mock.assert_not_called()
+ task_field_service_create_mock.assert_not_called()
diff --git a/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py b/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py
index 325c832ff..b55679202 100644
--- a/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py
+++ b/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py
@@ -1,6 +1,6 @@
-from datetime import timedelta
-
import pytest
+from datetime import timedelta
+
from django.contrib.auth import get_user_model
from django.utils import timezone
@@ -13,6 +13,8 @@
PredicateOperator,
WorkflowStatus,
)
+from src.processes.models.workflows.fieldset import FieldSet, FieldSetRule
+from src.processes.models.workflows.fields import FieldSelection, TaskField
from src.processes.models.workflows.raw_due_date import RawDueDate
from src.processes.models.workflows.task import (
Delay,
@@ -35,8 +37,16 @@
create_test_owner,
create_test_template,
create_test_workflow,
+ create_test_fieldset,
+)
+
+from src.processes.enums import (
+ FieldSetLayout,
+ FieldSetRuleType,
+ LabelPosition,
)
+
UserModel = get_user_model()
pytestmark = pytest.mark.django_db
@@ -1833,3 +1843,916 @@ def test_update_performers__removed_group_user_already_performer__not_sent(
get_data_for_list_mock.assert_not_called()
send_new_task_notification_mock.assert_not_called()
send_task_deleted_notification_mock.assert_not_called()
+
+
+def test__update_field__fieldset_none__ok():
+
+ """
+ Call with default `fieldset=None`
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {
+ 'api_name': 'field-1',
+ 'name': 'Test Field',
+ 'description': 'Test description',
+ 'type': FieldType.STRING,
+ 'is_required': False,
+ 'is_hidden': False,
+ 'order': 1,
+ 'dataset_id': None,
+ }
+
+ # act
+ field, created = service._update_field(field_data=field_data)
+
+ # assert
+ assert created is True
+ assert field.api_name == 'field-1'
+ assert field.name == 'Test Field'
+ assert field.description == 'Test description'
+ assert field.type == FieldType.STRING
+ assert field.is_required is False
+ assert field.is_hidden is False
+ assert field.order == 1
+ assert field.fieldset is None
+ assert field.task == task
+ assert field.workflow == workflow
+ assert field.account == user.account
+
+
+def test__update_field__fieldset_provided__ok():
+
+ """
+ Call with an explicit `fieldset` instance
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {
+ 'api_name': 'field-1',
+ 'name': 'Test Field',
+ 'description': 'Test description',
+ 'type': FieldType.STRING,
+ 'is_required': False,
+ 'is_hidden': False,
+ 'order': 1,
+ 'dataset_id': None,
+ }
+
+ # act
+ field, created = service._update_field(
+ field_data=field_data,
+ fieldset=fieldset,
+ )
+
+ # assert
+ assert created is True
+ assert field.api_name == 'field-1'
+ assert field.fieldset == fieldset
+ assert field.task == task
+ assert field.workflow == workflow
+ assert field.account == user.account
+
+
+def test__update_field_selections__no_selections_key__skip():
+
+ """
+ `field_data` has no `selections` key — `if` block is skipped
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ task_field = TaskField.objects.create(
+ task=task,
+ workflow=workflow,
+ account=user.account,
+ type=FieldType.DROPDOWN,
+ name='Test Field',
+ api_name='field-1',
+ order=0,
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {}
+
+ # act
+ service._update_field_selections(field=task_field, field_data=field_data)
+
+ # assert
+ assert FieldSelection.objects.filter(field=task_field).count() == 0
+
+
+def test__update_field_selections__selections_empty__skip():
+
+ """
+ `field_data['selections']` is an empty list — `if` block is skipped
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ task_field = TaskField.objects.create(
+ task=task,
+ workflow=workflow,
+ account=user.account,
+ type=FieldType.DROPDOWN,
+ name='Test Field',
+ api_name='field-1',
+ order=0,
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {'selections': []}
+
+ # act
+ service._update_field_selections(field=task_field, field_data=field_data)
+
+ # assert
+ assert FieldSelection.objects.filter(field=task_field).count() == 0
+
+
+def test__update_field_selections__selections_exist__ok():
+
+ """
+ `field_data['selections']` has items — `if` block executes
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ task_field = TaskField.objects.create(
+ task=task,
+ workflow=workflow,
+ account=user.account,
+ type=FieldType.DROPDOWN,
+ name='Test Field',
+ api_name='field-1',
+ order=0,
+ )
+ old_selection = FieldSelection.objects.create(
+ field=task_field,
+ api_name='old-selection-1',
+ value='Old Value',
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {
+ 'selections': [
+ {
+ 'api_name': 'selection-1',
+ 'value': 'New Value',
+ },
+ ],
+ }
+
+ # act
+ service._update_field_selections(field=task_field, field_data=field_data)
+
+ # assert
+ assert not FieldSelection.objects.filter(id=old_selection.id).exists()
+ assert FieldSelection.objects.filter(
+ field=task_field,
+ api_name='selection-1',
+ value='New Value',
+ ).exists()
+
+
+def test__update_fieldset_rules__rules_data_none__skip():
+
+ """
+ `rules_data=None` — treated as empty list, loop does not execute
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ )
+ existing_rule = FieldSetRule.objects.create(
+ fieldset=fieldset,
+ account_id=user.account_id,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ api_name='rule-1',
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+
+ # act
+ service._update_fieldset_rules(fieldset=fieldset, rules_data=None)
+
+ # assert
+ assert not FieldSetRule.objects.filter(id=existing_rule.id).exists()
+
+
+def test__update_fieldset_rules__rules_data_empty__skip():
+
+ """
+ `rules_data` is an empty list — loop does not execute
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ )
+ existing_rule = FieldSetRule.objects.create(
+ fieldset=fieldset,
+ account_id=user.account_id,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ api_name='rule-1',
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+
+ # act
+ service._update_fieldset_rules(fieldset=fieldset, rules_data=[])
+
+ # assert
+ assert not FieldSetRule.objects.filter(id=existing_rule.id).exists()
+
+
+def test__update_fieldset_rules__rules_data_provided__ok():
+
+ """
+ `rules_data` has items — loop executes for each rule
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ )
+ old_rule = FieldSetRule.objects.create(
+ fieldset=fieldset,
+ account_id=user.account_id,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='50',
+ api_name='old-rule-1',
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ rules_data = [
+ {
+ 'api_name': 'rule-1',
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '100',
+ },
+ ]
+
+ # act
+ service._update_fieldset_rules(fieldset=fieldset, rules_data=rules_data)
+
+ # assert
+ assert not FieldSetRule.objects.filter(id=old_rule.id).exists()
+ assert FieldSetRule.objects.filter(
+ fieldset=fieldset,
+ api_name='rule-1',
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ ).exists()
+
+
+def test__update_fieldset_fields__fields_data_none__skip(mocker):
+
+ """
+ `fields_data=None` — treated as empty list, loop does not execute
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ )
+ old_field = TaskField.objects.create(
+ workflow=workflow,
+ account=user.account,
+ type=FieldType.STRING,
+ name='Old Field',
+ api_name='old-field-1',
+ fieldset=fieldset,
+ order=0,
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ _update_field_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_field',
+ )
+ _update_field_selections_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_field_selections',
+ )
+ _update_field_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_field_rules',
+ )
+
+ # act
+ service._update_fieldset_fields(fieldset=fieldset, fields_data=None)
+
+ # assert
+ assert not TaskField.objects.filter(id=old_field.id).exists()
+ _update_field_mock.assert_not_called()
+ _update_field_selections_mock.assert_not_called()
+ _update_field_rules_mock.assert_not_called()
+
+
+def test__update_fieldset_fields__fields_data_empty__skip(mocker):
+
+ """
+ `fields_data` is an empty list — loop does not execute
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ )
+ old_field = TaskField.objects.create(
+ workflow=workflow,
+ account=user.account,
+ type=FieldType.STRING,
+ name='Old Field',
+ api_name='old-field-1',
+ fieldset=fieldset,
+ order=0,
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ _update_field_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_field',
+ )
+ _update_field_selections_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_field_selections',
+ )
+ _update_field_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_field_rules',
+ )
+
+ # act
+ service._update_fieldset_fields(fieldset=fieldset, fields_data=[])
+
+ # assert
+ assert not TaskField.objects.filter(id=old_field.id).exists()
+ _update_field_mock.assert_not_called()
+ _update_field_selections_mock.assert_not_called()
+ _update_field_rules_mock.assert_not_called()
+
+
+def test__update_fieldset_fields__fields_data_provided__ok(mocker):
+
+ """
+ `fields_data` has items — loop executes for each field
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ )
+ old_field = TaskField.objects.create(
+ workflow=workflow,
+ account=user.account,
+ type=FieldType.STRING,
+ name='Old Field',
+ api_name='old-field-1',
+ fieldset=fieldset,
+ order=0,
+ )
+ new_field = TaskField.objects.create(
+ task=task,
+ workflow=workflow,
+ account=user.account,
+ type=FieldType.STRING,
+ name='New Field',
+ api_name='new-field-1',
+ fieldset=fieldset,
+ order=1,
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ fields_data = [
+ {
+ 'api_name': 'new-field-1',
+ 'name': 'New Field',
+ 'description': '',
+ 'type': FieldType.STRING,
+ 'is_required': False,
+ 'is_hidden': False,
+ 'order': 1,
+ 'dataset_id': None,
+ },
+ ]
+ _update_field_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_field',
+ return_value=(new_field, False),
+ )
+ _update_field_selections_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_field_selections',
+ )
+ _update_field_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_field_rules',
+ )
+
+ # act
+ service._update_fieldset_fields(fieldset=fieldset, fields_data=fields_data)
+
+ # assert
+ assert not TaskField.objects.filter(id=old_field.id).exists()
+ assert TaskField.objects.filter(id=new_field.id).exists()
+ _update_field_mock.assert_called_once_with(
+ fields_data[0],
+ fieldset=fieldset,
+ )
+ _update_field_selections_mock.assert_called_once_with(
+ new_field,
+ fields_data[0],
+ )
+ _update_field_rules_mock.assert_called_once_with(
+ new_field,
+ fields_data[0],
+ fieldset,
+ )
+
+
+def test__update_fieldsets__data_none__ok(mocker):
+
+ """
+ `data=None` — loop does not execute, all task fieldsets are deleted
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ old_fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ _update_fieldset_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_fieldset_rules',
+ )
+ _update_fieldset_fields_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_fieldset_fields',
+ )
+
+ # act
+ service._update_fieldsets(data=None)
+
+ # assert
+ assert not FieldSet.objects.filter(id=old_fieldset.id).exists()
+ _update_fieldset_rules_mock.assert_not_called()
+ _update_fieldset_fields_mock.assert_not_called()
+
+
+def test__update_fieldsets__data_empty__ok(mocker):
+
+ """
+ `data` is an empty list — loop does not execute,
+ all task fieldsets are deleted
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ old_fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ _update_fieldset_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_fieldset_rules',
+ )
+ _update_fieldset_fields_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_fieldset_fields',
+ )
+
+ # act
+ service._update_fieldsets(data=[])
+
+ # assert
+ assert not FieldSet.objects.filter(id=old_fieldset.id).exists()
+ _update_fieldset_rules_mock.assert_not_called()
+ _update_fieldset_fields_mock.assert_not_called()
+
+
+def test__update_fieldsets__data_provided__ok(mocker):
+
+ """
+ `data` has items — loop executes for each fieldset
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=2)
+ task_1 = workflow.tasks.get(number=1)
+ old_fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task_1,
+ )
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task_1,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ data = [
+ {
+ 'api_name': 'fieldset-1',
+ 'name': 'New Fieldset',
+ 'title': 'Test title',
+ 'description': 'Test description',
+ 'order': 2,
+ 'label_position': LabelPosition.TOP,
+ 'layout': FieldSetLayout.VERTICAL,
+ 'rules': [
+ {
+ 'api_name': 'rule-1',
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '100',
+ },
+ ],
+ 'fields': [
+ {
+ 'api_name': 'field-1',
+ 'name': 'Test Field',
+ 'description': '',
+ 'type': FieldType.STRING,
+ 'is_required': False,
+ 'is_hidden': False,
+ 'order': 1,
+ 'dataset_id': None,
+ },
+ ],
+ },
+ ]
+ _update_fieldset_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_fieldset_rules',
+ )
+ _update_fieldset_fields_mock = mocker.patch(
+ 'src.processes.services.tasks.task_version.'
+ 'TaskUpdateVersionService._update_fieldset_fields',
+ )
+
+ # act
+ service._update_fieldsets(data=data)
+
+ # assert
+ assert not FieldSet.objects.filter(id=old_fieldset.id).exists()
+ new_fieldset = FieldSet.objects.get(
+ api_name='fieldset-1',
+ task=task_1,
+ )
+ assert new_fieldset.name == 'New Fieldset'
+ assert new_fieldset.description == 'Test description'
+ assert new_fieldset.order == 2
+ _update_fieldset_rules_mock.assert_called_once_with(
+ fieldset=new_fieldset,
+ rules_data=data[0]['rules'],
+ )
+ _update_fieldset_fields_mock.assert_called_once_with(
+ fieldset=new_fieldset,
+ fields_data=data[0]['fields'],
+ )
+
+
+def test__update_field_rules__rules_provided__ok():
+
+ """
+ `field_data` contains a non-empty `rules` list —
+ matching FieldSetRule is linked to the field via M2M.
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fs-1',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.first()
+ field = fieldset.fields.first()
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {
+ 'rules': [
+ {'api_name': 'fs-1-rule-1'},
+ ],
+ }
+
+ # act
+ service._update_field_rules(field, field_data, fieldset)
+
+ # assert
+ assert field.rules.count() == 1
+ assert field.rules.filter(id=rule.id).exists()
+
+
+def test__update_field_rules__rules_empty_list__clear():
+
+ """
+ `field_data['rules']` is an empty list —
+ existing M2M relations are cleared.
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fs-1',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.get(api_name='fs-1-rule-1')
+ field = fieldset.fields.get(api_name='fs-1-field-1')
+ field.rules.add(rule)
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {'rules': []}
+
+ # act
+ service._update_field_rules(field, field_data, fieldset)
+
+ # assert
+ assert field.rules.count() == 0
+
+
+def test__update_field_rules__rules_key_missing__clear():
+
+ """
+ `field_data` does not contain `rules` key —
+ `.get('rules', [])` returns `[]`, M2M is cleared.
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fs-1',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.get(api_name='fs-1-rule-1')
+ field = fieldset.fields.get(api_name='fs-1-field-1')
+ field.rules.add(rule)
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {}
+
+ # act
+ service._update_field_rules(field, field_data, fieldset)
+
+ # assert
+ assert field.rules.count() == 0
+
+
+def test__update_field_rules__multiple_rules__ok():
+
+ """
+ `field_data` contains multiple rules —
+ all matching FieldSetRule instances are linked.
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fs-1',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule_1 = fieldset.rules.get(api_name='fs-1-rule-1')
+ rule_2 = FieldSetRule.objects.create(
+ fieldset=fieldset,
+ account_id=user.account_id,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='200',
+ api_name='fs-1-rule-2',
+ )
+ field = fieldset.fields.get(api_name='fs-1-field-1')
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {
+ 'rules': [
+ {'api_name': 'fs-1-rule-1'},
+ {'api_name': 'fs-1-rule-2'},
+ ],
+ }
+
+ # act
+ service._update_field_rules(field, field_data, fieldset)
+
+ # assert
+ assert field.rules.count() == 2
+ assert field.rules.filter(id=rule_1.id).exists()
+ assert field.rules.filter(id=rule_2.id).exists()
+
+
+def test__update_field_rules__replaces_existing_rules__ok():
+
+ """
+ Field already has a linked rule — it is replaced by the new one.
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fs-1',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='50',
+ )
+ old_rule = fieldset.rules.get(api_name='fs-1-rule-1')
+ new_rule = FieldSetRule.objects.create(
+ fieldset=fieldset,
+ account_id=user.account_id,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ api_name='new-rule',
+ )
+ field = fieldset.fields.get(api_name='fs-1-field-1')
+ field.rules.add(old_rule)
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {'rules': [{'api_name': 'new-rule'}]}
+
+ # act
+ service._update_field_rules(field, field_data, fieldset)
+
+ # assert
+ assert field.rules.count() == 1
+ assert field.rules.filter(id=new_rule.id).exists()
+ assert not field.rules.filter(id=old_rule.id).exists()
+
+
+def test__update_field_rules__nonexistent_api_name__skip():
+
+ """
+ `api_name` in `field_data` does not match any FieldSetRule —
+ no rules are found, M2M is set to empty.
+ """
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fs-1',
+ )
+ field = fieldset.fields.get(api_name='fs-1-field-1')
+ service = TaskUpdateVersionService(
+ user=user,
+ instance=task,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ )
+ field_data = {'rules': [{'api_name': 'nonexistent-rule'}]}
+
+ # act
+ service._update_field_rules(field, field_data, fieldset)
+
+ # assert
+ assert field.rules.count() == 0
diff --git a/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py b/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py
index e07d7162f..84a6c45ba 100644
--- a/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py
+++ b/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py
@@ -2,6 +2,7 @@
from django.contrib.auth import get_user_model
from src.processes.enums import (
+ FieldSetRuleType,
FieldType,
WorkflowEventType,
)
@@ -10,8 +11,14 @@
FieldTemplate,
FieldTemplateSelection,
)
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplateRule,
+)
from src.processes.models.workflows.attachment import FileAttachment
from src.processes.models.workflows.event import WorkflowEvent
+from src.processes.models.workflows.fieldset import (
+ FieldSetRule,
+)
from src.processes.models.workflows.fields import (
TaskField,
FieldSelection,
@@ -34,7 +41,10 @@
create_test_owner,
create_test_template,
create_test_user,
- create_test_workflow, create_test_dataset,
+ create_test_workflow,
+ create_test_dataset,
+ create_test_fieldset_template,
+ create_test_fieldset,
)
UserModel = get_user_model()
@@ -869,6 +879,10 @@ def test__create_related__file_type_not_skip__ok(mocker):
'src.processes.services.tasks.field.'
'TaskFieldService._create_selections',
)
+ link_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService._link_rules',
+ )
service = TaskFieldService(instance=task_field, user=user)
raw_value = ['123']
@@ -882,6 +896,7 @@ def test__create_related__file_type_not_skip__ok(mocker):
# assert
link_new_attachments_mock.assert_called_once_with(raw_value)
create_selections_mock.assert_not_called()
+ link_rules_mock.assert_not_called()
def test__create_related__file_type_skip__skip(mocker):
@@ -917,6 +932,10 @@ def test__create_related__file_type_skip__skip(mocker):
'src.processes.services.tasks.field.'
'TaskFieldService._create_selections',
)
+ link_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService._link_rules',
+ )
service = TaskFieldService(instance=task_field, user=user)
# act
@@ -929,6 +948,7 @@ def test__create_related__file_type_skip__skip(mocker):
# assert
link_new_attachments_mock.assert_not_called()
create_selections_mock.assert_not_called()
+ link_rules_mock.assert_not_called()
def test__create_related__selection_type__ok(mocker):
@@ -964,6 +984,10 @@ def test__create_related__selection_type__ok(mocker):
'src.processes.services.tasks.field.'
'TaskFieldService._create_selections',
)
+ link_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService._link_rules',
+ )
service = TaskFieldService(instance=task_field, user=user)
# act
@@ -974,6 +998,7 @@ def test__create_related__selection_type__ok(mocker):
# assert
create_selections_mock.assert_called_once_with(field_template)
link_new_attachments_mock.assert_not_called()
+ link_rules_mock.assert_not_called()
def test__create_related__other_type__skip(mocker):
@@ -1009,6 +1034,10 @@ def test__create_related__other_type__skip(mocker):
'src.processes.services.tasks.field.'
'TaskFieldService._create_selections',
)
+ link_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService._link_rules',
+ )
service = TaskFieldService(instance=task_field, user=user)
# act
@@ -1019,6 +1048,59 @@ def test__create_related__other_type__skip(mocker):
# assert
link_new_attachments_mock.assert_not_called()
create_selections_mock.assert_not_called()
+ link_rules_mock.assert_not_called()
+
+
+def test__create_related__with_rules__ok(mocker):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ task_template = template.tasks.first()
+ fieldset_template = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task_template,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ field_template = fieldset_template.fields.first()
+ rule_template = fieldset_template.rules.first()
+ rule_template.fields.add(field_template)
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.get(number=1)
+ task_field = TaskField.objects.create(
+ task=task,
+ api_name='string-field-1',
+ type=FieldType.STRING,
+ workflow=workflow,
+ account=account,
+ )
+ link_new_attachments_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService._link_new_attachments',
+ )
+ create_selections_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService._create_selections',
+ )
+ link_rules_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService._link_rules',
+ )
+ service = TaskFieldService(instance=task_field, user=user)
+ kwargs = {'some': 'data'}
+
+ # act
+ service._create_related(
+ instance_template=field_template,
+ **kwargs,
+ )
+
+ # assert
+ link_new_attachments_mock.assert_not_called()
+ create_selections_mock.assert_not_called()
+ link_rules_mock.assert_called_once_with(field_template, **kwargs)
def test_partial_update__ok(mocker):
@@ -2391,3 +2473,315 @@ def test__get_valid_value__not_required_and_null_value__ok(
# assert
assert result == FieldData()
get_valid_string_value_mock.assert_not_called()
+
+
+def test__link_rules__one_rule__ok():
+
+ """One template rule → one FieldSetRule linked"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ task_template = template.tasks.first()
+ fieldset_api_name = 'fs1'
+ fieldset_template = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task_template,
+ api_name=fieldset_api_name,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ field_template = fieldset_template.fields.first()
+ rule_template = fieldset_template.rules.first()
+ rule_template.fields.add(field_template)
+
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name=fieldset_api_name,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ rule = fieldset.rules.first()
+ task_field = fieldset.fields.first()
+
+ service = TaskFieldService(
+ instance=task_field,
+ user=user,
+ )
+
+ # act
+ service._link_rules(
+ instance_template=field_template,
+ fieldset_id=fieldset.id,
+ )
+
+ # assert
+ assert task_field.rules.count() == 1
+ assert task_field.rules.first() == rule
+
+
+def test__link_rules__multiple_rules__ok():
+
+ """Two template rules → two FieldSetRules linked"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ task_template = template.tasks.first()
+ fieldset_api_name = 'fs1'
+ fieldset_template = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task_template,
+ api_name=fieldset_api_name,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ rule_tmpl_2 = FieldsetTemplateRule.objects.create(
+ fieldset=fieldset_template,
+ account=account,
+ api_name=f'{fieldset_api_name}-rule-2',
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='200',
+ )
+ field_template = fieldset_template.fields.first()
+ rule_tmpl_1 = fieldset_template.rules.get(
+ api_name=f'{fieldset_api_name}-rule-1',
+ )
+ field_template.rules.set(
+ [rule_tmpl_1, rule_tmpl_2],
+ )
+
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name=fieldset_api_name,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ rule_2 = FieldSetRule.objects.create(
+ fieldset=fieldset,
+ account=account,
+ api_name=f'{fieldset_api_name}-rule-2',
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='200',
+ )
+ rule_1 = fieldset.rules.exclude(id=rule_2.id).first()
+ task_field = fieldset.fields.first()
+
+ service = TaskFieldService(
+ instance=task_field,
+ user=user,
+ )
+
+ # act
+ service._link_rules(
+ instance_template=field_template,
+ fieldset_id=fieldset.id,
+ )
+
+ # assert
+ assert task_field.rules.count() == 2
+ linked_ids = set(
+ task_field.rules.values_list('id', flat=True),
+ )
+ assert linked_ids == {rule_1.id, rule_2.id}
+
+
+def test__link_rules__partial_match__ok():
+
+ """Two template rules, only one FieldSetRule exists
+ — only matched one linked"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ task_template = template.tasks.first()
+ fieldset_api_name = 'fs1'
+ fieldset_template = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task_template,
+ api_name=fieldset_api_name,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ rule_tmpl_2 = FieldsetTemplateRule.objects.create(
+ fieldset=fieldset_template,
+ account=account,
+ api_name=f'{fieldset_api_name}-rule-2',
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='200',
+ )
+ field_template = fieldset_template.fields.first()
+ rule_tmpl_1 = fieldset_template.rules.get(
+ api_name=f'{fieldset_api_name}-rule-1',
+ )
+ field_template.rules.set(
+ [rule_tmpl_1, rule_tmpl_2],
+ )
+
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name=fieldset_api_name,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ rule = fieldset.rules.first()
+ task_field = fieldset.fields.first()
+
+ service = TaskFieldService(
+ instance=task_field,
+ user=user,
+ )
+
+ # act
+ service._link_rules(
+ instance_template=field_template,
+ fieldset_id=fieldset.id,
+ )
+
+ # assert
+ assert task_field.rules.count() == 1
+ assert task_field.rules.first() == rule
+
+
+def test__link_rules__no_matching_rules__empty():
+
+ """Template has rule, but no FieldSetRule
+ with that api_name — M2M stays empty"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ task_template = template.tasks.first()
+ fieldset_api_name = 'fs1'
+ fieldset_template = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task_template,
+ api_name=fieldset_api_name,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ field_template = fieldset_template.fields.first()
+ rule_template = fieldset_template.rules.first()
+ rule_template.fields.add(field_template)
+
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ task = workflow.tasks.get(number=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name=fieldset_api_name,
+ )
+ FieldSetRule.objects.create(
+ fieldset=fieldset,
+ account=account,
+ api_name='different-rule',
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='999',
+ )
+ task_field = fieldset.fields.first()
+
+ service = TaskFieldService(
+ instance=task_field,
+ user=user,
+ )
+
+ # act
+ service._link_rules(
+ instance_template=field_template,
+ fieldset_id=fieldset.id,
+ )
+
+ # assert
+ assert task_field.rules.count() == 0
+
+
+def test__link_rules__another_fieldset_rule__not_linked():
+
+ """FieldSetRule has matching api_name
+ but belongs to another fieldset — not linked"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ task_template = template.tasks.first()
+ fieldset_api_name = 'fs1'
+ fieldset_template = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task_template,
+ api_name=fieldset_api_name,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ field_template = fieldset_template.fields.first()
+ rule_template = fieldset_template.rules.first()
+ rule_template.fields.add(field_template)
+
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ task = workflow.tasks.get(number=1)
+ fieldset_1 = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name=fieldset_api_name,
+ )
+ task_field = fieldset_1.fields.first()
+ create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fs2',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+
+ service = TaskFieldService(
+ instance=task_field,
+ user=user,
+ )
+
+ # act
+ service._link_rules(
+ instance_template=field_template,
+ fieldset_id=fieldset_1.id,
+ )
+
+ # assert
+ assert task_field.rules.count() == 0
diff --git a/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py b/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py
index b9972fded..18a8c64f5 100644
--- a/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py
+++ b/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py
@@ -502,6 +502,7 @@ def test_get_template_data__ok(mocker):
assert template_data['kickoff'] == {
'description': '',
'fields': [],
+ 'fieldsets': [],
}
task_1_data = template_data['tasks'][0]
assert task_1_data['number'] == 1
diff --git a/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py
new file mode 100644
index 000000000..809e13b41
--- /dev/null
+++ b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py
@@ -0,0 +1,619 @@
+import pytest
+
+from src.authentication.enums import AuthTokenType
+from src.processes.enums import (
+ FieldSetRuleType,
+ FieldType,
+)
+from src.processes.messages import fieldset as fs_messages
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+ FieldsetTemplateRule,
+)
+from src.processes.models.workflows.fields import TaskField
+from src.processes.services.exceptions import FieldsetServiceException
+from src.processes.services.workflows.fieldsets.fieldset_rule import (
+ FieldSetRuleService,
+)
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_owner,
+ create_test_template,
+ create_test_workflow,
+ create_test_fieldset,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+def test__create_instance__with_template__ok():
+
+ """
+ Call with instance_template and fieldset
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ name='Fieldset',
+ order=1,
+ )
+ fieldset_template = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset tmpl',
+ )
+ rule_template = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset_template,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+
+ # act
+ service._create_instance(
+ instance_template=rule_template,
+ fieldset=fieldset,
+ )
+
+ # assert
+ assert service.instance is not None
+ assert service.instance.fieldset_id == fieldset.id
+ assert service.instance.type == FieldSetRuleType.SUM_EQUAL
+ assert service.instance.value == '100'
+ assert service.instance.api_name == rule_template.api_name
+ assert service.instance.account_id == account.id
+
+
+def test__validate_sum_equal__within_threshold__ok():
+
+ """
+ Total within threshold
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ name='Fieldset',
+ order=1,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.first()
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ value='30',
+ order=1,
+ )
+ field_1.rules.add(rule)
+ field_2 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ value='70',
+ order=2,
+ )
+ field_2.rules.add(rule)
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ result = service._validate_sum_equal(
+ fieldset=fieldset,
+ value='100',
+ )
+
+ # assert
+ assert result is True
+
+
+def test__validate_sum_equal__one_not_required_field_blank__not_count():
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ name='Fieldset',
+ order=1,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.first()
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ value='',
+ order=1,
+ is_required=False,
+ )
+ field_1.rules.add(rule)
+ field_2 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ value='100',
+ order=2,
+ is_required=True,
+ )
+ field_2.rules.add(rule)
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ result = service._validate_sum_equal(
+ fieldset=fieldset,
+ value='100',
+ )
+
+ # assert
+ assert result is True
+
+
+def test__validate_sum_equal__all_not_required_fields_blank__not_count():
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ name='Fieldset',
+ order=1,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.first()
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ value='',
+ order=1,
+ is_required=False,
+ )
+ field_1.rules.add(rule)
+ field_2 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ value='',
+ order=2,
+ is_required=False,
+ )
+ field_2.rules.add(rule)
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ result = service._validate_sum_equal(
+ fieldset=fieldset,
+ value='100',
+ )
+
+ # assert
+ assert result is True
+
+
+def test__validate_sum_equal__required_field_blank__raise_exception():
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ name='Fieldset',
+ order=1,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.first()
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ value='',
+ order=1,
+ is_required=True,
+ )
+ field_1.rules.add(rule)
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ with pytest.raises(FieldsetServiceException) as ex:
+ service._validate_sum_equal(
+ fieldset=fieldset,
+ value='100',
+ )
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0002(100)
+
+
+def test__validate_sum_equal__negative_value__ok():
+
+ """
+ Total within threshold
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ name='Fieldset',
+ order=1,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='0',
+ )
+ rule = fieldset.rules.first()
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ value='30',
+ order=1,
+ )
+ field_1.rules.add(rule)
+ field_2 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ value='-30',
+ order=2,
+ )
+ field_2.rules.add(rule)
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ result = service._validate_sum_equal(
+ fieldset=fieldset,
+ value='0',
+ )
+
+ # assert
+ assert result is True
+
+
+def test__validate_sum_equal__exceeds__raise_exception():
+
+ """
+ Total exceeds threshold → exception
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ name='Fieldset',
+ order=1,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.first()
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ value='60',
+ order=1,
+ )
+ field_1.rules.add(rule)
+ field_2 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ value='50',
+ order=2,
+ )
+ field_2.rules.add(rule)
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ with pytest.raises(FieldsetServiceException) as ex:
+ service._validate_sum_equal(
+ fieldset=fieldset,
+ value='100',
+ )
+
+ # assert
+ assert ex.value.message == fs_messages.MSG_FS_0002(100)
+
+
+def test__validate_sum_equal__null_values__skip():
+
+ """
+ Fields with null values skipped
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ name='Fieldset',
+ order=1,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.first()
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ value='100',
+ order=1,
+ )
+ field_1.rules.add(rule)
+ field_2 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ value='',
+ order=2,
+ )
+ field_2.rules.add(rule)
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=rule,
+ )
+
+ # act
+ result = service._validate_sum_equal(
+ fieldset=fieldset,
+ value='100',
+ )
+
+ # assert
+ assert result is True
+
+
+def test_validate__ok(mocker):
+
+ """
+ Known type, validator exists
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user, tasks_count=1)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=workflow.kickoff_instance,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ rule = fieldset.rules.first()
+ service = FieldSetRuleService(
+ instance=rule,
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+
+ validate_sum_equal_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService._validate_sum_equal',
+ )
+ kwargs = {'some': 'value'}
+
+ # act
+ service.validate(**kwargs)
+
+ # assert
+ validate_sum_equal_mock.assert_called_once_with(**kwargs)
+
+
+def test_create__default_params__ok(mocker):
+
+ """
+ Call with default params
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_instance_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService._create_instance',
+ )
+ create_related_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService._create_related',
+ )
+ create_actions_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService._create_actions',
+ )
+ validate_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService.validate',
+ )
+ instance = mocker.Mock()
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=instance,
+ )
+ kwargs = {
+ 'some': 'value',
+ }
+
+ # act
+ result = service.create(**kwargs)
+
+ # assert
+ create_instance_mock.assert_called_once_with(**kwargs)
+ create_related_mock.assert_called_once_with(**kwargs)
+ create_actions_mock.assert_called_once_with(**kwargs)
+ validate_mock.assert_not_called()
+ assert result is instance
+
+
+def test_create__skip_validation_false__ok(mocker):
+
+ """
+ Call with skip_validation=False
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_instance_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService._create_instance',
+ )
+ create_related_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService._create_related',
+ )
+ create_actions_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService._create_actions',
+ )
+ validate_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService.validate',
+ )
+ instance = mocker.Mock()
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=instance,
+ )
+ kwargs = {
+ 'skip_validation': False,
+ }
+
+ # act
+ result = service.create(**kwargs)
+
+ # assert
+ create_instance_mock.assert_called_once_with(**kwargs)
+ create_related_mock.assert_called_once_with(**kwargs)
+ create_actions_mock.assert_called_once_with(**kwargs)
+ validate_mock.assert_called_once_with(**kwargs)
+ assert result is instance
+
+
+def test_partial_update__default_params__ok(mocker):
+
+ """
+ Call with default params
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ service = FieldSetRuleService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+
+ result_mock = mocker.Mock()
+ mock_super_partial_update = mocker.patch(
+ 'src.generics.base.service.'
+ 'BaseModelService.partial_update',
+ return_value=result_mock,
+ )
+ validate_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService.validate',
+ )
+ kwargs = {
+ 'value': 200,
+ }
+
+ # act
+ result = service.partial_update(**kwargs)
+
+ # assert
+ assert result is result_mock
+ mock_super_partial_update.assert_called_once_with(**kwargs)
+ validate_mock.assert_called_once_with(**kwargs)
diff --git a/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py
new file mode 100644
index 000000000..affcf9333
--- /dev/null
+++ b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py
@@ -0,0 +1,649 @@
+import pytest
+from src.authentication.enums import AuthTokenType
+from src.processes.enums import (
+ FieldSetRuleType,
+ FieldType,
+)
+from src.processes.messages.fieldset import (
+ MSG_FS_0002,
+ MSG_FS_0007,
+ MSG_FS_0012,
+)
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+ FieldsetTemplateRule,
+)
+from src.processes.models.templates.fields import FieldTemplate
+from src.processes.models.workflows.fieldset import (
+ FieldSetRule,
+)
+from src.processes.services.exceptions import FieldsetServiceException
+from src.processes.services.tasks.field import TaskFieldService
+from src.processes.services.workflows.fieldsets.fieldset import (
+ FieldSetService,
+)
+from src.processes.services.workflows.fieldsets.fieldset_rule import (
+ FieldSetRuleService,
+)
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_fieldset,
+ create_test_owner,
+ create_test_template,
+ create_test_workflow,
+ create_test_fieldset_template,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+def test__create_instance__with_kickoff__ok():
+
+ """
+ Call with kickoff
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ kickoff = workflow.kickoff_instance
+ order = 11
+ fieldset_template = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ order=order,
+ )
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+
+ # act
+ service._create_instance(
+ instance_template=fieldset_template,
+ workflow=workflow,
+ kickoff=kickoff,
+ order=order,
+ )
+
+ # assert
+ assert service.instance.account == account
+ assert service.instance.workflow_id == workflow.id
+ assert service.instance.task is None
+ assert service.instance.kickoff == kickoff
+ assert service.instance.api_name == fieldset_template.api_name
+ assert service.instance.name == fieldset_template.name
+ assert service.instance.title == fieldset_template.title
+ assert service.instance.description == fieldset_template.description
+ assert service.instance.order == order
+ assert service.instance.label_position == fieldset_template.label_position
+ assert service.instance.layout == fieldset_template.layout
+
+
+def test__create_instance__with_task__ok():
+
+ """
+ Call with task
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ task = workflow.tasks.first()
+ order = 11
+ fieldset_template = create_test_fieldset_template(
+ template=template,
+ account=account,
+ order=order,
+ )
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+
+ # act
+ service._create_instance(
+ instance_template=fieldset_template,
+ workflow=workflow,
+ task=task,
+ )
+
+ # assert
+ assert service.instance.account == account
+ assert service.instance.workflow == workflow
+ assert service.instance.task == task
+ assert service.instance.kickoff is None
+ assert service.instance.api_name == fieldset_template.api_name
+ assert service.instance.name == fieldset_template.name
+ assert service.instance.title == fieldset_template.title
+ assert service.instance.description == fieldset_template.description
+ assert service.instance.order == order
+ assert service.instance.label_position == fieldset_template.label_position
+ assert service.instance.layout == fieldset_template.layout
+
+
+def test__create_instance__no_kickoff_no_task__raise_exception():
+
+ """
+ Call without kickoff and task
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset_template = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ order = 11
+
+ # act
+ with pytest.raises(FieldsetServiceException) as ex:
+ service._create_instance(
+ instance_template=fieldset_template,
+ workflow=workflow,
+ order=order,
+ )
+
+ # assert
+ assert ex.value.message == MSG_FS_0007
+
+
+def test__create_fields__default_params__ok(mocker):
+
+ """
+ Call with default parameters
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset_template = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset_template,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ order=1,
+ )
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ )
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ task_field_service_init_mock = mocker.patch.object(
+ TaskFieldService,
+ attribute='__init__',
+ return_value=None,
+ )
+ task_field_service_create_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService.create',
+ )
+
+ # act
+ service._create_fields(
+ instance_template=fieldset_template,
+ )
+
+ # assert
+ task_field_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ task_field_service_create_mock.assert_called_once_with(
+ instance_template=fieldset_template.fields.first(),
+ workflow_id=fieldset.workflow_id,
+ fieldset_id=fieldset.id,
+ skip_value=False,
+ value='',
+ )
+
+
+def test__create_fields__with_fields_data__ok(mocker):
+
+ """
+ Call with fields_data provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset_template = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ field_template_1 = FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset_template,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ order=1,
+ )
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ )
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ fields_data = {field_template_1.api_name: '42'}
+ task_field_service_init_mock = mocker.patch.object(
+ TaskFieldService,
+ attribute='__init__',
+ return_value=None,
+ )
+ task_field_service_create_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService.create',
+ )
+
+ # act
+ service._create_fields(
+ instance_template=fieldset_template,
+ fields_data=fields_data,
+ )
+
+ # assert
+ task_field_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ task_field_service_create_mock.assert_called_once_with(
+ instance_template=field_template_1,
+ workflow_id=fieldset.workflow_id,
+ fieldset_id=fieldset.id,
+ skip_value=False,
+ value='42',
+ )
+
+
+def test__create_fields__skip_value_true__ok(mocker):
+
+ """
+ Call with skip_value=True
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset_template = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ FieldTemplate.objects.create(
+ account=account,
+ fieldset=fieldset_template,
+ name='Field 1',
+ type=FieldType.NUMBER,
+ order=1,
+ )
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ )
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ task_field_service_init_mock = mocker.patch.object(
+ TaskFieldService,
+ attribute='__init__',
+ return_value=None,
+ )
+ task_field_service_create_mock = mocker.patch(
+ 'src.processes.services.tasks.field.'
+ 'TaskFieldService.create',
+ )
+
+ # act
+ service._create_fields(
+ instance_template=fieldset_template,
+ skip_value=True,
+ )
+
+ # assert
+ task_field_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ task_field_service_create_mock.assert_called_once_with(
+ instance_template=fieldset_template.fields.first(),
+ workflow_id=fieldset.workflow_id,
+ fieldset_id=fieldset.id,
+ skip_value=True,
+ value='',
+ )
+
+
+def test__create_rules__with_template__ok(mocker):
+
+ """
+ Call with instance_template
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset_template = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ rule_template = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset_template,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='100',
+ )
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ )
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ field_set_rule_service_init_mock = mocker.patch.object(
+ FieldSetRuleService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_rule_service_create_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService.create',
+ )
+
+ # act
+ service._create_rules(instance_template=fieldset_template)
+
+ # assert
+ field_set_rule_service_init_mock.assert_called_once_with(
+ user=user,
+ )
+ field_set_rule_service_create_mock.assert_called_once_with(
+ instance_template=rule_template,
+ fieldset=fieldset,
+ skip_validation=None,
+ )
+
+
+def test__create_related__with_template__ok(mocker):
+
+ """
+ Call with instance_template
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ fieldset_template = FieldsetTemplate.objects.create(
+ template=template,
+ account=account,
+ name='Fieldset',
+ )
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ create_fields_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset.'
+ 'FieldSetService._create_fields',
+ )
+ create_rules_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset.'
+ 'FieldSetService._create_rules',
+ )
+
+ # act
+ service._create_related(instance_template=fieldset_template)
+
+ # assert
+ create_rules_mock.assert_called_once_with(
+ fieldset_template,
+ )
+ create_fields_mock.assert_called_once_with(
+ fieldset_template,
+ )
+
+
+def test_validate_rules__one_rule__ok(mocker):
+
+ """
+ Call with rules
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule = fieldset.rules.first()
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+ field_set_rule_service_init_mock = mocker.patch.object(
+ FieldSetRuleService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_rule_service_validate_mock = mocker.patch(
+ 'src.processes.services.workflows.fieldsets.fieldset_rule.'
+ 'FieldSetRuleService.validate',
+ )
+
+ # act
+ service.validate_rules()
+
+ # assert
+ field_set_rule_service_init_mock.assert_called_once_with(
+ user=user,
+ instance=rule,
+ )
+ field_set_rule_service_validate_mock.assert_called_once_with()
+
+
+def test_validate_rules__one_rule_none_matches__raise_exception(mocker):
+
+ """
+ Call with rules
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user=user, tasks_count=1)
+ workflow = create_test_workflow(user=user, template=template)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ field = fieldset.fields.first()
+ rule_100 = fieldset.rules.first()
+ rule_100.fields.add(field)
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ with pytest.raises(FieldsetServiceException) as ex:
+ service.validate_rules()
+
+ # assert
+ assert ex.value.message == MSG_FS_0002('100')
+
+
+def test_validate_rules__two_same_type_rules__first_value_matches__ok():
+
+ """
+ Two sum_equal rules with different values.
+ Fields sum equals first rule value — OR-logic: validation passes.
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='10',
+ )
+ field = fieldset.fields.first()
+ rule_10 = fieldset.rules.first()
+ rule_10.fields.add(field)
+ rule_0 = FieldSetRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='0',
+ )
+ rule_0.fields.add(field)
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ service.validate_rules()
+
+ # assert
+ assert service.instance == fieldset
+
+
+def test_validate_rules__two_same_type_rules__second_value_matches__ok():
+
+ """
+ Two sum_equal rules with different values.
+ Fields sum equals second rule value — OR-logic: validation passes.
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ field = fieldset.fields.first()
+ rule_100 = fieldset.rules.first()
+ rule_100.fields.add(field)
+ rule_10 = FieldSetRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='10',
+ )
+ rule_10.fields.add(field)
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ service.validate_rules()
+
+ # assert
+ assert service.instance == fieldset
+
+
+def test_validate_rules__two_same_type_rules__none_matches__raise():
+
+ """
+ Two sum_equal rules with different values.
+ Fields sum does not match any rule value —
+ raises FieldsetServiceException with MSG_FS_0012
+ listing all values from the group.
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user)
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ field = fieldset.fields.first()
+ rule_100 = fieldset.rules.first()
+ rule_100.fields.add(field)
+ rule_0 = FieldSetRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='0',
+ )
+ rule_0.fields.add(field)
+ service = FieldSetService(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ instance=fieldset,
+ )
+
+ # act
+ with pytest.raises(FieldsetServiceException) as ex:
+ service.validate_rules()
+
+ # assert
+ assert ex.value.message == MSG_FS_0012('100, 0')
diff --git a/backend/src/processes/tests/test_services/test_workflows/test_kickoff_version_service.py b/backend/src/processes/tests/test_services/test_workflows/test_kickoff_version_service.py
new file mode 100644
index 000000000..e002caf02
--- /dev/null
+++ b/backend/src/processes/tests/test_services/test_workflows/test_kickoff_version_service.py
@@ -0,0 +1,1107 @@
+import pytest
+
+from src.authentication.enums import AuthTokenType
+from src.processes.enums import (
+ FieldSetLayout,
+ FieldSetRuleType,
+ FieldType,
+ LabelPosition,
+)
+from src.processes.models.workflows.fields import (
+ FieldSelection,
+ TaskField,
+)
+from src.processes.models.workflows.fieldset import (
+ FieldSet,
+ FieldSetRule,
+)
+from src.processes.services.workflows.kickoff_version import (
+ KickoffUpdateVersionService,
+)
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_fieldset,
+ create_test_owner,
+ create_test_template,
+ create_test_workflow,
+)
+
+
+pytestmark = pytest.mark.django_db
+
+
+def test__update_field__no_fieldset__ok():
+
+ """
+ fieldset is None (default)
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ field_data = {
+ 'api_name': 'field-1',
+ 'name': 'Test field',
+ 'description': 'Desc',
+ 'type': FieldType.STRING,
+ 'is_required': True,
+ 'is_hidden': False,
+ 'order': 1,
+ 'dataset_id': None,
+ }
+
+ # act
+ field, created = service._update_field(
+ template=field_data,
+ fieldset=None,
+ )
+
+ # assert
+ assert created is True
+ assert field.kickoff == kickoff
+ assert field.api_name == 'field-1'
+ assert field.name == 'Test field'
+ assert field.description == 'Desc'
+ assert field.type == FieldType.STRING
+ assert field.is_required is True
+ assert field.is_hidden is False
+ assert field.order == 1
+ assert field.workflow == kickoff.workflow
+ assert field.account == kickoff.account
+ assert field.dataset_id is None
+ assert field.fieldset is None
+
+
+def test__update_field__fieldset__ok():
+
+ """
+ fieldset provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ name='Test fieldset',
+ api_name='fs-1',
+ )
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ field_data = {
+ 'api_name': 'field-1',
+ 'name': 'Number field',
+ 'description': '',
+ 'type': FieldType.NUMBER,
+ 'is_required': False,
+ 'is_hidden': True,
+ 'order': 2,
+ 'dataset_id': None,
+ }
+
+ # act
+ field, created = service._update_field(
+ template=field_data,
+ fieldset=fieldset,
+ )
+
+ # assert
+ assert created is True
+ assert field.kickoff == kickoff
+ assert field.fieldset == fieldset
+ assert field.api_name == 'field-1'
+ assert field.name == 'Number field'
+ assert field.type == FieldType.NUMBER
+ assert field.is_required is False
+ assert field.is_hidden is True
+ assert field.order == 2
+
+
+def test__update_field_selections__provided__ok():
+
+ """
+ selections provided — creates and deletes stale
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ field = TaskField.objects.create(
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ api_name='field-1',
+ name='Checkbox',
+ type=FieldType.CHECKBOX,
+ order=1,
+ )
+
+ # stale selection to be deleted
+ stale_selection = FieldSelection.objects.create(
+ field=field,
+ api_name='sel-old',
+ value='Old value',
+ )
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ field_data = {
+ 'selections': [
+ {
+ 'api_name': 'sel-1',
+ 'value': 'First',
+ },
+ {
+ 'api_name': 'sel-2',
+ 'value': 'Second',
+ },
+ ],
+ }
+
+ # act
+ service._update_field_selections(
+ field=field,
+ field_data=field_data,
+ )
+
+ # assert
+ selections = field.selections.order_by('api_name')
+ assert selections.count() == 2
+ assert selections[0].api_name == 'sel-1'
+ assert selections[0].value == 'First'
+ assert selections[1].api_name == 'sel-2'
+ assert selections[1].value == 'Second'
+ assert FieldSelection.objects.filter(
+ id=stale_selection.id,
+ ).exists() is False
+
+
+def test__update_field_selections__empty__skip():
+
+ """
+ no selections — skips
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ field = TaskField.objects.create(
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ api_name='field-1',
+ name='Text field',
+ type=FieldType.STRING,
+ order=1,
+ )
+ existing_selection = FieldSelection.objects.create(
+ field=field,
+ api_name='sel-existing',
+ value='Existing',
+ )
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ field_data = {}
+
+ # act
+ service._update_field_selections(
+ field=field,
+ field_data=field_data,
+ )
+
+ # assert
+ assert field.selections.count() == 1
+ assert field.selections.filter(
+ id=existing_selection.id,
+ ).exists() is True
+
+
+def test__update_fieldset_rules__rules_data_is_none__delete_all():
+
+ """
+ rules_data is None — defaults to empty, deletes all
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ name='FS',
+ api_name='fs-1',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ existing_rule = fieldset.rules.first()
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+
+ # act
+ service._update_fieldset_rules(
+ fieldset=fieldset,
+ rules_data=None,
+ )
+
+ # assert
+ assert fieldset.rules.count() == 0
+ assert FieldSetRule.objects.filter(
+ id=existing_rule.id,
+ ).exists() is False
+
+
+def test__update_fieldset_rules__provided__ok():
+
+ """
+ rules_data provided — creates and deletes stale
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ name='FS',
+ api_name='fs-1',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='50',
+ )
+
+ # stale rule to be deleted
+ stale_rule = fieldset.rules.first()
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ rules_data = [
+ {
+ 'api_name': 'rule-1',
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '100',
+ },
+ ]
+
+ # act
+ service._update_fieldset_rules(
+ fieldset=fieldset,
+ rules_data=rules_data,
+ )
+
+ # assert
+ rules = fieldset.rules.all()
+ assert rules.count() == 1
+ assert rules[0].api_name == 'rule-1'
+ assert rules[0].type == FieldSetRuleType.SUM_EQUAL
+ assert rules[0].value == '100'
+ assert rules[0].account_id == account.id
+ assert FieldSetRule.objects.filter(
+ id=stale_rule.id,
+ ).exists() is False
+
+
+def test__update_field_rules__provided__ok():
+
+ """
+ rules provided — links rules
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ name='FS',
+ api_name='fs-1',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule_1 = fieldset.rules.first()
+ field = TaskField.objects.create(
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ fieldset=fieldset,
+ api_name='field-1',
+ name='Number field',
+ type=FieldType.NUMBER,
+ order=1,
+ )
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ field_data = {
+ 'rules': [
+ {'api_name': rule_1.api_name},
+ ],
+ }
+
+ # act
+ service._update_field_rules(
+ field=field,
+ field_data=field_data,
+ fieldset=fieldset,
+ )
+
+ # assert
+ assert field.rules.count() == 1
+ assert field.rules.filter(id=rule_1.id).exists() is True
+
+
+def test__update_field_rules__empty__clear():
+
+ """
+ no rules — clears rules
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ name='FS',
+ api_name='fs-1',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ rule_1 = fieldset.rules.first()
+ field = TaskField.objects.create(
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ fieldset=fieldset,
+ api_name='field-1',
+ name='Number field',
+ type=FieldType.NUMBER,
+ order=1,
+ )
+ field.rules.add(rule_1)
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ field_data = {}
+
+ # act
+ service._update_field_rules(
+ field=field,
+ field_data=field_data,
+ fieldset=fieldset,
+ )
+
+ # assert
+ assert field.rules.count() == 0
+
+
+def test__update_fields__provided__ok(mocker):
+
+ """
+ fields data provided — deletes stale fields
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ field_1 = TaskField.objects.create(
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ api_name='field-1',
+ name='Field 1',
+ type=FieldType.STRING,
+ order=1,
+ )
+
+ # stale field to be deleted
+ stale_field = TaskField.objects.create(
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ api_name='field-stale',
+ name='Stale',
+ type=FieldType.STRING,
+ order=2,
+ )
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ field_data_1 = {
+ 'api_name': 'field-1',
+ 'name': 'Field 1',
+ 'description': '',
+ 'type': FieldType.STRING,
+ 'is_required': False,
+ 'is_hidden': False,
+ 'order': 1,
+ 'dataset_id': None,
+ }
+ data = [field_data_1]
+
+ update_field_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_field',
+ return_value=(field_1, True),
+ )
+ update_field_selections_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_field_selections',
+ )
+
+ # act
+ service._update_fields(data=data)
+
+ # assert
+ update_field_mock.assert_called_once_with(
+ field_data_1, fieldset=None,
+ )
+ update_field_selections_mock.assert_called_once_with(
+ field_1, field_data_1,
+ )
+ assert TaskField.objects.filter(
+ id=field_1.id,
+ ).exists() is True
+ assert TaskField.objects.filter(
+ id=stale_field.id,
+ ).exists() is False
+
+
+def test__update_fs_fields__none__delete_all(mocker):
+
+ """
+ fields_data is None — defaults to empty, deletes all
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ name='FS',
+ api_name='fs-1',
+ )
+
+ # existing field to be deleted
+ existing_field = TaskField.objects.create(
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ fieldset=fieldset,
+ api_name='field-old',
+ name='Old field',
+ type=FieldType.STRING,
+ order=1,
+ )
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+
+ update_field_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_field',
+ )
+ update_field_selections_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_field_selections',
+ )
+ update_field_rules_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_field_rules',
+ )
+
+ # act
+ service._update_fieldset_fields(
+ fieldset=fieldset,
+ fields_data=None,
+ )
+
+ # assert
+ update_field_mock.assert_not_called()
+ update_field_selections_mock.assert_not_called()
+ update_field_rules_mock.assert_not_called()
+ assert TaskField.objects.filter(
+ id=existing_field.id,
+ ).exists() is False
+
+
+def test__update_fs_fields__provided__ok(mocker):
+
+ """
+ fields_data provided — deletes stale fields
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ name='FS',
+ api_name='fs-1',
+ )
+ field_1 = TaskField.objects.create(
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ fieldset=fieldset,
+ api_name='field-1',
+ name='Field 1',
+ type=FieldType.STRING,
+ order=1,
+ )
+
+ # stale field to be deleted
+ stale_field = TaskField.objects.create(
+ kickoff=kickoff,
+ workflow=workflow,
+ account=account,
+ fieldset=fieldset,
+ api_name='field-stale',
+ name='Stale',
+ type=FieldType.STRING,
+ order=2,
+ )
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ field_data_1 = {'api_name': 'field-1'}
+ fields_data = [field_data_1]
+
+ update_field_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_field',
+ return_value=(field_1, True),
+ )
+ update_field_selections_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_field_selections',
+ )
+ update_field_rules_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_field_rules',
+ )
+
+ # act
+ service._update_fieldset_fields(
+ fieldset=fieldset,
+ fields_data=fields_data,
+ )
+
+ # assert
+ update_field_mock.assert_called_once_with(
+ field_data_1, fieldset=fieldset,
+ )
+ update_field_selections_mock.assert_called_once_with(
+ field_1, field_data_1,
+ )
+ update_field_rules_mock.assert_called_once_with(
+ field_1, field_data_1, fieldset,
+ )
+ assert TaskField.objects.filter(
+ id=field_1.id,
+ ).exists() is True
+ assert TaskField.objects.filter(
+ id=stale_field.id,
+ ).exists() is False
+
+
+def test__update_fieldsets__none__delete_all(mocker):
+
+ """
+ data is None — defaults to empty, deletes all
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+
+ # existing fieldset to be deleted
+ existing_fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ name='FS',
+ api_name='fs-old',
+ )
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+
+ update_fieldset_rules_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fieldset_rules',
+ )
+ update_fieldset_fields_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fieldset_fields',
+ )
+
+ # act
+ service._update_fieldsets(data=None)
+
+ # assert
+ update_fieldset_rules_mock.assert_not_called()
+ update_fieldset_fields_mock.assert_not_called()
+ assert FieldSet.objects.filter(
+ id=existing_fieldset.id,
+ ).exists() is False
+
+
+def test__update_fieldsets__provided__ok(mocker):
+
+ """
+ data provided — creates and deletes stale
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+
+ # stale fieldset to be deleted
+ stale_fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ name='Stale FS',
+ api_name='fs-stale',
+ order=1,
+ )
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ rules_data_1 = [
+ {
+ 'api_name': 'rule-1',
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '100',
+ },
+ ]
+ fields_data_1 = [
+ {'api_name': 'field-1'},
+ ]
+ data = [
+ {
+ 'api_name': 'fs-1',
+ 'name': 'Fieldset 1',
+ 'description': 'Desc',
+ 'kickoff_links': [
+ {
+ 'order': 11,
+ },
+ ],
+ 'label_position': LabelPosition.TOP,
+ 'layout': FieldSetLayout.VERTICAL,
+ 'rules': rules_data_1,
+ 'fields': fields_data_1,
+ },
+ ]
+
+ update_fieldset_rules_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fieldset_rules',
+ )
+ update_fieldset_fields_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fieldset_fields',
+ )
+
+ # act
+ service._update_fieldsets(data=data)
+
+ # assert
+ fieldset = FieldSet.objects.get(
+ kickoff=kickoff,
+ api_name='fs-1',
+ )
+ assert fieldset.name == 'Fieldset 1'
+ assert fieldset.description == 'Desc'
+ assert fieldset.order == 11
+ assert fieldset.label_position == LabelPosition.TOP
+ assert fieldset.layout == FieldSetLayout.VERTICAL
+ assert fieldset.account_id == account.id
+ update_fieldset_rules_mock.assert_called_once_with(
+ fieldset=fieldset,
+ rules_data=rules_data_1,
+ )
+ update_fieldset_fields_mock.assert_called_once_with(
+ fieldset=fieldset,
+ fields_data=fields_data_1,
+ )
+ assert FieldSet.objects.filter(
+ id=stale_fieldset.id,
+ ).exists() is False
+
+
+def test__update_from_version__fields__ok(mocker):
+
+ """
+ fields provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ fields_data = [{'api_name': 'field-1'}]
+ data = {'fields': fields_data}
+ version = 1
+
+ update_fields_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fields',
+ )
+ update_fieldsets_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fieldsets',
+ )
+
+ # act
+ service.update_from_version(
+ data=data,
+ version=version,
+ )
+
+ # assert
+ update_fields_mock.assert_called_once_with(
+ data=fields_data,
+ )
+ update_fieldsets_mock.assert_not_called()
+
+
+def test__update_from_version__no_fields__skip(mocker):
+
+ """
+ fields not provided
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ fieldsets_data = []
+ data = {'fieldsets': fieldsets_data}
+ version = 1
+
+ update_fields_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fields',
+ )
+ update_fieldsets_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fieldsets',
+ )
+
+ # act
+ service.update_from_version(
+ data=data,
+ version=version,
+ )
+
+ # assert
+ update_fields_mock.assert_not_called()
+ update_fieldsets_mock.assert_called_once_with(
+ data=fieldsets_data,
+ )
+
+
+def test__update_from_version__fieldsets__ok(mocker):
+
+ """
+ fieldsets provided (not None)
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ fieldsets_data = [{'api_name': 'fs-1'}]
+ data = {'fieldsets': fieldsets_data}
+ version = 1
+
+ update_fields_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fields',
+ )
+ update_fieldsets_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fieldsets',
+ )
+
+ # act
+ service.update_from_version(
+ data=data,
+ version=version,
+ )
+
+ # assert
+ update_fields_mock.assert_not_called()
+ update_fieldsets_mock.assert_called_once_with(
+ data=fieldsets_data,
+ )
+
+
+def test__update_from_version__no_fieldsets__skip(mocker):
+
+ """
+ fieldsets key missing
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template,
+ )
+ kickoff = workflow.kickoff_instance
+ service = KickoffUpdateVersionService(
+ user=user,
+ auth_type=AuthTokenType.USER,
+ is_superuser=False,
+ instance=kickoff,
+ )
+ data = {}
+ version = 1
+
+ update_fields_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fields',
+ )
+ update_fieldsets_mock = mocker.patch(
+ 'src.processes.services.workflows.kickoff_version.'
+ 'KickoffUpdateVersionService._update_fieldsets',
+ )
+
+ # act
+ service.update_from_version(
+ data=data,
+ version=version,
+ )
+
+ # assert
+ update_fields_mock.assert_not_called()
+ update_fieldsets_mock.assert_not_called()
diff --git a/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py b/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py
index adf846ebf..0e128d168 100644
--- a/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py
+++ b/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py
@@ -160,7 +160,7 @@ def test_update_from_version__ok(self, mocker):
instance=workflow.kickoff_instance,
)
kickoff_service_update_mock.assert_called_once_with(
- data={'fields': []},
+ data={'fields': [], 'fieldsets': []},
version=template_version.version,
)
workflow_service_init_mock.assert_called_once_with(
diff --git a/backend/src/processes/tests/test_views/test_fieldsets/__init__.py b/backend/src/processes/tests/test_views/test_fieldsets/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_create.py b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py
new file mode 100644
index 000000000..37ed01f5e
--- /dev/null
+++ b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py
@@ -0,0 +1,822 @@
+import pytest
+from datetime import timedelta
+
+from django.utils import timezone
+
+from src.accounts.enums import BillingPlanType
+from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041
+from src.authentication.enums import AuthTokenType
+from src.generics.exceptions import BaseServiceException
+from src.processes.enums import (
+ FieldSetLayout,
+ LabelPosition,
+ FieldSetRuleType, FieldType,
+)
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+ FieldsetTemplateRule,
+)
+from src.processes.models.templates.fields import FieldTemplate
+
+from src.processes.services.fieldsets.fieldset import (
+ FieldSetTemplateService,
+)
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_admin,
+ create_test_not_admin,
+ create_test_owner,
+ create_test_template,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+def test_create_fieldset__all_fields__ok(api_client, mocker):
+
+ """
+ Create fieldset with all fields in request
+ and check all fields in response
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ task = template.tasks.first()
+
+ data = {
+ 'name': 'All Fields Fieldset',
+ 'title': 'All Fields Title',
+ 'description': 'Description',
+ 'label_position': LabelPosition.LEFT,
+ 'layout': FieldSetLayout.HORIZONTAL,
+ 'api_name': 'fieldset_api_name',
+ 'fields': [
+ {
+ 'name': 'Field 1',
+ 'type': FieldType.TEXT,
+ 'order': 1,
+ 'api_name': 'f1',
+ },
+ ],
+ 'rules': [
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '10',
+ 'api_name': 'r1',
+ 'fields': [],
+ },
+ ],
+ }
+
+ fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ template=template,
+ name=data['name'],
+ title=data['title'],
+ description=data['description'],
+ label_position=data['label_position'],
+ layout=data['layout'],
+ api_name=data['api_name'],
+ )
+ field = FieldTemplate.objects.create(
+ account=account,
+ template=template,
+ task=task,
+ name='Field 1',
+ type='text',
+ order=1,
+ api_name='f1',
+ fieldset=fieldset,
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='10',
+ api_name='r1',
+ )
+ rule.fields.add(field)
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 201
+ assert response.data['id'] == fieldset.id
+ assert response.data['name'] == data['name']
+ assert response.data['title'] == data['title']
+ assert response.data['description'] == data['description']
+ assert response.data['label_position'] == data['label_position']
+ assert response.data['layout'] == data['layout']
+ assert response.data['api_name'] == data['api_name']
+
+ assert len(response.data['fields']) == 1
+ assert response.data['fields'][0]['name'] == 'Field 1'
+ assert response.data['fields'][0]['api_name'] == 'f1'
+
+ assert len(response.data['rules']) == 1
+ assert response.data['rules'][0]['type'] == FieldSetRuleType.SUM_EQUAL
+ assert response.data['rules'][0]['api_name'] == 'r1'
+
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_service_create_mock.assert_called_once_with(
+ name=data['name'],
+ title=data['title'],
+ description=data['description'],
+ layout=data['layout'],
+ label_position=data['label_position'],
+ api_name=data['api_name'],
+ rules=data['rules'],
+ fields=data['fields'],
+ )
+
+
+def test_create_fieldset__min_data__ok(api_client, mocker):
+
+ """Create fieldset with minimal request data"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ data = {
+ 'name': 'Minimal Fieldset',
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ template=template,
+ name='Minimal Fieldset',
+ )
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 201
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_service_create_mock.assert_called_once_with(
+ name='Minimal Fieldset',
+ rules=[],
+ fields=[],
+ )
+
+
+def test_create_fieldset__set_api_name__ok(api_client, mocker):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ data = {
+ 'name': 'Minimal Fieldset',
+ 'api_name': 'fs1',
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ template=template,
+ name='Minimal Fieldset',
+ )
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 201
+ assert response.data['title'] == ''
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_service_create_mock.assert_called_once_with(
+ name=data['name'],
+ api_name=data['api_name'],
+ rules=[],
+ fields=[],
+ )
+
+
+def test_create_fieldset__rule_with_one_field__ok(api_client, mocker):
+
+ """
+ Create fieldset with fields in rule request and check fields in response
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ template.tasks.first()
+ field_api_name = 'f1'
+ data = {
+ 'name': 'All Fields Fieldset',
+ 'fields': [
+ {
+ 'name': 'Field 1',
+ 'type': FieldType.STRING,
+ 'order': 2,
+ 'api_name': field_api_name,
+ },
+ {
+ 'name': 'Field 2',
+ 'type': FieldType.URL,
+ 'order': 1,
+ 'api_name': 'f2',
+ },
+ ],
+ 'rules': [
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '10',
+ 'fields': [field_api_name],
+ },
+ ],
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+
+ fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ template=template,
+ name=data['name'],
+ )
+ field = FieldTemplate.objects.create(
+ account=account,
+ template=template,
+ api_name=field_api_name,
+ fieldset=fieldset,
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='10',
+ )
+ rule.fields.add(field)
+
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 201
+
+ assert len(response.data['rules']) == 1
+ assert response.data['rules'][0]['fields'] == [field_api_name]
+
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_service_create_mock.assert_called_once_with(
+ name=data['name'],
+ rules=data['rules'],
+ fields=data['fields'],
+ )
+
+
+def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker):
+
+ """
+ Create fieldset with fields in rule request and check fields in response
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ field_1_api_name = 'f1'
+ field_2_api_name = 'f2'
+ data = {
+ 'name': 'All Fields Fieldset',
+ 'fields': [
+ {
+ 'name': 'Field 1',
+ 'type': FieldType.STRING,
+ 'order': 2,
+ 'api_name': field_1_api_name,
+ },
+ {
+ 'name': 'Field 2',
+ 'type': FieldType.URL,
+ 'order': 1,
+ 'api_name': field_2_api_name,
+ },
+ ],
+ 'rules': [
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '10',
+ 'fields': [field_2_api_name, field_1_api_name],
+ },
+ ],
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+
+ fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ template=template,
+ name=data['name'],
+ )
+ field_1 = FieldTemplate.objects.create(
+ account=account,
+ template=template,
+ api_name=field_1_api_name,
+ fieldset=fieldset,
+ )
+ field_2 = FieldTemplate.objects.create(
+ account=account,
+ template=template,
+ api_name=field_2_api_name,
+ fieldset=fieldset,
+ )
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='10',
+ )
+ rule.fields.set([field_1, field_2])
+
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 201
+
+ assert len(response.data['rules']) == 1
+ assert response.data['rules'][0]['fields'] == [
+ field_1_api_name,
+ field_2_api_name,
+ ]
+
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_service_create_mock.assert_called_once_with(
+ name=data['name'],
+ rules=data['rules'],
+ fields=data['fields'],
+ )
+
+
+def test_create_fieldset__unauthenticated__unauthorized(api_client, mocker):
+
+ """Unauthenticated request returns 401"""
+
+ # arrange
+ data = {
+ 'name': 'New Fieldset',
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ )
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 401
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_service_create_mock.assert_not_called()
+
+
+def test_create_fieldset__expired_sub__permission_denied(api_client, mocker):
+
+ """Expired subscription returns 403"""
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ plan_expiration=timezone.now() - timedelta(days=1),
+ )
+ user = create_test_owner(account=account)
+ data = {
+ 'name': 'New Fieldset',
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0035
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_service_create_mock.assert_not_called()
+
+
+def test_create_fieldset__billing_plan__permission_denied(api_client, mocker):
+
+ """ Billing plan permission denied returns 403 """
+
+ # arrange
+ account = create_test_account(plan=None)
+ user = create_test_owner(account=account)
+ data = {
+ 'name': 'New Fieldset',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0041
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_service_create_mock.assert_not_called()
+
+
+def test_create_fieldset__users_limit__permission_denied(api_client, mocker):
+
+ """ Users overlimited returns 403 """
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ max_users=1,
+ )
+ user = create_test_owner(account=account)
+ create_test_not_admin(
+ account=account,
+ email='extra@pneumatic.app',
+ )
+ account.active_users = 2
+ account.save()
+ data = {
+ 'name': 'New Fieldset',
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0037
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_service_create_mock.assert_not_called()
+
+
+def test_create_fieldset__non_admin__permission_denied(api_client, mocker):
+
+ """ Non-admin non-owner user returns 403 """
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ user = create_test_not_admin(account=account)
+ data = {
+ 'name': 'New Fieldset',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 403
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_service_create_mock.assert_not_called()
+
+
+def test_create_fieldset__admin__ok(api_client, mocker):
+
+ """ Admin (non-owner) user can create fieldset """
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ user = create_test_admin(account=account)
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ data = {
+ 'name': 'New Fieldset',
+ }
+ fieldset = FieldsetTemplate.objects.create(
+ account=account,
+ template=template,
+ name=data['name'],
+ )
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 201
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_service_create_mock.assert_called_once_with(
+ name=data['name'],
+ rules=[],
+ fields=[],
+ )
+
+
+def test_create_fieldset__blank_name__validation_error(api_client, mocker):
+
+ """ Invalid name field returns validation error """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ data = {
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 400
+ message = 'This field is required.'
+ assert response.data['message'] == message
+ assert response.data['details']['name'] == 'name'
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_service_create_mock.assert_not_called()
+
+
+def test_create_fieldset__invalid_layout__validation_error(api_client, mocker):
+
+ """ Invalid layout field returns validation error """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ data = {
+ 'name': 'Test Fieldset',
+ 'layout': 'invalid_layout',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 400
+ message = '"invalid_layout" is not a valid choice.'
+ assert response.data['message'] == message
+ assert response.data['details']['name'] == 'layout'
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_service_create_mock.assert_not_called()
+
+
+def test_create_fieldset__invalid_label_position__validation_error(
+ api_client,
+ mocker,
+):
+
+ """ Invalid label_position field returns validation error """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ data = {
+ 'name': 'Test Fieldset',
+ 'label_position': 'invalid_position',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 400
+ message = '"invalid_position" is not a valid choice.'
+ assert response.data['message'] == message
+ assert response.data['details']['name'] == 'label_position'
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_service_create_mock.assert_not_called()
+
+
+def test_create_fieldset__service_exception__validation_error(
+ api_client,
+ mocker,
+):
+ """ Service raises BaseServiceException returns validation error """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ data = {
+ 'name': 'Test Fieldset',
+ }
+ error_message = 'Service error occurred'
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_service_create_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.'
+ 'create_shared_fieldset',
+ side_effect=BaseServiceException(message=error_message),
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.post('/fieldsets', data=data)
+
+ # assert
+ assert response.status_code == 400
+ assert response.data['message'] == error_message
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_service_create_mock.assert_called_once_with(
+ name='Test Fieldset',
+ rules=[],
+ fields=[],
+ )
diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py b/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py
new file mode 100644
index 000000000..4ac7b306e
--- /dev/null
+++ b/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py
@@ -0,0 +1,331 @@
+import pytest
+from datetime import timedelta
+
+from django.utils import timezone
+
+from src.accounts.enums import BillingPlanType
+from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041
+from src.authentication.enums import AuthTokenType
+from src.generics.exceptions import BaseServiceException
+from src.processes.services.fieldsets.fieldset import (
+ FieldSetTemplateService,
+)
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_not_admin,
+ create_test_owner,
+ create_test_shared_fieldset,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+def test_destroy__ok(api_client, mocker):
+ """Delete existing fieldset"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ # mock FieldSetTemplateService
+ field_set_template_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_template_service_delete_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.delete',
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.delete(
+ f'/fieldsets/{fieldset.id}',
+ )
+
+ # assert
+ assert response.status_code == 204
+ field_set_template_service_init_mock.assert_called_once_with(
+ user=user,
+ instance=fieldset,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ field_set_template_service_delete_mock.assert_called_once_with()
+
+
+def test_destroy__unauthenticated__unauthorized(api_client, mocker):
+ """Unauthenticated request returns 401"""
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ field_set_template_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_template_service_delete_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.delete',
+ )
+ # act
+ response = api_client.delete(
+ f'/fieldsets/{fieldset.id}',
+ )
+
+ # assert
+ assert response.status_code == 401
+ field_set_template_service_init_mock.assert_not_called()
+ field_set_template_service_delete_mock.assert_not_called()
+
+
+def test_destroy__expired_sub__permission_denied(api_client, mocker):
+ """Expired subscription returns 403"""
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ plan_expiration=timezone.now() - timedelta(days=1),
+ )
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ field_set_template_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_template_service_delete_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.delete',
+ )
+ # act
+ response = api_client.delete(
+ f'/fieldsets/{fieldset.id}',
+ )
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0035
+ field_set_template_service_init_mock.assert_not_called()
+ field_set_template_service_delete_mock.assert_not_called()
+
+
+def test_destroy__billing_plan__permission_denied(api_client, mocker):
+ """Billing plan permission denied returns 403"""
+
+ # arrange
+ account = create_test_account(plan=None)
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ field_set_template_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_template_service_delete_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.delete',
+ )
+ # act
+ response = api_client.delete(
+ f'/fieldsets/{fieldset.id}',
+ )
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0041
+ field_set_template_service_init_mock.assert_not_called()
+ field_set_template_service_delete_mock.assert_not_called()
+
+
+def test_destroy__users_overlimit__permission_denied(api_client, mocker):
+ """Users overlimited returns 403"""
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ max_users=1,
+ )
+ user = create_test_owner(account=account)
+ create_test_not_admin(
+ account=account,
+ email='extra@pneumatic.app',
+ )
+ account.active_users = 2
+ account.save()
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ field_set_template_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_template_service_delete_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.delete',
+ )
+ # act
+ response = api_client.delete(
+ f'/fieldsets/{fieldset.id}',
+ )
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0037
+ field_set_template_service_init_mock.assert_not_called()
+ field_set_template_service_delete_mock.assert_not_called()
+
+
+def test_destroy__non_admin__permission_denied(api_client, mocker):
+ """Non-admin non-owner user returns 403"""
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ user = create_test_not_admin(account=account)
+
+ api_client.token_authenticate(user=user)
+
+ field_set_template_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_template_service_delete_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.delete',
+ )
+ # act
+ response = api_client.delete(
+ f'/fieldsets/{fieldset.id}',
+ )
+
+ # assert
+ assert response.status_code == 403
+ field_set_template_service_init_mock.assert_not_called()
+ field_set_template_service_delete_mock.assert_not_called()
+
+
+def test_destroy__service_exception__validation_error(api_client, mocker):
+ """Service raises BaseServiceException returns validation error"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ error_message = 'Service error occurred'
+
+ # mock FieldSetTemplateService
+ field_set_template_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_template_service_delete_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.delete',
+ side_effect=BaseServiceException(message=error_message),
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.delete(
+ f'/fieldsets/{fieldset.id}',
+ )
+
+ # assert
+ assert response.status_code == 400
+ assert response.data['message'] == error_message
+ field_set_template_service_init_mock.assert_called_once_with(
+ user=user,
+ instance=fieldset,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ field_set_template_service_delete_mock.assert_called_once_with()
+
+
+def test_destroy__not_existing__not_found(api_client, mocker):
+ """Non-existent fieldset returns 404"""
+
+ # arrange
+ mocker.Mock(id=1, api_name="dummy")
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ nonexistent_id = 999999
+
+ api_client.token_authenticate(user=user)
+
+ field_set_template_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_template_service_delete_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.delete',
+ )
+ # act
+ response = api_client.delete(
+ f'/fieldsets/{nonexistent_id}',
+ )
+
+ # assert
+ assert response.status_code == 404
+
+ field_set_template_service_init_mock.assert_not_called()
+ field_set_template_service_delete_mock.assert_not_called()
+
+
+def test_destroy__not_shared__not_found(api_client, mocker):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ fieldset.is_shared = False
+ fieldset.save()
+ field_set_template_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ field_set_template_service_delete_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.delete',
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.delete(
+ f'/fieldsets/{fieldset.id}',
+ )
+
+ # assert
+ assert response.status_code == 404
+ field_set_template_service_init_mock.assert_not_called()
+ field_set_template_service_delete_mock.assert_not_called()
diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_list.py b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py
new file mode 100644
index 000000000..ee7b470e4
--- /dev/null
+++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py
@@ -0,0 +1,737 @@
+
+import pytest
+from datetime import timedelta
+
+from django.utils import timezone
+
+from src.accounts.enums import BillingPlanType
+from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041
+from src.processes.enums import (
+ FieldSetRuleType,
+)
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_admin,
+ create_test_shared_fieldset,
+ create_test_not_admin,
+ create_test_owner,
+ create_test_template,
+)
+from src.processes.models.templates.fieldset import FieldsetTemplate
+from src.utils.validation import ErrorCode
+
+pytestmark = pytest.mark.django_db
+
+
+def test_list_fieldsets__all_data__ok(api_client):
+ """List fieldsets returning all fields including title and order"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ rule_type = FieldSetRuleType.SUM_EQUAL
+ rule_value = '10'
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ title='Fieldset Title',
+ order=3,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ )
+ field = fieldset.fields.get()
+ rule = fieldset.rules.get()
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 1
+ item_1 = response.data[0]
+ assert item_1['id'] == fieldset.id
+ assert item_1['api_name'] == fieldset.api_name
+ assert item_1['name'] == fieldset.name
+ assert item_1['title'] == 'Fieldset Title'
+ assert item_1['order'] == 3
+ assert item_1['description'] == ''
+ assert item_1['layout'] == fieldset.layout
+ assert item_1['label_position'] == fieldset.label_position
+
+ assert len(item_1['rules']) == 1
+ rules_data = item_1['rules']
+ assert rules_data[0]['type'] == rule_type
+ assert rules_data[0]['value'] == rule_value
+ assert rules_data[0]['api_name'] == rule.api_name
+
+ assert len(item_1['fields']) == 1
+ fields_data = item_1['fields']
+ assert fields_data[0]['name'] == field.name
+ assert fields_data[0]['type'] == field.type
+ assert fields_data[0]['api_name'] == field.api_name
+ assert fields_data[0]['description'] == ''
+ assert fields_data[0]['is_required'] is False
+ assert fields_data[0]['is_hidden'] is False
+ assert fields_data[0]['default'] == ''
+ assert 'dataset' not in fields_data[0]
+ assert 'selections' not in fields_data[0]
+
+
+def test_list_fieldsets__shared_fieldset_has_rules_and_fields__ok(api_client):
+
+ """List shared fieldsets returns rules and fields"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ rule_type = FieldSetRuleType.SUM_EQUAL
+ rule_value = '10'
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ )
+ field = fieldset.fields.get()
+ rule = fieldset.rules.get()
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 200
+ data = response.data[0]
+ assert data['id'] == fieldset.id
+ assert len(data['rules']) == 1
+ assert data['rules'][0]['api_name'] == rule.api_name
+ assert len(data['fields']) == 1
+ assert data['fields'][0]['api_name'] == field.api_name
+
+
+def test_list_fieldsets__pagination__ok(api_client):
+ """Paginated list returns correct count and slice"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ fieldset_1 = create_test_shared_fieldset(
+ account=account,
+ )
+ fieldset_2 = create_test_shared_fieldset(
+ account=account,
+ )
+ create_test_shared_fieldset(
+ account=account,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ '/fieldsets',
+ data={'limit': 2, 'offset': 1},
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['count'] == 3
+ assert len(response.data['results']) == 2
+
+ item_1 = response.data['results'][0]
+ assert item_1['id'] == fieldset_2.id
+
+ item_2 = response.data['results'][1]
+ assert item_2['id'] == fieldset_1.id
+
+
+def test_list_fieldsets__different_accounts__ok(api_client):
+ """List fieldsets filtered by account — other accounts excluded"""
+
+ # arrange
+ account_1 = create_test_account(name='Account 1')
+ user_1 = create_test_owner(account=account_1)
+ create_test_template(
+ user=user_1,
+ tasks_count=1,
+ )
+ fieldset_1 = create_test_shared_fieldset(
+ account=account_1,
+ )
+
+ account_2 = create_test_account(name='Account 2')
+ create_test_owner(
+ account=account_2,
+ email='owner2@pneumatic.app',
+ )
+ create_test_shared_fieldset(
+ account=account_2,
+ )
+
+ api_client.token_authenticate(user=user_1)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 1
+ assert response.data[0]['id'] == fieldset_1.id
+
+
+def test_list_fieldsets__rule_with_fields__ok(api_client):
+ """List fieldsets returning rules mapping to fields"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ rule_type = FieldSetRuleType.SUM_EQUAL
+ rule_value = '10'
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ )
+ field = fieldset.fields.get()
+ rule = fieldset.rules.get()
+ rule.fields.add(field)
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 1
+ item_1 = response.data[0]
+
+ assert len(item_1['rules']) == 1
+ rules_data = item_1['rules']
+ assert rules_data[0]['fields'] == [field.api_name]
+
+
+def test_list_fieldsets__unauthenticated__unauthorized(api_client):
+ """Unauthenticated request returns 401"""
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 401
+
+
+def test_list_fieldsets__expired_sub__permission_denied(api_client):
+ """Expired subscription returns 403"""
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ plan_expiration=timezone.now() - timedelta(days=1),
+ )
+ user = create_test_owner(account=account)
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0035
+
+
+def test_list_fieldsets__billing_plan__permission_denied(api_client):
+ """Billing plan permission denied returns 403"""
+
+ # arrange
+ account = create_test_account(plan=None)
+ user = create_test_owner(account=account)
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0041
+
+
+def test_list_fieldsets__users_overlimit__permission_denied(api_client):
+ """Users overlimited returns 403"""
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ max_users=1,
+ )
+ user = create_test_owner(account=account)
+ create_test_not_admin(
+ account=account,
+ email='extra@pneumatic.app',
+ )
+ account.active_users = 2
+ account.save()
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0037
+
+
+def test_list_fieldsets__non_admin__permission_denied(api_client):
+ """Non-admin non-owner user returns 403"""
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ user = create_test_not_admin(account=account)
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 403
+
+
+def test_list_fieldsets__admin__ok(api_client):
+ """Admin (non-owner) user can list fieldsets"""
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ user = create_test_admin(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 1
+ assert response.data[0]['id'] == fieldset.id
+
+
+def test_list_fieldsets__no_ordering__ok(api_client):
+
+ """ No ordering param — default -date_created """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ now = timezone.now()
+ fieldset_1 = create_test_shared_fieldset(
+ account=account,
+ name='Oldest',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_1.id).update(
+ date_created=now - timedelta(days=2),
+ )
+ fieldset_2 = create_test_shared_fieldset(
+ account=account,
+ name='Middle',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_2.id).update(
+ date_created=now - timedelta(days=1),
+ )
+ fieldset_3 = create_test_shared_fieldset(
+ account=account,
+ name='Newest',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_3.id).update(
+ date_created=now,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 3
+ item_1 = response.data[0]
+ assert item_1['id'] == fieldset_3.id
+ item_2 = response.data[1]
+ assert item_2['id'] == fieldset_2.id
+ item_3 = response.data[2]
+ assert item_3['id'] == fieldset_1.id
+
+
+def test_list_fieldsets__ordering_name_asc__ok(api_client):
+
+ """ ordering=name — ascending by name """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ fieldset_1 = create_test_shared_fieldset(
+ account=account,
+ name='Alpha',
+ )
+ fieldset_2 = create_test_shared_fieldset(
+ account=account,
+ name='Beta',
+ )
+ fieldset_3 = create_test_shared_fieldset(
+ account=account,
+ name='Gamma',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ '/fieldsets',
+ data={'ordering': 'name'},
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 3
+ item_1 = response.data[0]
+ assert item_1['id'] == fieldset_1.id
+ assert item_1['name'] == 'Alpha'
+ item_2 = response.data[1]
+ assert item_2['id'] == fieldset_2.id
+ assert item_2['name'] == 'Beta'
+ item_3 = response.data[2]
+ assert item_3['id'] == fieldset_3.id
+ assert item_3['name'] == 'Gamma'
+
+
+def test_list_fieldsets__ordering_name_desc__ok(api_client):
+
+ """ ordering=-name — descending by name """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ fieldset_1 = create_test_shared_fieldset(
+ account=account,
+ name='Alpha',
+ )
+ fieldset_2 = create_test_shared_fieldset(
+ account=account,
+ name='Beta',
+ )
+ fieldset_3 = create_test_shared_fieldset(
+ account=account,
+ name='Gamma',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ '/fieldsets',
+ data={'ordering': '-name'},
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 3
+ item_1 = response.data[0]
+ assert item_1['id'] == fieldset_3.id
+ assert item_1['name'] == 'Gamma'
+ item_2 = response.data[1]
+ assert item_2['id'] == fieldset_2.id
+ assert item_2['name'] == 'Beta'
+ item_3 = response.data[2]
+ assert item_3['id'] == fieldset_1.id
+ assert item_3['name'] == 'Alpha'
+
+
+def test_list_fieldsets__ordering_date_asc__ok(api_client):
+
+ """ ordering=date — ascending by date_created """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ now = timezone.now()
+ fieldset_1 = create_test_shared_fieldset(
+ account=account,
+ name='Oldest',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_1.id).update(
+ date_created=now - timedelta(days=2),
+ )
+ fieldset_2 = create_test_shared_fieldset(
+ account=account,
+ name='Middle',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_2.id).update(
+ date_created=now - timedelta(days=1),
+ )
+ fieldset_3 = create_test_shared_fieldset(
+ account=account,
+ name='Newest',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_3.id).update(
+ date_created=now,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ '/fieldsets',
+ data={'ordering': 'date'},
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 3
+ item_1 = response.data[0]
+ assert item_1['id'] == fieldset_1.id
+ item_2 = response.data[1]
+ assert item_2['id'] == fieldset_2.id
+ item_3 = response.data[2]
+ assert item_3['id'] == fieldset_3.id
+
+
+def test_list_fieldsets__ordering_date_desc__ok(api_client):
+
+ """ ordering=-date — descending by date_created """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ now = timezone.now()
+ fieldset_1 = create_test_shared_fieldset(
+ account=account,
+ name='Oldest',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_1.id).update(
+ date_created=now - timedelta(days=2),
+ )
+ fieldset_2 = create_test_shared_fieldset(
+ account=account,
+ name='Middle',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_2.id).update(
+ date_created=now - timedelta(days=1),
+ )
+ fieldset_3 = create_test_shared_fieldset(
+ account=account,
+ name='Newest',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_3.id).update(
+ date_created=now,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ '/fieldsets',
+ data={'ordering': '-date'},
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 3
+ item_1 = response.data[0]
+ assert item_1['id'] == fieldset_3.id
+ item_2 = response.data[1]
+ assert item_2['id'] == fieldset_2.id
+ item_3 = response.data[2]
+ assert item_3['id'] == fieldset_1.id
+
+
+def test_list_fieldsets__no_pagination__ok(api_client):
+
+ """ No pagination params — flat list response """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ create_test_shared_fieldset(
+ account=account,
+ name='First',
+ )
+ create_test_shared_fieldset(
+ account=account,
+ name='Second',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 200
+ assert isinstance(response.data, list)
+ assert len(response.data) == 2
+
+
+def test_list_fieldsets__ordering_invalid__validation_error(api_client):
+
+ """ Invalid ordering value returns validation error """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ create_test_shared_fieldset(
+ account=account,
+ name='First',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ '/fieldsets',
+ data={'ordering': 'foobar'},
+ )
+
+ # assert
+ assert response.status_code == 400
+ message = '"foobar" is not a valid choice.'
+ assert response.data['message'] == message
+ assert response.data['code'] == ErrorCode.VALIDATION_ERROR
+
+
+def test_list_fieldsets__ordering_empty__ok(api_client):
+
+ """ Empty ordering value falls back to default """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ now = timezone.now()
+ fieldset_1 = create_test_shared_fieldset(
+ account=account,
+ name='First',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_1.id).update(
+ date_created=now - timedelta(days=1),
+ )
+ fieldset_2 = create_test_shared_fieldset(
+ account=account,
+ name='Second',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset_2.id).update(
+ date_created=now,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ '/fieldsets',
+ data={'ordering': ''},
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 2
+
+ # default ordering is -date_created (newest first)
+ item_1 = response.data[0]
+ assert item_1['id'] == fieldset_2.id
+ item_2 = response.data[1]
+ assert item_2['id'] == fieldset_1.id
+
+
+def test_list_fieldsets__soft_deleted__ok(api_client):
+
+ """ Soft-deleted fieldsets are excluded """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ )
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ name='Deleted Fieldset',
+ )
+ FieldsetTemplate.objects.filter(id=fieldset.id).update(
+ is_deleted=True,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 0
+
+
+def test_list_fieldsets__not_shared__empty_list(api_client):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ fieldset.is_shared = False
+ fieldset.save()
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get('/fieldsets')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 0
diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_partial_update.py b/backend/src/processes/tests/test_views/test_fieldsets/test_partial_update.py
new file mode 100644
index 000000000..39846c015
--- /dev/null
+++ b/backend/src/processes/tests/test_views/test_fieldsets/test_partial_update.py
@@ -0,0 +1,810 @@
+import pytest
+from datetime import timedelta
+
+from django.utils import timezone
+
+from src.accounts.enums import BillingPlanType
+from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041
+from src.authentication.enums import AuthTokenType
+from src.generics.exceptions import BaseServiceException
+from src.processes.enums import (
+ FieldSetLayout,
+ LabelPosition,
+ FieldSetRuleType,
+ FieldType,
+)
+from src.processes.models.templates.fieldset import FieldsetTemplateRule
+from src.processes.models.templates.fields import FieldTemplate
+from src.processes.services.fieldsets.fieldset import (
+ FieldSetTemplateService,
+)
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_not_admin,
+ create_test_owner,
+ create_test_shared_fieldset,
+)
+from src.utils.validation import ErrorCode
+
+pytestmark = pytest.mark.django_db
+
+
+def test_partial_update__all_fields__ok(api_client, mocker):
+
+ """ Partial update with full request data """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ field_api_name = 'f1'
+ fieldset_api_name = 'fs1'
+ rule_api_name = 'r1'
+ data = {
+ 'name': 'Full Updated Fieldset',
+ 'description': 'Updated description',
+ 'api_name': fieldset_api_name,
+ 'layout': FieldSetLayout.HORIZONTAL,
+ 'label_position': LabelPosition.LEFT,
+ 'fields': [
+ {
+ 'name': 'Field 1',
+ 'type': FieldType.NUMBER,
+ 'order': 1,
+ 'api_name': field_api_name,
+ },
+ ],
+ 'rules': [
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '10',
+ 'api_name': rule_api_name,
+ 'fields': [field_api_name],
+ },
+ ],
+ }
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ name=data['name'],
+ description=data['description'],
+ label_position=data['label_position'],
+ layout=data['layout'],
+ api_name=data['api_name'],
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ field = fieldset.fields.first()
+ rule = fieldset.rules.first()
+ rule.fields.add(field)
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['id'] == fieldset.id
+ assert response.data['name'] == data['name']
+ assert response.data['description'] == data['description']
+ assert response.data['label_position'] == data['label_position']
+ assert response.data['layout'] == data['layout']
+ assert response.data['api_name'] == data['api_name']
+
+ assert len(response.data['fields']) == 1
+ assert response.data['fields'][0]['name'] == field.name
+ assert response.data['fields'][0]['api_name'] == field.api_name
+
+ assert len(response.data['rules']) == 1
+ assert response.data['rules'][0]['type'] == rule.type
+ assert response.data['rules'][0]['value'] == rule.value
+ assert response.data['rules'][0]['api_name'] == rule.api_name
+ assert response.data['rules'][0]['fields'] == [field.api_name]
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ instance=fieldset,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_partial_update_mock.assert_called_once_with(
+ name='Full Updated Fieldset',
+ api_name=fieldset.api_name,
+ description='Updated description',
+ layout=FieldSetLayout.HORIZONTAL,
+ label_position=LabelPosition.LEFT,
+ rules=data['rules'],
+ fields=data['fields'],
+ )
+
+
+def test_partial_update__name__ok(api_client, mocker):
+
+ """ Partial update with minimal request data """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'name': 'Updated Name',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ return_value=fieldset,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['id'] == fieldset.id
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ instance=fieldset,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_partial_update_mock.assert_called_once_with(
+ name=data['name'],
+ )
+
+
+def test_partial_update__with_rule_fields__ok(api_client, mocker):
+
+ """
+ Partial update with fields in rule request
+ and check fields in response
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ field_api_name = 'f1'
+ data = {
+ 'name': 'Updated Fieldset',
+ 'fields': [
+ {
+ 'name': 'Field 1',
+ 'type': FieldType.STRING,
+ 'order': 1,
+ 'api_name': field_api_name,
+ },
+ ],
+ 'rules': [
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '10',
+ 'api_name': 'r1',
+ 'fields': [field_api_name],
+ },
+ ],
+ }
+
+ # mock FieldSetTemplateService
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+
+ # Pre-add the field to the fieldset for the mock response verification
+ field = fieldset.fields.first()
+ rule = FieldsetTemplateRule.objects.create(
+ account=account,
+ fieldset=fieldset,
+ type=FieldSetRuleType.SUM_EQUAL,
+ value='val',
+ api_name='r1',
+ )
+ rule.fields.add(field)
+
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['id'] == fieldset.id
+
+ assert len(response.data['rules']) == 1
+ assert response.data['rules'][0]['api_name'] == 'r1'
+ assert response.data['rules'][0]['fields'] == [field.api_name]
+
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ instance=fieldset,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_partial_update_mock.assert_called_once_with(
+ name='Updated Fieldset',
+ rules=data['rules'],
+ fields=data['fields'],
+ )
+
+
+def test_partial_update__clear_fields__ok(api_client, mocker):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'name': 'Updated Fieldset',
+ 'rules': [
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '10',
+ 'api_name': 'r1',
+ 'fields': [],
+ },
+ ],
+ }
+
+ # mock FieldSetTemplateService
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ instance=fieldset,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_partial_update_mock.assert_called_once_with(
+ name='Updated Fieldset',
+ rules=data['rules'],
+ )
+
+
+def test_partial_update__unauthenticated__unauthorized(api_client, mocker):
+
+ """ Unauthenticated request returns 401 """
+
+ # arrange
+ account = create_test_account()
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'name': 'Updated Fieldset',
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ )
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 401
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__expired_sub__permission_denied(api_client, mocker):
+
+ """ Expired subscription returns 403 """
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ plan_expiration=timezone.now() - timedelta(days=1),
+ )
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'name': 'Updated Fieldset',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0035
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__billing_plan__permission_denied(api_client, mocker):
+
+ """ Billing plan permission denied returns 403 """
+
+ # arrange
+ account = create_test_account(plan=None)
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'name': 'Updated Fieldset',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0041
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__users_limit__permission_denied(api_client, mocker):
+
+ """ Users overlimited returns 403 """
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ max_users=1,
+ )
+ user = create_test_owner(account=account)
+ create_test_not_admin(
+ account=account,
+ email='extra@pneumatic.app',
+ )
+ account.active_users = 2
+ account.save()
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'name': 'Updated Fieldset',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0037
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__non_admin__permission_denied(api_client, mocker):
+
+ """ Non-admin non-owner user returns 403 """
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ user = create_test_not_admin(account=account)
+ data = {
+ 'name': 'Updated Fieldset',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 403
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__invalid_name__validation_error(api_client, mocker):
+
+ """ Invalid name field returns validation error """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'name': '',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 400
+ message = 'This field may not be blank.'
+ assert response.data['message'] == message
+ assert response.data['details']['name'] == 'name'
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__invalid_layout__validation_error(api_client, mocker):
+
+ """ Invalid layout field returns validation error """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'layout': 'invalid_layout',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 400
+ message = '"invalid_layout" is not a valid choice.'
+ assert response.data['message'] == message
+ assert response.data['details']['name'] == 'layout'
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__invalid_label_position__validation_error(
+ api_client,
+ mocker,
+):
+
+ """ Invalid label_position field returns validation error """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'label_position': 'invalid_position',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 400
+ message = '"invalid_position" is not a valid choice.'
+ assert response.data['message'] == message
+ assert response.data['details']['name'] == 'label_position'
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__service_exception__validation_error(
+ api_client,
+ mocker,
+):
+ """Service raises BaseServiceException returns validation error"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ data = {
+ 'name': 'Updated Fieldset',
+ }
+ error_message = 'Service error occurred'
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ side_effect=BaseServiceException(message=error_message),
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 400
+ assert response.data['message'] == error_message
+ assert response.data['code'] == ErrorCode.VALIDATION_ERROR
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ instance=fieldset,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_partial_update_mock.assert_called_once_with(
+ name=data['name'],
+ )
+
+
+def test_partial_update__not_existing_fieldset__not_found(api_client, mocker):
+
+ """ Non-existent fieldset returns 404 """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ nonexistent_id = 999999
+ data = {
+ 'name': 'Updated Fieldset',
+ }
+
+ api_client.token_authenticate(user=user)
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ )
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{nonexistent_id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 404
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__not_shared__not_found(api_client, mocker):
+
+ """ Partial update with minimal request data """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ fieldset.is_shared = False
+ fieldset.save()
+ data = {
+ 'name': 'Updated Name',
+ }
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ return_value=fieldset,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 404
+ fieldset_service_init_mock.assert_not_called()
+ fieldset_partial_update_mock.assert_not_called()
+
+
+def test_partial_update__two_rules_same_type__ok(api_client, mocker):
+
+ """
+ Partial update with two rules of the same type:
+ one existing (with api_name) and one new (without api_name).
+ Both rules reference the same fields.
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ field_1 = fieldset.fields.first()
+ field_2 = FieldTemplate.objects.create(
+ account=account,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ api_name='f2',
+ fieldset=fieldset,
+ )
+ existing_rule = fieldset.rules.first()
+ existing_rule.fields.add(field_1)
+ existing_rule.fields.add(field_2)
+
+ data = {
+ 'rules': [
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '1',
+ 'fields': [field_1.api_name, field_2.api_name],
+ 'api_name': existing_rule.api_name,
+ },
+ {
+ 'type': FieldSetRuleType.SUM_EQUAL,
+ 'value': '100',
+ 'fields': [field_1.api_name, field_2.api_name],
+ },
+ ],
+ }
+
+ fieldset_service_init_mock = mocker.patch.object(
+ FieldSetTemplateService,
+ attribute='__init__',
+ return_value=None,
+ )
+ fieldset_partial_update_mock = mocker.patch(
+ 'src.processes.views.fieldset.FieldSetTemplateService.partial_update',
+ return_value=fieldset,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.patch(
+ f'/fieldsets/{fieldset.id}',
+ data=data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldset_service_init_mock.assert_called_once_with(
+ user=user,
+ instance=fieldset,
+ is_superuser=False,
+ auth_type=AuthTokenType.USER,
+ )
+ fieldset_partial_update_mock.assert_called_once_with(
+ rules=data['rules'],
+ )
diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py
new file mode 100644
index 000000000..4e2dc2d57
--- /dev/null
+++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py
@@ -0,0 +1,268 @@
+import pytest
+from datetime import timedelta
+
+from django.utils import timezone
+
+from src.accounts.enums import BillingPlanType
+from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041
+from src.processes.enums import FieldSetRuleType
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_shared_fieldset,
+ create_test_not_admin,
+ create_test_owner,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+def test_retrieve__fieldset_all_data__ok(api_client):
+ """Retrieve existing fieldset"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ rule_type = FieldSetRuleType.SUM_EQUAL
+ rule_value = '10'
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ )
+ field = fieldset.fields.get()
+ rule = fieldset.rules.get()
+ rule.fields.add(field)
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(f'/fieldsets/{fieldset.id}')
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['id'] == fieldset.id
+ assert response.data['api_name'] == fieldset.api_name
+ assert response.data['name'] == fieldset.name
+ assert response.data['title'] == fieldset.title
+ assert response.data['description'] == fieldset.description
+ assert response.data['order'] == fieldset.order
+ assert response.data['layout'] == fieldset.layout
+ assert response.data['label_position'] == fieldset.label_position
+
+ assert len(response.data['rules']) == 1
+ assert response.data['rules'][0]['type'] == rule.type
+ assert response.data['rules'][0]['value'] == rule.value
+ assert response.data['rules'][0]['api_name'] == rule.api_name
+ assert response.data['rules'][0]['fields'] == [field.api_name]
+
+ assert len(response.data['fields']) == 1
+ assert response.data['fields'][0]['name'] == field.name
+ assert response.data['fields'][0]['description'] == ''
+ assert response.data['fields'][0]['type'] == field.type
+ assert response.data['fields'][0]['is_required'] == field.is_required
+ assert response.data['fields'][0]['is_hidden'] == field.is_hidden
+ assert response.data['fields'][0]['api_name'] == field.api_name
+ assert response.data['fields'][0]['default'] == field.default
+ assert response.data['fields'][0]['order'] == field.order
+
+
+def test_retrieve__fieldset_rule_with_fields__ok(api_client):
+
+ # arrange
+ account_1 = create_test_account(name='Account 1')
+ user_1 = create_test_owner(account=account_1)
+ fieldset = create_test_shared_fieldset(
+ account=account_1,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='10',
+ )
+ field = fieldset.fields.get()
+ rule = fieldset.rules.get()
+ rule.fields.add(field)
+
+ api_client.token_authenticate(user=user_1)
+
+ # act
+ response = api_client.get(f'/fieldsets/{fieldset.id}')
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['id'] == fieldset.id
+ assert len(response.data['rules']) == 1
+ assert response.data['rules'][0]['type'] == rule.type
+ assert response.data['rules'][0]['value'] == rule.value
+ assert response.data['rules'][0]['api_name'] == rule.api_name
+ assert response.data['rules'][0]['fields'] == [field.api_name]
+
+
+def test_retrieve__unauthenticated__unauthorized(api_client):
+ """Unauthenticated request returns 401"""
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ # act
+ response = api_client.get(f'/fieldsets/{fieldset.id}')
+
+ # assert
+ assert response.status_code == 401
+
+
+def test_retrieve__expired_sub__permission_denied(api_client):
+ """Expired subscription returns 403"""
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ plan_expiration=timezone.now() - timedelta(days=1),
+ )
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(f'/fieldsets/{fieldset.id}')
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0035
+
+
+def test_retrieve__billing_plan__permission_denied(api_client):
+ """Billing plan permission denied returns 403"""
+
+ # arrange
+ account = create_test_account(plan=None)
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(f'/fieldsets/{fieldset.id}')
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0041
+
+
+def test_retrieve__users_overlimit__permission_denied(api_client):
+ """Users overlimited returns 403"""
+
+ # arrange
+ account = create_test_account(
+ plan=BillingPlanType.PREMIUM,
+ max_users=1,
+ )
+ user = create_test_owner(account=account)
+ create_test_not_admin(
+ account=account,
+ email='extra@pneumatic.app',
+ )
+ account.active_users = 2
+ account.save()
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(f'/fieldsets/{fieldset.id}')
+
+ # assert
+ assert response.status_code == 403
+ assert response.data['detail'] == MSG_A_0037
+
+
+def test_retrieve__non_admin__permission_denied(api_client):
+ """Non-admin non-owner user returns 403"""
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ )
+ user = create_test_not_admin(account=account)
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(f'/fieldsets/{fieldset.id}')
+
+ # assert
+ assert response.status_code == 403
+
+
+def test_retrieve__not_existing__not_found(api_client):
+ """Non-existent fieldset returns 404"""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ nonexistent_id = 999999
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(f'/fieldsets/{nonexistent_id}')
+
+ # assert
+ assert response.status_code == 404
+
+
+def test_retrieve__another_account__not_found(api_client):
+
+ """Fieldset from another account returns 404"""
+
+ # arrange
+ account_1 = create_test_account(name='Account 1')
+ create_test_owner(account=account_1)
+ fieldset = create_test_shared_fieldset(
+ account=account_1,
+ )
+
+ account_2 = create_test_account(name='Account 2')
+ user_2 = create_test_owner(
+ account=account_2,
+ email='owner2@pneumatic.app',
+ )
+
+ api_client.token_authenticate(user=user_2)
+
+ # act
+ response = api_client.get(f'/fieldsets/{fieldset.id}')
+
+ # assert
+ assert response.status_code == 404
+
+
+def test_retrieve__not_shared__not_found(api_client):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ fieldset = create_test_shared_fieldset(
+ account=account,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='10',
+ )
+ fieldset.is_shared = False
+ fieldset.save()
+
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(f'/fieldsets/{fieldset.id}')
+
+ # assert
+ assert response.status_code == 404
diff --git a/backend/src/processes/tests/test_views/test_tasks/test_events.py b/backend/src/processes/tests/test_views/test_tasks/test_events.py
index d460285a9..6bdd63fe7 100644
--- a/backend/src/processes/tests/test_views/test_tasks/test_events.py
+++ b/backend/src/processes/tests/test_views/test_tasks/test_events.py
@@ -4,6 +4,7 @@
from src.authentication.services.guest_auth import GuestJWTAuthService
from src.processes.enums import WorkflowEventType, FieldType
from src.processes.models.workflows.fields import TaskField
+from src.processes.models.workflows.fieldset import FieldSet
from src.processes.models.workflows.task import TaskPerformer
from src.processes.services.events import (
WorkflowEventService,
@@ -599,3 +600,159 @@ def test_events__task_complete_with_dataset__ok(api_client):
assert field_data['api_name'] == field.api_name
assert field_data['value'] == dataset_item.value
assert field_data['order'] == field.order
+
+
+def test_events__task_complete_fieldsets_present__ok(api_client):
+
+ """
+ GET task events: TASK_COMPLETE row includes non-null task.fieldsets when
+ the task has at least one FieldSet.
+ """
+
+ # arrange
+
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task_1 = workflow.tasks.get(number=1)
+ fieldset = FieldSet.objects.create(
+ account=account,
+ workflow=workflow,
+ task=task_1,
+ name='Fieldset 1',
+ order=1,
+ )
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ task=task_1,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.TEXT,
+ order=1,
+ )
+ field_2 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ task=task_1,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ order=2,
+ )
+ WorkflowEventService.task_complete_event(
+ task=task_1,
+ user=user,
+ after_create_actions=False,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ path=f'/v2/tasks/{task_1.id}/events',
+ )
+
+ # assert
+ assert response.status_code == 200
+ event_data = response.data[0]
+ assert event_data['type'] == WorkflowEventType.TASK_COMPLETE
+ fieldsets_data = event_data['task']['fieldsets']
+ assert fieldsets_data is not None
+ assert len(fieldsets_data) == 1
+ fieldset.refresh_from_db()
+ field_1.refresh_from_db()
+ field_2.refresh_from_db()
+ fieldset_data = fieldsets_data[0]
+ assert fieldset_data['id'] == fieldset.id
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['order'] == fieldset.order
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ fields_data = fieldset_data['fields']
+ assert len(fields_data) == 2
+ field_2_data = fields_data[0]
+ assert field_2_data['id'] == field_2.id
+ assert field_2_data['order'] == field_2.order
+ assert field_2_data['type'] == field_2.type
+ assert field_2_data['is_required'] == field_2.is_required
+ assert field_2_data['is_hidden'] == field_2.is_hidden
+ assert field_2_data['description'] == field_2.description
+ assert field_2_data['api_name'] == field_2.api_name
+ assert field_2_data['name'] == field_2.name
+ assert field_2_data['value'] == field_2.value
+ assert field_2_data['markdown_value'] == field_2.markdown_value
+ assert field_2_data['clear_value'] == field_2.clear_value
+ assert field_2_data['user_id'] == field_2.user_id
+ assert field_2_data['group_id'] == field_2.group_id
+ assert field_2_data['selections'] == []
+ assert field_2_data['attachments'] == []
+ field_1_data = fields_data[1]
+ assert field_1_data['id'] == field_1.id
+
+
+def test_events__task_complete_fieldsets_absent__ok(api_client):
+
+ """
+ GET task events: TASK_COMPLETE row has task.fieldsets equal to null when
+ the task has no FieldSet rows.
+ """
+
+ # arrange
+
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task_1 = workflow.tasks.get(number=1)
+ WorkflowEventService.task_complete_event(
+ task=task_1,
+ user=user,
+ after_create_actions=False,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ path=f'/v2/tasks/{task_1.id}/events',
+ )
+
+ # assert
+
+ assert response.status_code == 200
+ item_1 = response.data[0]
+ assert item_1['type'] == WorkflowEventType.TASK_COMPLETE
+ task_payload = item_1['task']
+ assert task_payload['fieldsets'] is None
+
+
+def test_events__non_complete_task_fieldsets_null__ok(api_client):
+
+ """
+ GET task events: non-TASK_COMPLETE event exposes task.fieldsets as null.
+ """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task_1 = workflow.tasks.get(number=1)
+ create_test_event(
+ workflow=workflow,
+ user=user,
+ task=task_1,
+ type_event=WorkflowEventType.COMMENT,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+ response = api_client.get(
+ path=f'/v2/tasks/{task_1.id}/events',
+ )
+
+ # assert
+ assert response.status_code == 200
+ item_1 = response.data[0]
+ assert item_1['type'] == WorkflowEventType.COMMENT
+ task_payload = item_1['task']
+ assert task_payload['fieldsets'] is None
diff --git a/backend/src/processes/tests/test_views/test_tasks/test_webhook_example.py b/backend/src/processes/tests/test_views/test_tasks/test_webhook_example.py
index 879f64f22..9aa61a756 100644
--- a/backend/src/processes/tests/test_views/test_tasks/test_webhook_example.py
+++ b/backend/src/processes/tests/test_views/test_tasks/test_webhook_example.py
@@ -43,6 +43,7 @@ def test_webhook_example__body__ok(api_client):
'contains_comments': False,
'require_completion_by_all': False,
'output': [],
+ 'fieldsets': [],
'delay': None,
'date_started_tsp': task.date_started.timestamp(),
'date_completed_tsp': None,
@@ -90,6 +91,7 @@ def test_webhook_example__body__ok(api_client):
'kickoff': {
'id': workflow.kickoff_instance.id,
'output': [],
+ 'fieldsets': [],
},
'tasks': [
OrderedDict([
diff --git a/backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py b/backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py
new file mode 100644
index 000000000..c3b1351f6
--- /dev/null
+++ b/backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py
@@ -0,0 +1,544 @@
+import pytest
+
+from src.processes.enums import (
+ FieldSetLayout,
+ FieldSetRuleType,
+ FieldType,
+ LabelPosition,
+ OwnerRole,
+ OwnerType,
+ PerformerType,
+)
+from src.processes.tests.fixtures import (
+ create_test_shared_fieldset,
+ create_test_owner,
+ create_test_account,
+)
+from src.processes.models.templates.fields import (
+ FieldTemplate,
+ FieldTemplateSelection,
+)
+
+pytestmark = pytest.mark.django_db
+
+
+def test_clone__kickoff_and_task_fieldsets__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ shared = create_test_shared_fieldset(
+ account=account,
+ name='My Fieldset',
+ description='Some description',
+ label_position=LabelPosition.TOP,
+ layout=FieldSetLayout.VERTICAL,
+ )
+ api_client.token_authenticate(user)
+
+ create_response = api_client.post(
+ path='/templates',
+ data={
+ 'name': 'Template',
+ 'is_active': False,
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared.id,
+ 'order': 1,
+ 'title': 'My Fieldset Title',
+ 'description': 'My Fieldset Description',
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'Step 1',
+ 'api_name': 'task-1',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared.id,
+ 'order': 2,
+ 'title': 'My Fieldset Title 2',
+ 'description': 'My Fieldset Description 2',
+ },
+ ],
+ },
+ ],
+ },
+ )
+ assert create_response.status_code == 200
+ template_id = create_response.data['id']
+
+ # act
+ response = api_client.post(f'/templates/{template_id}/clone')
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['id'] != template_id
+
+ assert len(response.data['kickoff']['fieldsets']) == 1
+ clone_fieldsets_1 = response.data['kickoff']['fieldsets'][0]
+ assert clone_fieldsets_1['name'] == shared.name
+ assert clone_fieldsets_1['title'] == 'My Fieldset Title'
+ assert clone_fieldsets_1['description'] == 'My Fieldset Description'
+ assert clone_fieldsets_1['label_position'] == LabelPosition.TOP
+ assert clone_fieldsets_1['layout'] == FieldSetLayout.VERTICAL
+ assert clone_fieldsets_1['order'] == 1
+ assert isinstance(clone_fieldsets_1['fields'], list)
+ assert clone_fieldsets_1['rules'] == []
+ assert clone_fieldsets_1['shared_fieldset_id'] == shared.id
+
+ assert len(response.data['tasks'][0]['fieldsets']) == 1
+ clone_fieldsets_2 = response.data['tasks'][0]['fieldsets'][0]
+ assert clone_fieldsets_2['name'] == shared.name
+ assert clone_fieldsets_2['title'] == 'My Fieldset Title 2'
+ assert clone_fieldsets_2['description'] == 'My Fieldset Description 2'
+ assert clone_fieldsets_2['label_position'] == LabelPosition.TOP
+ assert clone_fieldsets_2['layout'] == FieldSetLayout.VERTICAL
+ assert clone_fieldsets_2['order'] == 2
+ assert isinstance(clone_fieldsets_2['fields'], list)
+ assert clone_fieldsets_2['rules'] == []
+ assert clone_fieldsets_2['shared_fieldset_id'] == shared.id
+
+
+def test_clone__fieldset_with_fields__ok(api_client):
+
+ """Cloning copies field records belonging to the fieldset."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ shared = create_test_shared_fieldset(account=account)
+ api_client.token_authenticate(user)
+
+ create_response = api_client.post(
+ path='/templates',
+ data={
+ 'name': 'Template',
+ 'is_active': False,
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared.id,
+ 'order': 1,
+ 'title': 'Fields Fieldset Title',
+ 'description': 'Fields Fieldset Description',
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'Step 1',
+ 'api_name': 'task-1',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+ assert create_response.status_code == 200
+ template_id = create_response.data['id']
+
+ # act
+ response = api_client.post(f'/templates/{template_id}/clone')
+
+ # assert
+ assert response.status_code == 200
+ field = shared.fields.first()
+ fields = response.data['kickoff']['fieldsets'][0]['fields']
+ assert len(fields) == 1
+ assert fields[0]['name'] == field.name
+ assert fields[0]['description'] == (field.description or '')
+ assert fields[0]['type'] == field.type
+ assert fields[0]['is_required'] == field.is_required
+ assert fields[0]['is_hidden'] == field.is_hidden
+ assert fields[0]['order'] == field.order
+ assert fields[0]['default'] == field.default
+
+
+def test_clone__fieldset_with_selections__ok(api_client):
+
+ """Cloning copies FieldTemplateSelection records
+ for dropdown fields in a fieldset."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ shared = create_test_shared_fieldset(
+ account=user.account,
+ name='Fieldset with dropdown',
+ )
+ shared.fields.all().delete()
+ field = FieldTemplate.objects.create(
+ fieldset=shared,
+ account=user.account,
+ name='Dropdown field',
+ type=FieldType.DROPDOWN,
+ order=1,
+ )
+ FieldTemplateSelection.objects.create(
+ field_template=field,
+ value='Option A',
+ )
+ FieldTemplateSelection.objects.create(
+ field_template=field,
+ value='Option B',
+ )
+ api_client.token_authenticate(user)
+
+ create_response = api_client.post(
+ path='/templates',
+ data={
+ 'name': 'Template',
+ 'is_active': False,
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared.id,
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'Step 1',
+ 'api_name': 'task-1',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+ assert create_response.status_code == 200
+ template_id = create_response.data['id']
+
+ # act
+ response = api_client.post(f'/templates/{template_id}/clone')
+
+ # assert
+ assert response.status_code == 200
+ field = response.data['kickoff']['fieldsets'][0]['fields'][0]
+ assert field['type'] == FieldType.DROPDOWN
+ selections = field['selections']
+ assert len(selections) == 2
+ assert selections[0]['value'] == 'Option A'
+ assert selections[1]['value'] == 'Option B'
+
+
+def test_clone__fieldset_with_rules__ok(api_client):
+
+ """Cloning copies rules and preserves the rule-field relationships."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ shared = create_test_shared_fieldset(
+ account=user.account,
+ name='Fieldset with rules',
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ field = shared.fields.first()
+ rule = shared.rules.first()
+ field.rules.add(rule)
+ api_client.token_authenticate(user)
+
+ create_response = api_client.post(
+ path='/templates',
+ data={
+ 'name': 'Template',
+ 'is_active': False,
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared.id,
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'Step 1',
+ 'api_name': 'task-1',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+ assert create_response.status_code == 200
+ template_id = create_response.data['id']
+
+ # act
+ response = api_client.post(f'/templates/{template_id}/clone')
+
+ # assert
+ assert response.status_code == 200
+ fieldset = response.data['kickoff']['fieldsets'][0]
+ rules = fieldset['rules']
+ assert len(rules) == 1
+ rule_data = rules[0]
+ assert rule_data['type'] == FieldSetRuleType.SUM_EQUAL
+ assert rule_data['value'] == '100'
+ clone_field_api_names = [f['api_name'] for f in fieldset['fields']]
+ assert len(clone_field_api_names) == 1
+ assert rule_data['fields'] == clone_field_api_names
+
+
+def test_clone__kickoff_multiple_fieldsets__ok(api_client):
+
+ """Cloning a template with multiple fieldsets copies all of them."""
+
+ # arrange
+ user = create_test_owner()
+ api_client.token_authenticate(user)
+
+ shared_1 = create_test_shared_fieldset(
+ account=user.account,
+ name='Fieldset One',
+ )
+ shared_2 = create_test_shared_fieldset(
+ account=user.account,
+ name='Fieldset Two',
+ )
+
+ create_response = api_client.post(
+ path='/templates',
+ data={
+ 'name': 'Template',
+ 'is_active': False,
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_1.id,
+ 'order': 1,
+ 'title': 'Fieldset One Title',
+ 'description': 'Fieldset One Description',
+ },
+ {
+ 'shared_fieldset_id': shared_2.id,
+ 'order': 2,
+ 'title': 'Fieldset Two Title',
+ 'description': 'Fieldset Two Description',
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'Step 1',
+ 'api_name': 'task-1',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+ assert create_response.status_code == 200
+ template_id = create_response.data['id']
+
+ # act
+ response = api_client.post(f'/templates/{template_id}/clone')
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert len(fieldsets) == 2
+ fieldset_1 = fieldsets[0]
+ assert fieldset_1['name'] == shared_1.name
+ fieldset_2 = fieldsets[1]
+ assert fieldset_2['name'] == shared_2.name
+
+
+def test_clone__no_fieldsets__ok(api_client):
+
+ """Cloning a template without fieldsets still works
+ and creates no fieldsets on the clone."""
+
+ # arrange
+ user = create_test_owner()
+ api_client.token_authenticate(user)
+
+ create_response = api_client.post(
+ path='/templates',
+ data={
+ 'name': 'Template',
+ 'is_active': False,
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {},
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'Step 1',
+ 'api_name': 'task-1',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+ assert create_response.status_code == 200
+ template_id = create_response.data['id']
+
+ # act
+ response = api_client.post(f'/templates/{template_id}/clone')
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['kickoff']['fieldsets'] == []
+
+
+def test_clone__fieldset_rule_multi_fields__ok(api_client):
+
+ """Cloning preserves a rule linked to multiple fields."""
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ shared = create_test_shared_fieldset(
+ account=account,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='200',
+ )
+ shared.fields.all().delete()
+ field_1 = FieldTemplate.objects.create(
+ fieldset=shared,
+ account=user.account,
+ name='Amount A',
+ type=FieldType.NUMBER,
+ order=3,
+ )
+ field_2 = FieldTemplate.objects.create(
+ fieldset=shared,
+ account=user.account,
+ name='Amount B',
+ type=FieldType.NUMBER,
+ order=2,
+ )
+ rule = shared.rules.first()
+ field_1.rules.add(rule)
+ field_2.rules.add(rule)
+
+ api_client.token_authenticate(user)
+ create_response = api_client.post(
+ path='/templates',
+ data={
+ 'name': 'Template',
+ 'is_active': False,
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared.id,
+ 'order': 1,
+ 'title': 'Multi-field Fieldset Title',
+ 'description': 'Multi-field Fieldset Description',
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'Step 1',
+ 'api_name': 'task-1',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+ assert create_response.status_code == 200
+ template_id = create_response.data['id']
+
+ # act
+ response = api_client.post(f'/templates/{template_id}/clone')
+
+ # assert
+ assert response.status_code == 200
+ clone_fieldsets = response.data['kickoff']['fieldsets'][0]
+ rule_data = clone_fieldsets['rules'][0]
+ assert rule_data['type'] == FieldSetRuleType.SUM_EQUAL
+ assert rule_data['value'] == '200'
+ clone_fields = clone_fieldsets['fields']
+ clone_field_api_names = [f['api_name'] for f in clone_fields]
+ assert set(rule_data['fields']) == set(clone_field_api_names)
diff --git a/backend/src/processes/tests/test_views/test_templates/test_clone/test_task.py b/backend/src/processes/tests/test_views/test_templates/test_clone/test_task.py
index 4c9b3368c..a802a6457 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_clone/test_task.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_clone/test_task.py
@@ -1,6 +1,5 @@
import pytest
-from src.authentication.enums import AuthTokenType
from src.processes.enums import (
OwnerRole,
ConditionAction,
@@ -99,15 +98,10 @@ def test_clone__ok(self, is_active, api_client):
def test_clone__due_date__ok(
self,
- mocker,
api_client,
):
# arrange
- analysis_mock = mocker.patch(
- 'src.processes.serializers.templates.task.'
- 'AnalyticService.templates_task_due_date_created',
- )
user = create_test_user()
api_client.token_authenticate(user)
duration = '10:00:00'
@@ -156,13 +150,6 @@ def test_clone__due_date__ok(
assert response.status_code == 200
task_data = response.data['tasks'][0]
assert task_data['raw_due_date']['duration'] == duration
- analysis_mock.assert_called_once_with(
- user=user,
- template=template,
- task=template.tasks.get(number=1),
- auth_type=AuthTokenType.USER,
- is_superuser=False,
- )
def test_clone__return_task__ok(
self,
diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py
index 53791313f..fbc832625 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py
@@ -47,6 +47,7 @@ def test_create__only_required_fields__defaults_ok(
'is_active': True,
'kickoff': {
'fields': [],
+ 'fieldsets': [],
},
'tasks': [
{
@@ -86,6 +87,7 @@ def test_create__all_fields__ok(
api_client.token_authenticate(user)
request_data = {
'fields': [],
+ 'fieldsets': [],
}
# act
@@ -217,6 +219,7 @@ def test_create_draft__not_kickoff__ok(
assert response.status_code == 200
assert response.data['kickoff'] == {
'fields': [],
+ 'fieldsets': [],
}
assert response.data['is_active'] is False
kickoff_create_mock.assert_called_once()
@@ -262,6 +265,7 @@ def test_create_draft__kickoff_is_null__ok(
assert response.status_code == 200
assert response.data['kickoff'] == {
'fields': [],
+ 'fieldsets': [],
}
assert response.data['is_active'] is False
kickoff_create_mock.assert_called_once()
diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py
index ccb575808..a9efa8275 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py
@@ -21,6 +21,9 @@
OwnerType,
PerformerType,
PredicateOperator,
+ FieldSetRuleType,
+ LabelPosition,
+ FieldSetLayout,
)
from src.processes.messages import template as messages
from src.processes.models.templates.conditions import (
@@ -38,12 +41,14 @@
from src.processes.services.templates.integrations import (
TemplateIntegrationsService,
)
+from src.processes.models.templates.fieldset import FieldsetTemplate
from src.processes.tests.fixtures import (
create_invited_user,
create_test_account,
create_test_group,
create_test_not_admin,
create_test_owner,
+ create_test_shared_fieldset,
create_test_template,
create_test_user,
)
@@ -3830,3 +3835,569 @@ def test_create__invalid_wf_name_template__validation_error(
assert response.data['message'] == str(messages.MSG_PT_0008)
templates_kickoff_created_mock.assert_not_called()
templates_created_mock.assert_not_called()
+
+
+def test_create__kickoff_fieldset_only_required_data__ok(
+ mocker,
+ api_client,
+):
+
+ """ Creating a template with one fieldset linked to kickoff
+ creates FieldsetTemplate linked to the kickoff. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_user(account=account)
+ api_client.token_authenticate(user)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_created',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_created',
+ )
+ fs_title = 'Some title'
+ fs_description = 'Some desc'
+ fs_name = 'Some name'
+ fs_order = 3
+ label_position = LabelPosition.LEFT
+ layout = FieldSetLayout.HORIZONTAL
+ rule_type = FieldSetRuleType.SUM_EQUAL
+ rule_value = '500'
+ api_name = 'fs-some-api-name'
+ shared_fieldset = create_test_shared_fieldset(
+ title=fs_title,
+ description=fs_description,
+ name=fs_name,
+ order=fs_order,
+ label_position=label_position,
+ layout=layout,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ api_name=api_name,
+ account=account,
+ )
+ shared_field = shared_fieldset.fields.first()
+ shared_rule = shared_fieldset.rules.first()
+ shared_rule.fields.add(shared_field)
+ request_data = {
+ 'name': 'Template with fieldset',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'is_active': True,
+ 'kickoff': {
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_fieldset.id,
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'First step',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ }
+
+ # act
+ response = api_client.post(
+ path='/templates',
+ data=request_data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ template = Template.objects.get(id=response.data['id'])
+ kickoff = template.kickoff_instance
+ fieldset = FieldsetTemplate.objects.get(
+ kickoff=kickoff,
+ shared_fieldset=shared_fieldset,
+ is_shared=False,
+ )
+ field = fieldset.fields.first()
+ rule = fieldset.rules.first()
+
+ kickoff_data = response.data['kickoff']
+ assert len(kickoff_data['fieldsets']) == 1
+ fieldset_data = kickoff_data['fieldsets'][0]
+ assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id
+ assert fieldset_data['order'] == 0
+ assert fieldset_data['title'] == fs_title
+ assert fieldset_data['description'] == fs_description
+ assert fieldset_data['name'] == fs_name
+ assert len(fieldset_data['api_name'])
+ assert fieldset_data['api_name'] != shared_fieldset.api_name
+ assert fieldset_data['label_position'] == label_position
+ assert fieldset_data['layout'] == layout
+
+ assert len(fieldset_data['rules']) == 1
+ rule_data = fieldset_data['rules'][0]
+ assert rule_data['type'] == rule_type
+ assert rule_data['value'] == str(rule_value)
+ assert rule_data['api_name'] == rule.api_name
+ assert rule_data['fields'] == [field.api_name]
+
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['name'] == shared_field.name
+ assert field_data['type'] == shared_field.type
+ assert field_data['order'] == shared_field.order
+ assert field_data['is_required'] == shared_field.is_required
+ assert field_data['is_hidden'] == shared_field.is_hidden
+ assert field_data['description'] == ''
+ assert field_data['default'] == ''
+ assert field_data['api_name'] == field.api_name
+
+
+def test_create__kickoff_fieldset_all_fieldset_data__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_user(account=account)
+ api_client.token_authenticate(user)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_created',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_created',
+ )
+ fs_title = 'Some title'
+ fs_description = 'Some desc'
+ fs_order = 3
+ fs_api_name = 'fs-some-api-name'
+ shared_fieldset = create_test_shared_fieldset(account=account)
+ shared_fieldset.fields.all().delete()
+ request_data = {
+ 'name': 'Template with fieldset',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'is_active': True,
+ 'kickoff': {
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_fieldset.id,
+ 'order': fs_order,
+ 'title': fs_title,
+ 'description': fs_description,
+ 'api_name': fs_api_name,
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'First step',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ }
+
+ # act
+ response = api_client.post(
+ path='/templates',
+ data=request_data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ template = Template.objects.get(id=response.data['id'])
+ kickoff = template.kickoff_instance
+ assert FieldsetTemplate.objects.get(
+ kickoff=kickoff,
+ shared_fieldset=shared_fieldset,
+ is_shared=False,
+ api_name=fs_api_name,
+ )
+
+ kickoff_data = response.data['kickoff']
+ assert len(kickoff_data['fieldsets']) == 1
+ fieldset_data = kickoff_data['fieldsets'][0]
+ assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id
+ assert fieldset_data['order'] == fs_order
+ assert fieldset_data['title'] == fs_title
+ assert fieldset_data['description'] == fs_description
+ assert fieldset_data['name'] == shared_fieldset.name
+ assert fieldset_data['api_name'] == fs_api_name
+ assert fieldset_data['label_position'] == shared_fieldset.label_position
+ assert fieldset_data['layout'] == shared_fieldset.layout
+ assert fieldset_data['rules'] == []
+ assert fieldset_data['fields'] == []
+
+
+def test_create__kickoff_with_empty_fieldsets__no_created(
+ mocker,
+ api_client,
+):
+
+ """ Creating a template with empty fieldsets list does not
+ create any FieldsetTemplateKickoff records. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_user(account=account)
+ api_client.token_authenticate(user)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_created',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_created',
+ )
+ request_data = {
+ 'name': 'Template no fieldsets',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'is_active': True,
+ 'kickoff': {
+ 'fieldsets': [],
+ },
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'First step',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ }
+
+ # act
+ response = api_client.post(
+ path='/templates',
+ data=request_data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ template = Template.objects.get(id=response.data['id'])
+ kickoff = template.kickoff_instance
+ assert FieldsetTemplate.objects.filter(
+ kickoff=kickoff,
+ ).count() == 0
+
+
+def test_create__task_fieldset_only_required_data__ok(
+ mocker,
+ api_client,
+):
+
+ """ Creating a template with one fieldset linked to a task
+ creates FieldsetTemplate linked to the task. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_user(account=account)
+ api_client.token_authenticate(user)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_created',
+ )
+ fs_title = 'Some title'
+ fs_description = 'Some desc'
+ fs_name = 'Some name'
+ fs_order = 3
+ label_position = LabelPosition.LEFT
+ layout = FieldSetLayout.HORIZONTAL
+ rule_type = FieldSetRuleType.SUM_EQUAL
+ rule_value = '500'
+ api_name = 'fs-some-api-name'
+ shared_fieldset = create_test_shared_fieldset(
+ title=fs_title,
+ description=fs_description,
+ name=fs_name,
+ order=fs_order,
+ label_position=label_position,
+ layout=layout,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ api_name=api_name,
+ account=account,
+ )
+ shared_field = shared_fieldset.fields.first()
+ shared_rule = shared_fieldset.rules.first()
+ shared_rule.fields.add(shared_field)
+ request_data = {
+ 'name': 'Template with task fieldset',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'is_active': True,
+ 'kickoff': {},
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'First step',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_fieldset.id,
+ },
+ ],
+ },
+ ],
+ }
+
+ # act
+ response = api_client.post(
+ path='/templates',
+ data=request_data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ template = Template.objects.get(id=response.data['id'])
+ task = template.tasks.first()
+ fieldset = FieldsetTemplate.objects.get(
+ task=task,
+ shared_fieldset=shared_fieldset,
+ is_shared=False,
+ )
+ field = fieldset.fields.first()
+ rule = fieldset.rules.first()
+
+ task_data = response.data['tasks'][0]
+ assert len(task_data['fieldsets']) == 1
+ fieldset_data = task_data['fieldsets'][0]
+ assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id
+ assert fieldset_data['order'] == 0
+ assert fieldset_data['title'] == fs_title
+ assert fieldset_data['description'] == fs_description
+ assert fieldset_data['name'] == fs_name
+ assert len(fieldset_data['api_name'])
+ assert fieldset_data['api_name'] != shared_fieldset.api_name
+ assert fieldset_data['label_position'] == label_position
+ assert fieldset_data['layout'] == layout
+
+ assert len(fieldset_data['rules']) == 1
+ rule_data = fieldset_data['rules'][0]
+ assert rule_data['type'] == rule_type
+ assert rule_data['value'] == rule_value
+ assert rule_data['api_name'] == rule.api_name
+ assert rule_data['fields'] == [field.api_name]
+
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['name'] == shared_field.name
+ assert field_data['type'] == shared_field.type
+ assert field_data['order'] == shared_field.order
+ assert field_data['is_required'] == shared_field.is_required
+ assert field_data['is_hidden'] == shared_field.is_hidden
+ assert field_data['description'] == ''
+ assert field_data['default'] == ''
+ assert field_data['api_name'] == field.api_name
+
+
+def test_create__task_fieldset_all_fieldset_data__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_user(account=account)
+ api_client.token_authenticate(user)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_created',
+ )
+ fs_title = 'Some title'
+ fs_description = 'Some desc'
+ fs_order = 3
+ fs_api_name = 'fs-some-api-name'
+ shared_fieldset = create_test_shared_fieldset(account=account)
+ shared_fieldset.fields.all().delete()
+ request_data = {
+ 'name': 'Template with task fieldset',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'is_active': True,
+ 'kickoff': {},
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'First step',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_fieldset.id,
+ 'order': fs_order,
+ 'title': fs_title,
+ 'description': fs_description,
+ 'api_name': fs_api_name,
+ },
+ ],
+ },
+ ],
+ }
+
+ # act
+ response = api_client.post(
+ path='/templates',
+ data=request_data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ template = Template.objects.get(id=response.data['id'])
+ task = template.tasks.first()
+ assert FieldsetTemplate.objects.get(
+ task=task,
+ shared_fieldset=shared_fieldset,
+ is_shared=False,
+ api_name=fs_api_name,
+ )
+
+ task_data = response.data['tasks'][0]
+ assert len(task_data['fieldsets']) == 1
+ fieldset_data = task_data['fieldsets'][0]
+ assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id
+ assert fieldset_data['order'] == fs_order
+ assert fieldset_data['title'] == fs_title
+ assert fieldset_data['description'] == fs_description
+ assert fieldset_data['name'] == shared_fieldset.name
+ assert fieldset_data['api_name'] == fs_api_name
+ assert fieldset_data['label_position'] == shared_fieldset.label_position
+ assert fieldset_data['layout'] == shared_fieldset.layout
+ assert fieldset_data['rules'] == []
+ assert fieldset_data['fields'] == []
+
+
+def test_create__task_with_empty_fieldsets__no_created(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_user(account=account)
+ api_client.token_authenticate(user)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_created',
+ )
+ request_data = {
+ 'name': 'Template without task fieldsets',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'is_active': True,
+ 'kickoff': {},
+ 'tasks': [
+ {
+ 'number': 1,
+ 'name': 'First step',
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [],
+ },
+ ],
+ }
+
+ # act
+ response = api_client.post(
+ path='/templates',
+ data=request_data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ template = Template.objects.get(id=response.data['id'])
+ task = template.tasks.first()
+ assert FieldsetTemplate.objects.filter(
+ task=task,
+ ).count() == 0
diff --git a/backend/src/processes/tests/test_views/test_templates/test_export.py b/backend/src/processes/tests/test_views/test_templates/test_export.py
index e5d8d2fa7..b5a57c02f 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_export.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_export.py
@@ -19,6 +19,7 @@
FieldTemplate,
FieldTemplateSelection,
)
+
from src.processes.models.templates.kickoff import Kickoff
from src.processes.models.templates.owner import TemplateOwner
from src.processes.models.templates.raw_due_date import RawDueDateTemplate
@@ -27,6 +28,7 @@
from src.processes.tests.fixtures import (
create_test_account,
create_test_admin,
+ create_test_fieldset_template,
create_test_group,
create_test_guest,
create_test_not_admin,
@@ -199,6 +201,51 @@ def test_export__response_format__ok(api_client):
assert predicates_template[0]['operator'] == predicate.operator
+def test_export__fieldsets__ok(api_client):
+ # arrange
+ account = create_test_account()
+ account_owner = create_test_owner(account=account)
+ api_client.token_authenticate(account_owner)
+ template = create_test_template(
+ user=account_owner,
+ tasks_count=1,
+ )
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+
+ kickoff_fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ order=0,
+ )
+
+ task_fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task,
+ order=1,
+ )
+
+ # act
+ response = api_client.get('/templates/export')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data) == 1
+ response_data = response.data[0]
+
+ kickoff_fieldsets = response_data['kickoff']['fieldsets']
+ assert len(kickoff_fieldsets) == 1
+ assert kickoff_fieldsets[0]['api_name'] == kickoff_fieldset.api_name
+ assert kickoff_fieldsets[0]['order'] == 0
+
+ task_fieldsets = response_data['tasks'][0]['fieldsets']
+ assert len(task_fieldsets) == 1
+ assert task_fieldsets[0]['api_name'] == task_fieldset.api_name
+ assert task_fieldsets[0]['order'] == 1
+
+
def test_export__not_auth__permission_denied(api_client):
# act
response = api_client.get('/templates/export')
diff --git a/backend/src/processes/tests/test_views/test_templates/test_fields.py b/backend/src/processes/tests/test_views/test_templates/test_fields.py
index fd6322286..9a760958e 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_fields.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_fields.py
@@ -9,7 +9,10 @@
OwnerType,
PerformerType,
)
-from src.processes.models.templates.fields import FieldTemplate
+from src.processes.models.templates.fields import (
+ FieldTemplate,
+ FieldTemplateSelection,
+)
from src.processes.models.templates.owner import TemplateOwner
from src.processes.models.templates.template import Template
from src.processes.models.workflows.task import TaskPerformer
@@ -21,6 +24,7 @@
create_test_owner,
create_test_template,
create_test_workflow,
+ create_test_fieldset_template, create_test_dataset,
)
pytestmark = pytest.mark.django_db
@@ -63,6 +67,7 @@ def test_fields__active_template__ok(api_client):
data = response.data
assert data['id'] == template.id
assert len(data['kickoff']['fields']) == 1
+ assert data['kickoff']['fieldsets'] == []
field_1_data = data['kickoff']['fields'][0]
assert field_1_data['name'] == kickoff_field.name
@@ -73,11 +78,11 @@ def test_fields__active_template__ok(api_client):
assert len(data['tasks']) == 1
task_data = data['tasks'][0]
- assert task_data['id'] == task.id
assert task_data['name'] == task.name
assert task_data['number'] == task.number
assert task_data['api_name'] == task.api_name
assert len(task_data['fields']) == 1
+ assert task_data['fieldsets'] == []
field_2_data = task_data['fields'][0]
assert field_2_data['name'] == task_field.name
@@ -156,7 +161,7 @@ def test_fields__draft_template__return_from_db(api_client):
# assert
assert response.status_code == 200
- assert response.data['kickoff'] == {'fields': []}
+ assert response.data['kickoff'] == {'fields': [], 'fieldsets': []}
assert response.data['tasks'] == []
@@ -520,3 +525,125 @@ def test_fields__ordering__ok(api_client):
assert data['tasks'][1]['fields'][0]['order'] == 2
assert data['tasks'][1]['fields'][1]['api_name'] == task_2_field_1.api_name
assert data['tasks'][1]['fields'][1]['order'] == 1
+
+
+def test_fields__kickoff_fieldset__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, tasks_count=1, is_active=True)
+ kickoff = template.kickoff_instance
+ fieldset_order = 999
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ order=fieldset_order,
+ )
+ fieldset.fields.delete()
+ dataset = create_test_dataset(account=account)
+ field = FieldTemplate.objects.create(
+ name='First task performer',
+ description='Some description',
+ type=FieldType.DROPDOWN,
+ fieldset=fieldset,
+ template=template,
+ order=1,
+ api_name='field-1',
+ account=account,
+ dataset=dataset,
+ )
+ FieldTemplateSelection.objects.create(
+ field_template=field,
+ value='Value 1',
+ template=template,
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get(f'/templates/{template.id}/fields')
+
+ # assert
+ assert response.status_code == 200
+ data = response.data
+ assert data['id'] == template.id
+ assert len(data['kickoff']['fieldsets']) == 1
+ fieldset_data = data['kickoff']['fieldsets'][0]
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert fieldset_data['order'] == fieldset_order
+ assert len(fieldset_data['fields']) == 1
+
+ field_data = fieldset_data['fields'][0]
+ assert field_data['name'] == field.name
+ assert field_data['type'] == field.type
+ assert field_data['order'] == field.order
+ assert field_data['description'] == field.description
+ assert field_data['is_hidden'] == field.is_hidden
+ assert field_data['api_name'] == field.api_name
+ assert 'selections' not in field_data
+ assert 'dataset' not in field_data
+
+
+def test_fields__task_fieldset__ok(api_client):
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, tasks_count=1, is_active=True)
+ task = template.tasks.get(number=1)
+ fieldset_order = 888
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task,
+ order=fieldset_order,
+ )
+ fieldset.fields.delete()
+ dataset = create_test_dataset(account=account)
+ field = FieldTemplate.objects.create(
+ name='First task performer',
+ description='Some description',
+ type=FieldType.DROPDOWN,
+ fieldset=fieldset,
+ template=template,
+ order=1,
+ api_name='field-1',
+ account=account,
+ dataset=dataset,
+ )
+ FieldTemplateSelection.objects.create(
+ field_template=field,
+ value='Value 1',
+ template=template,
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get(f'/templates/{template.id}/fields')
+
+ # assert
+ assert response.status_code == 200
+ data = response.data
+ assert data['id'] == template.id
+ assert len(data['tasks'][0]['fieldsets']) == 1
+ fieldset_data = data['tasks'][0]['fieldsets'][0]
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ assert fieldset_data['order'] == fieldset_order
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['name'] == field.name
+ assert field_data['type'] == field.type
+ assert field_data['order'] == field.order
+ assert field_data['description'] == field.description
+ assert field_data['is_hidden'] == field.is_hidden
+ assert field_data['api_name'] == field.api_name
+ assert 'selections' not in field_data
+ assert 'dataset' not in field_data
diff --git a/backend/src/processes/tests/test_views/test_templates/test_list.py b/backend/src/processes/tests/test_views/test_templates/test_list.py
index a268ed044..2e3766c36 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_list.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_list.py
@@ -21,8 +21,10 @@
create_invited_user,
create_test_account,
create_test_dataset,
+ create_test_fieldset_template,
create_test_group,
create_test_owner,
+ create_test_shared_fieldset,
create_test_template,
create_test_user,
create_test_workflow,
@@ -1429,3 +1431,127 @@ def test_list__kickoff_field_is_hidden_true(api_client):
assert field_data['api_name'] == field.api_name
assert field_data['is_hidden'] is True
assert field_data['is_required'] is False
+
+
+def test_list__kickoff_fieldset__ok(api_client):
+
+ """ GET /templates returns kickoff fieldset with all fields. """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ is_active=True,
+ )
+ kickoff = template.kickoff_instance
+ shared = create_test_shared_fieldset(
+ account=user.account,
+ description='Enter your personal information',
+ api_name='shared-fieldset-personal',
+ )
+ fieldset = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=kickoff,
+ shared_fieldset=shared,
+ api_name='fieldset-personal',
+ order=5,
+ )
+ fieldset_field = fieldset.fields.first()
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get('/templates')
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data[0]['kickoff']['fieldsets']
+ assert len(fieldsets) == 1
+ fieldset_data = fieldsets[0]
+ assert fieldset_data['shared_fieldset_id'] == shared.id
+ assert fieldset_data['order'] == fieldset.order
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['api_name'] == fieldset_field.api_name
+ assert field_data['name'] == fieldset_field.name
+ assert field_data['type'] == fieldset_field.type
+ assert field_data['order'] == fieldset_field.order
+
+
+def test_list__kickoff_no_fieldsets__empty_list(api_client):
+
+ """ GET /templates returns empty fieldsets list when none exist. """
+
+ # arrange
+ user = create_test_owner()
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ is_active=True,
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get('/templates')
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data[0]['kickoff']['fieldsets']
+ assert fieldsets == []
+
+
+def test_list__kickoff_multiple_fieldsets_ordered(api_client):
+
+ """ GET /templates returns kickoff fieldsets ordered by order. """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ is_active=True,
+ )
+ kickoff = template.kickoff_instance
+ shared_2 = create_test_shared_fieldset(
+ account=user.account,
+ api_name='shared-fieldset-second',
+ )
+ fieldset_2 = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=kickoff,
+ shared_fieldset=shared_2,
+ api_name='fieldset-second',
+ order=2,
+ )
+ shared_1 = create_test_shared_fieldset(
+ account=user.account,
+ api_name='shared-fieldset-first',
+ )
+ fieldset_1 = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=kickoff,
+ shared_fieldset=shared_1,
+ api_name='fieldset-first',
+ order=1,
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get('/templates')
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data[0]['kickoff']['fieldsets']
+ assert len(fieldsets) == 2
+ assert fieldsets[0]['order'] == fieldset_1.order
+ assert fieldsets[0]['api_name'] == fieldset_1.api_name
+ assert fieldsets[1]['order'] == fieldset_2.order
+ assert fieldsets[1]['api_name'] == fieldset_2.api_name
diff --git a/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py b/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py
index ae26a7921..ac56ba344 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py
@@ -5,15 +5,17 @@
PublicToken,
)
from src.processes.enums import (
- FieldType,
+ FieldType, FieldSetRuleType,
)
from src.processes.models.templates.fields import FieldTemplate, \
FieldTemplateSelection
from src.processes.tests.fixtures import (
create_test_template,
+ create_test_fieldset_template,
create_test_owner,
create_test_dataset,
create_test_account,
+ create_test_shared_fieldset,
)
pytestmark = pytest.mark.django_db
@@ -419,6 +421,196 @@ def test_retrieve__disable_captcha__ok(self, api_client, mocker):
get_template_mock.assert_called_once_with(token)
anonymous_user_workflow_exists_mock.assert_not_called()
+ def test_retrieve__kickoff_fieldset__ok(
+ self,
+ api_client,
+ mocker,
+ ):
+
+ """ GET /templates/public returns kickoff fieldset. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ is_public=True,
+ tasks_count=1,
+ )
+ kickoff = template.kickoff_instance
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ description='Enter info',
+ api_name='fieldset-personal',
+ order=5,
+ )
+ fieldset_field = fieldset.fields.first()
+ auth_header_value = (
+ f'Token {template.public_id}'
+ )
+ token = PublicToken(template.public_id)
+ get_token_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_token',
+ return_value=token,
+ )
+ get_template_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_template',
+ return_value=template,
+ )
+ settings_mock = mocker.patch(
+ 'src.processes.views.public.'
+ 'template.settings',
+ )
+ settings_mock.PROJECT_CONF = {'CAPTCHA': True}
+
+ # act
+ response = api_client.get(
+ path='/templates/public',
+ **{'X-Public-Authorization': auth_header_value},
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert len(fieldsets) == 1
+ fieldset_data = fieldsets[0]
+ assert fieldset_data['order'] == fieldset.order
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['api_name'] == fieldset_field.api_name
+ assert field_data['name'] == fieldset_field.name
+ assert field_data['type'] == fieldset_field.type
+ assert field_data['order'] == fieldset_field.order
+ get_token_mock.assert_called_once()
+ get_template_mock.assert_called_once_with(token)
+
+ def test_retrieve__kickoff_no_fieldsets__ok(
+ self,
+ api_client,
+ mocker,
+ ):
+
+ """ GET /templates/public returns empty fieldsets. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ is_public=True,
+ tasks_count=1,
+ )
+ auth_header_value = (
+ f'Token {template.public_id}'
+ )
+ token = PublicToken(template.public_id)
+ get_token_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_token',
+ return_value=token,
+ )
+ get_template_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_template',
+ return_value=template,
+ )
+ settings_mock = mocker.patch(
+ 'src.processes.views.public.'
+ 'template.settings',
+ )
+ settings_mock.PROJECT_CONF = {'CAPTCHA': True}
+
+ # act
+ response = api_client.get(
+ path='/templates/public',
+ **{'X-Public-Authorization': auth_header_value},
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert fieldsets == []
+ get_token_mock.assert_called_once()
+ get_template_mock.assert_called_once_with(token)
+
+ def test_retrieve__kickoff_fieldsets_ordered(
+ self,
+ api_client,
+ mocker,
+ ):
+
+ """ GET /templates/public returns ordered fieldsets. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ is_public=True,
+ tasks_count=1,
+ )
+ kickoff = template.kickoff_instance
+ fieldset_2 = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ order=2,
+ )
+ fieldset_1 = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ order=1,
+ )
+ auth_header_value = (
+ f'Token {template.public_id}'
+ )
+ token = PublicToken(template.public_id)
+ get_token_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_token',
+ return_value=token,
+ )
+ get_template_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_template',
+ return_value=template,
+ )
+ settings_mock = mocker.patch(
+ 'src.processes.views.public.'
+ 'template.settings',
+ )
+ settings_mock.PROJECT_CONF = {'CAPTCHA': True}
+
+ # act
+ response = api_client.get(
+ path='/templates/public',
+ **{'X-Public-Authorization': auth_header_value},
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert len(fieldsets) == 2
+ assert fieldsets[0]['order'] == fieldset_1.order
+ assert fieldsets[0]['api_name'] == fieldset_1.api_name
+ assert fieldsets[1]['order'] == fieldset_2.order
+ assert fieldsets[1]['api_name'] == fieldset_2.api_name
+ get_token_mock.assert_called_once()
+ get_template_mock.assert_called_once_with(token)
+
class TestRetrieveEmbedTemplate:
@@ -636,3 +828,203 @@ def test_retrieve__disable_captcha__ok(self, api_client, mocker):
get_token_mock.assert_called_once()
get_template_mock.assert_called_once_with(token)
anonymous_user_workflow_exists_mock.assert_not_called()
+
+ def test_retrieve__kickoff_fieldset__ok(
+ self,
+ api_client,
+ mocker,
+ ):
+
+ """ GET /templates/public returns embed fieldset. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ is_embedded=True,
+ tasks_count=1,
+ )
+ kickoff = template.kickoff_instance
+ shared_fieldset = create_test_shared_fieldset(
+ account=account,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ )
+ fieldset = create_test_fieldset_template(
+ shared_fieldset=shared_fieldset,
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ api_name='fieldset-personal',
+ order=5,
+ )
+ fieldset_field = fieldset.fields.first()
+ rule = fieldset.rules.first()
+ rule.fields.add(fieldset_field)
+ auth_header_value = (
+ f'Token {template.embed_id}'
+ )
+ token = EmbedToken(template.embed_id)
+ get_token_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_token',
+ return_value=token,
+ )
+ get_template_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_template',
+ return_value=template,
+ )
+ settings_mock = mocker.patch(
+ 'src.processes.views.public.'
+ 'template.settings',
+ )
+ settings_mock.PROJECT_CONF = {'CAPTCHA': True}
+
+ # act
+ response = api_client.get(
+ path='/templates/public',
+ **{'X-Public-Authorization': auth_header_value},
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert len(fieldsets) == 1
+ fieldset_data = fieldsets[0]
+ assert fieldset_data['order'] == fieldset.order
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['title'] == fieldset.title
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['api_name'] == fieldset_field.api_name
+ assert field_data['name'] == fieldset_field.name
+ assert field_data['type'] == fieldset_field.type
+ assert field_data['order'] == fieldset_field.order
+ get_token_mock.assert_called_once()
+ get_template_mock.assert_called_once_with(token)
+
+ def test_retrieve__kickoff_no_fieldsets__ok(
+ self,
+ api_client,
+ mocker,
+ ):
+
+ """ GET /templates/public returns empty embed fieldsets. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ is_embedded=True,
+ tasks_count=1,
+ )
+ auth_header_value = (
+ f'Token {template.embed_id}'
+ )
+ token = EmbedToken(template.embed_id)
+ get_token_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_token',
+ return_value=token,
+ )
+ get_template_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_template',
+ return_value=template,
+ )
+ settings_mock = mocker.patch(
+ 'src.processes.views.public.'
+ 'template.settings',
+ )
+ settings_mock.PROJECT_CONF = {'CAPTCHA': True}
+
+ # act
+ response = api_client.get(
+ path='/templates/public',
+ **{'X-Public-Authorization': auth_header_value},
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert fieldsets == []
+ get_token_mock.assert_called_once()
+ get_template_mock.assert_called_once_with(token)
+
+ def test_retrieve__kickoff_fieldsets_ordered(
+ self,
+ api_client,
+ mocker,
+ ):
+
+ """ GET /templates/public returns ordered embed fieldsets. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ is_embedded=True,
+ tasks_count=1,
+ )
+ kickoff = template.kickoff_instance
+ fieldset_2 = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ api_name='fieldset-second',
+ order=2,
+ )
+ fieldset_1 = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ api_name='fieldset-first',
+ order=1,
+ )
+ auth_header_value = (
+ f'Token {template.embed_id}'
+ )
+ token = EmbedToken(template.embed_id)
+ get_token_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_token',
+ return_value=token,
+ )
+ get_template_mock = mocker.patch(
+ 'src.authentication.services.public_auth.'
+ 'PublicAuthService.get_template',
+ return_value=template,
+ )
+ settings_mock = mocker.patch(
+ 'src.processes.views.public.'
+ 'template.settings',
+ )
+ settings_mock.PROJECT_CONF = {'CAPTCHA': True}
+
+ # act
+ response = api_client.get(
+ path='/templates/public',
+ **{'X-Public-Authorization': auth_header_value},
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert len(fieldsets) == 2
+ assert fieldsets[0]['order'] == fieldset_1.order
+ assert fieldsets[0]['api_name'] == fieldset_1.api_name
+ assert fieldsets[1]['order'] == fieldset_2.order
+ assert fieldsets[1]['api_name'] == fieldset_2.api_name
+ get_token_mock.assert_called_once()
+ get_template_mock.assert_called_once_with(token)
diff --git a/backend/src/processes/tests/test_views/test_templates/test_retrieve.py b/backend/src/processes/tests/test_views/test_templates/test_retrieve.py
index 9197bb208..00a35101e 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_retrieve.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_retrieve.py
@@ -27,6 +27,7 @@
from src.processes.tests.fixtures import (
create_invited_user,
create_test_account,
+ create_test_fieldset_template,
create_test_group,
create_test_not_admin,
create_test_owner,
@@ -763,3 +764,52 @@ def test_retrieve__not_found__not_found(api_client):
# assert
assert response.status_code == 404
+
+
+def test_retrieve__fieldsets__ok(api_client):
+ # arrange
+ account = create_test_account()
+ account_owner = create_test_owner(account=account)
+ api_client.token_authenticate(account_owner)
+ template = create_test_template(
+ user=account_owner,
+ is_active=True,
+ tasks_count=1,
+ )
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+
+ kickoff_fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ description='Kickoff fieldset desc',
+ api_name='fieldset-kickoff-1',
+ order=0,
+ )
+
+ task_fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task,
+ description='Task fieldset desc',
+ api_name='fieldset-task-1',
+ order=1,
+ )
+
+ # act
+ response = api_client.get(f'/templates/{template.id}')
+
+ # assert
+ assert response.status_code == 200
+ response_data = response.data
+
+ kickoff_fieldsets = response_data['kickoff']['fieldsets']
+ assert len(kickoff_fieldsets) == 1
+ assert kickoff_fieldsets[0]['api_name'] == kickoff_fieldset.api_name
+ assert kickoff_fieldsets[0]['order'] == 0
+
+ task_fieldsets = response_data['tasks'][0]['fieldsets']
+ assert len(task_fieldsets) == 1
+ assert task_fieldsets[0]['api_name'] == task_fieldset.api_name
+ assert task_fieldsets[0]['order'] == 1
diff --git a/backend/src/processes/tests/test_views/test_templates/test_run.py b/backend/src/processes/tests/test_views/test_templates/test_run.py
index c5fa5484b..56f20b099 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_run.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_run.py
@@ -20,6 +20,7 @@
ConditionAction,
DirectlyStatus,
DueDateRule,
+ FieldSetRuleType,
FieldType,
OwnerRole,
OwnerType,
@@ -28,8 +29,11 @@
TaskStatus,
WorkflowEventType,
WorkflowStatus,
+ LabelPosition,
+ FieldSetLayout,
)
from src.processes.messages import workflow as messages
+from src.processes.messages.fieldset import MSG_FS_0002
from src.processes.messages.workflow import (
MSG_PW_0028,
MSG_PW_0030,
@@ -67,18 +71,22 @@
from src.processes.tests.fixtures import (
create_test_account,
create_test_admin,
+ create_test_dataset,
+ create_test_fieldset_template,
create_test_group,
create_test_guest,
+ create_test_not_admin,
create_test_owner,
create_test_template,
create_test_user,
create_test_workflow,
create_wf_completed_webhook,
- create_wf_created_webhook, create_test_not_admin, create_test_dataset,
+ create_wf_created_webhook, create_test_shared_fieldset,
)
from src.utils.dates import date_format
from src.utils.validation import ErrorCode
+
pytestmark = pytest.mark.django_db
@@ -5182,3 +5190,389 @@ def test_run__template_starter_not_admin__ok(mocker, api_client):
# assert
assert response.status_code == 200
assert Workflow.objects.filter(id=response.data['id']).exists()
+
+
+def test_run__kickoff_with_one_fieldset__ok(mocker, api_client):
+
+ """ Kickoff with one fieldset creates FieldSet. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ fs_title = 'Some title'
+ fs_description = 'Some desc'
+ fs_name = 'Some name'
+ fs_order = 3
+ label_position = LabelPosition.LEFT
+ layout = FieldSetLayout.HORIZONTAL
+ rule_type = FieldSetRuleType.SUM_EQUAL
+ rule_value = '100'
+ shared_fieldset = create_test_shared_fieldset(
+ account=account,
+ title=fs_title,
+ description=fs_description,
+ name=fs_name,
+ label_position=label_position,
+ layout=layout,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ )
+ fieldset_template = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=template.kickoff_instance,
+ order=fs_order,
+ shared_fieldset=shared_fieldset,
+ )
+ field_template = fieldset_template.fields.first()
+ field_value = '100'
+ mocker.patch(
+ 'src.processes.services.workflow_action.'
+ 'WorkflowEventService.workflow_run_event',
+ )
+ mocker.patch(
+ 'src.analysis.services.AnalyticService.'
+ 'workflows_started',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.post(
+ path=f'/templates/{template.id}/run',
+ data={
+ 'kickoff': {
+ field_template.api_name: field_value,
+ },
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ workflow = Workflow.objects.get(id=response.data['id'])
+ kickoff_value = KickoffValue.objects.get(workflow=workflow)
+ assert kickoff_value.fieldsets.count() == 1
+ fieldset = kickoff_value.fieldsets.first()
+ assert fieldset.name == fieldset_template.name
+ assert fieldset.api_name == fieldset_template.api_name
+ fieldsets_data = response.data['kickoff']['fieldsets']
+ assert len(fieldsets_data) == 1
+ fieldset_data = fieldsets_data[0]
+ assert fieldset_data['id'] == fieldset.id
+ assert fieldset_data['api_name'] == fieldset_template.api_name
+ assert fieldset_data['name'] == fs_name
+ assert fieldset_data['description'] == fs_description
+ assert fieldset_data['order'] == fs_order
+ assert fieldset_data['label_position'] == fieldset_template.label_position
+ assert fieldset_data['layout'] == fieldset_template.layout
+
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['id']
+ assert field_data['api_name'] == field_template.api_name
+ assert field_data['name'] == field_template.name
+ assert field_data['type'] == field_template.type
+ assert field_data['order'] == field_template.order
+ assert field_data['is_required'] == field_template.is_required
+ assert field_data['is_hidden'] == field_template.is_hidden
+ assert field_data['description'] == field_template.description
+ assert field_data['value'] == field_value
+ assert field_data['markdown_value'] == field_value
+ assert field_data['clear_value'] == field_value
+ assert field_data['user_id'] is None
+ assert field_data['group_id'] is None
+ assert field_data['selections'] == []
+ assert field_data['attachments'] == []
+
+
+def test_run__kickoff_with_multiple_fieldsets__ok(mocker, api_client):
+
+ """ Multiple fieldsets created with correct order. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ kickoff = template.kickoff_instance
+ shared_fieldset_1 = create_test_shared_fieldset(
+ account=account,
+ title='First fieldset',
+ )
+ fieldset_1 = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=kickoff,
+ order=0,
+ shared_fieldset=shared_fieldset_1,
+ )
+ shared_fieldset_2 = create_test_shared_fieldset(
+ account=account,
+ title='Second fieldset',
+ )
+ fieldset_2 = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=kickoff,
+ order=1,
+ shared_fieldset=shared_fieldset_2,
+ )
+ field_1 = fieldset_1.fields.first()
+ field_2 = fieldset_2.fields.first()
+ field_value_1 = 'value 1'
+ field_value_2 = 'value 2'
+ mocker.patch(
+ 'src.processes.services.workflow_action.'
+ 'WorkflowEventService.workflow_run_event',
+ )
+ mocker.patch(
+ 'src.analysis.services.AnalyticService.'
+ 'workflows_started',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.post(
+ path=f'/templates/{template.id}/run',
+ data={
+ 'kickoff': {
+ field_1.api_name: field_value_1,
+ field_2.api_name: field_value_2,
+ },
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ workflow = Workflow.objects.get(id=response.data['id'])
+ kickoff_value = KickoffValue.objects.get(
+ workflow=workflow,
+ )
+ assert kickoff_value.fieldsets.count() == 2
+ fieldsets_data = response.data['kickoff']['fieldsets']
+ assert len(fieldsets_data) == 2
+ assert fieldsets_data[0]['title'] == fieldset_2.title
+ assert fieldsets_data[0]['order'] == 1
+ assert fieldsets_data[1]['title'] == fieldset_1.title
+ assert fieldsets_data[1]['order'] == 0
+
+
+def test_run__kickoff_fieldset_and_standalone__ok(
+ mocker,
+ api_client,
+):
+
+ """ Fieldset and standalone field both created. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ fieldset_template = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=template.kickoff_instance,
+ order=0,
+ )
+ field_template_1 = fieldset_template.fields.first()
+ field_template_2 = FieldTemplate.objects.create(
+ name='Standalone field',
+ type=FieldType.STRING,
+ is_required=False,
+ kickoff=template.kickoff_instance,
+ template=template,
+ account=user.account,
+ )
+ field_value_1 = 'fieldset field value'
+ field_value_2 = 'standalone field value'
+ wf_run_mock = mocker.patch(
+ 'src.processes.services.workflow_action.'
+ 'WorkflowEventService.workflow_run_event',
+ )
+ analytics_mock = mocker.patch(
+ 'src.analysis.services.AnalyticService.'
+ 'workflows_started',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.post(
+ path=f'/templates/{template.id}/run',
+ data={
+ 'kickoff': {
+ field_template_1.api_name: field_value_1,
+ field_template_2.api_name: field_value_2,
+ },
+ },
+ )
+
+ # assert
+ wf_run_mock.assert_called_once()
+ analytics_mock.assert_called_once()
+ assert response.status_code == 200
+ workflow = Workflow.objects.get(id=response.data['id'])
+ kickoff_value = workflow.kickoff_instance
+ assert kickoff_value.output.count() == 1
+ assert kickoff_value.output.get(api_name=field_template_2.api_name)
+
+ assert kickoff_value.fieldsets.count() == 1
+ fieldset = kickoff_value.fieldsets.get(api_name=fieldset_template.api_name)
+ assert fieldset.fields.get(api_name=field_template_1.api_name)
+
+ fieldset_fields_data = response.data['kickoff']['fieldsets'][0]['fields']
+ assert len(fieldset_fields_data) == 1
+ assert fieldset_fields_data[0]['api_name'] == field_template_1.api_name
+
+ fields_data = response.data['kickoff']['output']
+ assert len(fields_data) == 1
+ assert fields_data[0]['api_name'] == field_template_2.api_name
+
+
+def test_run__kickoff_fieldset_sum_equal__ok(
+ mocker,
+ api_client,
+):
+
+ """ Fieldset sum_equal rule passes on correct sum. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ fieldset_template = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=template.kickoff_instance,
+ order=0,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ field_1 = fieldset_template.fields.first()
+ field_2 = FieldTemplate.objects.create(
+ name='Second number',
+ type=FieldType.NUMBER,
+ fieldset=fieldset_template,
+ template=template,
+ order=2,
+ api_name=(
+ f'{fieldset_template.api_name}-field-2'
+ ),
+ account=user.account,
+ )
+ rule_template = fieldset_template.rules.first()
+ field_1.rules.add(rule_template)
+ field_2.rules.add(rule_template)
+ wf_run_mock = mocker.patch(
+ 'src.processes.services.workflow_action.'
+ 'WorkflowEventService.workflow_run_event',
+ )
+ analytics_mock = mocker.patch(
+ 'src.analysis.services.AnalyticService.'
+ 'workflows_started',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.post(
+ path=f'/templates/{template.id}/run',
+ data={
+ 'kickoff': {
+ field_1.api_name: 60,
+ field_2.api_name: 40,
+ },
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ workflow = Workflow.objects.get(id=response.data['id'])
+ kickoff_value = KickoffValue.objects.get(
+ workflow=workflow,
+ )
+ fieldset = kickoff_value.fieldsets.first()
+ assert fieldset.fields.count() == 2
+ assert fieldset.rules.count() == 1
+ wf_run_mock.assert_called_once()
+ analytics_mock.assert_called_once()
+
+
+def test_run__kickoff_fieldset_sum_equal__validation_error(
+ mocker,
+ api_client,
+):
+
+ """ Fieldset sum_equal returns 400 on wrong sum. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ fieldset_template = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=template.kickoff_instance,
+ order=0,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='100',
+ )
+ field_1 = fieldset_template.fields.first()
+ field_2 = FieldTemplate.objects.create(
+ name='Second number',
+ type=FieldType.NUMBER,
+ fieldset=fieldset_template,
+ template=template,
+ order=2,
+ api_name=(
+ f'{fieldset_template.api_name}-field-2'
+ ),
+ account=user.account,
+ )
+ rule_template = fieldset_template.rules.first()
+ field_1.rules.add(rule_template)
+ field_2.rules.add(rule_template)
+ wf_run_mock = mocker.patch(
+ 'src.processes.services.workflow_action.'
+ 'WorkflowEventService.workflow_run_event',
+ )
+ analytics_mock = mocker.patch(
+ 'src.analysis.services.AnalyticService.'
+ 'workflows_started',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.post(
+ path=f'/templates/{template.id}/run',
+ data={
+ 'kickoff': {
+ field_1.api_name: 60,
+ field_2.api_name: 50,
+ },
+ },
+ )
+
+ # assert
+ assert response.status_code == 400
+ assert response.data['code'] == ErrorCode.VALIDATION_ERROR
+ assert response.data['message'] == MSG_FS_0002('100')
+ wf_run_mock.assert_not_called()
+ analytics_mock.assert_not_called()
diff --git a/backend/src/processes/tests/test_views/test_templates/test_steps.py b/backend/src/processes/tests/test_views/test_templates/test_steps.py
index 18bbcd0d7..d43fc5a9b 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_steps.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_steps.py
@@ -95,7 +95,7 @@ def test_steps__admin__ok(api_client):
# assert
assert len(response.data) == 1
assert response.data[0]['number'] == 1
- assert response.data[0]['id'] == template_task.id
+ assert response.data[0]['name'] == template_task.name
assert response.data[0]['api_name'] == template_task.api_name
@@ -126,7 +126,7 @@ def test_steps__not_admin__ok(api_client):
# assert
assert len(response.data) == 1
assert response.data[0]['number'] == 1
- assert response.data[0]['id'] == template_task.id
+ assert response.data[0]['name'] == template_task.name
assert response.data[0]['api_name'] == template_task.api_name
@@ -161,7 +161,7 @@ def test_steps__group_is_template_owner__ok(api_client):
assert response.status_code == 200
assert len(response.data) == 1
assert response.data[0]['number'] == template_task.number
- assert response.data[0]['id'] == template_task.id
+ assert response.data[0]['name'] == template_task.name
assert response.data[0]['api_name'] == template_task.api_name
@@ -498,7 +498,7 @@ def test_steps__with_tasks_in_progress_true__another_task_completed__ok(
assert response.status_code == 200
assert len(response.data) == 1
assert response.data[0]['number'] == template_task_2.number
- assert response.data[0]['id'] == template_task_2.id
+ assert response.data[0]['name'] == template_task_2.name
assert response.data[0]['api_name'] == template_task_2.api_name
@@ -535,7 +535,7 @@ def test_steps__with_tasks_in_progress_true__not_template_owner__ok(
# assert
assert response.status_code == 200
assert len(response.data) == 1
- assert response.data[0]['id'] == template_task.id
+ assert response.data[0]['name'] == template_task.name
assert response.data[0]['number'] == template_task.number
assert response.data[0]['api_name'] == template_task.api_name
@@ -576,7 +576,7 @@ def test_steps__with_tasks_in_progress_true__performer_group__ok(
assert response.status_code == 200
assert len(response.data) == 1
assert response.data[0]['number'] == 1
- assert response.data[0]['id'] == template_task_1.id
+ assert response.data[0]['name'] == template_task_1.name
assert response.data[0]['api_name'] == template_task_1.api_name
@@ -694,7 +694,7 @@ def test_steps__with_tasks_in_progress_false__completed_task__ok(
assert response.status_code == 200
assert len(response.data) == 1
assert response.data[0]['number'] == template_task.number
- assert response.data[0]['id'] == template_task.id
+ assert response.data[0]['name'] == template_task.name
assert response.data[0]['api_name'] == template_task.api_name
@@ -771,7 +771,7 @@ def test_steps__with_tasks_in_progress_false__performer_group__ok(
assert response.status_code == 200
assert len(response.data) == 1
assert response.data[0]['number'] == 1
- assert response.data[0]['id'] == template_task_1.id
+ assert response.data[0]['name'] == template_task_1.name
assert response.data[0]['api_name'] == template_task_1.api_name
diff --git a/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py b/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py
index 6231e869d..fa2580d79 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py
@@ -1,12 +1,16 @@
import pytest
from src.accounts.enums import BillingPlanType
-from src.processes.enums import OwnerRole, OwnerType, FieldType
-from src.processes.models.templates.fields import FieldTemplate, \
- FieldTemplateSelection
+from src.processes.enums import OwnerRole, OwnerType, FieldType, \
+ FieldSetRuleType
+from src.processes.models.templates.fields import (
+ FieldTemplate,
+ FieldTemplateSelection,
+)
from src.processes.models.templates.owner import TemplateOwner
from src.processes.tests.fixtures import (
create_test_account,
+ create_test_fieldset_template,
create_test_group,
create_test_template,
create_test_owner,
@@ -442,3 +446,133 @@ def test_titles_by_owners__kickoff_field_with_dataset_and_selections__ok(
assert len(field_data['selections']) == 2
assert field_data['selections'][0] == selection.value
assert field_data['selections'][1] == dataset_item.value
+
+
+def test_titles_by_owners__kickoff_fieldset__ok(api_client):
+
+ """ GET titles-by-owners returns kickoff fieldset with all fields. """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ is_active=True,
+ )
+ kickoff = template.kickoff_instance
+ fieldset = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=kickoff,
+ title='Personal',
+ description='Enter your personal information',
+ api_name='fieldset-personal',
+ order=5,
+ rule_type=FieldSetRuleType.SUM_EQUAL,
+ rule_value='1',
+ )
+ fieldset_field = fieldset.fields.first()
+ rule = fieldset.rules.all().first()
+ rule.fields.add(fieldset_field)
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get('/templates/titles-by-owners')
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data[0]['kickoff']['fieldsets']
+ assert len(fieldsets) == 1
+ fieldset_data = fieldsets[0]
+ assert fieldset_data['order'] == fieldset.order
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['title'] == fieldset.title
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ assert len(fieldset_data['rules']) == 1
+
+ rule_data = fieldset_data['rules'][0]
+ assert rule_data['type'] == rule.type
+ assert rule_data['value'] == rule.value
+ assert rule_data['api_name'] == rule.api_name
+ assert rule_data['fields'] == [fieldset_field.api_name]
+
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['api_name'] == fieldset_field.api_name
+ assert field_data['name'] == fieldset_field.name
+ assert field_data['description'] == ''
+ assert field_data['type'] == fieldset_field.type
+ assert field_data['is_required'] == fieldset_field.is_required
+ assert field_data['is_hidden'] == fieldset_field.is_hidden
+ assert field_data['default'] == fieldset_field.default
+ assert field_data['order'] == fieldset_field.order
+
+
+def test_titles_by_owners__kickoff_no_fieldsets__empty_list(
+ api_client,
+):
+
+ """ GET titles-by-owners returns empty fieldsets when none exist. """
+
+ # arrange
+ user = create_test_owner()
+ create_test_template(
+ user=user,
+ tasks_count=1,
+ is_active=True,
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get('/templates/titles-by-owners')
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data[0]['kickoff']['fieldsets']
+ assert fieldsets == []
+
+
+def test_titles_by_owners__kickoff_fieldsets_ordered(
+ api_client,
+):
+
+ """ GET titles-by-owners returns fieldsets ordered by order. """
+
+ # arrange
+ user = create_test_owner()
+ template = create_test_template(
+ user=user,
+ tasks_count=1,
+ is_active=True,
+ )
+ kickoff = template.kickoff_instance
+ fieldset_2 = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=kickoff,
+ api_name='fieldset-second',
+ order=2,
+ )
+ fieldset_1 = create_test_fieldset_template(
+ account=user.account,
+ template=template,
+ kickoff=kickoff,
+ api_name='fieldset-first',
+ order=1,
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get('/templates/titles-by-owners')
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data[0]['kickoff']['fieldsets']
+ assert len(fieldsets) == 2
+ assert fieldsets[0]['order'] == fieldset_1.order
+ assert fieldsets[0]['api_name'] == fieldset_1.api_name
+ assert fieldsets[1]['order'] == fieldset_2.order
+ assert fieldsets[1]['api_name'] == fieldset_2.api_name
diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py
new file mode 100644
index 000000000..0b10f49fd
--- /dev/null
+++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py
@@ -0,0 +1,1083 @@
+import pytest
+
+from src.processes.enums import (
+ FieldSetLayout,
+ FieldSetRuleType,
+ LabelPosition,
+ OwnerRole,
+ OwnerType,
+ PerformerType,
+)
+from src.processes.models.templates.fieldset import FieldsetTemplate
+from src.processes.tests.fixtures import (
+ create_test_account,
+ create_test_fieldset_template,
+ create_test_owner,
+ create_test_shared_fieldset,
+ create_test_template,
+)
+
+pytestmark = pytest.mark.django_db
+
+# Kickoff fieldsets
+
+
+def test_update__create_kickoff_fieldset_only_required_data__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ fs_title = 'Some title'
+ fs_description = 'Some desc'
+ fs_name = 'Some name'
+ fs_order = 3
+ label_position = LabelPosition.LEFT
+ layout = FieldSetLayout.HORIZONTAL
+ rule_type = FieldSetRuleType.SUM_EQUAL
+ rule_value = '100'
+ shared_fieldset = create_test_shared_fieldset(
+ account=account,
+ title=fs_title,
+ description=fs_description,
+ name=fs_name,
+ order=fs_order,
+ label_position=label_position,
+ layout=layout,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ )
+ shared_field = shared_fieldset.fields.first()
+ shared_rule = shared_fieldset.rules.first()
+ shared_field.rules.add(shared_rule)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_fieldset.id,
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldset = FieldsetTemplate.objects.get(
+ kickoff=kickoff,
+ shared_fieldset=shared_fieldset,
+ is_shared=False,
+ )
+ field = fieldset.fields.first()
+ rule = fieldset.rules.first()
+
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert len(fieldsets) == 1
+ fieldset_data = fieldsets[0]
+ assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id
+ assert fieldset_data['order'] == 0
+ assert fieldset_data['name'] == fs_name
+ assert fieldset_data['title'] == fs_title
+ assert fieldset_data['description'] == fs_description
+ assert fieldset_data['label_position'] == label_position
+ assert fieldset_data['layout'] == layout
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['name'] == shared_field.name
+ assert field_data['description'] == ''
+ assert field_data['type'] == shared_field.type
+ assert field_data['is_required'] == shared_field.is_required
+ assert field_data['is_hidden'] == shared_field.is_hidden
+ assert field_data['order'] == shared_field.order
+ assert field_data['default'] == shared_field.default
+ assert field_data['api_name'] == field.api_name
+ assert len(fieldset_data['rules']) == 1
+ rule_data = fieldset_data['rules'][0]
+ assert rule_data['type'] == rule_type
+ assert rule_data['value'] == rule_value
+ assert rule_data['api_name'] == rule.api_name
+ assert rule_data['fields'] == [field.api_name]
+
+
+def test_update__kickoff_fieldset_all_fieldset_data__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ fs_title = 'Some title'
+ fs_description = 'Some desc'
+ fs_order = 3
+ fs_api_name = 'fs-some-api-name'
+ shared_fieldset = create_test_shared_fieldset(account=account)
+ shared_fieldset.fields.all().delete()
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_fieldset.id,
+ 'order': fs_order,
+ 'title': fs_title,
+ 'description': fs_description,
+ 'api_name': fs_api_name,
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert FieldsetTemplate.objects.get(
+ kickoff=kickoff,
+ shared_fieldset=shared_fieldset,
+ is_shared=False,
+ api_name=fs_api_name,
+ )
+
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert len(fieldsets) == 1
+ fieldset_data = fieldsets[0]
+ assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id
+ assert fieldset_data['order'] == fs_order
+ assert fieldset_data['title'] == fs_title
+ assert fieldset_data['description'] == fs_description
+ assert fieldset_data['name'] == shared_fieldset.name
+ assert fieldset_data['api_name'] == fs_api_name
+ assert fieldset_data['label_position'] == shared_fieldset.label_position
+ assert fieldset_data['layout'] == shared_fieldset.layout
+ assert fieldset_data['rules'] == []
+ assert fieldset_data['fields'] == []
+
+
+def test_update__kickoff_create_two_fieldsets__ok(
+ mocker,
+ api_client,
+):
+
+ """ Updating a template with multiple fieldsets linked to
+ kickoff creates multiple child fieldset records. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ shared_1 = create_test_shared_fieldset(account=account)
+ shared_2 = create_test_shared_fieldset(account=account)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_1.id,
+ 'order': 0,
+ },
+ {
+ 'shared_fieldset_id': shared_2.id,
+ 'order': 1,
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert len(fieldsets) == 2
+ assert kickoff.fieldsets.filter(
+ shared_fieldset_id=shared_1.id,
+ order=0,
+ ).count() == 1
+ assert kickoff.fieldsets.filter(
+ shared_fieldset_id=shared_2.id,
+ order=1,
+ ).count() == 1
+
+
+def test_update__kickoff_replace_fieldset__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ shared_1 = create_test_shared_fieldset(account=account)
+ shared_2 = create_test_shared_fieldset(account=account)
+ # create an fieldset child fieldset linked to kickoff from shared_1
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ shared_fieldset=shared_1,
+ order=0,
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_2.id,
+ 'order': 2,
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['kickoff']['fieldsets']
+ assert len(fieldsets) == 1
+ assert fieldsets[0]['shared_fieldset_id'] == shared_2.id
+ assert fieldsets[0]['order'] == 2
+ assert not kickoff.fieldsets.filter(id=fieldset.id).exists()
+ assert kickoff.fieldsets.filter(
+ shared_fieldset_id=shared_2.id,
+ order=2,
+ ).count() == 1
+
+
+def test_update__kickoff_remove_fieldset__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ shared_fieldset = create_test_shared_fieldset(account=account)
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ kickoff=kickoff,
+ shared_fieldset=shared_fieldset,
+ order=0,
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ 'fieldsets': [],
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['kickoff']['fieldsets'] == []
+ assert not kickoff.fieldsets.filter(id=fieldset.id).exists()
+
+
+# Task fieldsets
+
+
+def test_update__create_task_fieldset_only_required_data__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ fs_title = 'Some title'
+ fs_description = 'Some desc'
+ fs_name = 'Some name'
+ fs_order = 3
+ label_position = LabelPosition.LEFT
+ layout = FieldSetLayout.HORIZONTAL
+ rule_type = FieldSetRuleType.SUM_EQUAL
+ rule_value = '200'
+ shared_fieldset = create_test_shared_fieldset(
+ account=account,
+ title=fs_title,
+ description=fs_description,
+ name=fs_name,
+ order=fs_order,
+ label_position=label_position,
+ layout=layout,
+ rule_type=rule_type,
+ rule_value=rule_value,
+ )
+ shared_field = shared_fieldset.fields.first()
+ shared_rule = shared_fieldset.rules.first()
+ shared_field.rules.add(shared_rule)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_fieldset.id,
+ },
+ ],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldset = FieldsetTemplate.objects.get(
+ task=task,
+ shared_fieldset=shared_fieldset,
+ is_shared=False,
+ )
+ field = fieldset.fields.first()
+ rule = fieldset.rules.first()
+
+ fieldsets = response.data['tasks'][0]['fieldsets']
+ assert len(fieldsets) == 1
+ fieldset_data = fieldsets[0]
+ assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id
+ assert fieldset_data['order'] == 0
+ assert fieldset_data['name'] == fs_name
+ assert fieldset_data['title'] == fs_title
+ assert fieldset_data['description'] == fs_description
+ assert fieldset_data['label_position'] == label_position
+ assert fieldset_data['layout'] == layout
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert len(fieldset_data['fields']) == 1
+ field_data = fieldset_data['fields'][0]
+ assert field_data['name'] == shared_field.name
+ assert field_data['description'] == ''
+ assert field_data['type'] == shared_field.type
+ assert field_data['is_required'] == shared_field.is_required
+ assert field_data['is_hidden'] == shared_field.is_hidden
+ assert field_data['order'] == shared_field.order
+ assert field_data['default'] == shared_field.default
+ assert field_data['api_name'] == field.api_name
+ assert len(fieldset_data['rules']) == 1
+ rule_data = fieldset_data['rules'][0]
+ assert rule_data['type'] == rule_type
+ assert rule_data['value'] == rule_value
+ assert rule_data['api_name'] == rule.api_name
+ assert rule_data['fields'] == [field.api_name]
+
+
+def test_update__task_fieldset_all_fieldset_data__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ fs_title = 'Some title'
+ fs_description = 'Some desc'
+ fs_order = 3
+ fs_api_name = 'fs-some-api-name'
+ shared_fieldset = create_test_shared_fieldset(account=account)
+ shared_fieldset.fields.all().delete()
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_fieldset.id,
+ 'order': fs_order,
+ 'title': fs_title,
+ 'description': fs_description,
+ 'api_name': fs_api_name,
+ },
+ ],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert FieldsetTemplate.objects.get(
+ task=task,
+ shared_fieldset=shared_fieldset,
+ is_shared=False,
+ api_name=fs_api_name,
+ )
+
+ task_data = response.data['tasks'][0]
+ assert len(task_data['fieldsets']) == 1
+ fieldset_data = task_data['fieldsets'][0]
+ assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id
+ assert fieldset_data['order'] == fs_order
+ assert fieldset_data['title'] == fs_title
+ assert fieldset_data['description'] == fs_description
+ assert fieldset_data['name'] == shared_fieldset.name
+ assert fieldset_data['api_name'] == fs_api_name
+ assert fieldset_data['label_position'] == shared_fieldset.label_position
+ assert fieldset_data['layout'] == shared_fieldset.layout
+ assert fieldset_data['rules'] == []
+ assert fieldset_data['fields'] == []
+
+
+def test_update__task_create_two_fieldsets__ok(
+ mocker,
+ api_client,
+):
+
+ """ Updating a template with multiple fieldsets linked to a task
+ creates multiple child fieldset records. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ task = template.tasks.first()
+ kickoff = template.kickoff_instance
+ shared_1 = create_test_shared_fieldset(account=account)
+ shared_2 = create_test_shared_fieldset(account=account)
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_1.id,
+ 'order': 1,
+ },
+ {
+ 'shared_fieldset_id': shared_2.id,
+ 'order': 0,
+ },
+ ],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['tasks'][0]['fieldsets']
+ assert len(fieldsets) == 2
+ assert task.fieldsets.filter(
+ shared_fieldset_id=shared_1.id,
+ order=1,
+ ).count() == 1
+ assert task.fieldsets.filter(
+ shared_fieldset_id=shared_2.id,
+ order=0,
+ ).count() == 1
+
+
+def test_update__task_replace_fieldset__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ shared_1 = create_test_shared_fieldset(account=account)
+ shared_2 = create_test_shared_fieldset(account=account)
+ # create an fieldset child fieldset linked to task from shared_1
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task,
+ shared_fieldset=shared_1,
+ order=0,
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_2.id,
+ 'order': 2,
+ },
+ ],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ fieldsets = response.data['tasks'][0]['fieldsets']
+ assert len(fieldsets) == 1
+ assert fieldsets[0]['shared_fieldset_id'] == shared_2.id
+ assert fieldsets[0]['order'] == 2
+ assert not task.fieldsets.filter(id=fieldset.id).exists()
+ assert task.fieldsets.filter(
+ shared_fieldset_id=shared_2.id,
+ order=2,
+ ).count() == 1
+
+
+def test_update__tasks_remove_fieldset__ok(
+ mocker,
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ shared_fieldset = create_test_shared_fieldset(account=account)
+ fieldset = create_test_fieldset_template(
+ account=account,
+ template=template,
+ task=task,
+ shared_fieldset=shared_fieldset,
+ order=0,
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['tasks'][0]['fieldsets'] == []
+ assert not task.fieldsets.filter(id=fieldset.id).exists()
+
+
+def test_update__task_with_empty_fieldsets__no_create_fieldsets(
+ mocker,
+ api_client,
+):
+
+ """ Updating a template with empty fieldsets list in task does not
+ create any task fieldset records. """
+
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ template = create_test_template(user, is_active=True, tasks_count=1)
+ kickoff = template.kickoff_instance
+ task = template.tasks.first()
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.'
+ 'create_integrations_for_template',
+ )
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.AnalyticService.templates_updated',
+ )
+ mocker.patch(
+ 'src.processes.views.template.'
+ 'AnalyticService.templates_kickoff_updated',
+ )
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data={
+ 'id': template.id,
+ 'is_active': True,
+ 'name': 'Updated template',
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'api_name': task.api_name,
+ 'number': task.number,
+ 'name': task.name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ 'fieldsets': [],
+ },
+ ],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert task.fieldsets.count() == 0
diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py
index 6e3f5f393..088be71eb 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py
@@ -211,6 +211,7 @@ def test_update_draft__not_kickoff__ok(
assert response.status_code == 200
assert response.data['kickoff'] == {
'fields': [],
+ 'fieldsets': [],
}
assert response.data['is_active'] is False
kickoff_update_mock.assert_not_called()
@@ -267,6 +268,7 @@ def test_update_draft__kickoff_id_null__ok(
assert response.status_code == 200
assert response.data['kickoff'] == {
'fields': [],
+ 'fieldsets': [],
}
assert response.data['is_active'] is False
kickoff_update_mock.assert_not_called()
@@ -309,5 +311,6 @@ def test_update_draft__not_send_kickoff_id__ok(
assert response.status_code == 200
assert response.data['kickoff'] == {
'fields': [],
+ 'fieldsets': [],
}
kickoff_update_mock.assert_not_called()
diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py
index ab13bff56..4e208a3cc 100644
--- a/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py
+++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py
@@ -40,6 +40,7 @@
create_test_template,
create_test_user,
create_test_workflow,
+ create_test_fieldset_template, create_test_shared_fieldset,
)
pytestmark = pytest.mark.django_db
@@ -2829,3 +2830,74 @@ def test_update__inactive_template__analytics_skipped(mocker, api_client):
api_request_mock.assert_not_called()
templates_kickoff_updated_mock.assert_not_called()
templates_updated_mock.assert_not_called()
+
+
+def test_update__wf_name_template_with_fieldset_field__ok(
+ mocker,
+ api_client,
+):
+ # arrange
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ api_client.token_authenticate(user)
+ template = create_test_template(user=user, tasks_count=1)
+ task = template.tasks.first()
+ kickoff = template.kickoff_instance
+ shared_fieldset = create_test_shared_fieldset(account=account)
+ fieldset = create_test_fieldset_template(
+ account=account,
+ shared_fieldset=shared_fieldset,
+ )
+ field = fieldset.fields.first()
+ mocker.patch(
+ 'src.processes.services.templates.'
+ 'integrations.TemplateIntegrationsService.template_updated',
+ )
+ wf_name_template = f'Template {{ {field.api_name} }}'
+
+ request_data = {
+ 'id': template.id,
+ 'is_active': True,
+ 'wf_name_template': wf_name_template,
+ 'name': template.name,
+ 'owners': [
+ {
+ 'type': OwnerType.USER,
+ 'source_id': user.id,
+ 'role': OwnerRole.OWNER,
+ },
+ ],
+ 'kickoff': {
+ 'id': kickoff.id,
+ 'fieldsets': [
+ {
+ 'shared_fieldset_id': shared_fieldset.id,
+ 'order': 1,
+ },
+ ],
+ },
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'number': task.number,
+ 'name': task.name,
+ 'api_name': task.api_name,
+ 'raw_performers': [
+ {
+ 'type': PerformerType.USER,
+ 'source_id': user.id,
+ },
+ ],
+ },
+ ],
+ }
+
+ # act
+ response = api_client.put(
+ path=f'/templates/{template.id}',
+ data=request_data,
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert response.data['wf_name_template'] == wf_name_template
diff --git a/backend/src/processes/tests/test_views/test_workflow/test_events.py b/backend/src/processes/tests/test_views/test_workflow/test_events.py
index 330e614bf..693095712 100644
--- a/backend/src/processes/tests/test_views/test_workflow/test_events.py
+++ b/backend/src/processes/tests/test_views/test_workflow/test_events.py
@@ -36,6 +36,8 @@
TaskPerformer,
)
from src.processes.models.workflows.workflow import Workflow
+from src.processes.models.workflows.fields import TaskField
+from src.processes.models.workflows.fieldset import FieldSet
from src.processes.services.events import (
WorkflowEventService,
)
@@ -54,6 +56,7 @@
create_test_template,
create_test_user,
create_test_workflow,
+ create_test_event,
)
pytestmark = pytest.mark.django_db
@@ -1716,3 +1719,166 @@ def test_workflow_events__template_starter_own_workflow__ok(api_client):
# assert
assert response.status_code == status.HTTP_200_OK
+
+
+def test_events__task_complete_fieldsets_present__ok(api_client):
+
+ """
+ GET workflow events: TASK_COMPLETE row includes non-null task.fieldsets
+ when the task has at least one FieldSet.
+ """
+
+ # arrange
+
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task_1 = workflow.tasks.get(number=1)
+ fieldset = FieldSet.objects.create(
+ account=account,
+ workflow=workflow,
+ task=task_1,
+ name='Fieldset 1',
+ order=1,
+ )
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ task=task_1,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.TEXT,
+ order=1,
+ )
+ field_2 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ task=task_1,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ order=2,
+ )
+ WorkflowEventService.task_complete_event(
+ task=task_1,
+ user=user,
+ after_create_actions=False,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+
+ response = api_client.get(
+ path=f'/workflows/{workflow.id}/events',
+ )
+
+ # assert
+
+ assert response.status_code == 200
+ event_data = response.data[0]
+ assert event_data['type'] == WorkflowEventType.TASK_COMPLETE
+ fieldsets_data = event_data['task']['fieldsets']
+ assert fieldsets_data is not None
+ assert len(fieldsets_data) == 1
+ fieldset.refresh_from_db()
+ field_1.refresh_from_db()
+ field_2.refresh_from_db()
+ fieldset_data = fieldsets_data[0]
+ assert fieldset_data['id'] == fieldset.id
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['order'] == fieldset.order
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ fields_data = fieldset_data['fields']
+ assert len(fields_data) == 2
+ field_2_data = fields_data[0]
+ assert field_2_data['id'] == field_2.id
+ assert field_2_data['order'] == field_2.order
+ assert field_2_data['type'] == field_2.type
+ assert field_2_data['is_required'] == field_2.is_required
+ assert field_2_data['is_hidden'] == field_2.is_hidden
+ assert field_2_data['description'] == field_2.description
+ assert field_2_data['api_name'] == field_2.api_name
+ assert field_2_data['name'] == field_2.name
+ assert field_2_data['value'] == field_2.value
+ assert field_2_data['markdown_value'] == field_2.markdown_value
+ assert field_2_data['clear_value'] == field_2.clear_value
+ assert field_2_data['user_id'] == field_2.user_id
+ assert field_2_data['group_id'] == field_2.group_id
+ assert field_2_data['selections'] == []
+ assert field_2_data['attachments'] == []
+ field_1_data = fields_data[1]
+ assert field_1_data['id'] == field_1.id
+
+
+def test_events__task_complete_fieldsets_absent__ok(api_client):
+
+ """
+ GET workflow events: TASK_COMPLETE row has task.fieldsets equal to null
+ when the task has no FieldSet rows.
+ """
+
+ # arrange
+
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task_1 = workflow.tasks.get(number=1)
+ WorkflowEventService.task_complete_event(
+ task=task_1,
+ user=user,
+ after_create_actions=False,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+
+ response = api_client.get(
+ path=f'/workflows/{workflow.id}/events',
+ )
+
+ # assert
+
+ assert response.status_code == 200
+ item_1 = response.data[0]
+ assert item_1['type'] == WorkflowEventType.TASK_COMPLETE
+ task_payload = item_1['task']
+ assert task_payload['fieldsets'] is None
+
+
+def test_events__non_complete_task_fieldsets_null__ok(api_client):
+
+ """
+ GET workflow events: non-TASK_COMPLETE event still exposes task.fieldsets
+ as null in serialized task payload.
+ """
+
+ # arrange
+
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task_1 = workflow.tasks.get(number=1)
+ create_test_event(
+ workflow=workflow,
+ user=user,
+ task=task_1,
+ type_event=WorkflowEventType.COMMENT,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+
+ response = api_client.get(
+ path=f'/workflows/{workflow.id}/events',
+ )
+
+ # assert
+
+ assert response.status_code == 200
+ item_1 = response.data[0]
+ assert item_1['type'] == WorkflowEventType.COMMENT
+ task_payload = item_1['task']
+ assert task_payload['fieldsets'] is None
diff --git a/backend/src/processes/tests/test_views/test_workflow/test_fields.py b/backend/src/processes/tests/test_views/test_workflow/test_fields.py
index 31ff2c1b1..d269793b9 100644
--- a/backend/src/processes/tests/test_views/test_workflow/test_fields.py
+++ b/backend/src/processes/tests/test_views/test_workflow/test_fields.py
@@ -22,6 +22,7 @@
from src.processes.tests.fixtures import (
create_test_account,
create_test_admin,
+ create_test_fieldset,
create_test_guest,
create_test_not_admin,
create_test_owner,
@@ -865,3 +866,195 @@ def test_fields__all_params__ok(api_client):
# assert
assert response.status_code == 200
+
+
+def test_fields__filter_by_fields_fieldset__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+
+ create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-2',
+ )
+ field = fieldset.fields.first()
+
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get(
+ '/workflows/fields',
+ data={
+ 'fields': [field.api_name],
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ data = response.data['results']
+ assert len(data) == 1
+ assert data[0]['id'] == workflow.id
+ fields_data = data[0]['fields']
+ assert len(fields_data) == 1
+ assert fields_data[0]['id'] == field.id
+ assert fields_data[0]['fieldset_id'] == fieldset.id
+
+
+def test_fields__filter_by_multiple_fields_fieldset__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=2)
+ task_1 = workflow.tasks.get(number=1)
+
+ fieldset_1 = create_test_fieldset(
+ workflow=workflow,
+ kickoff=workflow.kickoff_instance,
+ api_name='fieldset-1',
+ )
+ field_1 = fieldset_1.fields.first()
+
+ fieldset_2 = create_test_fieldset(
+ workflow=workflow,
+ task=task_1,
+ api_name='fieldset-2',
+ )
+ field_2 = fieldset_2.fields.first()
+ field_2.order = 2
+ field_2.save()
+
+ create_test_fieldset(
+ workflow=workflow,
+ task=task_1,
+ api_name='fieldset-non-selected',
+ )
+
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get(
+ '/workflows/fields',
+ data={
+ 'fields': (
+ f'{field_1.api_name},'
+ f'{field_2.api_name}'
+ ),
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ data = response.data['results']
+ assert len(data) == 1
+ assert data[0]['id'] == workflow.id
+
+ fields_data = data[0]['fields']
+ assert len(fields_data) == 2
+ assert fields_data[0]['id'] == field_2.id
+ assert fields_data[0]['task_id'] == task_1.id
+ assert fields_data[0]['fieldset_id'] == fieldset_2.id
+ assert fields_data[1]['id'] == field_1.id
+ assert fields_data[1]['kickoff_id'] is None
+ assert fields_data[1]['fieldset_id'] == fieldset_1.id
+
+
+def test_fields__multiple_workflows_and_multiple_fields_fieldset__ok(
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=2)
+
+ fieldset_1 = create_test_fieldset(
+ workflow=workflow,
+ kickoff=workflow.kickoff_instance,
+ api_name='fieldset-1',
+ )
+ field_1 = fieldset_1.fields.first()
+
+ workflow_2 = create_test_workflow(owner, tasks_count=2)
+
+ fieldset_2 = create_test_fieldset(
+ workflow=workflow_2,
+ kickoff=workflow_2.kickoff_instance,
+ api_name='fieldset-2',
+ )
+ field_2 = fieldset_2.fields.first()
+
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get(
+ '/workflows/fields',
+ data={
+ 'fields': (
+ f'{field_1.api_name},'
+ f'{field_2.api_name}'
+ ),
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ data = response.data['results']
+ assert len(data) == 2
+ assert data[0]['id'] == workflow_2.id
+ fields_data = data[0]['fields']
+ assert len(fields_data) == 1
+ assert fields_data[0]['id'] == field_2.id
+ assert fields_data[0]['fieldset_id'] == fieldset_2.id
+
+ assert data[1]['id'] == workflow.id
+ fields_data = data[1]['fields']
+ assert len(fields_data) == 1
+ assert fields_data[0]['id'] == field_1.id
+ assert fields_data[0]['fieldset_id'] == fieldset_1.id
+
+
+def test_fields__filter_by_invalid_fields_fieldset__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=1)
+ task = workflow.tasks.get(number=1)
+
+ create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+ create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-2',
+ )
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get(
+ '/workflows/fields',
+ data={
+ 'fields': 'field-non-existing',
+ },
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 1
+ data = response.data['results'][0]
+ assert data['id'] == workflow.id
+ assert len(data['fields']) == 0
diff --git a/backend/src/processes/tests/test_views/test_workflow/test_list.py b/backend/src/processes/tests/test_views/test_workflow/test_list.py
index 0b0d91d28..e8ca9ef65 100644
--- a/backend/src/processes/tests/test_views/test_workflow/test_list.py
+++ b/backend/src/processes/tests/test_views/test_workflow/test_list.py
@@ -46,6 +46,7 @@
create_invited_user,
create_test_account,
create_test_admin,
+ create_test_fieldset,
create_test_group,
create_test_guest,
create_test_not_admin,
@@ -2181,6 +2182,7 @@ def test_list__filter_fields_kickoff_field__ok(api_client):
assert field_data['is_required'] == field.is_required
assert field_data['task_id'] is None
assert field_data['kickoff_id'] == workflow.kickoff_instance.id
+ assert field_data['fieldset_id'] is None
assert field_data['type'] == field.type
assert field_data['api_name'] == field.api_name
assert field_data['name'] == field.name
@@ -2548,6 +2550,427 @@ def test_list__filter_fields_and_another_template_id__empty_list(api_client):
assert len(response.data['results']) == 0
+def test_list__filter_fields_kickoff_fieldset__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=1)
+
+ create_test_fieldset(
+ workflow=workflow,
+ kickoff=workflow.kickoff_instance,
+ api_name='fieldset-1',
+ )
+
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=workflow.kickoff_instance,
+ api_name='fieldset-2',
+ )
+ field = fieldset.fields.first()
+ field.value = 'raw value'
+ field.clear_value = 'clear value'
+ field.markdown_value = '**bold** value'
+ field.order = 2
+ field.description = 'desc'
+ field.is_required = True
+ field.save()
+
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get(f'/workflows?fields={field.api_name}')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 1
+ assert response.data['results'][0]['id'] == workflow.id
+ assert len(response.data['results'][0]['fields']) == 1
+ field_data = response.data['results'][0]['fields'][0]
+ assert field_data['id'] == field.id
+ assert field_data['order'] == field.order
+ assert field_data['is_required'] == field.is_required
+ assert field_data['fieldset_id'] == fieldset.id
+ assert field_data['task_id'] is None
+ assert field_data['kickoff_id'] is None
+ assert field_data['type'] == field.type
+ assert field_data['api_name'] == field.api_name
+ assert field_data['name'] == field.name
+ assert field_data['description'] == field.description
+ assert field_data['value'] == field.value
+ assert field_data['markdown_value'] == field.markdown_value
+ assert field_data['clear_value'] == field.clear_value
+ assert field_data['user_id'] is None
+ assert field_data['group_id'] is None
+
+
+def test_list__filter_fields_task_fieldset__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=1)
+ task = workflow.tasks.first()
+
+ create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-2',
+ )
+ field = fieldset.fields.first()
+ field.value = 'raw value'
+ field.clear_value = 'clear value'
+ field.markdown_value = '**bold** value'
+ field.order = 2
+ field.description = 'desc'
+ field.is_required = True
+ field.save()
+
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get(f'/workflows?fields={field.api_name}')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 1
+ assert response.data['results'][0]['id'] == workflow.id
+ assert len(response.data['results'][0]['fields']) == 1
+ field_data = response.data['results'][0]['fields'][0]
+ assert field_data['id'] == field.id
+ assert field_data['order'] == field.order
+ assert field_data['is_required'] == field.is_required
+ assert field_data['task_id'] == task.id
+ assert field_data['kickoff_id'] is None
+ assert field_data['fieldset_id'] == fieldset.id
+ assert field_data['type'] == field.type
+ assert field_data['api_name'] == field.api_name
+ assert field_data['name'] == field.name
+ assert field_data['description'] == field.description
+ assert field_data['value'] == field.value
+ assert field_data['markdown_value'] == field.markdown_value
+ assert field_data['clear_value'] == field.clear_value
+ assert field_data['user_id'] is None
+ assert field_data['group_id'] is None
+
+
+def test_list__filter_fields__fieldset_ordering__ok(api_client):
+
+ # arrange
+ user = create_test_owner()
+ workflow = create_test_workflow(user, tasks_count=2)
+ task_1 = workflow.tasks.get(number=1)
+
+ fieldset_4 = create_test_fieldset(
+ workflow=workflow,
+ task=task_1,
+ api_name='fs-4',
+ )
+ field_4 = fieldset_4.fields.first()
+ field_4.order = 3
+ field_4.save()
+
+ task_2 = workflow.tasks.get(number=2)
+
+ fieldset_3 = create_test_fieldset(
+ workflow=workflow,
+ task=task_2,
+ api_name='fs-3',
+ )
+ field_3 = fieldset_3.fields.first()
+ field_3.order = 2
+ field_3.save()
+
+ fieldset_2 = create_test_fieldset(
+ workflow=workflow,
+ task=task_2,
+ api_name='fs-2',
+ )
+ field_2 = fieldset_2.fields.first()
+ field_2.order = 1
+ field_2.save()
+
+ kickoff = workflow.kickoff_instance
+ fieldset_1 = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ api_name='fs-1',
+ )
+ field_1 = fieldset_1.fields.first()
+ field_1.order = 5
+ field_1.save()
+
+ task_1.number = 3
+ task_1.save()
+ task_2.number = 1
+ task_2.save()
+
+ api_client.token_authenticate(user)
+ fields_filter = (
+ f'{field_4.api_name},'
+ f'{field_3.api_name},'
+ f'{field_2.api_name},'
+ f'{field_1.api_name}'
+ )
+
+ # act
+ response = api_client.get(f'/workflows?fields={fields_filter}')
+
+ # assert
+ assert response.status_code == 200
+ fields_data = response.data['results'][0]['fields']
+ assert len(fields_data) == 4
+ assert fields_data[0]['id'] == field_3.id
+ assert fields_data[1]['id'] == field_2.id
+ assert fields_data[2]['id'] == field_4.id
+ assert fields_data[3]['id'] == field_1.id
+
+
+def test_list__filter_fields_multiple_values_fieldset__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=1)
+ task = workflow.tasks.first()
+
+ fieldset_1 = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+ field_1 = fieldset_1.fields.first()
+ field_1.order = 3
+ field_1.save()
+
+ fieldset_2 = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-2',
+ )
+ field_2 = fieldset_2.fields.first()
+ field_2.type = FieldType.NUMBER
+ field_2.value = '2.13'
+ field_2.order = 2
+ field_2.save()
+
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get(
+ f'/workflows?fields={field_1.api_name},{field_2.api_name}',
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 1
+ assert response.data['results'][0]['id'] == workflow.id
+ assert len(response.data['results'][0]['fields']) == 2
+ field_1_data = response.data['results'][0]['fields'][0]
+ assert field_1_data['id'] == field_1.id
+ field_2_data = response.data['results'][0]['fields'][1]
+ assert field_2_data['id'] == field_2.id
+
+
+def test_list__filter_fields_blank_fieldset__empty_list(api_client):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=1)
+ task = workflow.tasks.first()
+
+ create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get('/workflows?fields=')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 1
+ assert response.data['results'][0]['id'] == workflow.id
+ assert len(response.data['results'][0]['fields']) == 0
+
+
+def test_list__filter_fields_not_existent_fieldset__empty_list(api_client):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=1)
+ task = workflow.tasks.first()
+
+ create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get('/workflows?fields=not-existent')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 1
+ assert response.data['results'][0]['id'] == workflow.id
+ assert len(response.data['results'][0]['fields']) == 0
+
+
+def test_list__filter_fields__another_account_fieldset__empty_list(
+ api_client,
+):
+
+ # arrange
+ another_account = create_test_account()
+ another_owner = create_test_owner(account=another_account)
+ workflow = create_test_workflow(another_owner, tasks_count=1)
+ task = workflow.tasks.first()
+
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+ field = fieldset.fields.first()
+
+ account = create_test_account()
+ owner = create_test_owner(
+ account=account,
+ email='owner2@pneumatic.app',
+ )
+ workflow_2 = create_test_workflow(user=owner, tasks_count=1)
+ api_client.token_authenticate(owner)
+
+ # act
+ response = api_client.get(f'/workflows?fields={field.api_name}')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 1
+ assert response.data['results'][0]['id'] == workflow_2.id
+ assert len(response.data['results'][0]['fields']) == 0
+
+
+def test_list__filter_fields__another_workflow_fieldset__empty_list(
+ api_client,
+):
+
+ # arrange
+ account = create_test_account()
+ owner = create_test_owner(account=account)
+ workflow = create_test_workflow(owner, tasks_count=1)
+ task = workflow.tasks.first()
+
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+ field = fieldset.fields.first()
+
+ user = create_test_admin(account=account)
+ workflow_2 = create_test_workflow(user, tasks_count=1)
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get(f'/workflows?fields={field.api_name}')
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 1
+ assert response.data['results'][0]['id'] == workflow_2.id
+ assert len(response.data['results'][0]['fields']) == 0
+
+
+def test_list__filter_fields_and_template_fieldset__ok(api_client):
+
+ # arrange
+ user = create_test_owner()
+ template_1 = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template_1,
+ )
+ task = workflow.tasks.first()
+
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+ field = fieldset.fields.first()
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get(
+ f'/workflows?fields={field.api_name};template_id={template_1.id}',
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 1
+ assert response.data['results'][0]['id'] == workflow.id
+ assert len(response.data['results'][0]['fields']) == 1
+ field_data = response.data['results'][0]['fields'][0]
+ assert field_data['id'] == field.id
+ assert field_data['fieldset_id'] == fieldset.id
+
+
+def test_list__filter_fields_and_another_template_id_fieldset__empty_list(
+ api_client,
+):
+
+ # arrange
+ user = create_test_owner()
+ template_1 = create_test_template(
+ user=user,
+ is_active=True,
+ tasks_count=1,
+ )
+ workflow = create_test_workflow(
+ user=user,
+ template=template_1,
+ )
+ task = workflow.tasks.first()
+
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ task=task,
+ api_name='fieldset-1',
+ )
+ field = fieldset.fields.first()
+
+ another_template = create_test_template(user=user, tasks_count=1)
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get(
+ f'/workflows?fields={field.api_name};'
+ f'template_id={another_template.id}',
+ )
+
+ # assert
+ assert response.status_code == 200
+ assert len(response.data['results']) == 0
+
+
def test_list__ordering_oldest__ok(api_client):
# arrange
diff --git a/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py b/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py
index 177004fd3..c7b0f2ac6 100644
--- a/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py
+++ b/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py
@@ -61,6 +61,7 @@ def test_webhook_example__body__ok(api_client):
'kickoff': {
'id': workflow.kickoff_instance.id,
'output': [],
+ 'fieldsets': [],
},
'tasks': [
OrderedDict([
diff --git a/backend/src/processes/tests/test_webhooks/test_webhooks.py b/backend/src/processes/tests/test_webhooks/test_webhooks.py
index bb3462cb6..d1e15f53c 100644
--- a/backend/src/processes/tests/test_webhooks/test_webhooks.py
+++ b/backend/src/processes/tests/test_webhooks/test_webhooks.py
@@ -58,6 +58,7 @@ def test_send_task_completed_webhook__ok(api_client, mocker):
'contains_comments': False,
'require_completion_by_all': False,
'output': [],
+ 'fieldsets': [],
'delay': None,
'date_started_tsp': task_1.date_started.timestamp(),
'date_completed_tsp': task_1.date_completed.timestamp(),
@@ -108,6 +109,7 @@ def test_send_task_completed_webhook__ok(api_client, mocker):
'kickoff': {
'id': workflow.kickoff_instance.id,
'output': [],
+ 'fieldsets': [],
},
'tasks': [
OrderedDict([
@@ -226,6 +228,7 @@ def test_send_task_completed_webhook__sub_workflows__ok(api_client, mocker):
'contains_comments': False,
'require_completion_by_all': False,
'output': [],
+ 'fieldsets': [],
'delay': None,
'date_started_tsp': task_1.date_started.timestamp(),
'date_completed_tsp': task_1.date_completed.timestamp(),
@@ -343,6 +346,7 @@ def test_send_task_completed_webhook__sub_workflows__ok(api_client, mocker):
'kickoff': {
'id': workflow.kickoff_instance.id,
'output': [],
+ 'fieldsets': [],
},
'tasks': [
OrderedDict([
@@ -446,6 +450,7 @@ def test_send_task_returned_webhook__ok(api_client, mocker):
'contains_comments': False,
'require_completion_by_all': False,
'output': [],
+ 'fieldsets': [],
'delay': None,
'date_started_tsp': None,
'date_completed_tsp': None,
@@ -499,6 +504,7 @@ def test_send_task_returned_webhook__ok(api_client, mocker):
'kickoff': {
'id': workflow.kickoff_instance.id,
'output': [],
+ 'fieldsets': [],
},
'tasks': [
OrderedDict([
@@ -605,6 +611,7 @@ def test_send_workflow_started_webhook__ok(api_client, mocker):
'kickoff': {
'id': workflow.kickoff_instance.id,
'output': [],
+ 'fieldsets': [],
},
'tasks': [
OrderedDict([
@@ -708,6 +715,7 @@ def test_send_workflow_completed_webhook__ok(api_client, mocker):
'kickoff': {
'id': workflow.kickoff_instance.id,
'output': [],
+ 'fieldsets': [],
},
'tasks': [
OrderedDict([
diff --git a/backend/src/processes/urls/templates.py b/backend/src/processes/urls/templates.py
index e4ba1202f..1f4b4e667 100644
--- a/backend/src/processes/urls/templates.py
+++ b/backend/src/processes/urls/templates.py
@@ -13,6 +13,7 @@
)
from src.processes.views.template_preset import TemplatePresetViewSet
+
router = DefaultRouter(trailing_slash=False)
router.register(
prefix='system',
diff --git a/backend/src/processes/views/fieldset.py b/backend/src/processes/views/fieldset.py
new file mode 100644
index 000000000..da757e102
--- /dev/null
+++ b/backend/src/processes/views/fieldset.py
@@ -0,0 +1,132 @@
+from rest_framework.viewsets import GenericViewSet
+
+from src.accounts.permissions import (
+ BillingPlanPermission,
+ ExpiredSubscriptionPermission,
+ UserIsAdminOrAccountOwner,
+ UsersOverlimitedPermission,
+)
+from src.generics.exceptions import BaseServiceException
+from src.generics.filters import PneumaticFilterBackend
+from src.generics.mixins.views import CustomViewSetMixin
+from src.generics.permissions import UserIsAuthenticated
+from src.processes.filters import FieldSetFilter
+from src.processes.models.templates.fieldset import (
+ FieldsetTemplate,
+)
+from src.processes.serializers.templates.fieldset import (
+ SharedFieldsetTemplateSerializer,
+)
+from src.processes.serializers.templates.template import (
+ FieldsetTemplateFilterSerializer,
+)
+from src.processes.services.fieldsets.fieldset import (
+ FieldSetTemplateService,
+)
+from src.utils.validation import raise_validation_error
+
+
+class SharedFieldsetTemplateViewSet(
+ CustomViewSetMixin,
+ GenericViewSet,
+):
+ serializer_class = SharedFieldsetTemplateSerializer
+ filter_backends = (PneumaticFilterBackend,)
+
+ action_filterset_classes = {
+ 'list': FieldSetFilter,
+ }
+
+ def get_serializer_context(self, **kwargs):
+ context = super().get_serializer_context(**kwargs)
+ context['user'] = self.request.user
+ context['account'] = self.request.user.account
+ context['is_superuser'] = self.request.is_superuser
+ context['auth_type'] = self.request.token_type
+ return context
+
+ def get_permissions(self):
+ return (
+ UserIsAuthenticated(),
+ ExpiredSubscriptionPermission(),
+ BillingPlanPermission(),
+ UsersOverlimitedPermission(),
+ UserIsAdminOrAccountOwner(),
+ )
+
+ def get_queryset(self):
+ user = self.request.user
+ return (
+ FieldsetTemplate.objects
+ .select_related('template')
+ .shared()
+ .on_account(user.account_id)
+ )
+
+ def list(self, request, *args, **kwargs):
+ filter_slz = FieldsetTemplateFilterSerializer(data=request.GET)
+ filter_slz.is_valid(raise_exception=True)
+ queryset = self.filter_queryset(self.get_queryset())
+ return self.paginated_response(queryset)
+
+ def create(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ service = FieldSetTemplateService(
+ user=request.user,
+ is_superuser=request.is_superuser,
+ auth_type=request.token_type,
+ )
+ try:
+ fieldset = service.create_shared_fieldset(
+ **serializer.validated_data,
+ )
+ except BaseServiceException as ex:
+ raise_validation_error(message=ex.message)
+ else:
+ response_serializer = SharedFieldsetTemplateSerializer(fieldset)
+ return self.response_created(response_serializer.data)
+
+ def retrieve(self, request, *args, **kwargs):
+ fieldset = self.get_object()
+ serializer = self.get_serializer(fieldset)
+ return self.response_ok(serializer.data)
+
+ def partial_update(self, request, *args, **kwargs):
+ fieldset = self.get_object()
+ serializer = self.get_serializer(
+ fieldset,
+ data=request.data,
+ partial=True,
+ extra_fields={'template': fieldset.template},
+ )
+ serializer.is_valid(raise_exception=True)
+ service = FieldSetTemplateService(
+ user=request.user,
+ instance=fieldset,
+ is_superuser=request.is_superuser,
+ auth_type=request.token_type,
+ )
+ try:
+ fieldset = service.partial_update(
+ **serializer.validated_data,
+ )
+ except BaseServiceException as ex:
+ raise_validation_error(message=ex.message)
+ fieldset.refresh_from_db()
+ response_serializer = SharedFieldsetTemplateSerializer(fieldset)
+ return self.response_ok(response_serializer.data)
+
+ def destroy(self, request, *args, **kwargs):
+ fieldset = self.get_object()
+ service = FieldSetTemplateService(
+ user=request.user,
+ instance=fieldset,
+ is_superuser=request.is_superuser,
+ auth_type=request.token_type,
+ )
+ try:
+ service.delete()
+ except BaseServiceException as ex:
+ raise_validation_error(message=ex.message)
+ return self.response_ok()
diff --git a/backend/src/processes/views/task.py b/backend/src/processes/views/task.py
index 03faf7bb5..62abe6821 100644
--- a/backend/src/processes/views/task.py
+++ b/backend/src/processes/views/task.py
@@ -76,6 +76,7 @@
from src.processes.services.exceptions import (
CommentServiceException,
WorkflowActionServiceException,
+ FieldsetServiceException,
)
from src.processes.services.tasks.exceptions import (
GroupPerformerServiceException,
@@ -269,14 +270,32 @@ def prefetch_queryset(
if self.action == 'retrieve':
queryset = queryset.prefetch_related(
'checklists__selections',
- 'output__attachments',
Prefetch(
- 'output__selections',
+ 'output',
+ queryset=TaskField.objects.filter(
+ fieldset__isnull=True,
+ ).prefetch_related(
+ 'attachments',
+ Prefetch(
+ 'selections',
+ queryset=FieldSelection.objects.order_by('id'),
+ to_attr='selections_values',
+ ),
+ Prefetch(
+ 'dataset__items',
+ queryset=DatasetItem.objects.order_by('order'),
+ to_attr='dataset_values',
+ ),
+ ),
+ ),
+ 'fieldsets__fields__attachments',
+ Prefetch(
+ 'fieldsets__fields__selections',
queryset=FieldSelection.objects.order_by('id'),
to_attr='selections_values',
),
Prefetch(
- 'output__dataset__items',
+ 'fieldsets__fields__dataset__items',
queryset=DatasetItem.objects.order_by('order'),
to_attr='dataset_values',
),
@@ -505,7 +524,10 @@ def complete(self, request, *args, **kwargs):
fields_values=serializer.validated_data.get('output'),
)
service.check_delay_workflow()
- except WorkflowActionServiceException as ex:
+ except (
+ WorkflowActionServiceException,
+ FieldsetServiceException,
+ ) as ex:
raise_validation_error(message=ex.message)
except TaskFieldException as ex:
raise_validation_error(
@@ -516,7 +538,9 @@ def complete(self, request, *args, **kwargs):
instance=Task.objects.prefetch_related(
Prefetch(
lookup='output',
- queryset=TaskField.objects.all().prefetch_related(
+ queryset=TaskField.objects.filter(
+ fieldset__isnull=True,
+ ).prefetch_related(
Prefetch(
lookup='selections',
queryset=FieldSelection.objects.order_by('id'),
@@ -530,6 +554,17 @@ def complete(self, request, *args, **kwargs):
'attachments',
),
),
+ 'fieldsets__fields__attachments',
+ Prefetch(
+ 'fieldsets__fields__selections',
+ queryset=FieldSelection.objects.order_by('id'),
+ to_attr='selections_values',
+ ),
+ Prefetch(
+ 'fieldsets__fields__dataset__items',
+ queryset=DatasetItem.objects.order_by('order'),
+ to_attr='dataset_values',
+ ),
).get(pk=task.pk),
context={'user': request.user},
)
diff --git a/backend/src/processes/views/template.py b/backend/src/processes/views/template.py
index 0ca92d79a..54428dc75 100644
--- a/backend/src/processes/views/template.py
+++ b/backend/src/processes/views/template.py
@@ -17,12 +17,13 @@
from src.analysis.services import AnalyticService
from src.authentication.enums import AuthTokenType
from src.executor import RawSqlExecutor
-from src.generics.filters import PneumaticFilterBackend
from src.generics.mixins.views import (
CustomViewSetMixin,
)
from src.generics.permissions import UserIsAuthenticated
-from src.processes.filters import TemplateFilter
+from src.processes.filters import (
+ FieldSetFilter,
+)
from src.processes.models.templates.fields import FieldTemplate
from src.processes.models.templates.kickoff import Kickoff
from src.processes.models.templates.preset import TemplatePreset
@@ -30,6 +31,7 @@
from src.processes.models.templates.task import TaskTemplate
from src.processes.models.templates.template import Template
from src.processes.models.templates.owner import TemplateOwner
+from src.processes.models.templates.fields import FieldTemplateSelection
from src.processes.permissions import (
TemplateAccessPermission,
TemplateAdminOwnerPermission,
@@ -51,6 +53,9 @@
TemplateStepFilterSerializer,
TemplateStepNameSerializer,
)
+from src.processes.serializers.templates.template_fields import (
+ TemplateOnlyFieldsSerializer,
+)
from src.processes.serializers.templates.template import (
TemplateAiSerializer,
TemplateByNameSerializer,
@@ -58,7 +63,6 @@
TemplateExportFilterSerializer,
TemplateListFilterSerializer,
TemplateListSerializer,
- TemplateOnlyFieldsSerializer,
TemplateSerializer,
TemplateTitlesByEventsSerializer,
TemplateTitlesByTasksSerializer,
@@ -110,8 +114,6 @@ class TemplateViewSet(
GenericViewSet,
):
pagination_class = LimitOffsetPagination
- filter_backends = (PneumaticFilterBackend,)
- filterset_class = TemplateFilter
serializer_class = TemplateSerializer
action_serializer_classes = {
'list': TemplateListSerializer,
@@ -128,6 +130,9 @@ class TemplateViewSet(
'presets': TemplatePresetSerializer,
'preset': TemplatePresetSerializer,
}
+ action_filterset_classes = {
+ 'list_fieldsets': FieldSetFilter,
+ }
def get_permissions(self):
if self.action in (
@@ -242,6 +247,7 @@ def prefetch_queryset(
'kickoff',
'kickoff__fields',
'kickoff__fields__selections',
+ 'kickoff__fieldsets',
Prefetch('owners', queryset=owners_qs),
Prefetch(
lookup='tasks',
@@ -251,6 +257,7 @@ def prefetch_queryset(
.prefetch_related(
'fields',
'fields__selections',
+ 'fieldsets',
'checklists',
'checklists__selections',
'conditions',
@@ -271,10 +278,21 @@ def prefetch_queryset(
Prefetch(
lookup='fields',
queryset=(
- FieldTemplate.objects.all()
+ FieldTemplate.objects.prefetch_related(
+ Prefetch(
+ 'selections',
+ queryset=(
+ FieldTemplateSelection.objects
+ .order_by('id')
+ ),
+ to_attr='selections_values',
+ ),
+ )
+ .all()
.order_by('-order')
),
),
+ 'fieldsets',
)
),
),
@@ -291,6 +309,7 @@ def prefetch_queryset(
.order_by('-order')
),
),
+ 'fieldsets',
)
),
),
@@ -421,6 +440,7 @@ def list(self, request, *args, **kwargs):
SQL pagination (by LIMIT, OFFSET) is not possible because
it is impossible to calculate response 'count' value """
+
filter_slz = TemplateListFilterSerializer(data=request.GET)
filter_slz.is_valid(raise_exception=True)
diff --git a/backend/src/reports/serializers.py b/backend/src/reports/serializers.py
index 30bf3eff2..f62021075 100644
--- a/backend/src/reports/serializers.py
+++ b/backend/src/reports/serializers.py
@@ -14,6 +14,7 @@
from src.processes.serializers.workflows.field import (
TaskFieldEventSerializer,
)
+from src.processes.serializers.workflows.fieldset import FieldSetSerializer
UserModel = get_user_model()
@@ -45,9 +46,11 @@ class Meta:
model = KickoffValue
fields = (
'output',
+ 'fieldsets',
)
output = TaskFieldEventSerializer(many=True)
+ fieldsets = FieldSetSerializer(many=True)
class ActivityWorkflowSerializer(serializers.ModelSerializer):
diff --git a/backend/src/reports/tests/test_views/test_highlights.py b/backend/src/reports/tests/test_views/test_highlights.py
index de83c5c33..f53ca8f90 100644
--- a/backend/src/reports/tests/test_views/test_highlights.py
+++ b/backend/src/reports/tests/test_views/test_highlights.py
@@ -21,6 +21,7 @@
from src.processes.models.workflows.fields import TaskField
from src.processes.models.workflows.task import Delay
from src.processes.models.workflows.workflow import Workflow
+from src.processes.models.workflows.fieldset import FieldSet
from src.processes.services.events import (
WorkflowEventService,
)
@@ -34,7 +35,7 @@
create_test_template,
create_test_user,
create_test_workflow, create_test_owner, create_test_dataset,
- create_test_event,
+ create_test_event, create_test_admin, create_test_fieldset,
)
from src.utils.validation import ErrorCode
@@ -1514,7 +1515,6 @@ def test_complete_task_event__task_field_with_dataset__ok(api_client):
assert field_data['id'] == field.id
assert field_data['type'] == FieldType.DROPDOWN
assert field_data['value'] == dataset_item.value
- assert 'selections' not in field_data
def test_complete_task_event__kickoff_field_with_dataset__ok(api_client):
@@ -1553,4 +1553,283 @@ def test_complete_task_event__kickoff_field_with_dataset__ok(api_client):
assert field_data['id'] == field.id
assert field_data['type'] == FieldType.DROPDOWN
assert field_data['value'] == dataset_item.value
- assert 'selections' not in field_data
+
+
+def test_highlights__task_complete_fieldsets_present__ok(api_client):
+
+ """
+ GET reports highlights: TASK_COMPLETE item includes non-null task.fieldsets
+ when the task has at least one FieldSet.
+ """
+
+ # arrange
+
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task_1 = workflow.tasks.get(number=1)
+ fieldset = FieldSet.objects.create(
+ account=account,
+ workflow=workflow,
+ task=task_1,
+ name='Fieldset 1',
+ order=1,
+ )
+ field_1 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ task=task_1,
+ fieldset=fieldset,
+ name='Field 1',
+ type=FieldType.TEXT,
+ order=1,
+ )
+ field_2 = TaskField.objects.create(
+ account=account,
+ workflow=workflow,
+ task=task_1,
+ fieldset=fieldset,
+ name='Field 2',
+ type=FieldType.NUMBER,
+ order=2,
+ )
+ WorkflowEventService.task_complete_event(
+ task=task_1,
+ user=user,
+ after_create_actions=False,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+
+ response = api_client.get(
+ path='/reports/highlights',
+ )
+
+ # assert
+ assert response.status_code == 200
+ event_data = response.data[0]
+ assert event_data['type'] == WorkflowEventType.TASK_COMPLETE
+ fieldsets_data = event_data['task']['fieldsets']
+ assert len(fieldsets_data) == 1
+
+ fieldset_data = fieldsets_data[0]
+ assert fieldset_data['id'] == fieldset.id
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['order'] == fieldset.order
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ fields_data = fieldset_data['fields']
+ assert len(fields_data) == 2
+ field_2_data = fields_data[0]
+ assert field_2_data['id'] == field_2.id
+ assert field_2_data['order'] == field_2.order
+ assert field_2_data['type'] == field_2.type
+ assert field_2_data['is_required'] == field_2.is_required
+ assert field_2_data['is_hidden'] == field_2.is_hidden
+ assert field_2_data['description'] == field_2.description
+ assert field_2_data['api_name'] == field_2.api_name
+ assert field_2_data['name'] == field_2.name
+ assert field_2_data['value'] == field_2.value
+ assert field_2_data['markdown_value'] == field_2.markdown_value
+ assert field_2_data['clear_value'] == field_2.clear_value
+ assert field_2_data['user_id'] == field_2.user_id
+ assert field_2_data['group_id'] == field_2.group_id
+ assert field_2_data['attachments'] == []
+ field_1_data = fields_data[1]
+ assert field_1_data['id'] == field_1.id
+
+
+def test_highlights__task_complete_fieldsets_absent__ok(api_client):
+
+ """
+ GET reports highlights: TASK_COMPLETE item has task.fieldsets null when the
+ task has no FieldSet rows.
+ """
+
+ # arrange
+
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task_1 = workflow.tasks.get(number=1)
+ WorkflowEventService.task_complete_event(
+ task=task_1,
+ user=user,
+ after_create_actions=False,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+
+ response = api_client.get(
+ path='/reports/highlights',
+ )
+
+ # assert
+
+ assert response.status_code == 200
+ item_1 = response.data[0]
+ assert item_1['type'] == WorkflowEventType.TASK_COMPLETE
+ task_payload = item_1['task']
+ assert task_payload['fieldsets'] is None
+
+
+def test_highlights__non_complete_fieldsets_null__ok(api_client):
+
+ """
+ GET reports highlights: non-TASK_COMPLETE highlight exposes task.fieldsets
+ as null.
+ """
+
+ # arrange
+
+ account = create_test_account()
+ user = create_test_owner(account=account)
+ workflow = create_test_workflow(user=user, tasks_count=1)
+ task_1 = workflow.tasks.get(number=1)
+ create_test_event(
+ workflow=workflow,
+ user=user,
+ task=task_1,
+ type_event=WorkflowEventType.COMMENT,
+ )
+ api_client.token_authenticate(user=user)
+
+ # act
+
+ response = api_client.get(
+ path='/reports/highlights',
+ )
+
+ # assert
+
+ assert response.status_code == 200
+ item_1 = response.data[0]
+ assert item_1['type'] == WorkflowEventType.COMMENT
+ task_payload = item_1['task']
+ assert task_payload['fieldsets'] is None
+
+
+def test_highlights__start_workflow_kickoff_fieldset_present__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ user = create_test_admin(account=account)
+ workflow = create_test_workflow(user, tasks_count=2, active_task_number=2)
+ kickoff = workflow.kickoff_instance
+ fieldset = create_test_fieldset(
+ workflow=workflow,
+ kickoff=kickoff,
+ order=1,
+ )
+ field = fieldset.fields.first()
+ WorkflowEventService.workflow_run_event(workflow)
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get('/reports/highlights')
+
+ # assert
+ assert response.status_code == 200
+ event_data = response.data[0]
+ assert event_data['type'] == WorkflowEventType.RUN
+ fieldsets_data = event_data['workflow']['kickoff']['fieldsets']
+ assert len(fieldsets_data) == 1
+ fieldset_data = fieldsets_data[0]
+ assert fieldset_data['id'] == fieldset.id
+ assert fieldset_data['api_name'] == fieldset.api_name
+ assert fieldset_data['name'] == fieldset.name
+ assert fieldset_data['description'] == fieldset.description
+ assert fieldset_data['order'] == fieldset.order
+ assert fieldset_data['label_position'] == fieldset.label_position
+ assert fieldset_data['layout'] == fieldset.layout
+ fields_data = fieldset_data['fields']
+ assert len(fields_data) == 1
+ field_data = fields_data[0]
+ assert field_data['id'] == field.id
+ assert field_data['order'] == field.order
+ assert field_data['type'] == field.type
+ assert field_data['is_required'] == field.is_required
+ assert field_data['is_hidden'] == field.is_hidden
+ assert field_data['description'] == field.description
+ assert field_data['api_name'] == field.api_name
+ assert field_data['name'] == field.name
+ assert field_data['value'] == field.value
+ assert field_data['markdown_value'] == field.markdown_value
+ assert field_data['clear_value'] == field.clear_value
+ assert field_data['user_id'] == field.user_id
+ assert field_data['group_id'] == field.group_id
+ assert field_data['attachments'] == []
+
+
+def test_highlights__start_workflow_kickoff_field_present__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ user = create_test_admin(account=account)
+ workflow = create_test_workflow(user, tasks_count=2, active_task_number=2)
+ kickoff = workflow.kickoff_instance
+
+ field = TaskField.objects.create(
+ type=FieldType.DROPDOWN,
+ name='dropdown',
+ kickoff=kickoff,
+ value='some value',
+ workflow=workflow,
+ account=account,
+ )
+ WorkflowEventService.workflow_run_event(workflow)
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get('/reports/highlights')
+
+ # assert
+ assert response.status_code == 200
+ event_data = response.data[0]
+ assert event_data['type'] == WorkflowEventType.RUN
+ fields_data = event_data['workflow']['kickoff']['output']
+ assert len(fields_data) == 1
+ field_data = fields_data[0]
+
+ assert field_data['id'] == field.id
+ assert field_data['order'] == field.order
+ assert field_data['type'] == field.type
+ assert field_data['is_required'] == field.is_required
+ assert field_data['is_hidden'] == field.is_hidden
+ assert field_data['description'] == field.description
+ assert field_data['api_name'] == field.api_name
+ assert field_data['name'] == field.name
+ assert field_data['value'] == field.value
+ assert field_data['markdown_value'] == field.markdown_value
+ assert field_data['clear_value'] == field.clear_value
+ assert field_data['user_id'] == field.user_id
+ assert field_data['group_id'] == field.group_id
+ assert field_data['attachments'] == []
+
+
+def test_highlights__start_workflow_fieldset_absent__ok(api_client):
+
+ # arrange
+ account = create_test_account()
+ create_test_owner(account=account)
+ user = create_test_admin(account=account)
+ workflow = create_test_workflow(user, tasks_count=2, active_task_number=2)
+
+ WorkflowEventService.workflow_run_event(workflow)
+ api_client.token_authenticate(user)
+
+ # act
+ response = api_client.get('/reports/highlights')
+
+ # assert
+ assert response.status_code == 200
+ event_data = response.data[0]
+ assert event_data['type'] == WorkflowEventType.RUN
+ assert event_data['workflow']['kickoff']['fieldsets'] == []
+ assert event_data['workflow']['kickoff']['output'] == []
diff --git a/backend/src/services/locale/de/LC_MESSAGES/django.po b/backend/src/services/locale/de/LC_MESSAGES/django.po
index 4c78de97e..14fdd4edb 100644
--- a/backend/src/services/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/services/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/services/locale/django.pot b/backend/src/services/locale/django.pot
index 4c78de97e..14fdd4edb 100644
--- a/backend/src/services/locale/django.pot
+++ b/backend/src/services/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/services/locale/es/LC_MESSAGES/django.po b/backend/src/services/locale/es/LC_MESSAGES/django.po
index 4c78de97e..14fdd4edb 100644
--- a/backend/src/services/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/services/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/services/locale/fr/LC_MESSAGES/django.po b/backend/src/services/locale/fr/LC_MESSAGES/django.po
index 4c78de97e..14fdd4edb 100644
--- a/backend/src/services/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/services/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/services/locale/ru/LC_MESSAGES/django.po b/backend/src/services/locale/ru/LC_MESSAGES/django.po
index afa6bfaef..77647008d 100644
--- a/backend/src/services/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/services/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/settings.py b/backend/src/settings.py
index 10b911fd3..01b970f73 100644
--- a/backend/src/settings.py
+++ b/backend/src/settings.py
@@ -403,10 +403,10 @@ class Common(Configuration):
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
- 'NAME': env.get('POSTGRES_DB', 'pneumatic'),
- 'USER': env.get('POSTGRES_USER', 'pneumatic'),
- 'PASSWORD': env.get('POSTGRES_PASSWORD', 'pneumatic'),
- 'HOST': env.get('POSTGRES_HOST', 'localhost'),
+ 'NAME': env.get('POSTGRES_DB'),
+ 'USER': env.get('POSTGRES_USER'),
+ 'PASSWORD': env.get('POSTGRES_PASSWORD'),
+ 'HOST': env.get('POSTGRES_HOST'),
'PORT': env.get('POSTGRES_PORT', '5432'),
},
}
@@ -553,18 +553,18 @@ class Staging(Development):
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
- 'NAME': env.get('POSTGRES_DB', 'pneumatic'),
- 'USER': env.get('POSTGRES_USER', 'pneumatic'),
- 'PASSWORD': env.get('POSTGRES_PASSWORD', 'pneumatic'),
- 'HOST': env.get('POSTGRES_HOST', 'localhost'),
+ 'NAME': env.get('POSTGRES_DB'),
+ 'USER': env.get('POSTGRES_USER'),
+ 'PASSWORD': env.get('POSTGRES_PASSWORD'),
+ 'HOST': env.get('POSTGRES_HOST'),
'PORT': env.get('POSTGRES_PORT', '5432'),
},
'replica': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
- 'NAME': env.get('POSTGRES_REPLICA_DB', 'pneumatic'),
- 'USER': env.get('POSTGRES_REPLICA_USER', 'pneumatic'),
- 'PASSWORD': env.get('POSTGRES_REPLICA_PASSWORD', 'pneumatic'),
- 'HOST': env.get('POSTGRES_REPLICA_HOST', 'localhost'),
+ 'NAME': env.get('POSTGRES_REPLICA_DB'),
+ 'USER': env.get('POSTGRES_REPLICA_USER'),
+ 'PASSWORD': env.get('POSTGRES_REPLICA_PASSWORD'),
+ 'HOST': env.get('POSTGRES_REPLICA_HOST'),
'PORT': env.get('POSTGRES_REPLICA_PORT', '5432'),
},
}
diff --git a/backend/src/urls.py b/backend/src/urls.py
index 56d39be38..d6d5ab9bc 100644
--- a/backend/src/urls.py
+++ b/backend/src/urls.py
@@ -22,6 +22,9 @@
TasksListView,
TaskViewSet,
)
+from src.processes.views.fieldset import (
+ SharedFieldsetTemplateViewSet,
+)
from src.datasets.views import DatasetViewSet, DatasetItemViewSet
from src.processes.views.template import (
TemplateViewSet,
@@ -54,7 +57,11 @@
router.register('faq', FaqViewSet, basename='faq')
router.register('datasets', DatasetViewSet, basename='datasets')
router.register('datasets/items', DatasetItemViewSet, basename='dataset-items')
-
+router.register(
+ prefix='fieldsets',
+ viewset=SharedFieldsetTemplateViewSet,
+ basename='fieldsets',
+)
urlpatterns = [
path('', views.index, name='index'),
diff --git a/backend/src/webhooks/locale/de/LC_MESSAGES/django.po b/backend/src/webhooks/locale/de/LC_MESSAGES/django.po
index 845dad6b8..9939fa64a 100644
--- a/backend/src/webhooks/locale/de/LC_MESSAGES/django.po
+++ b/backend/src/webhooks/locale/de/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/webhooks/locale/django.pot b/backend/src/webhooks/locale/django.pot
index 377162eb8..d6ea74e5e 100644
--- a/backend/src/webhooks/locale/django.pot
+++ b/backend/src/webhooks/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/webhooks/locale/es/LC_MESSAGES/django.po b/backend/src/webhooks/locale/es/LC_MESSAGES/django.po
index 5671f5944..6daacbb82 100644
--- a/backend/src/webhooks/locale/es/LC_MESSAGES/django.po
+++ b/backend/src/webhooks/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/webhooks/locale/fr/LC_MESSAGES/django.po b/backend/src/webhooks/locale/fr/LC_MESSAGES/django.po
index ba4ab911e..71e7f0a78 100644
--- a/backend/src/webhooks/locale/fr/LC_MESSAGES/django.po
+++ b/backend/src/webhooks/locale/fr/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/backend/src/webhooks/locale/ru/LC_MESSAGES/django.po b/backend/src/webhooks/locale/ru/LC_MESSAGES/django.po
index 80904b730..c63a2d6ba 100644
--- a/backend/src/webhooks/locale/ru/LC_MESSAGES/django.po
+++ b/backend/src/webhooks/locale/ru/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-04-07 11:48+0000\n"
+"POT-Creation-Date: 2026-04-13 23:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
diff --git a/frontend/config/common.json b/frontend/config/common.json
index 6f653b68e..a1fbc87c5 100644
--- a/frontend/config/common.json
+++ b/frontend/config/common.json
@@ -153,6 +153,8 @@
"tenantToken": "/tenants/:id/token",
"datasets": "/datasets",
"dataset": "/datasets/:id",
+ "fieldsets": "/fieldsets",
+ "fieldset": "/fieldsets/:id",
"getFaq": "/faq",
"vacationActivate": "/accounts/user/activate-vacation",
"vacationDeactivate": "/accounts/user/deactivate-vacation",
diff --git a/frontend/src/public/__stubs__/fieldsets.factory.ts b/frontend/src/public/__stubs__/fieldsets.factory.ts
new file mode 100644
index 000000000..907284240
--- /dev/null
+++ b/frontend/src/public/__stubs__/fieldsets.factory.ts
@@ -0,0 +1,95 @@
+import {
+ EFieldLabelPosition, IFieldsetBinding, IFieldsetBindingClient, IFieldsetCatalogItem,
+ IFieldsetField, IFieldsetRuntime, IFieldsetTaskAPI, IFieldsetTemplateRule,
+} from '../types/fieldset';
+import { IExtraField } from '../types/template';
+
+export const makeFieldsetField = (overrides: Partial = {}): IFieldsetField => ({
+ apiName: 'field-1',
+ name: 'Field 1',
+ description: '',
+ type: 'string',
+ isRequired: false,
+ isHidden: false,
+ order: 0,
+ default: '',
+ dataset: null,
+ selections: [],
+ ...overrides,
+});
+
+export const makeFieldsetRuntime = (overrides: Partial = {}): IFieldsetRuntime => ({
+ apiNameBinding: 'fs-1',
+ name: 'Fieldset 1',
+ description: '',
+ order: 0,
+ fields: [],
+ labelPosition: EFieldLabelPosition.Top,
+ layout: 'vertical',
+ title: '',
+ ...overrides,
+});
+
+
+export const makeFieldsetTemplateRule = (overrides: Partial = {}): IFieldsetTemplateRule => ({
+ apiName: 'rule-1',
+ type: 'sum_equal',
+ value: '100',
+ fields: [],
+ ...overrides,
+});
+
+export const makeFieldsetCatalogItem = (overrides: Partial = {}): IFieldsetCatalogItem => ({
+ id: 1,
+ apiName: 'fs-1',
+ name: 'Test Fieldset',
+ description: '',
+ labelPosition: EFieldLabelPosition.Top,
+ layout: 'vertical',
+ order: 0,
+ title: '',
+ rules: [],
+ fields: [],
+ ...overrides,
+});
+
+export const makeFieldsetBindingClient = (overrides: Partial = {}): IFieldsetBindingClient => ({
+ sharedFieldsetId: 1,
+ apiNameBinding: 'fs-binding-1',
+ name: 'Test Fieldset',
+ description: '',
+ labelPosition: EFieldLabelPosition.Top,
+ layout: 'vertical',
+ order: 0,
+ title: '',
+ rules: [],
+ fields: [],
+ ...overrides,
+});
+
+export const makeFieldsetBinding = (overrides: Partial = {}): IFieldsetBinding => ({
+ apiName: 'catalog-fs-1',
+ sharedFieldsetId: 10,
+ name: 'Test Fieldset',
+ description: '',
+ labelPosition: EFieldLabelPosition.Top,
+ layout: 'vertical',
+ order: 0,
+ title: '',
+ rules: [],
+ fields: [],
+ ...overrides,
+});
+
+export const makeFieldsetTaskAPI = (overrides: Partial = {}): IFieldsetTaskAPI => ({
+ id: 100,
+ apiName: 'task-fs-1',
+ name: 'Task Fieldset',
+ description: '',
+ order: 0,
+ labelPosition: EFieldLabelPosition.Top,
+ layout: 'vertical',
+ title: '',
+ fields: [] as IExtraField[],
+ ...overrides,
+});
diff --git a/frontend/src/public/__stubs__/tasks.factory.ts b/frontend/src/public/__stubs__/tasks.factory.ts
new file mode 100644
index 000000000..ec06669e2
--- /dev/null
+++ b/frontend/src/public/__stubs__/tasks.factory.ts
@@ -0,0 +1,47 @@
+import type { ITask, ITaskAPI, TTaskChecklists } from '../types/tasks';
+import { EWorkflowStatus } from '../types/workflow';
+
+export const makeTask = (overrides: Partial = {}): ITask => ({
+ id: 1,
+ name: 'Task',
+ output: [],
+ description: null,
+ workflow: { id: 1, name: 'Workflow', status: EWorkflowStatus.Running, dateCompleted: null, templateName: 'T' },
+ performers: [],
+ containsComments: false,
+ isCompleted: false,
+ dateStarted: '2024-01-01T00:00:00Z',
+ dateCompleted: null,
+ dueDate: null,
+ isUrgent: false,
+ subWorkflows: [],
+ areChecklistsHandling: false,
+ checklistsTotal: 0,
+ checklistsMarked: 0,
+ checklists: {} as TTaskChecklists,
+ revertTasks: [],
+ ...overrides,
+});
+
+export const makeTaskAPI = (overrides: Partial = {}): ITaskAPI => ({
+ id: 1,
+ name: 'Task 1',
+ output: [],
+ description: null,
+ workflow: { id: 1, name: 'Workflow', status: EWorkflowStatus.Running, dateCompleted: null, templateName: 'T' },
+ performers: [],
+ containsComments: false,
+ isCompleted: false,
+ dueDate: null,
+ isUrgent: false,
+ checklists: [],
+ checklistsTotal: 0,
+ checklistsMarked: 0,
+ revertTasks: [],
+ fieldsets: [],
+ dueDateTsp: null,
+ dateStartedTsp: 1719700800,
+ dateCompletedTsp: null,
+ subWorkflows: [],
+ ...overrides,
+});
diff --git a/frontend/src/public/__stubs__/team.factory.ts b/frontend/src/public/__stubs__/team.factory.ts
new file mode 100644
index 000000000..f9ea864a9
--- /dev/null
+++ b/frontend/src/public/__stubs__/team.factory.ts
@@ -0,0 +1,10 @@
+import { IGroup, EUserGroupType } from '../redux/team/types';
+
+export const makeGroup = (overrides: Partial = {}): IGroup => ({
+ id: 1,
+ name: 'Group',
+ photo: null,
+ users: [],
+ type: EUserGroupType.Regular,
+ ...overrides,
+});
diff --git a/frontend/src/public/__stubs__/templates.factory.ts b/frontend/src/public/__stubs__/templates.factory.ts
new file mode 100644
index 000000000..8301def2f
--- /dev/null
+++ b/frontend/src/public/__stubs__/templates.factory.ts
@@ -0,0 +1,76 @@
+import { IKickoffClient, ITemplateResponse, ITemplateTask, ITemplateTaskClient } from '../types/template';
+import { createEmptyTaskDueDate } from '../utils/dueDate/createEmptyTaskDueDate';
+
+export const makeTemplateTaskClient = (overrides: Partial = {}): ITemplateTaskClient => ({
+ id: 1,
+ apiName: 'task-1',
+ name: 'Task',
+ description: '',
+ number: 1,
+ rawPerformers: [],
+ requireCompletionByAll: false,
+ skipForStarter: false,
+ fields: [],
+ fieldsets: [],
+ delay: null,
+ rawDueDate: createEmptyTaskDueDate(),
+ conditions: [],
+ uuid: 'task-uuid',
+ checklists: [],
+ revertTask: null,
+ ancestors: [],
+ ...overrides,
+});
+
+export const makeTemplateTask = (overrides: Partial = {}): ITemplateTask => ({
+ apiName: 'task-1',
+ number: 1,
+ name: 'Task',
+ description: '',
+ delay: null,
+ rawDueDate: createEmptyTaskDueDate(),
+ requireCompletionByAll: false,
+ skipForStarter: false,
+ rawPerformers: [],
+ fields: [],
+ fieldsets: [],
+ uuid: 'task-uuid',
+ conditions: [],
+ checklists: [],
+ revertTask: null,
+ ancestors: [],
+ ...overrides,
+});
+
+export const makeKickoffClient = (overrides: Partial = {}): IKickoffClient => ({
+ description: '',
+ fields: [],
+ fieldsets: [],
+ ...overrides,
+});
+
+export const makeTemplateResponse = (overrides: Partial = {}): ITemplateResponse => ({
+ id: 1,
+ name: 'Template',
+ description: '',
+ isActive: true,
+ finalizable: false,
+ completionNotification: false,
+ reminderNotification: false,
+ dateUpdated: null,
+ updatedBy: null,
+ owners: [],
+ isPublic: false,
+ publicUrl: null,
+ publicSuccessUrl: null,
+ isEmbedded: false,
+ embedUrl: null,
+ wfNameTemplate: null,
+ tasks: [],
+ kickoff: {
+ description: '',
+ fields: [],
+ fieldsets: [],
+ },
+ ...overrides,
+});
diff --git a/frontend/src/public/__stubs__/templates.ts b/frontend/src/public/__stubs__/templates.ts
index 759693c60..dd62429a6 100644
--- a/frontend/src/public/__stubs__/templates.ts
+++ b/frontend/src/public/__stubs__/templates.ts
@@ -1,5 +1,5 @@
import { ITaskCommentItem } from '../types/workflow';
-import { ITemplate, ITemplateListItem } from '../types/template';
+import { ITemplateClient, ITemplateListItem } from '../types/template';
import { getEmptyKickoff } from '../utils/template';
export const templatesList: ITemplateListItem[] = [
@@ -71,7 +71,7 @@ export const templatesList: ITemplateListItem[] = [
},
];
-const DEFAULT_TEMPLATE: ITemplate = {
+const DEFAULT_TEMPLATE: ITemplateClient = {
id: 1,
name: 'Customer study process',
description: '',
@@ -115,11 +115,11 @@ export const mockComments: Array = [
},
];
-export const getTemplate = (id: string): ITemplate => {
+export const getTemplate = (id: string): ITemplateClient => {
const listItem = templatesList.find((process) => String(process.id) === id);
return {
...DEFAULT_TEMPLATE,
- ...(listItem as unknown as ITemplate),
+ ...(listItem as unknown as ITemplateClient),
};
};
diff --git a/frontend/src/public/__stubs__/users.factory.ts b/frontend/src/public/__stubs__/users.factory.ts
new file mode 100644
index 000000000..8d572e4b5
--- /dev/null
+++ b/frontend/src/public/__stubs__/users.factory.ts
@@ -0,0 +1,13 @@
+import { TUserListItem, EUserStatus } from '../types/user';
+
+export const makeUser = (overrides: Partial = {}): TUserListItem => ({
+ id: 1,
+ email: '',
+ firstName: '',
+ lastName: '',
+ phone: '',
+ photo: '',
+ status: EUserStatus.Active,
+ type: 'user',
+ ...overrides,
+});
diff --git a/frontend/src/public/api/__tests__/getTemplateFields.test.ts b/frontend/src/public/api/__tests__/getTemplateFields.test.ts
new file mode 100644
index 000000000..c71aeaeaa
--- /dev/null
+++ b/frontend/src/public/api/__tests__/getTemplateFields.test.ts
@@ -0,0 +1,37 @@
+
+const MOCK_URLS = {
+ templateFields: '/templates/:id/fields',
+};
+
+jest.mock('../../utils/getConfig', () => ({
+ getBrowserConfigEnv: jest.fn().mockReturnValue({
+ api: { urls: MOCK_URLS },
+ }),
+}));
+
+jest.mock('../commonRequest');
+
+import { commonRequest } from '../commonRequest';
+import { getTemplateFields } from '../getTemplateFields';
+
+describe('getTemplateFields', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('calls commonRequest with GET, correct URL and signal', async () => {
+ const mockResponse = { tasks: [], kickoff: { fields: [], fieldsets: [] } };
+ (commonRequest as jest.Mock).mockResolvedValue(mockResponse);
+
+ const abortController = new AbortController();
+ const result = await getTemplateFields('42', abortController.signal);
+
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+ expect(commonRequest).toHaveBeenCalledWith(
+ '/templates/42/fields',
+ { signal: abortController.signal },
+ { shouldThrow: true },
+ );
+ expect(result).toBe(mockResponse);
+ });
+});
diff --git a/frontend/src/public/api/__tests__/vacation.test.ts b/frontend/src/public/api/__tests__/vacation.test.ts
index 75bc4d2a0..5b146fdbf 100644
--- a/frontend/src/public/api/__tests__/vacation.test.ts
+++ b/frontend/src/public/api/__tests__/vacation.test.ts
@@ -1,4 +1,3 @@
-//
import {
activateVacation,
deactivateVacation,
diff --git a/frontend/src/public/api/createTemplateByName.ts b/frontend/src/public/api/createTemplateByName.ts
index a9d9077b8..134be8ea3 100644
--- a/frontend/src/public/api/createTemplateByName.ts
+++ b/frontend/src/public/api/createTemplateByName.ts
@@ -1,4 +1,4 @@
-import { ITemplate } from '../types/template';
+import { ITemplateResponse } from '../types/template';
import { getBrowserConfigEnv } from '../utils/getConfig';
import { mapRequestBody } from '../utils/mappers';
@@ -17,4 +17,4 @@ export function createTemplateByName(template: TCreateTemplateByNameRequest) {
);
}
-export type TCreateTemplateByNameRequest = Pick
+export type TCreateTemplateByNameRequest = Pick
diff --git a/frontend/src/public/api/fieldsets/__tests__/fieldsets-api.test.ts b/frontend/src/public/api/fieldsets/__tests__/fieldsets-api.test.ts
new file mode 100644
index 000000000..b2ac269fb
--- /dev/null
+++ b/frontend/src/public/api/fieldsets/__tests__/fieldsets-api.test.ts
@@ -0,0 +1,217 @@
+
+const MOCK_URLS = {
+ fieldsets: '/fieldsets',
+ fieldset: '/fieldsets/:id',
+};
+
+jest.mock('../../../utils/getConfig', () => ({
+ getBrowserConfigEnv: jest.fn().mockReturnValue({
+ api: { urls: MOCK_URLS },
+ }),
+}));
+
+jest.mock('../../commonRequest');
+
+jest.mock('../../../utils/mappers', () => ({
+ mapRequestBody: jest.fn((obj: object) => JSON.stringify(obj)),
+}));
+
+import { commonRequest } from '../../commonRequest';
+import { mapRequestBody } from '../../../utils/mappers';
+import { createFieldset } from '../createFieldset';
+import { getFieldset } from '../getFieldset';
+import { getFieldsets } from '../getFieldsets';
+import { updateFieldset } from '../updateFieldset';
+import { deleteFieldset } from '../deleteFieldset';
+import { EFieldsetsSorting } from '../../../types/fieldset';
+
+describe('fieldsets API clients', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('createFieldset', () => {
+ it('calls commonRequest with POST, correct URL and body via mapRequestBody', async () => {
+ const mockResponse = { id: 1, name: 'Test' };
+ (commonRequest as jest.Mock).mockResolvedValue(mockResponse);
+
+ const params = {
+ name: 'New Fieldset',
+ description: 'Desc',
+ rules: [],
+ fields: [],
+ };
+
+ const result = await createFieldset(params);
+
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+ expect(commonRequest).toHaveBeenCalledWith(
+ MOCK_URLS.fieldsets,
+ {
+ method: 'POST',
+ data: expect.any(String),
+ },
+ { shouldThrow: true },
+ );
+
+ expect(mapRequestBody).toHaveBeenCalledTimes(1);
+ expect(mapRequestBody).toHaveBeenCalledWith({
+ name: 'New Fieldset',
+ description: 'Desc',
+ rules: [],
+ fields: [],
+ });
+
+ expect(result).toBe(mockResponse);
+ });
+ });
+
+ describe('getFieldset', () => {
+ it('calls commonRequest with GET, correct URL and signal in requestOptions', async () => {
+ const mockResponse = { id: 5, name: 'My Fieldset' };
+ (commonRequest as jest.Mock).mockResolvedValue(mockResponse);
+
+ const abortController = new AbortController();
+ const result = await getFieldset({ id: 5, signal: abortController.signal });
+
+ const expectedUrl = MOCK_URLS.fieldset.replace(':id', '5');
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+ expect(commonRequest).toHaveBeenCalledWith(
+ expectedUrl,
+ {
+ method: 'GET',
+ signal: abortController.signal,
+ },
+ { shouldThrow: true },
+ );
+ expect(result).toBe(mockResponse);
+ });
+ });
+
+ describe('getFieldsets', () => {
+ it('builds URL without query params when ordering/limit/offset are absent', async () => {
+ (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] });
+
+ await getFieldsets({});
+
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+ expect(commonRequest).toHaveBeenCalledWith(
+ MOCK_URLS.fieldsets,
+ expect.objectContaining({ method: 'GET' }),
+ { shouldThrow: true },
+ );
+ });
+
+ it('adds limit and offset to query string', async () => {
+ (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] });
+
+ await getFieldsets({ limit: 30, offset: 0 });
+
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+ const calledUrl = (commonRequest as jest.Mock).mock.calls[0][0] as string;
+ expect(calledUrl).toContain('limit=30');
+ expect(calledUrl).toContain('offset=0');
+ });
+
+ it.each([
+ [EFieldsetsSorting.NameAsc, 'ordering=name'],
+ [EFieldsetsSorting.NameDesc, 'ordering=-name'],
+ [EFieldsetsSorting.DateAsc, 'ordering=date'],
+ [EFieldsetsSorting.DateDesc, 'ordering=-date'],
+ ])('maps %s to backend ordering %s', async (sorting, expected) => {
+ (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] });
+
+ await getFieldsets({ ordering: sorting });
+
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+ const calledUrl = (commonRequest as jest.Mock).mock.calls[0][0] as string;
+ expect(calledUrl).toContain(expected);
+ });
+
+ it('passes unknown ordering as fallback', async () => {
+ (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] });
+
+ await getFieldsets({ ordering: 'custom-sort' });
+
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+ const calledUrl = (commonRequest as jest.Mock).mock.calls[0][0] as string;
+ expect(calledUrl).toContain('ordering=custom-sort');
+ });
+
+ it('combines all params into one query string and passes signal', async () => {
+ (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] });
+ const abortController = new AbortController();
+
+ await getFieldsets({
+ ordering: EFieldsetsSorting.DateDesc,
+ limit: 20,
+ offset: 40,
+ signal: abortController.signal,
+ });
+
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+
+ const calledUrl = (commonRequest as jest.Mock).mock.calls[0][0] as string;
+ expect(calledUrl).toContain('ordering=-date');
+ expect(calledUrl).toContain('limit=20');
+ expect(calledUrl).toContain('offset=40');
+
+ expect(commonRequest).toHaveBeenCalledWith(
+ calledUrl,
+ expect.objectContaining({ signal: abortController.signal }),
+ { shouldThrow: true },
+ );
+ });
+ });
+
+ describe('updateFieldset', () => {
+ it('calls commonRequest with PATCH, id in URL, signal in requestOptions, data via mapRequestBody', async () => {
+ const mockResponse = { id: 7, name: 'Updated' };
+ (commonRequest as jest.Mock).mockResolvedValue(mockResponse);
+
+ const abortController = new AbortController();
+ const result = await updateFieldset({
+ id: 7,
+ name: 'Updated',
+ description: 'New desc',
+ signal: abortController.signal,
+ });
+
+ const expectedUrl = MOCK_URLS.fieldset.replace(':id', '7');
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+ expect(commonRequest).toHaveBeenCalledWith(
+ expectedUrl,
+ {
+ method: 'PATCH',
+ data: expect.any(String),
+ signal: abortController.signal,
+ },
+ { shouldThrow: true },
+ );
+
+ expect(mapRequestBody).toHaveBeenCalledTimes(1);
+ expect(mapRequestBody).toHaveBeenCalledWith({
+ name: 'Updated',
+ description: 'New desc',
+ });
+
+ expect(result).toBe(mockResponse);
+ });
+ });
+
+ describe('deleteFieldset', () => {
+ it('calls commonRequest with DELETE, correct URL and responseType empty', async () => {
+ (commonRequest as jest.Mock).mockResolvedValue(undefined);
+
+ await deleteFieldset({ id: 99 });
+
+ const expectedUrl = MOCK_URLS.fieldset.replace(':id', '99');
+ expect(commonRequest).toHaveBeenCalledTimes(1);
+ expect(commonRequest).toHaveBeenCalledWith(
+ expectedUrl,
+ { method: 'DELETE' },
+ { shouldThrow: true, responseType: 'empty' },
+ );
+ });
+ });
+});
diff --git a/frontend/src/public/api/fieldsets/createFieldset.ts b/frontend/src/public/api/fieldsets/createFieldset.ts
new file mode 100644
index 000000000..7cd237123
--- /dev/null
+++ b/frontend/src/public/api/fieldsets/createFieldset.ts
@@ -0,0 +1,23 @@
+import { commonRequest } from '../commonRequest';
+import { IFieldsetCatalogItem, ICreateFieldsetParams } from '../../types/fieldset';
+import { getBrowserConfigEnv } from '../../utils/getConfig';
+import { mapRequestBody } from '../../utils/mappers';
+
+export function createFieldset(params: ICreateFieldsetParams) {
+ const {
+ api: { urls },
+ } = getBrowserConfigEnv();
+
+ const url = urls.fieldsets;
+
+ return commonRequest(
+ url,
+ {
+ method: 'POST',
+ data: mapRequestBody(params),
+ },
+ {
+ shouldThrow: true,
+ },
+ );
+}
diff --git a/frontend/src/public/api/fieldsets/deleteFieldset.ts b/frontend/src/public/api/fieldsets/deleteFieldset.ts
new file mode 100644
index 000000000..c6613b772
--- /dev/null
+++ b/frontend/src/public/api/fieldsets/deleteFieldset.ts
@@ -0,0 +1,22 @@
+import { commonRequest } from '../commonRequest';
+import { IDeleteFieldsetParams } from '../../types/fieldset';
+import { getBrowserConfigEnv } from '../../utils/getConfig';
+
+export function deleteFieldset({ id }: IDeleteFieldsetParams) {
+ const {
+ api: { urls },
+ } = getBrowserConfigEnv();
+
+ const url = urls.fieldset.replace(':id', String(id));
+
+ return commonRequest(
+ url,
+ {
+ method: 'DELETE',
+ },
+ {
+ shouldThrow: true,
+ responseType: 'empty',
+ },
+ );
+}
diff --git a/frontend/src/public/api/fieldsets/getAllFieldsetsCatalog.ts b/frontend/src/public/api/fieldsets/getAllFieldsetsCatalog.ts
new file mode 100644
index 000000000..596acbe57
--- /dev/null
+++ b/frontend/src/public/api/fieldsets/getAllFieldsetsCatalog.ts
@@ -0,0 +1,20 @@
+import { commonRequest } from '../commonRequest';
+import { IFieldsetCatalogItem } from '../../types/fieldset';
+import { getBrowserConfigEnv } from '../../utils/getConfig';
+
+export function getAllFieldsetsCatalog(signal?: AbortSignal) {
+ const {
+ api: { urls },
+ } = getBrowserConfigEnv();
+
+ return commonRequest(
+ urls.fieldsets,
+ {
+ method: 'GET',
+ signal,
+ },
+ {
+ shouldThrow: true,
+ },
+ );
+}
diff --git a/frontend/src/public/api/fieldsets/getFieldset.ts b/frontend/src/public/api/fieldsets/getFieldset.ts
new file mode 100644
index 000000000..d9ff754a2
--- /dev/null
+++ b/frontend/src/public/api/fieldsets/getFieldset.ts
@@ -0,0 +1,22 @@
+import { commonRequest } from '../commonRequest';
+import { IFieldsetCatalogItem, IGetFieldsetParams } from '../../types/fieldset';
+import { getBrowserConfigEnv } from '../../utils/getConfig';
+
+export function getFieldset({ id, signal }: IGetFieldsetParams) {
+ const {
+ api: { urls },
+ } = getBrowserConfigEnv();
+
+ const url = urls.fieldset.replace(':id', String(id));
+
+ return commonRequest(
+ url,
+ {
+ method: 'GET',
+ signal,
+ },
+ {
+ shouldThrow: true,
+ },
+ );
+}
diff --git a/frontend/src/public/api/fieldsets/getFieldsets.ts b/frontend/src/public/api/fieldsets/getFieldsets.ts
new file mode 100644
index 000000000..54b9017a8
--- /dev/null
+++ b/frontend/src/public/api/fieldsets/getFieldsets.ts
@@ -0,0 +1,36 @@
+import { commonRequest } from '../commonRequest';
+import { IGetFieldsetsResponse, IGetFieldsetsParams } from '../../types/fieldset';
+import { fieldsetsOrderingMap } from '../../constants/sortings';
+import { getBrowserConfigEnv } from '../../utils/getConfig';
+
+export function getFieldsets(config: IGetFieldsetsParams) {
+ const {
+ api: { urls },
+ } = getBrowserConfigEnv();
+
+ const { signal } = config;
+ const queryString = getFieldsetsQueryString(config);
+ const baseUrl = urls.fieldsets;
+ const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
+
+ return commonRequest(
+ url,
+ {
+ method: 'GET',
+ signal,
+ },
+ {
+ shouldThrow: true,
+ },
+ );
+}
+
+function getFieldsetsQueryString({ ordering, limit, offset }: IGetFieldsetsParams): string {
+ const backendOrdering = ordering ? fieldsetsOrderingMap[ordering] || ordering : undefined;
+
+ return [
+ backendOrdering && `ordering=${backendOrdering}`,
+ limit !== undefined && `limit=${limit}`,
+ offset !== undefined && `offset=${offset}`,
+ ].filter(Boolean).join('&');
+}
diff --git a/frontend/src/public/api/fieldsets/updateFieldset.ts b/frontend/src/public/api/fieldsets/updateFieldset.ts
new file mode 100644
index 000000000..399a7c1e5
--- /dev/null
+++ b/frontend/src/public/api/fieldsets/updateFieldset.ts
@@ -0,0 +1,24 @@
+import { commonRequest } from '../commonRequest';
+import { IFieldsetCatalogItem, IUpdateFieldsetParams } from '../../types/fieldset';
+import { getBrowserConfigEnv } from '../../utils/getConfig';
+import { mapRequestBody } from '../../utils/mappers';
+
+export function updateFieldset({ id, signal, ...data }: IUpdateFieldsetParams) {
+ const {
+ api: { urls },
+ } = getBrowserConfigEnv();
+
+ const url = urls.fieldset.replace(':id', String(id));
+
+ return commonRequest(
+ url,
+ {
+ method: 'PATCH',
+ data: mapRequestBody(data),
+ signal,
+ },
+ {
+ shouldThrow: true,
+ },
+ );
+}
diff --git a/frontend/src/public/api/getTemplateFields.ts b/frontend/src/public/api/getTemplateFields.ts
index c1369e0cb..df6ef1bf8 100644
--- a/frontend/src/public/api/getTemplateFields.ts
+++ b/frontend/src/public/api/getTemplateFields.ts
@@ -1,10 +1,14 @@
import { commonRequest } from './commonRequest';
import { getBrowserConfigEnv } from '../utils/getConfig';
-import { IKickoff, ITemplateTask } from '../types/template';
+import { IKickoff, ITemplateTask, TTemplateFieldFieldset } from '../types/template';
export type TGetTemplateFieldsResponse = {
- tasks: Pick[];
- kickoff: Pick;
+ tasks: (Pick & {
+ fieldsets: TTemplateFieldFieldset[];
+ })[];
+ kickoff: Pick & {
+ fieldsets: TTemplateFieldFieldset[];
+ };
};
export function getTemplateFields(id: string, signal?: AbortSignal) {
diff --git a/frontend/src/public/api/getUserTasks.ts b/frontend/src/public/api/getUserTasks.ts
index 89b3e7e2a..619f24d11 100644
--- a/frontend/src/public/api/getUserTasks.ts
+++ b/frontend/src/public/api/getUserTasks.ts
@@ -1,13 +1,13 @@
import { commonRequest } from './commonRequest';
import { getBrowserConfigEnv } from '../utils/getConfig';
-import { ITask, ETaskListSorting, ETaskListCompletionStatus, ETaskListCompleteSorting } from '../types/tasks';
+import { TTaskListItemResponse, ETaskListSorting, ETaskListCompletionStatus, ETaskListCompleteSorting } from '../types/tasks';
import { ETimeouts } from '../constants/defaultValues';
export interface IGetTasksResponse {
count: number;
next: string;
previous: string;
- results: ITask[];
+ results: TTaskListItemResponse[];
}
export interface IGetTasksConfig {
diff --git a/frontend/src/public/components/App/AppRoutes.tsx b/frontend/src/public/components/App/AppRoutes.tsx
index 4dfe30ff4..952fcb661 100644
--- a/frontend/src/public/components/App/AppRoutes.tsx
+++ b/frontend/src/public/components/App/AppRoutes.tsx
@@ -22,6 +22,7 @@ import { MainLayout } from '../../layout';
import { GuestTask } from '../GuestTask';
import { TenantsView } from '../../views/Tenants';
import { DatasetsView } from '../../views/Datasets';
+import { FieldsetsView } from '../../views/Fieldsets';
import { ELoggedState, IAuthUser } from '../../types/redux';
import { CollectPaymentDetails } from '../CollectPaymentDetails';
import { AfterPaymentDetailsProvided } from '../AfterPaymentDetailsProvided';
@@ -127,6 +128,12 @@ export function AppRoutes({ containerClassnames, user }: IAppRoutesProps) {
>
+
+
+
diff --git a/frontend/src/public/components/Datasets/DatasetCard/__tests__/DatasetCard.test.tsx b/frontend/src/public/components/Datasets/DatasetCard/__tests__/DatasetCard.test.tsx
index 73e89ca3a..cfedd64b3 100644
--- a/frontend/src/public/components/Datasets/DatasetCard/__tests__/DatasetCard.test.tsx
+++ b/frontend/src/public/components/Datasets/DatasetCard/__tests__/DatasetCard.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
diff --git a/frontend/src/public/components/Datasets/DatasetDetails/DatasetDetails.tsx b/frontend/src/public/components/Datasets/DatasetDetails/DatasetDetails.tsx
index 549d38ae3..dd258eaf0 100644
--- a/frontend/src/public/components/Datasets/DatasetDetails/DatasetDetails.tsx
+++ b/frontend/src/public/components/Datasets/DatasetDetails/DatasetDetails.tsx
@@ -69,10 +69,14 @@ const DatasetDetails = ({ match: { params: { id: matchParamId } } }: TDatasetDet
};
}, []);
- if (isLoading || !dataset) {
+ if (isLoading) {
return ;
}
+ if (!dataset) {
+ return null;
+ }
+
const handleAddRow = () => {
setIsAddingRow(true);
setEditingItemId(null);
diff --git a/frontend/src/public/components/Datasets/DatasetDetails/__tests__/DatasetDetails.test.tsx b/frontend/src/public/components/Datasets/DatasetDetails/__tests__/DatasetDetails.test.tsx
index 99857b065..5b82aff9f 100644
--- a/frontend/src/public/components/Datasets/DatasetDetails/__tests__/DatasetDetails.test.tsx
+++ b/frontend/src/public/components/Datasets/DatasetDetails/__tests__/DatasetDetails.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, act } from '@testing-library/react';
import { useDispatch, useSelector } from 'react-redux';
diff --git a/frontend/src/public/components/Datasets/DatasetDetails/__tests__/DatasetItemsList.test.tsx b/frontend/src/public/components/Datasets/DatasetDetails/__tests__/DatasetItemsList.test.tsx
index f7f6b9e00..ee2f168a7 100644
--- a/frontend/src/public/components/Datasets/DatasetDetails/__tests__/DatasetItemsList.test.tsx
+++ b/frontend/src/public/components/Datasets/DatasetDetails/__tests__/DatasetItemsList.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render } from '@testing-library/react';
diff --git a/frontend/src/public/components/Datasets/DatasetModal/__tests__/DatasetModal.test.tsx b/frontend/src/public/components/Datasets/DatasetModal/__tests__/DatasetModal.test.tsx
index dd294ce97..fe36c5093 100644
--- a/frontend/src/public/components/Datasets/DatasetModal/__tests__/DatasetModal.test.tsx
+++ b/frontend/src/public/components/Datasets/DatasetModal/__tests__/DatasetModal.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
diff --git a/frontend/src/public/components/Datasets/DatasetRow/__tests__/DatasetRow.test.tsx b/frontend/src/public/components/Datasets/DatasetRow/__tests__/DatasetRow.test.tsx
index 4f1488806..899f34c0c 100644
--- a/frontend/src/public/components/Datasets/DatasetRow/__tests__/DatasetRow.test.tsx
+++ b/frontend/src/public/components/Datasets/DatasetRow/__tests__/DatasetRow.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
diff --git a/frontend/src/public/components/Datasets/__tests__/Datasets.test.tsx b/frontend/src/public/components/Datasets/__tests__/Datasets.test.tsx
index 0181ae2ea..85ca3a3e6 100644
--- a/frontend/src/public/components/Datasets/__tests__/Datasets.test.tsx
+++ b/frontend/src/public/components/Datasets/__tests__/Datasets.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render } from '@testing-library/react';
import { useDispatch, useSelector } from 'react-redux';
diff --git a/frontend/src/public/components/Field/Field.tsx b/frontend/src/public/components/Field/Field.tsx
index 0ba76efc1..d6a19f254 100644
--- a/frontend/src/public/components/Field/Field.tsx
+++ b/frontend/src/public/components/Field/Field.tsx
@@ -36,6 +36,7 @@ export function Field({
disabled,
accountId,
isNumericField,
+ editorClassName,
isFromConditionValueField,
onKeyDown,
autoFocus: _autoFocus,
@@ -106,6 +107,7 @@ export function Field({
return Promise.resolve(val);
}}
className={styles['rich-editor']}
+ editorClassName={editorClassName}
accountId={accountId as number}
withMentions={false}
/>
diff --git a/frontend/src/public/components/Field/types.ts b/frontend/src/public/components/Field/types.ts
index 7bbadde73..d406445ad 100644
--- a/frontend/src/public/components/Field/types.ts
+++ b/frontend/src/public/components/Field/types.ts
@@ -19,6 +19,7 @@ export interface IFieldProps extends NativeInputProps {
accountId?: number;
onKeyDown?(event: KeyboardEvent): void;
isNumericField?: boolean;
+ editorClassName?: string;
isFromConditionValueField?: boolean;
value?: string | number | string[];
innerRef?: React.Ref;
diff --git a/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.css b/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.css
new file mode 100644
index 000000000..c8bdc4abe
--- /dev/null
+++ b/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.css
@@ -0,0 +1,29 @@
+.fieldset-group {
+ margin-top: 16px;
+ margin-bottom: 16px;
+ padding-left: 16px;
+ border-left: 3px solid var(--pneumatic-color-link);
+}
+
+.fieldset-group__title {
+ margin: 0 0 4px;
+ font-family: Nunito, sans-serif;
+ font-size: 14px;
+ font-weight: bold;
+ line-height: 20px;
+ color: #262522;
+}
+
+.fieldset-group__description {
+ margin: 0 0 8px;
+ font-family: Nunito, sans-serif;
+ font-size: 13px;
+ line-height: 18px;
+ color: #79756d;
+}
+
+.fieldset-group__error {
+ margin-top: 4px;
+ font-size: 13px;
+ color: var(--color-error, #e74c3c);
+}
diff --git a/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.tsx b/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.tsx
new file mode 100644
index 000000000..60c7a3a61
--- /dev/null
+++ b/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.tsx
@@ -0,0 +1,58 @@
+import * as React from 'react';
+import { IExtraField, EExtraFieldMode } from '../../types/template';
+import { EInputNameBackgroundColor } from '../../types/workflow';
+import { EFieldLabelPosition } from '../../types/fieldset';
+import { ExtraFieldIntl } from '../TemplateEdit/ExtraFields';
+
+import styles from './FieldsetFieldGroup.css';
+
+export interface IFieldsetFieldGroupProps {
+ fields: IExtraField[];
+ title?: string;
+ description?: string;
+ onEditField: (apiName: string) => (changedProps: Partial) => void;
+ mode: EExtraFieldMode;
+ labelBackgroundColor: EInputNameBackgroundColor;
+ accountId: number;
+ fieldClassName?: string;
+ validationError?: string | null;
+ labelPosition: EFieldLabelPosition;
+}
+
+export function FieldsetFieldGroup({
+ fields,
+ title,
+ description,
+ onEditField,
+ mode,
+ labelBackgroundColor,
+ accountId,
+ fieldClassName,
+ validationError,
+ labelPosition,
+}: IFieldsetFieldGroupProps) {
+ return (
+
+ {title &&
{title}
}
+ {description &&
{description}
}
+ {fields.map((field) => (
+
+ ))}
+ {validationError && (
+
{validationError}
+ )}
+
+ );
+}
diff --git a/frontend/src/public/components/FieldsetFieldGroup/__tests__/FieldsetFieldGroup.test.tsx b/frontend/src/public/components/FieldsetFieldGroup/__tests__/FieldsetFieldGroup.test.tsx
new file mode 100644
index 000000000..6a7ca93c2
--- /dev/null
+++ b/frontend/src/public/components/FieldsetFieldGroup/__tests__/FieldsetFieldGroup.test.tsx
@@ -0,0 +1,144 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { FieldsetFieldGroup } from '../FieldsetFieldGroup';
+import { makeExtraField } from '../../../__stubs__/fields.factory';
+import { EExtraFieldMode, IExtraField } from '../../../types/template';
+import { EInputNameBackgroundColor } from '../../../types/workflow';
+import { EFieldLabelPosition } from '../../../types/fieldset';
+
+type TExtraFieldIntlMockProps = {
+ field: IExtraField;
+ editField: (changedProps: Partial) => void;
+};
+
+jest.mock('../../TemplateEdit/ExtraFields', () => ({
+ ExtraFieldIntl: jest.fn(({ field, editField }: TExtraFieldIntlMockProps) => (
+
+ {field.name}
+
+
+ )),
+}));
+
+const makeField = (apiName: string, order = 0) => makeExtraField({
+ apiName,
+ name: `Field ${apiName}`,
+ ...(order !== 0 && { order }),
+});
+
+const baseProps = {
+ fields: [] as IExtraField[],
+ onEditField: jest.fn(() => jest.fn()),
+ mode: EExtraFieldMode.Kickoff,
+ labelBackgroundColor: EInputNameBackgroundColor.White,
+ accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
+};
+
+describe('FieldsetFieldGroup', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('group title', () => {
+ it('renders when title is a non-empty string', () => {
+ render();
+
+ expect(screen.getByText('Contact details')).toBeInTheDocument();
+ });
+
+ it('does not render when title is omitted', () => {
+ render();
+
+ expect(screen.queryByText('Contact details')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('group description', () => {
+ it('renders when description is a non-empty string', () => {
+ render();
+
+ expect(screen.getByText('Fill carefully')).toBeInTheDocument();
+ });
+
+ it('renders even when title is omitted (independent of title)', () => {
+ render();
+
+ expect(screen.queryByText('Contact details')).not.toBeInTheDocument();
+ expect(screen.getByText('Description only')).toBeInTheDocument();
+ });
+ });
+
+ describe('validation error message', () => {
+ const ERROR_TEXT = 'Required field';
+
+ it('is visible when validationError is a non-empty string', () => {
+ render();
+
+ expect(screen.getByText(ERROR_TEXT)).toBeInTheDocument();
+ });
+
+ it.each<'' | null | undefined>(['', null, undefined])(
+ 'does not render for falsy validationError = %p',
+ (value) => {
+ render();
+
+ expect(screen.queryByText(ERROR_TEXT)).not.toBeInTheDocument();
+ },
+ );
+ });
+
+ it('renders all provided fields in the original array order, without re-sorting', () => {
+ const fields = [
+ makeField('email', 5),
+ makeField('phone', 1),
+ makeField('city', 3),
+ ];
+
+ render();
+
+ const rendered = screen.getAllByTestId(/^extra-field-/);
+
+ expect(rendered).toHaveLength(3);
+ expect(rendered.map((el) => el.getAttribute('data-testid'))).toEqual([
+ 'extra-field-email',
+ 'extra-field-phone',
+ 'extra-field-city',
+ ]);
+ });
+
+ it('routes edits to the specific apiName when editing a field', () => {
+ const collected: Array<{ apiName: string; changedProps: Partial }> = [];
+ const onEditField = jest.fn(
+ (apiName: string) => (changedProps: Partial) => {
+ collected.push({ apiName, changedProps });
+ },
+ );
+
+ const fields = [makeField('email'), makeField('phone'), makeField('city')];
+
+ render(
+ ,
+ );
+
+ expect(onEditField).toHaveBeenCalledTimes(3);
+ expect(onEditField.mock.calls.map(([apiName]) => apiName)).toEqual([
+ 'email',
+ 'phone',
+ 'city',
+ ]);
+
+ userEvent.click(screen.getByRole('button', { name: 'edit phone' }));
+
+ expect(collected).toEqual([
+ { apiName: 'phone', changedProps: { value: 'value-for-phone' } },
+ ]);
+ });
+});
diff --git a/frontend/src/public/components/FieldsetFieldGroup/index.ts b/frontend/src/public/components/FieldsetFieldGroup/index.ts
new file mode 100644
index 000000000..61e098002
--- /dev/null
+++ b/frontend/src/public/components/FieldsetFieldGroup/index.ts
@@ -0,0 +1 @@
+export { FieldsetFieldGroup } from './FieldsetFieldGroup';
diff --git a/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.css b/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.css
new file mode 100644
index 000000000..0978e1c6b
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.css
@@ -0,0 +1,57 @@
+.card {
+ @mixin page-card;
+}
+
+.card__content {
+ @mixin page-card-content;
+}
+
+.card__header {
+ @mixin page-card-header;
+}
+
+.card__title {
+ text-decoration: none;
+ cursor: pointer;
+ color: inherit;
+
+ &:hover {
+ color: var(--pneumatic-color-link-hover);
+ }
+
+ @mixin page-card-title;
+}
+
+.card__more {
+ @mixin page-card-more;
+}
+
+.card__footer {
+ margin-top: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+
+ .card-stats {
+ font-style: normal;
+ color: var(--pneumatic-color-black48);
+
+ @mixin text-small;
+
+ > *:not(:last-child) {
+ margin-right: 0.8rem;
+ }
+ }
+
+ .card-stats--items {
+ color: var(--pneumatic-color-black72);
+
+ @mixin text-base 700;
+ }
+
+ .card-stats--rules {
+ color: var(--pneumatic-color-black48);
+
+ @mixin text-small;
+ }
+}
diff --git a/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.tsx b/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.tsx
new file mode 100644
index 000000000..2f7d35853
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.tsx
@@ -0,0 +1,140 @@
+import * as React from 'react';
+import { useState } from 'react';
+import { useDispatch } from 'react-redux';
+import { useIntl } from 'react-intl';
+import classnames from 'classnames';
+
+import { Dropdown, TDropdownOption } from '../../UI';
+import { MoreIcon, PencilIcon, TrashIcon } from '../../icons';
+import { WarningPopup } from '../../UI/WarningPopup';
+import { openEditModal, deleteFieldsetAction, setCurrentFieldset } from '../../../redux/fieldsets/slice';
+import { history } from '../../../utils/history';
+import { ERoutes } from '../../../constants/routes';
+import { sanitizeText } from '../../../utils/strings';
+import { IFieldsetCatalogItem } from '../../../types/fieldset';
+
+import styles from './FieldsetCard.css';
+
+export function FieldsetCard({
+ id,
+ apiName,
+ name,
+ description,
+ labelPosition,
+ layout,
+ order,
+ title,
+ rules,
+ fields,
+}: IFieldsetCatalogItem) {
+ const { formatMessage } = useIntl();
+ const dispatch = useDispatch();
+
+ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
+ const handleOpenDeleteModal = () => setIsDeleteModalVisible(true);
+ const handleCloseDeleteModal = () => setIsDeleteModalVisible(false);
+
+ const handleConfirmDelete = () => {
+ dispatch(deleteFieldsetAction({ id }));
+ handleCloseDeleteModal();
+ };
+
+ const handleEditName = () => {
+ dispatch(setCurrentFieldset({
+ id,
+ apiName,
+ name,
+ description,
+ labelPosition,
+ layout,
+ order,
+ title,
+ rules,
+ fields,
+ }));
+ dispatch(openEditModal());
+ };
+
+ const handleCardClick = () => {
+ history.push(
+ ERoutes.FieldsetDetail
+ .replace(':id', id.toString()),
+ );
+ };
+
+ const dropdownOptions: TDropdownOption[] = [
+ {
+ label: formatMessage({ id: 'fieldsets.edit' }),
+ onClick: handleEditName,
+ Icon: PencilIcon,
+ size: 'sm',
+ },
+ {
+ label: formatMessage({ id: 'fieldsets.delete' }),
+ onClick: handleOpenDeleteModal,
+ Icon: TrashIcon,
+ color: 'red',
+ withUpperline: true,
+ size: 'sm',
+ },
+ ];
+
+ const hasContent = fields.length > 0 || rules.length > 0;
+
+ return (
+
+
{name} })}
+ closeModal={handleCloseDeleteModal}
+ isOpen={isDeleteModalVisible}
+ onConfirm={handleConfirmDelete}
+ onReject={handleCloseDeleteModal}
+ />
+
+
+
+
e.key === 'Enter' && handleCardClick()}
+ role="link"
+ tabIndex={0}
+ >
+ {sanitizeText(name)}
+
+
+
(
+
+ )}
+ options={dropdownOptions}
+ />
+
+
+ {hasContent && (
+
+ {fields.length > 0 && (
+
+ {formatMessage(
+ { id: 'fieldsets.stats.fields' },
+ { count: fields.length },
+ )}
+
+ )}
+ {rules.length > 0 && (
+
+ {formatMessage(
+ { id: 'fieldsets.stats.rules' },
+ { count: rules.length },
+ )}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/public/components/Fieldsets/FieldsetCard/__tests__/FieldsetCard.test.tsx b/frontend/src/public/components/Fieldsets/FieldsetCard/__tests__/FieldsetCard.test.tsx
new file mode 100644
index 000000000..7931b1bc2
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetCard/__tests__/FieldsetCard.test.tsx
@@ -0,0 +1,210 @@
+import * as React from 'react';
+import { render, screen, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useDispatch } from 'react-redux';
+
+import { FieldsetCard } from '../FieldsetCard';
+import { Dropdown } from '../../../UI';
+import { WarningPopup } from '../../../UI/WarningPopup';
+import {
+ openEditModal,
+ deleteFieldsetAction,
+ setCurrentFieldset,
+} from '../../../../redux/fieldsets/slice';
+import { history } from '../../../../utils/history';
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { IFieldsetField } from '../../../../types/fieldset';
+import { makeFieldsetCatalogItem, makeFieldsetTemplateRule } from '../../../../__stubs__/fieldsets.factory';
+
+jest.mock('../../../../utils/history', () => ({
+ history: { push: jest.fn(), location: { pathname: '/' }, listen: jest.fn() },
+}));
+
+jest.mock('../../../../utils/strings', () => ({
+ sanitizeText: jest.fn((text: string) => text),
+}));
+
+jest.mock('../../../../redux/fieldsets/slice', () => ({
+ openEditModal: jest.fn(() => ({ type: 'fieldsets/openEditModal' })),
+ deleteFieldsetAction: jest.fn((p) => ({ type: 'fieldsets/deleteFieldsetAction', payload: p })),
+ setCurrentFieldset: jest.fn((p) => ({ type: 'fieldsets/setCurrentFieldset', payload: p })),
+}));
+
+jest.mock('../../../UI', () => ({
+ Dropdown: jest.fn(() => null),
+}));
+
+jest.mock('../../../UI/WarningPopup', () => ({
+ WarningPopup: jest.fn(() => null),
+}));
+
+jest.mock('../../../icons', () => ({
+ MoreIcon: () => null,
+ PencilIcon: () => null,
+ TrashIcon: () => null,
+}));
+
+describe('FieldsetCard', () => {
+ const mockDispatch = jest.fn();
+
+ const formatMsg = (id: string) => intlMock.formatMessage({ id });
+ const EDIT_LABEL = formatMsg('fieldsets.edit');
+ const DELETE_LABEL = formatMsg('fieldsets.delete');
+ const FIELDS_STATS = (count: number) => intlMock.formatMessage({ id: 'fieldsets.stats.fields' }, { count });
+ const RULES_STATS = (count: number) => intlMock.formatMessage({ id: 'fieldsets.stats.rules' }, { count });
+
+ let fieldCounter = 0;
+
+ const makeField = (overrides: Partial = {}): IFieldsetField => ({
+ type: 'string',
+ name: 'Field',
+ order: 0,
+ apiName: `f-${++fieldCounter}`,
+ ...overrides,
+ });
+
+ const makeProps = makeFieldsetCatalogItem;
+
+ const getDropdownOptions = () => {
+ const mock = Dropdown as unknown as jest.Mock;
+ const lastCall = mock.mock.calls[mock.mock.calls.length - 1];
+ return lastCall[0].options;
+ };
+
+ const findDropdownOption = (label: string) => {
+ return getDropdownOptions().find((opt: { label: string }) => opt.label === label);
+ };
+
+ const getWarningPopupProps = () => {
+ const mock = WarningPopup as jest.Mock;
+ const lastCall = mock.mock.calls[mock.mock.calls.length - 1];
+ return lastCall[0];
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ fieldCounter = 0;
+ (useDispatch as jest.Mock).mockReturnValue(mockDispatch);
+ });
+
+ describe('Navigation', () => {
+ it('navigates to detail page on title click', () => {
+ render(React.createElement(FieldsetCard, makeProps({ id: 10 })));
+
+ const titleLink = screen.getByRole('link');
+ userEvent.click(titleLink);
+
+ expect(history.push).toHaveBeenCalledTimes(1);
+ expect(history.push).toHaveBeenCalledWith('/fieldsets/10/');
+ });
+
+ it('navigates to detail page on Enter key', () => {
+ render(React.createElement(FieldsetCard, makeProps({ id: 10 })));
+
+ const titleLink = screen.getByRole('link');
+ titleLink.focus();
+ userEvent.keyboard('{Enter}');
+
+ expect(history.push).toHaveBeenCalledTimes(1);
+ expect(history.push).toHaveBeenCalledWith('/fieldsets/10/');
+ });
+ });
+
+ describe('Dropdown — Edit', () => {
+ it('dispatches setCurrentFieldset and openEditModal on Edit click', () => {
+ const props = makeProps();
+ render(React.createElement(FieldsetCard, props));
+
+ const editOption = findDropdownOption(EDIT_LABEL);
+ editOption.onClick();
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setCurrentFieldset({
+ id: props.id,
+ apiName: props.apiName,
+ name: props.name,
+ description: props.description,
+ labelPosition: props.labelPosition,
+ layout: props.layout,
+ order: props.order,
+ title: props.title,
+ rules: props.rules,
+ fields: props.fields,
+ }),
+ );
+ expect(mockDispatch).toHaveBeenCalledWith(openEditModal());
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('Dropdown — Delete', () => {
+ it('opens WarningPopup on Delete click', () => {
+ render(React.createElement(FieldsetCard, makeProps()));
+
+ expect(getWarningPopupProps().isOpen).toBe(false);
+
+ const deleteOption = findDropdownOption(DELETE_LABEL);
+ act(() => { deleteOption.onClick(); });
+
+ expect(getWarningPopupProps().isOpen).toBe(true);
+ });
+
+ it('dispatches deleteFieldsetAction on WarningPopup confirm', () => {
+ render(React.createElement(FieldsetCard, makeProps({ id: 10 })));
+
+ act(() => { findDropdownOption(DELETE_LABEL).onClick(); });
+ act(() => { getWarningPopupProps().onConfirm(); });
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(deleteFieldsetAction({ id: 10 }));
+
+ expect(getWarningPopupProps().isOpen).toBe(false);
+ });
+
+ it('closes WarningPopup without dispatch on cancel', () => {
+ render(React.createElement(FieldsetCard, makeProps()));
+
+ act(() => { findDropdownOption(DELETE_LABEL).onClick(); });
+ expect(getWarningPopupProps().isOpen).toBe(true);
+
+ act(() => { getWarningPopupProps().onReject(); });
+
+ expect(getWarningPopupProps().isOpen).toBe(false);
+ expect(deleteFieldsetAction).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Statistics footer', () => {
+ it('shows field count and rule count when both are > 0', () => {
+ const props = makeProps({
+ fields: [makeField(), makeField()],
+ rules: [makeFieldsetTemplateRule()],
+ });
+ render(React.createElement(FieldsetCard, props));
+
+ expect(screen.getByText(FIELDS_STATS(2))).toBeInTheDocument();
+ expect(screen.getByText(RULES_STATS(1))).toBeInTheDocument();
+ });
+
+ it('hides footer when fields and rules are both empty', () => {
+ render(React.createElement(FieldsetCard, makeProps({ fields: [], rules: [] })));
+
+ expect(screen.queryByText(FIELDS_STATS(0))).not.toBeInTheDocument();
+ expect(screen.queryByText(RULES_STATS(0))).not.toBeInTheDocument();
+ });
+
+ it('shows only fields count when rules are empty', () => {
+ render(React.createElement(FieldsetCard, makeProps({ fields: [makeField()], rules: [] })));
+
+ expect(screen.getByText(FIELDS_STATS(1))).toBeInTheDocument();
+ expect(screen.queryByText(RULES_STATS(0))).not.toBeInTheDocument();
+ });
+
+ it('shows only rules count when fields are empty', () => {
+ render(React.createElement(FieldsetCard, makeProps({ fields: [], rules: [makeFieldsetTemplateRule()] })));
+
+ expect(screen.getByText(RULES_STATS(1))).toBeInTheDocument();
+ expect(screen.queryByText(FIELDS_STATS(0))).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/public/components/Fieldsets/FieldsetCard/index.ts b/frontend/src/public/components/Fieldsets/FieldsetCard/index.ts
new file mode 100644
index 000000000..050e95ea0
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetCard/index.ts
@@ -0,0 +1 @@
+export * from './FieldsetCard';
diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.css b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.css
new file mode 100644
index 000000000..a6efe6e6f
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.css
@@ -0,0 +1,286 @@
+.container {
+ margin: 0 auto;
+ max-width: 108.8rem;
+}
+
+.header {
+ @mixin details-header;
+
+ .header__config {
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ }
+}
+
+.description {
+ padding: 0 0 2.4rem;
+ color: var(--pneumatic-color-black72);
+
+ @mixin text-base;
+}
+
+.section-title {
+ margin-bottom: 1.6rem;
+ font-size: 1.8rem;
+ font-weight: bold;
+
+ @mixin text-base;
+}
+
+.list {
+ margin: 0 -1.6rem;
+ padding: 3.2rem 1.6rem;
+ background-color: var(--pneumatic-color-white);
+
+ &:not(:last-child) {
+ margin-bottom: 2.4rem;
+ }
+
+ @media (--desktop) {
+ margin: initial;
+ padding: 3.2rem;
+ border-radius: 2.4rem;
+
+ &:not(:last-child) {
+ margin-bottom: 2.4rem;
+ }
+ }
+}
+
+.settings-form {
+ display: flex;
+ flex-direction: column;
+ gap: 2.4rem;
+}
+
+.settings-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+}
+
+.settings-label {
+ font-weight: 600;
+ color: var(--pneumatic-color-black72);
+
+ @mixin text-base;
+}
+
+.settings-textarea {
+ padding: 1.2rem 1.6rem;
+ width: 100%;
+ min-height: 8rem;
+ font-family: inherit;
+ resize: vertical;
+ border: 1px solid var(--pneumatic-color-black16);
+ border-radius: 0.8rem;
+ outline: none;
+ transition: border-color 0.2s;
+
+ @mixin text-base;
+
+ &:focus {
+ border-color: var(--pneumatic-color-blue);
+ }
+}
+
+.settings-select {
+ padding: 0.8rem 1.2rem;
+ width: fit-content;
+ min-width: 20rem;
+ cursor: pointer;
+ background-color: var(--pneumatic-color-white);
+ border: 1px solid var(--pneumatic-color-black16);
+ border-radius: 0.8rem;
+ outline: none;
+ transition: border-color 0.2s;
+ appearance: auto;
+
+ @mixin text-base;
+
+ &:focus {
+ border-color: var(--pneumatic-color-blue);
+ }
+}
+
+.settings-input {
+ padding: 0.8rem 1.2rem;
+ width: fit-content;
+ min-width: 20rem;
+ border: 1px solid var(--pneumatic-color-black16);
+ border-radius: 0.8rem;
+ outline: none;
+ transition: border-color 0.2s;
+
+ @mixin text-base;
+
+ &:focus {
+ border-color: var(--pneumatic-color-blue);
+ }
+}
+
+.empty-text {
+ color: var(--pneumatic-color-black48);
+
+ @mixin text-base;
+}
+
+.components {
+ display: flex;
+ flex-flow: row nowrap;
+ overflow-x: scroll;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.components::-webkit-scrollbar {
+ display: none;
+}
+
+.fields {
+ margin-top: 1.6rem;
+}
+
+.save-bar {
+ margin-top: 2.4rem;
+ display: flex;
+ align-items: center;
+ gap: 1.6rem;
+}
+
+.save-bar__hint {
+ color: var(--pneumatic-color-black48);
+
+ @mixin text-small;
+}
+
+.fields-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.fields-table th {
+ padding: 0.8rem 1.6rem;
+ font-weight: 600;
+ text-align: left;
+ color: var(--pneumatic-color-black48);
+ border-bottom: 2px solid var(--pneumatic-color-black16);
+
+ @mixin text-small;
+}
+
+.fields-table td {
+ padding: 1.2rem 1.6rem;
+ border-bottom: 1px solid var(--pneumatic-color-black16);
+
+ @mixin text-base;
+}
+
+.fields-table tbody tr:hover {
+ background-color: var(--pneumatic-color-black4);
+}
+
+.rule-row {
+ padding: 1.2rem 0;
+ display: flex;
+ border-bottom: 1px solid var(--pneumatic-color-black16);
+ align-items: center;
+ gap: 1.2rem;
+ flex-wrap: wrap;
+}
+
+.rule-row:last-child {
+ border-bottom: none;
+}
+
+.rule-fields-selector {
+ flex-basis: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+}
+
+.rule-fields-label {
+ font-weight: 600;
+ white-space: nowrap;
+ color: var(--pneumatic-color-black72);
+
+ @mixin text-small;
+}
+
+.rule-fields-select {
+ flex: 1;
+ min-width: 0;
+}
+
+.rule-type-label {
+ min-width: 10rem;
+ font-weight: 600;
+
+ @mixin text-base;
+}
+
+.rule-value-input {
+ flex: 1;
+ padding: 0.8rem 1.2rem;
+ border: 1px solid var(--pneumatic-color-black16);
+ border-radius: 0.8rem;
+ outline: none;
+ transition: border-color 0.2s;
+
+ @mixin text-base;
+
+ &:focus {
+ border-color: var(--pneumatic-color-blue);
+ }
+}
+
+.rule-delete-btn {
+ padding: 0.6rem 1.2rem;
+ cursor: pointer;
+ color: var(--pneumatic-color-black48);
+ background: none;
+ border: 1px solid var(--pneumatic-color-black16);
+ border-radius: 0.8rem;
+ transition: all 0.2s;
+
+ @mixin text-small;
+
+ &:hover {
+ color: var(--pneumatic-color-red);
+ border-color: var(--pneumatic-color-red);
+ }
+}
+
+.add-rule-btn {
+ margin-top: 1.6rem;
+ padding: 0.8rem 1.6rem;
+ font-weight: 600;
+ cursor: pointer;
+ color: var(--pneumatic-color-blue);
+ background: none;
+ border: 1px dashed var(--pneumatic-color-black16);
+ border-radius: 0.8rem;
+ transition: all 0.2s;
+
+ @mixin text-base;
+
+ &:hover {
+ background-color: var(--pneumatic-color-blue-light);
+ border-color: var(--pneumatic-color-blue);
+ }
+}
+
+.header-skeleton {
+ background-color: var(--pneumatic-color-skeleton-gray-bg);
+ background-image:
+ linear-gradient(
+ 90deg,
+ var(--pneumatic-color-skeleton-gray-bg),
+ var(--pneumatic-color-skeleton-gray-bg-highlight),
+ var(--pneumatic-color-skeleton-gray-bg)
+ );
+ background-repeat: no-repeat;
+ background-size: 200px 100%;
+}
diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.tsx b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.tsx
new file mode 100644
index 000000000..df58f5e23
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.tsx
@@ -0,0 +1,475 @@
+import * as React from 'react';
+import { useEffect, useState, useMemo, useCallback } from 'react';
+import { useIntl } from 'react-intl';
+import { useDispatch, useSelector } from 'react-redux';
+
+import {
+ openEditModal,
+ deleteFieldsetAction,
+ loadCurrentFieldset,
+ resetCurrentFieldset,
+ updateFieldsetAction,
+} from '../../../redux/fieldsets/slice';
+
+import { history } from '../../../utils/history';
+import { ERoutes } from '../../../constants/routes';
+
+import { ModifyDropdown, Button, FilterSelect } from '../../UI';
+import { EModifyDropdownToggle } from '../../UI/ModifyDropdown/types';
+import { FieldsetModal } from '../FieldsetModal/FieldsetModal';
+import { EFieldsetModalType } from '../FieldsetModal/types';
+import { FieldsetDetailsSkeleton } from './FieldsetDetailsSkeleton';
+
+import { getCurrentFieldset, isCurrentFieldsetLoading } from '../../../redux/selectors/fieldsets';
+import { getAccountId } from '../../../redux/selectors/user';
+
+import { EExtraFieldMode, EExtraFieldType, IExtraField } from '../../../types/template';
+import { EInputNameBackgroundColor, EMoveDirections } from '../../../types/workflow';
+import { IFieldsetTemplateRule, EFieldLabelPosition } from '../../../types/fieldset';
+import { ExtraFieldsMap } from '../../TemplateEdit/ExtraFields/utils/ExtraFieldsMap';
+import { ExtraFieldIcon } from '../../TemplateEdit/ExtraFields/utils/ExtraFieldIcon';
+import { ExtraFieldIntl } from '../../TemplateEdit/ExtraFields';
+import { getEmptyField } from '../../TemplateEdit/KickoffRedux/utils/getEmptyField';
+import { getEditedFields } from '../../TemplateEdit/ExtraFields/utils/getEditedFields';
+import { getNormalizeFieldsOrders, moveWorkflowField } from '../../../utils/workflows';
+import { useDatasetOptions } from '../../TemplateEdit/ExtraFields/utils/useDatasetOptions';
+
+import { normalizeFieldsForUI } from './fieldsetFieldMappers';
+
+import { useCheckDevice } from '../../../hooks/useCheckDevice';
+
+import { TFieldsetDetailsProps } from './types';
+import styles from './FieldsetDetails.css';
+
+const RULE_TYPES = [
+ { value: 'sum_equal', labelKey: 'fieldsets.rule-type-sum_equal' },
+] as const;
+
+const LABEL_POSITION_OPTIONS: { value: EFieldLabelPosition; labelKey: string }[] = [
+ { value: EFieldLabelPosition.Top, labelKey: 'fieldsets.settings.label-position.top' },
+ { value: EFieldLabelPosition.Left, labelKey: 'fieldsets.settings.label-position.left' },
+];
+
+const FieldsetDetails = ({ match: { params: { id: matchParamId } } }: TFieldsetDetailsProps) => {
+ const { formatMessage } = useIntl();
+ const dispatch = useDispatch();
+ const fieldset = useSelector(getCurrentFieldset);
+ const isLoading = useSelector(isCurrentFieldsetLoading);
+ const accountId = useSelector(getAccountId);
+ const { isDesktop } = useCheckDevice();
+
+ const [localFields, setLocalFields] = useState([]);
+ const datasetOptions = useDatasetOptions(localFields);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+
+ const [localRules, setLocalRules] = useState([]);
+ const [hasUnsavedRuleChanges, setHasUnsavedRuleChanges] = useState(false);
+
+ // Settings local state
+ const [localDescription, setLocalDescription] = useState('');
+ const [localLabelPosition, setLocalLabelPosition] = useState(EFieldLabelPosition.Top);
+
+ const [hasUnsavedSettingsChanges, setHasUnsavedSettingsChanges] = useState(false);
+
+ const fieldsetListRoute = ERoutes.Fieldsets;
+
+
+
+
+ useEffect(() => {
+ const id = Number(matchParamId);
+
+ if (Number.isNaN(id)) {
+ history.push(fieldsetListRoute);
+ return;
+ }
+
+ if (fieldset?.id === id) return;
+ dispatch(loadCurrentFieldset({ id }));
+ }, [matchParamId]);
+
+ useEffect(() => {
+ return () => {
+ dispatch(resetCurrentFieldset());
+ };
+ }, []);
+
+ // Sync local fields when fieldset loads/updates from server
+ useEffect(() => {
+ if (fieldset?.fields) {
+ setLocalFields(normalizeFieldsForUI(fieldset.fields as unknown as IExtraField[]));
+ setHasUnsavedChanges(false);
+ }
+ }, [fieldset?.fields]);
+
+ // Sync local rules when fieldset loads/updates from server
+ useEffect(() => {
+ if (fieldset?.rules) {
+ setLocalRules(fieldset.rules);
+ setHasUnsavedRuleChanges(false);
+ }
+ }, [fieldset?.rules]);
+
+ // Sync settings when fieldset loads/updates from server
+ useEffect(() => {
+ if (fieldset) {
+ setLocalDescription(fieldset.description || '');
+ setLocalLabelPosition(fieldset.labelPosition);
+ setHasUnsavedSettingsChanges(false);
+ }
+ }, [
+ fieldset?.id,
+ fieldset?.description,
+ fieldset?.labelPosition,
+ ]);
+
+ const handleSettingsDescriptionChange = (e: React.ChangeEvent) => {
+ setLocalDescription(e.target.value);
+ setHasUnsavedSettingsChanges(true);
+ };
+
+ const handleSettingsLabelPositionChange = (e: React.ChangeEvent) => {
+ setLocalLabelPosition(e.target.value as EFieldLabelPosition);
+ setHasUnsavedSettingsChanges(true);
+ };
+
+ const handleSaveSettings = () => {
+ if (!fieldset) return;
+ dispatch(updateFieldsetAction({
+ id: fieldset.id,
+ description: localDescription,
+ labelPosition: localLabelPosition,
+ }));
+ };
+
+ const getSortedFields = useCallback(() => {
+ return [...localFields].sort((a, b) => b.order - a.order);
+ }, [localFields]);
+
+ const sortedFields = useMemo(() => getSortedFields(), [getSortedFields]);
+
+ const handleCreateField = (type: EExtraFieldType) => {
+ const newFields = getNormalizeFieldsOrders([...localFields, getEmptyField(type, formatMessage)]);
+ setLocalFields(newFields);
+ setHasUnsavedChanges(true);
+ };
+
+ const handleEditField = (apiName: string) => (changedProps: Partial) => {
+ const newFields = getEditedFields(getSortedFields(), apiName, changedProps);
+ setLocalFields(newFields);
+ setHasUnsavedChanges(true);
+ };
+
+ const handleDeleteField = (idx: number) => {
+ const newFields = getSortedFields().filter((_, index) => index !== idx);
+ setLocalFields(getNormalizeFieldsOrders(newFields));
+ setHasUnsavedChanges(true);
+ };
+
+ const handleMoveField = (from: number, direction: EMoveDirections) => {
+ const to = direction === EMoveDirections.Up ? from - 1 : from + 1;
+ const newFields = moveWorkflowField(from, to, getSortedFields());
+ setLocalFields(newFields);
+ setHasUnsavedChanges(true);
+ };
+
+ const handleSaveFields = () => {
+ if (!fieldset) return;
+ const fieldsPayload = localFields.map(({ id: _id, ...rest }) => rest);
+ dispatch(updateFieldsetAction({
+ id: fieldset.id,
+ fields: fieldsPayload as any,
+ }));
+ };
+
+ // Rules handlers
+ const handleAddRule = () => {
+ const newRule: IFieldsetTemplateRule = {
+ apiName: `temporary-${Date.now()}`,
+ type: RULE_TYPES[0].value,
+ value: '',
+ fields: [],
+ };
+ setLocalRules([...localRules, newRule]);
+ setHasUnsavedRuleChanges(true);
+ };
+
+ const handleEditRuleValue = (index: number, value: string) => {
+ const updated = localRules.map((rule, i) =>
+ i === index ? { ...rule, value } : rule,
+ );
+ setLocalRules(updated);
+ setHasUnsavedRuleChanges(true);
+ };
+
+ const handleEditRuleType = (index: number, type: string) => {
+ const updated = localRules.map((rule, i) =>
+ i === index ? { ...rule, type } : rule,
+ );
+ setLocalRules(updated);
+ setHasUnsavedRuleChanges(true);
+ };
+
+ const handleEditRuleFields = (index: number, fieldApiNames: (string | number | null)[]) => {
+ const updated = localRules.map((rule, i) =>
+ i === index ? { ...rule, fields: fieldApiNames.filter((n): n is string => typeof n === 'string') } : rule,
+ );
+ setLocalRules(updated);
+ setHasUnsavedRuleChanges(true);
+ };
+
+ const handleDeleteRule = (index: number) => {
+ setLocalRules(localRules.filter((_, i) => i !== index));
+ setHasUnsavedRuleChanges(true);
+ };
+
+ const handleSaveRules = () => {
+ if (!fieldset) return;
+ // Strip temporary api_names for new rules so the backend creates them
+ const rulesPayload = localRules.map(({ apiName, ...rule }) => ({
+ ...rule,
+ ...(apiName.startsWith('temporary-') ? {} : { apiName }),
+ }));
+ dispatch(updateFieldsetAction({
+ id: fieldset.id,
+ rules: rulesPayload as IFieldsetTemplateRule[],
+ }));
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!fieldset) {
+ return null;
+ }
+
+ return (
+
+
+ {fieldset.name}
+
+ dispatch(openEditModal())}
+ onDelete={() => {
+ dispatch(deleteFieldsetAction({ id: fieldset.id }));
+ history.push(fieldsetListRoute);
+ }}
+ editLabel={formatMessage({ id: 'fieldsets.edit' })}
+ deleteLabel={formatMessage({ id: 'fieldsets.delete' })}
+ toggleType={EModifyDropdownToggle.Modify}
+ />
+
+
+
+
+
+ {formatMessage({ id: 'fieldsets.settings-section' })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {hasUnsavedSettingsChanges && (
+
+
+
+ {formatMessage({ id: 'fieldsets.unsaved-settings-changes' })}
+
+
+ )}
+
+
+
+
+ {formatMessage({ id: 'fieldsets.fields-section' })}
+
+
+
+ {ExtraFieldsMap.map((x) => (
+ handleCreateField(x.id)} />
+ ))}
+
+
+ {sortedFields.length > 0 && (
+
+ {sortedFields.map((field, index) => (
+ handleDeleteField(index)}
+ moveFieldUp={() => handleMoveField(index, EMoveDirections.Up)}
+ moveFieldDown={() => handleMoveField(index, EMoveDirections.Down)}
+ editField={handleEditField(field.apiName)}
+ accountId={accountId}
+ mode={EExtraFieldMode.Kickoff}
+ showDropdown
+ datasetOptions={datasetOptions}
+ labelPosition={isDesktop ? localLabelPosition : EFieldLabelPosition.Top}
+ />
+ ))}
+
+ )}
+
+ {sortedFields.length === 0 && (
+
+ {formatMessage({ id: 'fieldsets.no-fields' })}
+
+ )}
+
+ {hasUnsavedChanges && (
+
+
+
+ {formatMessage({ id: 'fieldsets.unsaved-changes' })}
+
+
+ )}
+
+
+
+
+ {formatMessage({ id: 'fieldsets.rules-section' })}
+
+
+ {localRules.length === 0 && !hasUnsavedRuleChanges && (
+
+ {formatMessage({ id: 'fieldsets.no-rules' })}
+
+ )}
+
+ {localRules.map((rule, index) => (
+
+
+
+
handleEditRuleValue(index, e.target.value)}
+ />
+
+
+
+
+
+ {formatMessage({ id: 'fieldsets.rule-fields' })}
+
+
+
+ isMultiple
+ optionIdKey="apiName"
+ optionLabelKey="name"
+ options={localFields.map((f) => ({ apiName: f.apiName, name: f.name }))}
+ selectedOptions={rule.fields || []}
+ placeholderText={formatMessage({ id: 'fieldsets.rule-fields-placeholder' })}
+ selectAllLabel={formatMessage({ id: 'fieldsets.rule-fields-all' })}
+ onChange={(fieldApiNames) => handleEditRuleFields(index, fieldApiNames)}
+ resetFilter={() => handleEditRuleFields(index, [])}
+ renderPlaceholder={(opts) => {
+ const selected = (rule.fields || []).length;
+ if (selected === 0) return formatMessage({ id: 'fieldsets.rule-fields-placeholder' });
+ const selectedNames = opts
+ .filter((o) => (rule.fields || []).includes(o.apiName))
+ .map((o) => o.name);
+ return selectedNames.join(', ');
+ }}
+ />
+
+
+
+ ))}
+
+
+
+ {hasUnsavedRuleChanges && (
+
+
+
+ {formatMessage({ id: 'fieldsets.unsaved-rule-changes' })}
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default FieldsetDetails;
+
diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetailsSkeleton.tsx b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetailsSkeleton.tsx
new file mode 100644
index 000000000..573961dad
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetailsSkeleton.tsx
@@ -0,0 +1,17 @@
+import * as React from 'react';
+
+import styles from './FieldsetDetails.css';
+
+export function FieldsetDetailsSkeleton() {
+ return (
+
+ );
+}
diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/__tests__/FieldsetDetails.test.tsx b/frontend/src/public/components/Fieldsets/FieldsetDetails/__tests__/FieldsetDetails.test.tsx
new file mode 100644
index 000000000..5838a7faf
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/__tests__/FieldsetDetails.test.tsx
@@ -0,0 +1,604 @@
+import * as React from 'react';
+import { render, screen, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useDispatch, useSelector } from 'react-redux';
+
+import FieldsetDetails from '../FieldsetDetails';
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { history } from '../../../../utils/history';
+import { ERoutes } from '../../../../constants/routes';
+import { TFieldsetDetailsProps } from '../types';
+import {
+ openEditModal,
+ deleteFieldsetAction,
+ loadCurrentFieldset,
+ resetCurrentFieldset,
+ updateFieldsetAction,
+} from '../../../../redux/fieldsets/slice';
+import { ModifyDropdown, FilterSelect } from '../../../UI';
+import { ExtraFieldIntl } from '../../../TemplateEdit/ExtraFields';
+import { FieldsetModal } from '../../FieldsetModal/FieldsetModal';
+import { getEmptyField } from '../../../TemplateEdit/KickoffRedux/utils/getEmptyField';
+import { getEditedFields } from '../../../TemplateEdit/ExtraFields/utils/getEditedFields';
+import { moveWorkflowField } from '../../../../utils/workflows';
+import { EExtraFieldType } from '../../../../types/template';
+import { makeFieldsetCatalogItem, makeFieldsetTemplateRule } from '../../../../__stubs__/fieldsets.factory';
+
+jest.mock('../../../../utils/history', () => ({
+ history: { push: jest.fn(), location: { pathname: '/' }, listen: jest.fn() },
+}));
+
+jest.mock('../../../../redux/fieldsets/slice', () => ({
+ openEditModal: jest.fn(() => ({ type: 'fieldsets/openEditModal' })),
+ deleteFieldsetAction: jest.fn((p) => ({ type: 'fieldsets/deleteFieldsetAction', payload: p })),
+ loadCurrentFieldset: jest.fn((p) => ({ type: 'fieldsets/loadCurrentFieldset', payload: p })),
+ resetCurrentFieldset: jest.fn(() => ({ type: 'fieldsets/resetCurrentFieldset' })),
+ updateFieldsetAction: jest.fn((p) => ({ type: 'fieldsets/updateFieldsetAction', payload: p })),
+}));
+
+jest.mock('../../../UI', () => ({
+ ModifyDropdown: jest.fn(() => null),
+ Button: jest.fn((props: { label: string; onClick?: () => void; disabled?: boolean }) =>
+ React.createElement('button', {
+ onClick: props.onClick,
+ disabled: props.disabled,
+ }, props.label),
+ ),
+ FilterSelect: jest.fn((props: {
+ options: { apiName: string; name: string }[];
+ selectedOptions: (string | number | null)[];
+ onChange: (vals: (string | number | null)[]) => void;
+ renderPlaceholder?: (opts: { apiName: string; name: string }[]) => React.ReactNode;
+ }) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': 'filter-select' },
+ React.createElement(
+ 'span',
+ { 'data-testid': 'filter-placeholder' },
+ props.renderPlaceholder ? props.renderPlaceholder(props.options) : null,
+ ),
+ ...props.options.map((opt) =>
+ React.createElement(
+ 'button',
+ {
+ key: opt.apiName,
+ type: 'button',
+ 'data-testid': `filter-option-${opt.apiName}`,
+ onClick: () => {
+ const isSelected = props.selectedOptions.includes(opt.apiName);
+ const next = isSelected
+ ? props.selectedOptions.filter((v) => v !== opt.apiName)
+ : [...props.selectedOptions, opt.apiName];
+ props.onChange(next);
+ },
+ },
+ opt.name,
+ ),
+ ),
+ ),
+ ),
+}));
+
+jest.mock('../../../UI/ModifyDropdown/types', () => ({
+ EModifyDropdownToggle: { Modify: 'modify' },
+}));
+
+jest.mock('../../FieldsetModal/FieldsetModal', () => ({
+ FieldsetModal: jest.fn(() => null),
+}));
+
+jest.mock('../../FieldsetModal/types', () => ({
+ EFieldsetModalType: { Edit: 'edit' },
+}));
+
+jest.mock('../FieldsetDetailsSkeleton', () => ({
+ FieldsetDetailsSkeleton: jest.fn(() =>
+ React.createElement('div', { role: 'status', 'aria-label': 'Loading' }),
+ ),
+}));
+
+jest.mock('../../../TemplateEdit/ExtraFields', () => ({
+ ExtraFieldIntl: jest.fn((props: { field: { apiName: string } }) =>
+ React.createElement('div', { 'data-testid': `extra-field-${props.field.apiName}` }),
+ ),
+}));
+
+jest.mock('../../../TemplateEdit/ExtraFields/utils/ExtraFieldsMap', () => ({
+ ExtraFieldsMap: [{ id: 'string', title: 'Text' }],
+}));
+
+jest.mock('../../../TemplateEdit/ExtraFields/utils/ExtraFieldIcon', () => ({
+ ExtraFieldIcon: jest.fn((props: { id: string; onClick: () => void }) =>
+ React.createElement('button', {
+ 'data-testid': `field-icon-${props.id}`,
+ onClick: props.onClick,
+ }),
+ ),
+}));
+
+jest.mock('../../../TemplateEdit/KickoffRedux/utils/getEmptyField', () => ({
+ getEmptyField: jest.fn((type: string) => ({
+ apiName: `new-${type}`,
+ name: 'New Field',
+ type,
+ order: 0,
+ userId: null,
+ groupId: null,
+ })),
+}));
+
+jest.mock('../../../TemplateEdit/ExtraFields/utils/getEditedFields', () => ({
+ getEditedFields: jest.fn((fields: unknown[]) => fields),
+}));
+
+jest.mock('../../../../utils/workflows', () => ({
+ getNormalizeFieldsOrders: jest.fn((f: unknown[]) => f),
+ moveWorkflowField: jest.fn((_from: number, _to: number, fields: unknown[]) => fields),
+}));
+
+jest.mock('../../../TemplateEdit/ExtraFields/utils/useDatasetOptions', () => ({
+ useDatasetOptions: jest.fn(() => []),
+}));
+
+jest.mock('../fieldsetFieldMappers', () => ({
+ normalizeFieldsForUI: jest.fn((f: unknown[]) => f),
+}));
+
+describe('FieldsetDetails', () => {
+ const mockDispatch = jest.fn();
+ const formatMsg = (id: string) => intlMock.formatMessage({ id });
+
+ const SAVE_SETTINGS_LABEL = formatMsg('fieldsets.save-settings');
+ const SAVE_FIELDS_LABEL = formatMsg('fieldsets.save-fields');
+ const SAVE_RULES_LABEL = formatMsg('fieldsets.save-rules');
+ const UNSAVED_SETTINGS_HINT = formatMsg('fieldsets.unsaved-settings-changes');
+ const UNSAVED_FIELDS_HINT = formatMsg('fieldsets.unsaved-changes');
+ const UNSAVED_RULES_HINT = formatMsg('fieldsets.unsaved-rule-changes');
+ const NO_FIELDS_TEXT = formatMsg('fieldsets.no-fields');
+ const NO_RULES_TEXT = formatMsg('fieldsets.no-rules');
+ const ADD_RULE_TEXT = formatMsg('fieldsets.add-rule');
+ const RULE_DELETE_TEXT = formatMsg('fieldsets.rule-delete');
+
+ const makeProps = (id: string = '10'): TFieldsetDetailsProps => ({
+ match: { params: { id }, isExact: true, path: '', url: '' },
+ location: {
+ pathname: `/fieldsets/${id}/`,
+ search: '',
+ hash: '',
+ state: undefined,
+ },
+ history: {
+ ...history,
+ length: 1,
+ action: 'PUSH' as const,
+ go: jest.fn(),
+ goBack: jest.fn(),
+ goForward: jest.fn(),
+ replace: jest.fn(),
+ block: jest.fn(),
+ createHref: jest.fn(),
+ },
+ });
+
+ const makeField = (overrides = {}) => ({
+ apiName: `f-${Math.random().toString(36).slice(2, 6)}`,
+ name: 'Field',
+ type: EExtraFieldType.String,
+ order: 0,
+ userId: null,
+ groupId: null,
+ ...overrides,
+ });
+
+ const loadingState = {
+ fieldsets: { currentFieldset: null, isCurrentFieldsetLoading: true },
+ authUser: { account: { id: 1 } },
+ };
+
+ const nullFieldsetState = {
+ fieldsets: { currentFieldset: null, isCurrentFieldsetLoading: false },
+ authUser: { account: { id: 1 } },
+ };
+
+ const makeLoadedState = (fieldsetOverrides = {}) => {
+ const fieldset = makeFieldsetCatalogItem({
+ id: 10,
+ layout: 'horizontal' as const,
+ ...fieldsetOverrides,
+ });
+ return {
+ fieldsets: { currentFieldset: fieldset, isCurrentFieldsetLoading: false },
+ authUser: { account: { id: 1 } },
+ };
+ };
+
+ const getModifyDropdownProps = () => (ModifyDropdown as jest.Mock).mock.calls[0][0];
+ const getFieldsetModalProps = () => (FieldsetModal as jest.Mock).mock.calls[0][0];
+ const getExtraFieldIntlMock = () => ExtraFieldIntl as unknown as jest.Mock;
+ const getUpdateActionMock = () => updateFieldsetAction as unknown as jest.Mock;
+ const getFilterSelectMock = () => FilterSelect as unknown as jest.Mock;
+
+ const renderWithState = (state: object, props = makeProps()) => {
+ (useSelector as jest.Mock).mockImplementation((selector: (s: object) => unknown) => selector(state));
+ return render(React.createElement(FieldsetDetails, props));
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useDispatch as jest.Mock).mockReturnValue(mockDispatch);
+ (useSelector as jest.Mock).mockImplementation((selector: (s: object) => unknown) => selector(loadingState));
+ });
+
+ describe('URL parameter validation', () => {
+ it('redirects to fieldsets list on invalid id', () => {
+ render(React.createElement(FieldsetDetails, makeProps('xyz')));
+ const expectedRoute = ERoutes.Fieldsets;
+ expect(history.push).toHaveBeenCalledTimes(1);
+ expect(history.push).toHaveBeenCalledWith(expectedRoute);
+ });
+
+ it('dispatches loadCurrentFieldset on valid params', () => {
+ render(React.createElement(FieldsetDetails, makeProps('10')));
+ expect(history.push).not.toHaveBeenCalled();
+ expect(mockDispatch).toHaveBeenCalledWith(loadCurrentFieldset({ id: 10 }));
+ });
+ });
+
+ it('dispatches resetCurrentFieldset on unmount', () => {
+ const { unmount } = render(React.createElement(FieldsetDetails, makeProps()));
+ mockDispatch.mockClear();
+ unmount();
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(resetCurrentFieldset());
+ });
+
+ describe('Loading state', () => {
+ it('renders Skeleton when isLoading=true', () => {
+ renderWithState(loadingState);
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument();
+ });
+
+ it('renders nothing when fieldset=null and isLoading=false', () => {
+ const { container } = renderWithState(nullFieldsetState);
+ expect(container).toBeEmptyDOMElement();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Header and initialization', () => {
+ it('displays fieldset name in heading', () => {
+ renderWithState(makeLoadedState({ name: 'My Fieldset' }));
+ const heading = screen.getByRole('heading', { level: 1 });
+ expect(heading).toHaveTextContent(/^My Fieldset$/);
+ });
+
+ it('onEdit in ModifyDropdown dispatches openEditModal', () => {
+ renderWithState(makeLoadedState());
+ const props = getModifyDropdownProps();
+ act(() => { props.onEdit(); });
+ expect(openEditModal).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(openEditModal());
+ });
+
+ it('onDelete in ModifyDropdown dispatches deleteFieldsetAction and redirects', () => {
+ renderWithState(makeLoadedState({ id: 10 }), makeProps('10'));
+ const props = getModifyDropdownProps();
+ act(() => { props.onDelete(); });
+ expect(deleteFieldsetAction).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(deleteFieldsetAction({ id: 10 }));
+ const expectedRoute = ERoutes.Fieldsets;
+ expect(history.push).toHaveBeenCalledTimes(1);
+ expect(history.push).toHaveBeenCalledWith(expectedRoute);
+ });
+
+ it('skips loadCurrentFieldset when fieldset is already loaded with same id', () => {
+ renderWithState(makeLoadedState({ id: 10 }), makeProps('10'));
+ expect(loadCurrentFieldset).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Initial save bar state', () => {
+ it('no save bars are visible on initial render', () => {
+ const fields = [makeField({ apiName: 'f1', order: 1 })];
+ const rules = [makeFieldsetTemplateRule({ apiName: 'rule-1', value: '100', fields: ['f1'] })];
+ renderWithState(makeLoadedState({ fields, rules }));
+
+ expect(screen.queryByText(UNSAVED_SETTINGS_HINT)).not.toBeInTheDocument();
+ expect(screen.queryByText(UNSAVED_FIELDS_HINT)).not.toBeInTheDocument();
+ expect(screen.queryByText(UNSAVED_RULES_HINT)).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Settings section', () => {
+ it('syncs description and labelPosition from fieldset', () => {
+ renderWithState(makeLoadedState({ description: 'Test desc', labelPosition: 'left' }));
+ const textarea = screen.getByLabelText(formatMsg('fieldsets.settings.description'));
+ const select = screen.getByLabelText(formatMsg('fieldsets.settings.label-position'));
+ expect(textarea).toHaveValue('Test desc');
+ expect(select).toHaveValue('left');
+ });
+
+ it('shows save bar after changing description', () => {
+ renderWithState(makeLoadedState());
+ const textarea = screen.getByLabelText(formatMsg('fieldsets.settings.description'));
+ userEvent.type(textarea, 'new text');
+ expect(screen.getByText(UNSAVED_SETTINGS_HINT)).toBeInTheDocument();
+ });
+
+ it('shows save bar after changing label position', () => {
+ renderWithState(makeLoadedState());
+ const select = screen.getByLabelText(formatMsg('fieldsets.settings.label-position'));
+ userEvent.selectOptions(select, 'left');
+ expect(screen.getByText(UNSAVED_SETTINGS_HINT)).toBeInTheDocument();
+ });
+
+ it('Save Settings dispatches updateFieldsetAction and hides save bar', () => {
+ renderWithState(makeLoadedState({ id: 10 }));
+ const textarea = screen.getByLabelText(formatMsg('fieldsets.settings.description'));
+ userEvent.type(textarea, 'updated');
+
+ userEvent.click(screen.getByRole('button', { name: SAVE_SETTINGS_LABEL }));
+
+ expect(getUpdateActionMock()).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(
+ updateFieldsetAction(expect.objectContaining({
+ id: 10,
+ description: 'updated',
+ labelPosition: 'top',
+ })),
+ );
+ });
+ });
+
+ describe('Fields section — display', () => {
+ it('shows "No fields yet" when fields are empty', () => {
+ renderWithState(makeLoadedState({ fields: [] }));
+ expect(screen.getByText(NO_FIELDS_TEXT)).toBeInTheDocument();
+ });
+
+ it('renders ExtraFieldIntl for each field (order desc)', () => {
+ const fields = [
+ makeField({ apiName: 'f1', order: 1 }),
+ makeField({ apiName: 'f2', order: 2 }),
+ ];
+ renderWithState(makeLoadedState({ fields }));
+
+ expect(screen.getByTestId('extra-field-f1')).toBeInTheDocument();
+ expect(screen.getByTestId('extra-field-f2')).toBeInTheDocument();
+
+ const mock = getExtraFieldIntlMock();
+ const fieldCalls = mock.mock.calls.filter(
+ (c: unknown[]) => (c[0] as { field: { apiName: string } }).field.apiName.startsWith('f'),
+ );
+ expect(fieldCalls[0][0].field.apiName).toBe('f2');
+ expect(fieldCalls[1][0].field.apiName).toBe('f1');
+ });
+ });
+
+ describe('Fields section — CRUD', () => {
+ it('handleCreateField adds a field and shows save bar', () => {
+ renderWithState(makeLoadedState({ fields: [] }));
+
+ userEvent.click(screen.getByTestId('field-icon-string'));
+
+ expect(getEmptyField).toHaveBeenCalledTimes(1);
+ expect(screen.getByText(UNSAVED_FIELDS_HINT)).toBeInTheDocument();
+ expect(screen.getByTestId('extra-field-new-string')).toBeInTheDocument();
+ });
+
+ it('handleEditField calls getEditedFields with correct arguments', () => {
+ const fields = [makeField({ apiName: 'f1', order: 1, name: 'Old' })];
+ renderWithState(makeLoadedState({ fields }));
+
+ const mock = getExtraFieldIntlMock();
+ const editField = mock.mock.calls[mock.mock.calls.length - 1][0].editField;
+ act(() => { editField({ name: 'New' }); });
+
+ expect(getEditedFields).toHaveBeenCalledTimes(1);
+ expect(getEditedFields).toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ apiName: 'f1' })]),
+ 'f1',
+ { name: 'New' },
+ );
+ });
+
+ it('handleDeleteField removes a field', () => {
+ const fields = [
+ makeField({ apiName: 'f1', order: 2 }),
+ makeField({ apiName: 'f2', order: 1 }),
+ ];
+ renderWithState(makeLoadedState({ fields }));
+
+ expect(screen.getByTestId('extra-field-f1')).toBeInTheDocument();
+ expect(screen.getByTestId('extra-field-f2')).toBeInTheDocument();
+
+ const mock = getExtraFieldIntlMock();
+ const lastRenderCalls = mock.mock.calls.slice(-2);
+ const deleteField = lastRenderCalls[0][0].deleteField;
+ act(() => { deleteField(); });
+
+ expect(screen.queryByTestId('extra-field-f1')).not.toBeInTheDocument();
+ expect(screen.getByTestId('extra-field-f2')).toBeInTheDocument();
+ });
+
+ it('handleMoveField calls moveWorkflowField with correct arguments', () => {
+ const fields = [
+ makeField({ apiName: 'f1', order: 2 }),
+ makeField({ apiName: 'f2', order: 1 }),
+ ];
+ renderWithState(makeLoadedState({ fields }));
+
+ const mock = getExtraFieldIntlMock();
+ const lastRenderCalls = mock.mock.calls.slice(-2);
+
+ act(() => { lastRenderCalls[0][0].moveFieldDown(); });
+
+ expect(moveWorkflowField).toHaveBeenCalledTimes(1);
+ expect(moveWorkflowField).toHaveBeenCalledWith(
+ 0, 1, expect.arrayContaining([expect.objectContaining({ apiName: 'f1' })]),
+ );
+ });
+
+ it('Save Fields dispatches updateFieldsetAction without id in fields', () => {
+ renderWithState(makeLoadedState({ id: 10, fields: [] }));
+
+ userEvent.click(screen.getByTestId('field-icon-string'));
+
+ userEvent.click(screen.getByRole('button', { name: SAVE_FIELDS_LABEL }));
+
+ expect(getUpdateActionMock()).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(
+ updateFieldsetAction(expect.objectContaining({ id: 10 })),
+ );
+
+ const fieldsPayload = getUpdateActionMock().mock.calls[0][0].fields;
+ expect(fieldsPayload).toBeDefined();
+ fieldsPayload.forEach((field: Record) => {
+ expect(field).not.toHaveProperty('id');
+ });
+ });
+ });
+
+ describe('Rules section — empty state', () => {
+ it('shows "No rules yet" when rules are empty', () => {
+ renderWithState(makeLoadedState({ rules: [] }));
+ expect(screen.getByText(NO_RULES_TEXT)).toBeInTheDocument();
+ });
+
+ it('Add Rule button is always visible', () => {
+ renderWithState(makeLoadedState());
+ expect(screen.getByRole('button', { name: new RegExp(ADD_RULE_TEXT, 'i') })).toBeInTheDocument();
+ });
+ });
+
+ describe('Rules section — CRUD', () => {
+ it('handleAddRule adds a rule and shows save bar', () => {
+ renderWithState(makeLoadedState());
+
+ userEvent.click(screen.getByRole('button', { name: new RegExp(ADD_RULE_TEXT, 'i') }));
+
+ expect(screen.getByText(UNSAVED_RULES_HINT)).toBeInTheDocument();
+ const ruleInput = screen.getByPlaceholderText(formatMsg('fieldsets.rule-value-placeholder'));
+ expect(ruleInput).toBeInTheDocument();
+ });
+
+ it('handleEditRuleValue updates rule value', () => {
+ renderWithState(makeLoadedState());
+
+ userEvent.click(screen.getByRole('button', { name: new RegExp(ADD_RULE_TEXT, 'i') }));
+
+ const ruleInput = screen.getByPlaceholderText(formatMsg('fieldsets.rule-value-placeholder'));
+ userEvent.type(ruleInput, '100');
+ expect(ruleInput).toHaveValue('100');
+ });
+
+ it('handleDeleteRule removes a rule', () => {
+ renderWithState(makeLoadedState());
+
+ userEvent.click(screen.getByRole('button', { name: new RegExp(ADD_RULE_TEXT, 'i') }));
+ expect(screen.getByPlaceholderText(formatMsg('fieldsets.rule-value-placeholder'))).toBeInTheDocument();
+
+ userEvent.click(screen.getByText(RULE_DELETE_TEXT));
+ expect(screen.queryByPlaceholderText(formatMsg('fieldsets.rule-value-placeholder'))).not.toBeInTheDocument();
+ });
+
+ it('Save Rules end-to-end: add, edit fields, save, verify payload', () => {
+ const fields = [
+ makeField({ apiName: 'field-1', order: 1 }),
+ makeField({ apiName: 'field-2', order: 2 }),
+ ];
+ renderWithState(makeLoadedState({ id: 10, fields }));
+
+ userEvent.click(screen.getByRole('button', { name: new RegExp(ADD_RULE_TEXT, 'i') }));
+
+ const filterMock = getFilterSelectMock();
+ const lastFilterCall = filterMock.mock.calls[filterMock.mock.calls.length - 1];
+ const onChange = lastFilterCall[0].onChange;
+
+ act(() => { onChange(['field-1', null, 42, 'field-2']); });
+
+ userEvent.click(screen.getByRole('button', { name: SAVE_RULES_LABEL }));
+
+ expect(getUpdateActionMock()).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(
+ updateFieldsetAction(expect.objectContaining({
+ id: 10,
+ rules: expect.arrayContaining([
+ expect.objectContaining({
+ type: 'sum_equal',
+ fields: ['field-1', 'field-2'],
+ }),
+ ]),
+ })),
+ );
+
+ const rulesPayload = getUpdateActionMock().mock.calls[0][0].rules;
+ expect(rulesPayload[0].apiName).toBeUndefined();
+ });
+ });
+
+ describe('FieldsetModal', () => {
+ it('renders FieldsetModal with type=Edit', () => {
+ renderWithState(makeLoadedState());
+ const props = getFieldsetModalProps();
+ expect(props.type).toBe('edit');
+ });
+ });
+
+ describe('External rules sync', () => {
+ it('re-syncs local rules from store and hides save bar on external fieldset update', () => {
+ const initialState = makeLoadedState({
+ id: 10,
+ rules: [makeFieldsetTemplateRule({ apiName: 'rule-1', value: 'old' })],
+ });
+ (useSelector as jest.Mock).mockImplementation((selector: (s: object) => unknown) =>
+ selector(initialState),
+ );
+ const { rerender } = render(React.createElement(FieldsetDetails, makeProps()));
+
+ const ruleInput = screen.getByPlaceholderText(formatMsg('fieldsets.rule-value-placeholder'));
+ userEvent.type(ruleInput, '!');
+ expect(ruleInput).toHaveValue('old!');
+ expect(screen.getByText(UNSAVED_RULES_HINT)).toBeInTheDocument();
+
+ const updatedState = makeLoadedState({
+ id: 10,
+ rules: [makeFieldsetTemplateRule({ apiName: 'rule-2', value: 'fresh' })],
+ });
+ (useSelector as jest.Mock).mockImplementation((selector: (s: object) => unknown) =>
+ selector(updatedState),
+ );
+ rerender(React.createElement(FieldsetDetails, makeProps()));
+
+ expect(screen.queryByDisplayValue('old!')).not.toBeInTheDocument();
+ expect(screen.getByDisplayValue('fresh')).toBeInTheDocument();
+ expect(screen.queryByText(UNSAVED_RULES_HINT)).not.toBeInTheDocument();
+ expect(mockDispatch).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'fieldsets/updateFieldsetAction' }),
+ );
+ });
+ });
+
+ describe('Selected fields placeholder in FilterSelect', () => {
+ it('shows selected field names joined by comma after picking fields in UI', () => {
+ const fields = [
+ makeField({ apiName: 'field-1', name: 'Total', order: 1 }),
+ makeField({ apiName: 'field-2', name: 'Tax', order: 2 }),
+ makeField({ apiName: 'field-3', name: 'Discount', order: 3 }),
+ ];
+ renderWithState(makeLoadedState({ id: 10, fields }));
+
+ userEvent.click(screen.getByRole('button', { name: new RegExp(ADD_RULE_TEXT, 'i') }));
+
+ expect(screen.getByTestId('filter-placeholder'))
+ .toHaveTextContent(formatMsg('fieldsets.rule-fields-placeholder'));
+
+ userEvent.click(screen.getByTestId('filter-option-field-1'));
+ userEvent.click(screen.getByTestId('filter-option-field-3'));
+
+ expect(screen.getByTestId('filter-placeholder')).toHaveTextContent(/^Total, Discount$/);
+ });
+ });
+});
diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/__tests__/fieldsetFieldMappers.test.ts b/frontend/src/public/components/Fieldsets/FieldsetDetails/__tests__/fieldsetFieldMappers.test.ts
new file mode 100644
index 000000000..f8f64e274
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/__tests__/fieldsetFieldMappers.test.ts
@@ -0,0 +1,68 @@
+
+import { normalizeFieldForUI, normalizeFieldsForUI } from '../fieldsetFieldMappers';
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { EExtraFieldType, IExtraField } from '../../../../types/template';
+
+const makeField = (overrides: Partial = {}) => makeExtraField({
+ apiName: `f-${Math.random().toString(36).slice(2, 6)}`,
+ ...overrides,
+});
+
+describe('fieldsetFieldMappers', () => {
+ describe('normalizeFieldForUI', () => {
+ it('sets default values for missing optional properties', () => {
+ const input = {
+ apiName: 'f-bare',
+ name: 'Field',
+ type: EExtraFieldType.String,
+ order: 0,
+ } as IExtraField;
+
+ const result = normalizeFieldForUI(input);
+
+ expect(result.description).toBe('');
+ expect(result.isRequired).toBe(false);
+ expect(result.isHidden).toBe(false);
+ expect(result.dataset).toBeNull();
+ expect(result.userId).toBeNull();
+ expect(result.groupId).toBeNull();
+ });
+
+ it('preserves both truthy and falsy values without overwriting with defaults', () => {
+ const input = makeField({
+ description: 'desc',
+ isRequired: true,
+ isHidden: false,
+ dataset: null,
+ userId: 1,
+ groupId: null,
+ });
+
+ const result = normalizeFieldForUI(input);
+
+ expect(result.description).toBe('desc');
+ expect(result.isRequired).toBe(true);
+ expect(result.isHidden).toBe(false);
+ expect(result.dataset).toBeNull();
+ expect(result.userId).toBe(1);
+ expect(result.groupId).toBeNull();
+ });
+ });
+
+ describe('normalizeFieldsForUI', () => {
+ it('normalizes an array of fields', () => {
+ const fields = [
+ makeField({ apiName: 'f1', description: undefined, isRequired: undefined }),
+ makeField({ apiName: 'f2', description: undefined, isHidden: undefined }),
+ ];
+
+ const result = normalizeFieldsForUI(fields);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].description).toBe('');
+ expect(result[0].isRequired).toBe(false);
+ expect(result[1].description).toBe('');
+ expect(result[1].isHidden).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/fieldsetFieldMappers.ts b/frontend/src/public/components/Fieldsets/FieldsetDetails/fieldsetFieldMappers.ts
new file mode 100644
index 000000000..4aac0e4f3
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/fieldsetFieldMappers.ts
@@ -0,0 +1,31 @@
+import { IExtraField } from '../../../types/template';
+
+/**
+ * Normalize a field object coming from the API into a proper IExtraField.
+ *
+ * The Axios response interceptor (commonRequest.ts) automatically converts
+ * all API response keys to camelCase via `mapToCamelCase`, so by the time
+ * data reaches this component the fields are ALREADY in camelCase format
+ * (apiName, isRequired, isHidden, etc.) — i.e. they already match IExtraField.
+ *
+ * This helper just ensures default values for optional properties
+ * (userId, groupId, description, etc.) so the UI components work correctly.
+ */
+export function normalizeFieldForUI(field: IExtraField): IExtraField {
+ return {
+ ...field,
+ description: field.description || '',
+ isRequired: field.isRequired ?? false,
+ isHidden: field.isHidden ?? false,
+ dataset: field.dataset ?? null,
+ userId: field.userId ?? null,
+ groupId: field.groupId ?? null,
+ };
+}
+
+/**
+ * Batch normalize: API → UI
+ */
+export function normalizeFieldsForUI(fields: IExtraField[]): IExtraField[] {
+ return fields.map(normalizeFieldForUI);
+}
diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/index.ts b/frontend/src/public/components/Fieldsets/FieldsetDetails/index.ts
new file mode 100644
index 000000000..db3e3b019
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line no-restricted-exports
+export { default } from './FieldsetDetails';
diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/types.ts b/frontend/src/public/components/Fieldsets/FieldsetDetails/types.ts
new file mode 100644
index 000000000..84a1a1075
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/types.ts
@@ -0,0 +1,7 @@
+import { RouteComponentProps } from 'react-router-dom';
+
+export interface IFieldsetDetailsRouteParams {
+ id: string;
+}
+
+export type TFieldsetDetailsProps = RouteComponentProps;
diff --git a/frontend/src/public/components/Fieldsets/FieldsetModal/FieldsetModal.css b/frontend/src/public/components/Fieldsets/FieldsetModal/FieldsetModal.css
new file mode 100644
index 000000000..94a0614eb
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetModal/FieldsetModal.css
@@ -0,0 +1,16 @@
+.fieldset-modal__title {
+ margin-bottom: 1.6rem;
+}
+
+.fieldset-modal__description {
+ margin-bottom: 1.6rem;
+ color: var(--pneumatic-color-black72);
+
+ @mixin text-base;
+}
+
+.fieldset-modal__footer {
+ margin-top: 3.2rem;
+ display: flex;
+ gap: 1.6rem;
+}
diff --git a/frontend/src/public/components/Fieldsets/FieldsetModal/FieldsetModal.tsx b/frontend/src/public/components/Fieldsets/FieldsetModal/FieldsetModal.tsx
new file mode 100644
index 000000000..75f3bc584
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetModal/FieldsetModal.tsx
@@ -0,0 +1,137 @@
+import * as React from 'react';
+import { useState, useEffect, FormEvent, ChangeEvent } from 'react';
+import { useIntl } from 'react-intl';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { validateFieldsetName } from '../../../utils/validators';
+import { Button, Header, InputField, Modal } from '../../UI';
+import {
+ closeCreateModal,
+ closeEditModal,
+ createFieldsetAction,
+ updateFieldsetAction,
+} from '../../../redux/fieldsets/slice';
+import {
+ isCreateModalOpen,
+ isEditModalOpen,
+ getCurrentFieldset,
+} from '../../../redux/selectors/fieldsets';
+
+import { EFieldsetModalType, IFieldsetModalProps } from './types';
+import styles from './FieldsetModal.css';
+
+const CONFIG = {
+ [EFieldsetModalType.Create]: {
+ title: 'fieldsets.new-fieldset.title',
+ description: 'fieldsets.new-fieldset.description',
+ submitLabel: 'fieldsets.modal-button-create',
+ isModalOpenSelector: isCreateModalOpen,
+ closeAction: closeCreateModal,
+ placeholder: 'fieldsets.new-fieldset.title',
+ },
+ [EFieldsetModalType.Edit]: {
+ title: 'fieldsets.edit-fieldset.title',
+ description: 'fieldsets.edit-fieldset.description',
+ submitLabel: 'fieldsets.modal-button-confirm',
+ isModalOpenSelector: isEditModalOpen,
+ closeAction: closeEditModal,
+ placeholder: '',
+ },
+};
+
+export function FieldsetModal({ type }: IFieldsetModalProps) {
+ const dispatch = useDispatch();
+ const { formatMessage } = useIntl();
+
+ const { title, description, submitLabel, isModalOpenSelector, closeAction, placeholder } = CONFIG[type];
+ const isOpen = useSelector(isModalOpenSelector);
+ const currentFieldset = useSelector(getCurrentFieldset);
+
+ const [inputName, setInputName] = useState('');
+ const [error, setError] = useState('');
+ const [hasChanges, setHasChanges] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) {
+ setInputName(type === EFieldsetModalType.Edit && currentFieldset ? currentFieldset.name : '');
+ setError('');
+ setHasChanges(false);
+ }
+ }, [isOpen, currentFieldset, type]);
+
+ const validationForm = () => {
+ const currentError = validateFieldsetName(inputName);
+ if (currentError) {
+ setError(currentError);
+ return false;
+ }
+
+ return true;
+ };
+
+ const handleCloseModal = () => {
+ dispatch(closeAction());
+ };
+
+ const handleInputChange = (e: ChangeEvent) => {
+ const newValue = e.currentTarget.value;
+ setHasChanges(true);
+ setInputName(newValue);
+
+ if (error) {
+ setError(validateFieldsetName(newValue));
+ }
+ };
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ if (!validationForm()) return;
+
+ if (type === EFieldsetModalType.Create) {
+ dispatch(createFieldsetAction({ name: inputName }));
+ } else if (currentFieldset) {
+ dispatch(updateFieldsetAction({ id: currentFieldset.id, name: inputName }));
+ }
+ handleCloseModal();
+ };
+
+ const isSubmitDisabled = !inputName || (type === EFieldsetModalType.Edit && !hasChanges);
+
+ return (
+
+
+
+ {formatMessage({ id: title })}
+
+
+ {formatMessage({ id: description })}
+
+
+
+
+ );
+}
diff --git a/frontend/src/public/components/Fieldsets/FieldsetModal/__tests__/FieldsetModal.test.tsx b/frontend/src/public/components/Fieldsets/FieldsetModal/__tests__/FieldsetModal.test.tsx
new file mode 100644
index 000000000..4898ecddb
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetModal/__tests__/FieldsetModal.test.tsx
@@ -0,0 +1,282 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { FieldsetModal } from '../FieldsetModal';
+import { EFieldsetModalType } from '../types';
+
+import {
+ closeCreateModal,
+ closeEditModal,
+ createFieldsetAction,
+ updateFieldsetAction,
+} from '../../../../redux/fieldsets/slice';
+import { validateFieldsetName } from '../../../../utils/validators';
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { makeFieldsetCatalogItem } from '../../../../__stubs__/fieldsets.factory';
+
+jest.mock('../../../../redux/fieldsets/slice', () => ({
+ closeCreateModal: jest.fn(() => ({ type: 'fieldsets/closeCreateModal' })),
+ closeEditModal: jest.fn(() => ({ type: 'fieldsets/closeEditModal' })),
+ createFieldsetAction: jest.fn((p) => ({ type: 'fieldsets/createFieldsetAction', payload: p })),
+ updateFieldsetAction: jest.fn((p) => ({ type: 'fieldsets/updateFieldsetAction', payload: p })),
+}));
+
+jest.mock('../../../../utils/validators', () => ({
+ validateFieldsetName: jest.fn(() => ''),
+}));
+
+jest.mock('../../../UI', () => ({
+ Modal: jest.fn(({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) =>
+ isOpen ? React.createElement('div', { 'data-testid': 'modal' }, children) : null,
+ ),
+ InputField: jest.fn(({ value, onChange, errorMessage }: {
+ value: string;
+ onChange: (e: React.ChangeEvent) => void;
+ errorMessage?: string;
+ }) =>
+ React.createElement('div', null,
+ React.createElement('input', {
+ 'data-testid': 'fieldset-name-input',
+ value,
+ onChange,
+ }),
+ errorMessage
+ ? React.createElement('span', { 'data-testid': 'error-message' }, errorMessage)
+ : null,
+ ),
+ ),
+ Button: jest.fn(({ type, disabled, onClick, label }: {
+ type?: string;
+ disabled?: boolean;
+ onClick?: () => void;
+ label: string;
+ }) =>
+ React.createElement('button', {
+ type: type || 'button',
+ disabled,
+ onClick,
+ 'data-testid': `btn-${label}`,
+ }, label),
+ ),
+ Header: jest.fn(({ children }: { children: React.ReactNode }) =>
+ React.createElement('span', null, children),
+ ),
+}));
+
+describe('FieldsetModal', () => {
+ const mockDispatch = jest.fn();
+ const formatMsg = (id: string) => intlMock.formatMessage({ id });
+ const CREATE_LABEL = formatMsg('fieldsets.modal-button-create');
+ const CONFIRM_LABEL = formatMsg('fieldsets.modal-button-confirm');
+ const CANCEL_LABEL = formatMsg('fieldsets.modal-button-cancel');
+
+ const defaultState = {
+ fieldsets: {
+ isCreateModalOpen: false,
+ isEditModalOpen: false,
+ currentFieldset: null,
+ },
+ };
+
+ const createOpenState = {
+ fieldsets: {
+ ...defaultState.fieldsets,
+ isCreateModalOpen: true,
+ },
+ };
+
+ const editOpenState = {
+ fieldsets: {
+ ...defaultState.fieldsets,
+ isEditModalOpen: true,
+ currentFieldset: makeFieldsetCatalogItem({
+ id: 10,
+ name: 'Original Name',
+ description: 'desc',
+ }),
+ },
+ };
+
+ const getSubmitButton = (label: string) =>
+ screen.getByTestId(`btn-${label}`);
+
+ const getNameInput = () =>
+ screen.getByTestId('fieldset-name-input');
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useDispatch as jest.Mock).mockReturnValue(mockDispatch);
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(defaultState));
+ (validateFieldsetName as jest.Mock).mockReturnValue('');
+ });
+
+ describe('Create mode — submit', () => {
+ it('dispatches createFieldsetAction on valid submit', () => {
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(createOpenState));
+
+ render(React.createElement(FieldsetModal, { type: EFieldsetModalType.Create }));
+
+ const input = getNameInput();
+ userEvent.type(input, 'New FS');
+
+ const submitBtn = getSubmitButton(CREATE_LABEL);
+ userEvent.click(submitBtn);
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ createFieldsetAction({ name: 'New FS' }),
+ );
+ expect(mockDispatch).toHaveBeenCalledWith(closeCreateModal());
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('Edit mode — submit and initialization', () => {
+ it('dispatches updateFieldsetAction on changed name submit', () => {
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(editOpenState));
+
+ render(React.createElement(FieldsetModal, { type: EFieldsetModalType.Edit }));
+
+ const input = getNameInput();
+ userEvent.clear(input);
+ userEvent.type(input, 'New Name');
+
+ const submitBtn = getSubmitButton(CONFIRM_LABEL);
+ userEvent.click(submitBtn);
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ updateFieldsetAction({ id: 10, name: 'New Name' }),
+ );
+ expect(mockDispatch).toHaveBeenCalledWith(closeEditModal());
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ });
+
+ it('initializes input with currentFieldset name', () => {
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(editOpenState));
+
+ render(React.createElement(FieldsetModal, { type: EFieldsetModalType.Edit }));
+
+ const input = getNameInput();
+ expect(input).toHaveValue('Original Name');
+ });
+ });
+
+ describe('Validation', () => {
+ it('shows error and does not dispatch action on invalid submit', () => {
+ (validateFieldsetName as jest.Mock).mockReturnValue('Name is required');
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(createOpenState));
+
+ render(React.createElement(FieldsetModal, { type: EFieldsetModalType.Create }));
+
+ const input = getNameInput();
+ userEvent.type(input, 'bad name');
+
+ const submitBtn = getSubmitButton(CREATE_LABEL);
+ userEvent.click(submitBtn);
+
+ expect(screen.getByTestId('error-message')).toHaveTextContent('Name is required');
+ expect(createFieldsetAction).not.toHaveBeenCalled();
+ expect(updateFieldsetAction).not.toHaveBeenCalled();
+ });
+
+ it('updates inline error on input change after first submit', () => {
+ (validateFieldsetName as jest.Mock).mockReturnValue('Name is required');
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(createOpenState));
+
+ render(React.createElement(FieldsetModal, { type: EFieldsetModalType.Create }));
+
+ const input = getNameInput();
+ userEvent.type(input, 'bad name');
+
+ const submitBtn = getSubmitButton(CREATE_LABEL);
+ userEvent.click(submitBtn);
+
+ (validateFieldsetName as jest.Mock).mockClear();
+ (validateFieldsetName as jest.Mock).mockReturnValue('Still invalid');
+
+ userEvent.type(input, 'a');
+
+ expect(validateFieldsetName).toHaveBeenCalledTimes(1);
+ expect(validateFieldsetName).toHaveBeenCalledWith('bad namea');
+ });
+ });
+
+ describe('Submit button disabled state', () => {
+ it('submit is disabled when Create input is empty', () => {
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(createOpenState));
+
+ render(React.createElement(FieldsetModal, { type: EFieldsetModalType.Create }));
+
+ const submitBtn = getSubmitButton(CREATE_LABEL);
+ expect(submitBtn).toBeDisabled();
+ });
+
+ it('submit is disabled in Edit mode when input is unchanged', () => {
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(editOpenState));
+
+ render(React.createElement(FieldsetModal, { type: EFieldsetModalType.Edit }));
+
+ const submitBtn = getSubmitButton(CONFIRM_LABEL);
+ expect(submitBtn).toBeDisabled();
+ });
+
+ it('submit is enabled in Edit mode after input change', () => {
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(editOpenState));
+
+ render(React.createElement(FieldsetModal, { type: EFieldsetModalType.Edit }));
+
+ const input = getNameInput();
+ userEvent.type(input, ' updated');
+
+ const submitBtn = getSubmitButton(CONFIRM_LABEL);
+ expect(submitBtn).not.toBeDisabled();
+ });
+ });
+
+ describe('Modal close', () => {
+ it('dispatches closeCreateModal on Cancel click', () => {
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(createOpenState));
+
+ render(React.createElement(FieldsetModal, { type: EFieldsetModalType.Create }));
+
+ const cancelBtn = getSubmitButton(CANCEL_LABEL);
+ userEvent.click(cancelBtn);
+
+ expect(mockDispatch).toHaveBeenCalledWith(closeCreateModal());
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('State reset', () => {
+ it('clears error when modal is reopened', () => {
+ (validateFieldsetName as jest.Mock).mockReturnValue('Name is required');
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(createOpenState));
+
+ const { rerender } = render(
+ React.createElement(FieldsetModal, { type: EFieldsetModalType.Create }),
+ );
+
+ const input = getNameInput();
+ userEvent.type(input, 'bad');
+
+ const submitBtn = getSubmitButton(CREATE_LABEL);
+ userEvent.click(submitBtn);
+
+ expect(screen.getByTestId('error-message')).toBeInTheDocument();
+
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(defaultState));
+ rerender(
+ React.createElement(FieldsetModal, { type: EFieldsetModalType.Create }),
+ );
+
+ (validateFieldsetName as jest.Mock).mockReturnValue('');
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(createOpenState));
+ rerender(
+ React.createElement(FieldsetModal, { type: EFieldsetModalType.Create }),
+ );
+
+ expect(screen.queryByTestId('error-message')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/public/components/Fieldsets/FieldsetModal/types.ts b/frontend/src/public/components/Fieldsets/FieldsetModal/types.ts
new file mode 100644
index 000000000..65c568261
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/FieldsetModal/types.ts
@@ -0,0 +1,8 @@
+export enum EFieldsetModalType {
+ Create = 'create',
+ Edit = 'edit',
+}
+
+export interface IFieldsetModalProps {
+ type: EFieldsetModalType;
+}
diff --git a/frontend/src/public/components/Fieldsets/Fieldsets.css b/frontend/src/public/components/Fieldsets/Fieldsets.css
new file mode 100644
index 000000000..be32db9fe
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/Fieldsets.css
@@ -0,0 +1,24 @@
+.container {
+ margin: 0 auto;
+ display: flex;
+ flex-flow: column;
+ max-width: 108.8rem;
+ min-height: 100%;
+
+ @media (--mobile) {
+ padding-top: 2rem;
+ }
+}
+
+.cards-wrapper {
+ @mixin page-cards-wrapper;
+}
+
+.card {
+ @mixin page-card;
+}
+
+.container-loading {
+ pointer-events: none;
+ opacity: 0.4;
+}
diff --git a/frontend/src/public/components/Fieldsets/Fieldsets.tsx b/frontend/src/public/components/Fieldsets/Fieldsets.tsx
new file mode 100644
index 000000000..9cc6deeab
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/Fieldsets.tsx
@@ -0,0 +1,68 @@
+import * as React from 'react';
+import { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useIntl } from 'react-intl';
+import classnames from 'classnames';
+
+import InfiniteScroll from 'react-infinite-scroll-component';
+
+import { IFieldsetCatalogItem } from '../../types/fieldset';
+
+import { PageTitle } from '../PageTitle';
+import { EPageTitle } from '../../constants/defaultValues';
+import { openCreateModal, loadFieldsets } from '../../redux/fieldsets/slice';
+import { getFieldsetsListSelection, getFieldsetsIsLoading, getFieldsetsSorting } from '../../redux/selectors/fieldsets';
+import { AddCardButton } from '../UI';
+import { AIPlusIcon } from '../icons';
+import { FieldsetModal } from './FieldsetModal/FieldsetModal';
+import { EFieldsetModalType } from './FieldsetModal/types';
+import { FieldsetCard } from './FieldsetCard';
+
+import styles from './Fieldsets.css';
+
+export function Fieldsets() {
+ const dispatch = useDispatch();
+ const { formatMessage } = useIntl();
+
+ const { items: fieldsetsList, count, offset } = useSelector(getFieldsetsListSelection) || { items: [], count: 0, offset: 0 };
+ const isLoading = useSelector(getFieldsetsIsLoading);
+ const fieldsetsSorting = useSelector(getFieldsetsSorting);
+
+ useEffect(() => {
+ dispatch(loadFieldsets({ offset: 0 }));
+ }, [dispatch, fieldsetsSorting]);
+
+ const handleOpenCreateModal = () => {
+ dispatch(openCreateModal());
+ };
+
+ return (
+
+
+
dispatch(loadFieldsets({ offset: offset + 1 }))}
+ loader={null}
+ hasMore={count > fieldsetsList.length || isLoading}
+ className={classnames(styles['cards-wrapper'], { [styles['container-loading']]: isLoading })}
+ scrollableTarget="app-container"
+ >
+ {isLoading && fieldsetsList.length === 0 && }
+ }
+ />
+ {fieldsetsList.map((fieldset: IFieldsetCatalogItem) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export default Fieldsets;
diff --git a/frontend/src/public/components/Fieldsets/__tests__/Fieldsets.test.tsx b/frontend/src/public/components/Fieldsets/__tests__/Fieldsets.test.tsx
new file mode 100644
index 000000000..0fe9619d1
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/__tests__/Fieldsets.test.tsx
@@ -0,0 +1,227 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { Fieldsets } from '../Fieldsets';
+import { FieldsetCard } from '../FieldsetCard';
+import { FieldsetModal } from '../FieldsetModal/FieldsetModal';
+import { AddCardButton } from '../../UI';
+import {
+ openCreateModal,
+ loadFieldsets,
+} from '../../../redux/fieldsets/slice';
+import { intlMock } from '../../../__stubs__/intlMock';
+import { EFieldsetsSorting, IFieldsetCatalogItem } from '../../../types/fieldset';
+import { makeFieldsetCatalogItem } from '../../../__stubs__/fieldsets.factory';
+import InfiniteScroll from 'react-infinite-scroll-component';
+
+jest.mock('../../../utils/history', () => ({
+ history: { push: jest.fn(), location: { pathname: '/' }, listen: jest.fn() },
+}));
+
+jest.mock('../../../redux/fieldsets/slice', () => ({
+ openCreateModal: jest.fn(() => ({ type: 'fieldsets/openCreateModal' })),
+ loadFieldsets: jest.fn((p) => ({ type: 'fieldsets/loadFieldsets', payload: p })),
+}));
+
+jest.mock('react-infinite-scroll-component', () => ({
+ __esModule: true,
+ default: jest.fn(({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'infinite-scroll' }, children),
+ ),
+}));
+
+jest.mock('../../PageTitle', () => ({
+ PageTitle: jest.fn(() => null),
+}));
+
+jest.mock('../../UI', () => ({
+ AddCardButton: jest.fn(() => null),
+}));
+
+jest.mock('../../icons', () => ({
+ AIPlusIcon: () => null,
+}));
+
+jest.mock('../FieldsetModal/FieldsetModal', () => ({
+ FieldsetModal: jest.fn(() => null),
+}));
+
+jest.mock('../FieldsetModal/types', () => ({
+ EFieldsetModalType: { Create: 'create', Edit: 'edit' },
+}));
+
+jest.mock('../FieldsetCard', () => ({
+ FieldsetCard: jest.fn(() => null),
+}));
+
+describe('Fieldsets', () => {
+ const mockDispatch = jest.fn();
+
+ const formatMsg = (id: string) => intlMock.formatMessage({ id });
+ const NEW_FIELDSET_TITLE = formatMsg('fieldsets.new-fieldset.title');
+
+ const getAddCardButtonProps = () => {
+ const mock = AddCardButton as unknown as jest.Mock;
+ const lastCall = mock.mock.calls[mock.mock.calls.length - 1];
+ return lastCall[0];
+ };
+
+ const getInfiniteScrollProps = () => {
+ const mock = InfiniteScroll as unknown as jest.Mock;
+ const lastCall = mock.mock.calls[mock.mock.calls.length - 1];
+ return lastCall[0];
+ };
+
+ const defaultState = {
+ fieldsets: {
+ fieldsetsList: { items: [] as IFieldsetCatalogItem[], count: 0, offset: 0 },
+ isLoading: false,
+ fieldsetsListSorting: EFieldsetsSorting.DateDesc,
+ },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useDispatch as jest.Mock).mockReturnValue(mockDispatch);
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(defaultState));
+ });
+
+ describe('Initial load', () => {
+ it('dispatches loadFieldsets on mount', () => {
+ render(React.createElement(Fieldsets));
+
+ expect(mockDispatch).toHaveBeenCalledWith(loadFieldsets({ offset: 0 }));
+ });
+ });
+
+ describe('Loading state', () => {
+ it('shows loading indicator when isLoading=true and list is empty', () => {
+ const loadingState = {
+ fieldsets: {
+ ...defaultState.fieldsets,
+ isLoading: true,
+ },
+ };
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(loadingState));
+
+ render(React.createElement(Fieldsets));
+
+ expect(screen.getByTestId('fieldsets-loading')).toBeInTheDocument();
+ });
+
+ it('does not show loading indicator when isLoading=true but list is not empty', () => {
+ const loadingWithDataState = {
+ fieldsets: {
+ ...defaultState.fieldsets,
+ isLoading: true,
+ fieldsetsList: {
+ items: [makeFieldsetCatalogItem()],
+ count: 1,
+ offset: 0,
+ },
+ },
+ };
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(loadingWithDataState));
+
+ render(React.createElement(Fieldsets));
+
+ expect(screen.queryByTestId('fieldsets-loading')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Fieldset cards rendering', () => {
+ it('renders FieldsetCard for each fieldset', () => {
+ const fieldsets = [
+ makeFieldsetCatalogItem({ id: 1, name: 'FS1' }),
+ makeFieldsetCatalogItem({ id: 2, apiName: 'fs-2', name: 'FS2' }),
+ ];
+ const stateWithData = {
+ fieldsets: {
+ ...defaultState.fieldsets,
+ fieldsetsList: { items: fieldsets, count: 2, offset: 0 },
+ },
+ };
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(stateWithData));
+
+ render(React.createElement(Fieldsets));
+
+ const mock = FieldsetCard as jest.Mock;
+ expect(mock).toHaveBeenCalledTimes(2);
+ expect(mock).toHaveBeenCalledWith(expect.objectContaining({ id: 1, name: 'FS1' }), {});
+ expect(mock).toHaveBeenCalledWith(expect.objectContaining({ id: 2, name: 'FS2' }), {});
+ });
+ });
+
+ describe('Create new fieldset', () => {
+ it('dispatches openCreateModal when AddCardButton is clicked', () => {
+ render(React.createElement(Fieldsets));
+
+ const addCardProps = getAddCardButtonProps();
+ expect(addCardProps.title).toBe(NEW_FIELDSET_TITLE);
+
+ addCardProps.onClick();
+
+ expect(mockDispatch).toHaveBeenCalledWith(openCreateModal());
+ });
+ });
+
+ describe('Modals', () => {
+ it('renders FieldsetModal for Create and Edit', () => {
+ render(React.createElement(Fieldsets));
+
+ const mock = FieldsetModal as jest.Mock;
+ expect(mock).toHaveBeenCalledTimes(2);
+ expect(mock).toHaveBeenCalledWith(expect.objectContaining({ type: 'create' }), {});
+ expect(mock).toHaveBeenCalledWith(expect.objectContaining({ type: 'edit' }), {});
+ });
+ });
+
+ describe('Pagination', () => {
+ it('next() loads next page with offset+1', () => {
+ const stateWithOffset = {
+ fieldsets: {
+ ...defaultState.fieldsets,
+ fieldsetsList: {
+ items: [makeFieldsetCatalogItem()],
+ count: 10,
+ offset: 2,
+ },
+ },
+ };
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(stateWithOffset));
+
+ render(React.createElement(Fieldsets));
+
+ mockDispatch.mockClear();
+
+ const scrollProps = getInfiniteScrollProps();
+ scrollProps.next();
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ expect(mockDispatch).toHaveBeenCalledWith(loadFieldsets({ offset: 3 }));
+ });
+ });
+
+ describe('Sorting change', () => {
+ it('reloads list when sorting changes', () => {
+ const { rerender } = render(React.createElement(Fieldsets));
+
+ expect(mockDispatch).toHaveBeenCalledWith(loadFieldsets({ offset: 0 }));
+ mockDispatch.mockClear();
+ (loadFieldsets as unknown as jest.Mock).mockClear();
+
+ const newSortingState = {
+ fieldsets: {
+ ...defaultState.fieldsets,
+ fieldsetsListSorting: EFieldsetsSorting.NameAsc,
+ },
+ };
+ (useSelector as jest.Mock).mockImplementation((selector) => selector(newSortingState));
+
+ rerender(React.createElement(Fieldsets));
+
+ expect(mockDispatch).toHaveBeenCalledWith(loadFieldsets({ offset: 0 }));
+ });
+ });
+});
diff --git a/frontend/src/public/components/Fieldsets/index.ts b/frontend/src/public/components/Fieldsets/index.ts
new file mode 100644
index 000000000..dc1d43d68
--- /dev/null
+++ b/frontend/src/public/components/Fieldsets/index.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line no-restricted-exports
+export { default } from './Fieldsets';
diff --git a/frontend/src/public/components/Highlights/FeedItemHeader.tsx b/frontend/src/public/components/Highlights/FeedItemHeader.tsx
index e1f17e12a..3b1d54a67 100644
--- a/frontend/src/public/components/Highlights/FeedItemHeader.tsx
+++ b/frontend/src/public/components/Highlights/FeedItemHeader.tsx
@@ -1,4 +1,5 @@
-import React, { useState, useEffect, useRef } from 'react';
+import * as React from 'react';
+import { useState, useEffect, useRef } from 'react';
import classnames from 'classnames';
import { useIntl } from 'react-intl';
@@ -6,6 +7,7 @@ import { RichText } from '../RichText';
import { Attachments } from '../Attachments';
import { EWorkflowLogEvent } from '../../types/workflow';
import { EExtraFieldType, IExtraField } from '../../types/template';
+import { IFieldsetRuntime } from '../../types/fieldset';
import { IHighlightsItem } from '../../types/highlights';
import { isArrayWithItems } from '../../utils/helpers';
import { EKickoffOutputsViewModes, KickoffOutputs } from '../KickoffOutputs';
@@ -50,37 +52,60 @@ export function FeedItemHeader({
};
const renderOutputsContents = () => {
- if (!task) {
+ const { output: taskOutput, fieldsets: taskFieldsets } = task || {};
+ const { output: kickoffOutput, fieldsets: kickoffFieldsets } = kickoff || {};
+
+ if (
+ !isArrayWithItems(taskOutput) && !isArrayWithItems(kickoffOutput)
+ && !isArrayWithItems(taskFieldsets) && !isArrayWithItems(kickoffFieldsets)
+ ) {
return null;
}
- const { output: taskOutput } = task;
+
const OUTPUTS_MAP: { [key in EWorkflowLogEvent]?: IExtraField[] } = {
- [EWorkflowLogEvent.WorkflowRun]: kickoff?.output,
+ [EWorkflowLogEvent.WorkflowRun]: kickoffOutput,
[EWorkflowLogEvent.WorkflowComplete]: taskOutput,
[EWorkflowLogEvent.TaskComplete]: taskOutput,
[EWorkflowLogEvent.WorkflowsReturned]: taskOutput,
[EWorkflowLogEvent.TaskRevert]: taskOutput,
};
+ const FIELDSETS_MAP: { [key in EWorkflowLogEvent]?: IFieldsetRuntime[] } = {
+ [EWorkflowLogEvent.WorkflowRun]: kickoffFieldsets,
+ [EWorkflowLogEvent.WorkflowComplete]: taskFieldsets,
+ [EWorkflowLogEvent.TaskComplete]: taskFieldsets,
+ [EWorkflowLogEvent.WorkflowsReturned]: taskFieldsets,
+ [EWorkflowLogEvent.TaskRevert]: taskFieldsets,
+ };
+
const outputs = OUTPUTS_MAP[type];
+ const fieldsets = FIELDSETS_MAP[type];
- if (!outputs || !isArrayWithItems(outputs)) {
+ if (!isArrayWithItems(outputs) && !isArrayWithItems(fieldsets)) {
return null;
}
- const filteredOutputs = outputs.filter((output) => {
+ const filterField = (output: IExtraField) => {
const value = output.type === EExtraFieldType.User ? output.userId || output.groupId : output.value;
return value || output.attachments?.length;
- });
+ };
+
+ const filteredOutputs = (outputs || []).filter(filterField);
+
+ const filteredFieldsets = (fieldsets || []).map((fs) => ({
+ ...fs,
+ fields: fs.fields.filter(filterField),
+ })).filter((fs) => fs.fields.length > 0);
return (
<>
- {filteredOutputs.length > 1 && !isTextExpanded && renderElipsis()}
+ {(filteredOutputs.length + filteredFieldsets.flatMap((fs) => fs.fields).length) > 1 && !isTextExpanded && renderElipsis()}
>
);
};
diff --git a/frontend/src/public/components/Highlights/__tests__/FeedItemHeader.test.tsx b/frontend/src/public/components/Highlights/__tests__/FeedItemHeader.test.tsx
new file mode 100644
index 000000000..3fd434cac
--- /dev/null
+++ b/frontend/src/public/components/Highlights/__tests__/FeedItemHeader.test.tsx
@@ -0,0 +1,470 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { FeedItemHeader } from '../FeedItemHeader';
+import { KickoffOutputs } from '../../KickoffOutputs';
+import { EWorkflowLogEvent, EWorkflowStatus } from '../../../types/workflow';
+import { EExtraFieldType, IExtraField } from '../../../types/template';
+import { IFieldsetRuntime } from '../../../types/fieldset';
+import { makeExtraField } from '../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../__stubs__/fieldsets.factory';
+import { IHighlightsItem } from '../../../types/highlights';
+import { Ellipsis } from '../Ellipsis';
+
+jest.mock('../../KickoffOutputs', () => ({
+ KickoffOutputs: jest.fn(() => null),
+ EKickoffOutputsViewModes: { Short: 'short', Detailed: 'detailed' },
+}));
+
+jest.mock('../../RichText', () => ({ RichText: () => null }));
+jest.mock('../../Attachments', () => ({ Attachments: () => null }));
+jest.mock('../../UserData', () => ({ UserData: () => null }));
+jest.mock('../Ellipsis', () => ({
+ Ellipsis: jest.fn(({ expand }: { expand: () => void }) => (
+
+ )),
+}));
+jest.mock('../utils/TruncatedContent', () => ({
+ TruncatedContent: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+jest.mock('../../../utils/helpers', () => {
+ const actual = jest.requireActual('../../../utils/helpers');
+ return { isArrayWithItems: actual.isArrayWithItems };
+});
+
+jest.mock('../../../utils/users', () => ({
+ getUserFullName: jest.fn(() => 'User'),
+}));
+
+jest.mock('../../../utils/dateTime', () => ({
+ getSnoozedUntilDate: jest.fn(() => ''),
+}));
+
+describe('FeedItemHeader', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const makeField = (overrides: Partial = {}) => makeExtraField({
+ apiName: `field-${Math.random()}`,
+ ...overrides,
+ });
+
+ const makeBaseProps = (overrides: Partial = {}): IHighlightsItem => ({
+ id: 1,
+ type: EWorkflowLogEvent.WorkflowRun,
+ task: null,
+ text: '',
+ attachments: [],
+ created: '2026-01-01T00:00:00Z',
+ user: null,
+ workflow: {
+ id: 1,
+ name: 'Test Workflow',
+ currentTask: 1,
+ tasksCount: 1,
+ template: null,
+ isLegacyTemplate: false,
+ legacyTemplateName: '',
+ status: EWorkflowStatus.Running,
+ kickoff: null,
+ isExternal: false,
+ },
+ userId: null,
+ targetUserId: null,
+ delay: null,
+ ...overrides,
+ });
+
+ describe('Kickoff outputs for WorkflowRun with task=null', () => {
+ it('renders KickoffOutputs with kickoff.output and fieldsets when task=null', () => {
+ const kickoffFields = [
+ makeField({ apiName: 'f1', order: 1, value: 'hello' }),
+ makeField({ apiName: 'f2', order: 2, value: 'world' }),
+ ];
+
+ const kickoffFieldsets: IFieldsetRuntime[] = [
+ makeFieldsetRuntime({
+ fields: [
+ makeField({ apiName: 'fs1-f1', order: 1, value: 'fieldset value' }),
+ ],
+ }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.WorkflowRun,
+ task: null,
+ workflow: {
+ id: 1,
+ name: 'Test Workflow',
+ currentTask: 1,
+ tasksCount: 1,
+ template: null,
+ isLegacyTemplate: false,
+ legacyTemplateName: '',
+ status: EWorkflowStatus.Running,
+ kickoff: {
+ id: 1,
+ description: null,
+ output: kickoffFields,
+ fieldsets: kickoffFieldsets,
+ },
+ isExternal: false,
+ },
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ const mock = KickoffOutputs as jest.Mock;
+ const lastCall = mock.mock.calls[mock.mock.calls.length - 1][0];
+ expect(lastCall.outputs).toEqual(kickoffFields);
+ expect(lastCall.fieldsets).toEqual(kickoffFieldsets);
+ });
+ });
+
+ describe('Fieldsets handling in highlights', () => {
+ const makeFieldset = (overrides: Partial & { fields: IExtraField[] }) => makeFieldsetRuntime({
+ name: 'Test Fieldset',
+ ...overrides,
+ });
+
+ const makeWorkflow = (kickoff: IHighlightsItem['workflow']['kickoff']) => ({
+ id: 1,
+ name: 'Test Workflow',
+ currentTask: 1,
+ tasksCount: 1,
+ template: null,
+ isLegacyTemplate: false,
+ legacyTemplateName: '',
+ status: EWorkflowStatus.Running,
+ kickoff,
+ isExternal: false,
+ });
+
+ const getLastKickoffOutputsProps = () => {
+ const mock = KickoffOutputs as jest.Mock;
+ return mock.mock.calls[mock.mock.calls.length - 1][0];
+ };
+
+ it('passes fieldsets when outputs is empty', () => {
+ const fieldsets = [
+ makeFieldset({ fields: [makeField({ value: 'val1', order: 1 })] }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.WorkflowRun,
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: [],
+ fieldsets,
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ const mock = KickoffOutputs as jest.Mock;
+ expect(mock).toHaveBeenCalled();
+ expect(getLastKickoffOutputsProps().fieldsets).toEqual(fieldsets);
+ });
+
+ it.each([
+ EWorkflowLogEvent.TaskComplete,
+ EWorkflowLogEvent.WorkflowComplete,
+ EWorkflowLogEvent.WorkflowsReturned,
+ ])('uses task.fieldsets instead of kickoff.fieldsets for %s', (eventType) => {
+ const kickoffFieldsets = [
+ makeFieldset({ apiNameBinding: 'fs-kickoff', name: 'Kickoff FS', fields: [makeField({ value: 'kickoff-val', order: 1 })] }),
+ ];
+ const taskFieldsets = [
+ makeFieldset({ apiNameBinding: 'fs-task', name: 'Task FS', fields: [makeField({ value: 'task-val', order: 1 })] }),
+ ];
+
+ const props = makeBaseProps({
+ type: eventType,
+ task: {
+ id: 1,
+ name: 'Task 1',
+ number: 1,
+ process: 1,
+ delay: null,
+ output: [makeField({ value: 'task-output', order: 1 })],
+ fieldsets: taskFieldsets,
+ dueDate: null,
+ },
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: [],
+ fieldsets: kickoffFieldsets,
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ const mock = KickoffOutputs as jest.Mock;
+ expect(mock).toHaveBeenCalled();
+
+ const passedFieldsets = getLastKickoffOutputsProps().fieldsets;
+ expect(passedFieldsets).toEqual(taskFieldsets);
+ expect(passedFieldsets).not.toEqual(kickoffFieldsets);
+ });
+
+ it('does not render KickoffOutputs for TaskRevert', () => {
+ const taskFieldsets = [
+ makeFieldset({ apiNameBinding: 'fs-task-2', name: 'Task FS', fields: [makeField({ value: 'task-val', order: 1 })] }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.TaskRevert,
+ task: {
+ id: 1,
+ name: 'Task 1',
+ number: 1,
+ process: 1,
+ delay: null,
+ output: [makeField({ value: 'task-output', order: 1 })],
+ fieldsets: taskFieldsets,
+ dueDate: null,
+ },
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: [],
+ fieldsets: [],
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ const mock = KickoffOutputs as jest.Mock;
+ expect(mock).not.toHaveBeenCalled();
+ });
+
+ it('filters out empty fields inside fieldsets', () => {
+ const fieldsets = [
+ makeFieldset({
+ fields: [
+ makeField({ apiName: 'filled', value: 'hello', order: 1 }),
+ makeField({ apiName: 'empty', value: '', order: 2 }),
+ ],
+ }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.WorkflowRun,
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: [],
+ fieldsets,
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ const mock = KickoffOutputs as jest.Mock;
+ expect(mock).toHaveBeenCalled();
+
+ const passedFieldsets = getLastKickoffOutputsProps().fieldsets;
+ expect(passedFieldsets).toHaveLength(1);
+ expect(passedFieldsets[0].fields).toHaveLength(1);
+ expect(passedFieldsets[0].fields[0].apiName).toBe('filled');
+ });
+
+ it('removes fieldsets with all empty fields after filtering', () => {
+ const fieldsets = [
+ makeFieldset({
+ apiNameBinding: 'fs-empty',
+ name: 'All Empty',
+ fields: [
+ makeField({ apiName: 'e1', value: '', order: 1 }),
+ makeField({ apiName: 'e2', value: '', order: 2 }),
+ ],
+ }),
+ makeFieldset({
+ apiNameBinding: 'fs-has-val',
+ name: 'Has Value',
+ fields: [
+ makeField({ apiName: 'f1', value: 'data', order: 1 }),
+ ],
+ }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.WorkflowRun,
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: [],
+ fieldsets,
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ const mock = KickoffOutputs as jest.Mock;
+ expect(mock).toHaveBeenCalled();
+
+ const passedFieldsets = getLastKickoffOutputsProps().fieldsets;
+ expect(passedFieldsets).toHaveLength(1);
+ expect(passedFieldsets[0].name).toBe('Has Value');
+ });
+
+ it('shows Ellipsis when fieldset fields count exceeds 1', () => {
+ const fieldsets = [
+ makeFieldset({
+ fields: [
+ makeField({ apiName: 'f1', value: 'v1', order: 1 }),
+ makeField({ apiName: 'f2', value: 'v2', order: 2 }),
+ ],
+ }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.WorkflowRun,
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: [],
+ fieldsets,
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ expect(Ellipsis).toHaveBeenCalled();
+ });
+
+ it('keeps a User field inside a fieldset that has only groupId set (no value, no userId)', () => {
+ const fieldsets = [
+ makeFieldset({
+ fields: [
+ makeField({
+ apiName: 'owner-group',
+ type: EExtraFieldType.User,
+ value: null,
+ userId: null,
+ groupId: 99,
+ order: 1,
+ }),
+ ],
+ }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.WorkflowRun,
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: [],
+ fieldsets,
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ const passedFieldsets = getLastKickoffOutputsProps().fieldsets;
+ expect(passedFieldsets).toHaveLength(1);
+ expect(passedFieldsets[0].fields).toHaveLength(1);
+ expect(passedFieldsets[0].fields[0].apiName).toBe('owner-group');
+ });
+
+ it('keeps a fieldset field that has no value but has attachments', () => {
+ const fieldsets = [
+ makeFieldset({
+ fields: [
+ makeField({
+ apiName: 'doc',
+ type: EExtraFieldType.File,
+ value: null,
+ attachments: [{ id: 1, url: 'doc.pdf', name: 'doc.pdf', size: 10 }],
+ order: 1,
+ }),
+ makeField({
+ apiName: 'empty',
+ value: '',
+ attachments: [],
+ order: 2,
+ }),
+ ],
+ }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.WorkflowRun,
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: [],
+ fieldsets,
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ const passedFieldsets = getLastKickoffOutputsProps().fieldsets;
+ expect(passedFieldsets).toHaveLength(1);
+ expect(passedFieldsets[0].fields).toHaveLength(1);
+ expect(passedFieldsets[0].fields[0].apiName).toBe('doc');
+ });
+
+ it('shows Ellipsis when total count of outputs + fieldset fields is greater than 1', () => {
+ const outputs = [makeField({ apiName: 'plain', value: 'top-value', order: 5 })];
+ const fieldsets = [
+ makeFieldset({
+ fields: [makeField({ apiName: 'inside', value: 'inside-value', order: 1 })],
+ }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.WorkflowRun,
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: outputs,
+ fieldsets,
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ expect(Ellipsis).toHaveBeenCalled();
+ expect(screen.getByTestId('ellipsis')).toBeInTheDocument();
+ });
+
+ it('after Ellipsis click passes isTruncated=false to KickoffOutputs and hides the ellipsis', () => {
+ const fieldsets = [
+ makeFieldset({
+ fields: [
+ makeField({ apiName: 'a', value: 'va', order: 1 }),
+ makeField({ apiName: 'b', value: 'vb', order: 2 }),
+ ],
+ }),
+ ];
+
+ const props = makeBaseProps({
+ type: EWorkflowLogEvent.WorkflowRun,
+ workflow: makeWorkflow({
+ id: 1,
+ description: null,
+ output: [],
+ fieldsets,
+ }),
+ });
+
+ render(React.createElement(FeedItemHeader, props));
+
+ expect(getLastKickoffOutputsProps().isTruncated).toBe(true);
+
+ userEvent.click(screen.getByTestId('ellipsis'));
+
+ expect(getLastKickoffOutputsProps().isTruncated).toBe(false);
+ expect(screen.queryByTestId('ellipsis')).toBeNull();
+ });
+ });
+});
diff --git a/frontend/src/public/components/KickoffEdit/KickoffEdit.tsx b/frontend/src/public/components/KickoffEdit/KickoffEdit.tsx
index 6e336a70f..8ff2ddade 100644
--- a/frontend/src/public/components/KickoffEdit/KickoffEdit.tsx
+++ b/frontend/src/public/components/KickoffEdit/KickoffEdit.tsx
@@ -1,37 +1,43 @@
-import React, { useEffect } from 'react';
+import * as React from 'react';
+import { useEffect } from 'react';
import classnames from 'classnames';
import { useIntl } from 'react-intl';
-import { IExtraField, EExtraFieldMode, IKickoff } from '../../types/template';
+import { IExtraField, IKickoffClient } from '../../types/template';
+import { IFieldsetRuntime } from '../../types/fieldset';
import { EInputNameBackgroundColor } from '../../types/workflow';
import { isArrayWithItems } from '../../utils/helpers';
import { IntlMessages } from '../IntlMessages';
-import { ExtraFieldIntl } from '../TemplateEdit/ExtraFields';
import { checkExtraFieldsAreValid } from '../WorkflowEditPopup/utils/areKickoffFieldsValid';
import { Loader } from '../UI/Loader';
import { autoFocusFirstField } from '../../utils/autoFocusFirstField';
+import { MergedOutputList } from '../MergedOutputList';
import { Button } from '../UI/Buttons/Button';
import styles from './KickoffEdit.css';
export interface IEditKickoffProps {
- kickoff: IKickoff | null;
+ kickoff: IKickoffClient | null;
+ fieldsets?: IFieldsetRuntime[];
isLoading?: boolean;
accountId: number;
onEditField(apiName: string): (changedProps: Partial) => void;
+ onEditFieldsetField?(apiName: string): (changedProps: Partial) => void;
onSave?(): void;
onCancel?(): void;
}
export function EditKickoff({
kickoff,
+ fieldsets = [],
isLoading = false,
accountId,
onEditField,
+ onEditFieldsetField,
onSave,
onCancel,
}: IEditKickoffProps) {
- if (!kickoff || !isArrayWithItems(kickoff.fields)) return null;
+ if (!kickoff || (!isArrayWithItems(kickoff.fields) && !isArrayWithItems(fieldsets))) return null;
const { formatMessage } = useIntl();
const wrapperRef = React.useRef(null);
@@ -48,7 +54,10 @@ export function EditKickoff({
{Boolean(onSave) && (
{kickoff.description && {kickoff.description}
}
- {kickoff.fields.map((field) => (
-
- ))}
+
{renderButtons()}
diff --git a/frontend/src/public/components/KickoffEdit/__tests__/KickoffEdit.test.tsx b/frontend/src/public/components/KickoffEdit/__tests__/KickoffEdit.test.tsx
new file mode 100644
index 000000000..36745a164
--- /dev/null
+++ b/frontend/src/public/components/KickoffEdit/__tests__/KickoffEdit.test.tsx
@@ -0,0 +1,265 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import { enMessages } from '../../../lang/locales/en_US';
+
+import { EditKickoff } from '../KickoffEdit';
+import { makeExtraField } from '../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../__stubs__/fieldsets.factory';
+import { IExtraField } from '../../../types/template';
+import { IFieldsetRuntime } from '../../../types/fieldset';
+import { MergedOutputList } from '../../MergedOutputList';
+
+jest.mock('../../MergedOutputList', () => ({
+ MergedOutputList: jest.fn(() => ),
+}));
+
+jest.mock('../../../utils/autoFocusFirstField', () => ({
+ autoFocusFirstField: jest.fn(),
+}));
+
+jest.mock('../../UI/Loader', () => ({
+ Loader: () => null,
+}));
+
+jest.mock('../../IntlMessages', () => ({
+ IntlMessages: ({ id }: { id: string }) => {id},
+}));
+
+jest.mock('../../UI/Buttons/Button', () => ({
+ Button: (props: { label: string; disabled?: boolean; type?: string; onClick?: () => void }) =>
+ React.createElement('button', {
+ type: props.type || 'button',
+ disabled: props.disabled,
+ 'data-testid': props.type === 'submit' ? 'save-button' : 'cancel-button',
+ }, props.label),
+}));
+
+const makeField = (apiName: string, order: number, overrides: Partial = {}) => makeExtraField({
+ apiName,
+ name: apiName,
+ ...(order !== 0 && { order }),
+ ...overrides,
+});
+
+const makeFieldset = (overrides: Partial & { fields: IExtraField[] }) => makeFieldsetRuntime({
+ name: 'Fieldset',
+ ...overrides,
+});
+
+const baseProps = {
+ accountId: 1,
+ onEditField: jest.fn(() => jest.fn()),
+ onSave: jest.fn(),
+};
+
+
+const renderWithIntl = (ui: React.ReactElement) =>
+ render(
+
+ {ui}
+ ,
+ );
+
+describe('KickoffEdit', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders MergedOutputList and passes fields and fieldsets', () => {
+ const kickoff = {
+ description: '',
+ fields: [makeField('f1', 1)],
+ fieldsets: [],
+ };
+ const fieldsets = [
+ makeFieldset({ fields: [], order: 2 }),
+ ];
+
+ renderWithIntl(
+ jest.fn())}
+ />,
+ );
+
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+
+ expect(MergedOutputList).toHaveBeenCalledTimes(1);
+ expect(MergedOutputList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fields: expect.arrayContaining([expect.objectContaining({ apiName: 'f1' })]),
+ fieldsets,
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('passes empty fieldsets when none provided', () => {
+ const kickoff = {
+ description: '',
+ fields: [makeField('f1', 0), makeField('f2', 1)],
+ fieldsets: [],
+ };
+
+ renderWithIntl();
+
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+
+ expect(MergedOutputList).toHaveBeenCalledTimes(1);
+ expect(MergedOutputList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fields: expect.arrayContaining([
+ expect.objectContaining({ apiName: 'f1' }),
+ expect.objectContaining({ apiName: 'f2' }),
+ ]),
+ fieldsets: [],
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('disables Save button when fieldset contains an empty required field', () => {
+ const kickoff = {
+ description: '',
+ fields: [makeField('f1', 0, { value: 'filled' })],
+ fieldsets: [],
+ };
+
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldset({
+ fields: [
+ makeField('fs-field-1', 1, { isRequired: true, value: '' }),
+ ],
+ order: 1,
+ }),
+ ];
+
+ renderWithIntl(
+ jest.fn())}
+ />,
+ );
+
+ const saveButton = screen.getByTestId('save-button');
+ expect(saveButton).toBeDisabled();
+ });
+
+ it('enables Save button when all required fields including fieldsets are filled', () => {
+ const kickoff = {
+ description: '',
+ fields: [makeField('f1', 0, { value: 'filled' })],
+ fieldsets: [],
+ };
+
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldset({
+ fields: [
+ makeField('fs-field-1', 1, { isRequired: true, value: 'also filled' }),
+ ],
+ order: 1,
+ }),
+ ];
+
+ renderWithIntl(
+ jest.fn())}
+ />,
+ );
+
+ const saveButton = screen.getByTestId('save-button');
+ expect(saveButton).not.toBeDisabled();
+ });
+
+ it('does not render the form when there are neither plain fields nor fieldsets', () => {
+ const kickoff = {
+ description: '',
+ fields: [],
+ fieldsets: [],
+ };
+
+ renderWithIntl();
+
+ expect(screen.queryByTestId('merged-output-list')).toBeNull();
+ expect(screen.queryByTestId('save-button')).toBeNull();
+ expect(screen.queryByTestId('cancel-button')).toBeNull();
+ expect(MergedOutputList).not.toHaveBeenCalled();
+ });
+
+ it('renders the form when there are no plain fields but there are fieldsets', () => {
+ const kickoff = {
+ description: '',
+ fields: [],
+ fieldsets: [],
+ };
+
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldset({
+ fields: [makeField('fs-field-1', 1)],
+ order: 1,
+ }),
+ ];
+
+ renderWithIntl(
+ jest.fn())}
+ />,
+ );
+
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+ expect(MergedOutputList).toHaveBeenCalledTimes(1);
+ expect(MergedOutputList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fields: [],
+ fieldsets,
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('forwards onEditField as onEditFieldsetField when a dedicated handler is not provided', () => {
+ const onEditField = jest.fn(() => jest.fn());
+ const kickoff = {
+ description: '',
+ fields: [makeField('f1', 0)],
+ fieldsets: [],
+ };
+
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldset({
+ fields: [makeField('fs-field-1', 1)],
+ order: 1,
+ }),
+ ];
+
+ renderWithIntl(
+ ,
+ );
+
+ expect(MergedOutputList).toHaveBeenCalledTimes(1);
+ expect(MergedOutputList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ onEditField,
+ onEditFieldsetField: onEditField,
+ }),
+ expect.anything(),
+ );
+ });
+});
diff --git a/frontend/src/public/components/KickoffOutputs/KickoffOutputs.css b/frontend/src/public/components/KickoffOutputs/KickoffOutputs.css
index f2bacff82..0d46506d9 100644
--- a/frontend/src/public/components/KickoffOutputs/KickoffOutputs.css
+++ b/frontend/src/public/components/KickoffOutputs/KickoffOutputs.css
@@ -81,3 +81,27 @@
color: var(--pneumatic-color-link-hover);
}
}
+
+.fieldset-output-group {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ padding-left: 12px;
+ border-left: 3px solid var(--pneumatic-color-link);
+}
+
+.fieldset-output-group__title {
+ margin: 0 0 4px;
+ font-family: Nunito, sans-serif;
+ font-size: 14px;
+ font-weight: bold;
+ line-height: 20px;
+ color: #262522;
+}
+
+.fieldset-output-group__description {
+ margin: 0 0 8px;
+ font-family: Nunito, sans-serif;
+ font-size: 13px;
+ line-height: 18px;
+ color: #79756d;
+}
diff --git a/frontend/src/public/components/KickoffOutputs/KickoffOutputs.tsx b/frontend/src/public/components/KickoffOutputs/KickoffOutputs.tsx
index bc3bb2b59..3b4a00bff 100644
--- a/frontend/src/public/components/KickoffOutputs/KickoffOutputs.tsx
+++ b/frontend/src/public/components/KickoffOutputs/KickoffOutputs.tsx
@@ -1,10 +1,12 @@
/* eslint-disable */
-import React from 'react';
+import * as React from 'react';
import classnames from 'classnames';
import { useIntl } from 'react-intl';
import { EditIcon } from '../icons';
import { EExtraFieldType, IExtraField } from '../../types/template';
+import { IFieldsetRuntime } from '../../types/fieldset';
+import { TOutputItem } from './types';
import { CheckboxOutput } from './CheckboxOutput';
import { RadioOutput } from './RadioOutput';
@@ -12,7 +14,7 @@ import { TextOutput } from './TextOutput';
import { UrlOutput } from './UrlOutput';
import { FileOutput } from './FileOutput';
import { UserOutput } from './UserOutput';
-import { flatten, isArrayWithItems } from '../../utils/helpers';
+import { isArrayWithItems } from '../../utils/helpers';
import { Attachments } from '../Attachments';
import { TUploadedFile } from '../../utils/uploadFiles';
import { RichText } from '../RichText';
@@ -29,6 +31,7 @@ export interface IKickoffOutputs {
description?: string | null;
viewMode: EKickoffOutputsViewModes;
outputs?: IExtraField[];
+ fieldsets: IFieldsetRuntime[];
onEdit?(): void;
isOnlyAttachmentsShown?: boolean;
isTruncated?: boolean;
@@ -38,50 +41,100 @@ export function KickoffOutputs({
containerClassName,
viewMode,
outputs,
+ fieldsets,
description,
onEdit,
isOnlyAttachmentsShown = false,
isTruncated,
}: IKickoffOutputs) {
- if (!outputs || !isArrayWithItems(outputs)) return null;
+ if ((!outputs || !isArrayWithItems(outputs)) && !isArrayWithItems(fieldsets)) return null;
+
+
+ const orderedOutputs: TOutputItem[] = [
+ ...(outputs || []).map((field): TOutputItem => ({ kind: 'field', order: field.order, data: field })),
+ ...(fieldsets || []).map((fs): TOutputItem => ({ kind: 'fieldset', order: fs.order, data: fs })),
+ ].sort((a, b) => b.order - a.order);
if (isOnlyAttachmentsShown) {
- const fileOutputs = outputs.filter(({ type }) => type === EExtraFieldType.File);
- const attachments = flatten(fileOutputs.map(({ attachments }) => attachments || [])) as TUploadedFile[];
- return ;
+ const fileAttachments = orderedOutputs.flatMap((item) => {
+ if (item.kind === 'field') {
+ return item.data.type === EExtraFieldType.File ? (item.data.attachments || []) : [];
+ }
+
+ return item.data.fields
+ .filter(({ type }) => type === EExtraFieldType.File)
+ .flatMap(({ attachments }) => attachments || []);
+ }) as TUploadedFile[];
+
+ return ;
}
const { formatMessage, messages } = useIntl();
+ const outputsMap: { [key in EExtraFieldType]: Function } = {
+ [EExtraFieldType.Number]: TextOutput,
+ [EExtraFieldType.Checkbox]: CheckboxOutput,
+ [EExtraFieldType.Creatable]: RadioOutput,
+ [EExtraFieldType.Date]: TextOutput,
+ [EExtraFieldType.Radio]: RadioOutput,
+ [EExtraFieldType.String]: TextOutput,
+ [EExtraFieldType.Text]: TextOutput,
+ [EExtraFieldType.Url]: UrlOutput,
+ [EExtraFieldType.File]: FileOutput,
+ [EExtraFieldType.User]: UserOutput,
+ };
+
+ const renderSingleOutput = (output: IExtraField, key: string | number) => {
+ const OutputComponent = outputsMap[output.type];
+ const value = output.type === EExtraFieldType.User ? output.userId || output.groupId : output.value;
+ const hasValue = Array.isArray(value) ? value.length > 0 : Boolean(value);
+ const isEmpty = !(hasValue || output.attachments?.length);
+ return !isEmpty ? : null;
+ };
+
+ const renderFieldsetGroup = (fieldset: IFieldsetRuntime, children: React.ReactNode, key?: string) => (
+
+ {fieldset.name &&
{fieldset.name}
}
+ {fieldset.description &&
{fieldset.description}
}
+ {children}
+
+ );
+
const renderOutputsList = () => {
- const outputsMap: { [key in EExtraFieldType]: Function } = {
- [EExtraFieldType.Number]: TextOutput,
- [EExtraFieldType.Checkbox]: CheckboxOutput,
- [EExtraFieldType.Creatable]: RadioOutput,
- [EExtraFieldType.Date]: TextOutput,
- [EExtraFieldType.Radio]: RadioOutput,
- [EExtraFieldType.String]: TextOutput,
- [EExtraFieldType.Text]: TextOutput,
- [EExtraFieldType.Url]: UrlOutput,
- [EExtraFieldType.File]: FileOutput,
- [EExtraFieldType.User]: UserOutput,
- };
-
- if (isTruncated) {
- const firstOutput = outputs[0];
- const OutputComponent = outputsMap[firstOutput.type];
-
- return ;
+ if (isTruncated && orderedOutputs.length > 0) {
+ const firstItem = orderedOutputs[0];
+ if (firstItem.kind === 'field') {
+ return renderSingleOutput(firstItem.data, 'truncated-field');
+ }
+
+ if (firstItem.data.fields.length) {
+ const fieldset = firstItem.data;
+ return renderFieldsetGroup(
+ fieldset,
+ renderSingleOutput(fieldset.fields[0], 'truncated-fieldset-field'),
+ );
+ }
}
- return outputs?.map((output, index) => {
- const OutputComponent = outputsMap[output.type];
- const value = output.type === EExtraFieldType.User ? output.userId || output.groupId : output.value;
- const hasValue = Array.isArray(value) ? value.length > 0 : Boolean(value);
- const isEmpty = !(hasValue || output.attachments?.length);
-
- return !isEmpty ? : null;
- });
+ return (
+ <>
+ {orderedOutputs.map((item, index) => {
+ if (item.kind === 'field') {
+ return renderSingleOutput(item.data, `field-${item.data.apiName || index}`);
+ }
+
+ const fieldset = item.data;
+
+ return renderFieldsetGroup(
+ fieldset,
+ fieldset.fields.map((output, fieldIndex) =>
+ renderSingleOutput(output, `fieldset-${fieldset.apiNameBinding}-${output.apiName || fieldIndex}`),
+ ),
+ `fieldset-${fieldset.apiNameBinding}`,
+ );
+ })}
+ >
+ );
};
const renderTitle = () => {
diff --git a/frontend/src/public/components/KickoffOutputs/__tests__/CheckboxOutput.test.tsx b/frontend/src/public/components/KickoffOutputs/__tests__/CheckboxOutput.test.tsx
index a0cfc320a..3d61ab601 100644
--- a/frontend/src/public/components/KickoffOutputs/__tests__/CheckboxOutput.test.tsx
+++ b/frontend/src/public/components/KickoffOutputs/__tests__/CheckboxOutput.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
diff --git a/frontend/src/public/components/KickoffOutputs/__tests__/KickoffOutputs.test.tsx b/frontend/src/public/components/KickoffOutputs/__tests__/KickoffOutputs.test.tsx
new file mode 100644
index 000000000..c467054c6
--- /dev/null
+++ b/frontend/src/public/components/KickoffOutputs/__tests__/KickoffOutputs.test.tsx
@@ -0,0 +1,218 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { KickoffOutputs, EKickoffOutputsViewModes } from '../KickoffOutputs';
+import { makeExtraField } from '../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../__stubs__/fieldsets.factory';
+import { EExtraFieldType, IExtraField } from '../../../types/template';
+import { Attachments } from '../../Attachments';
+
+type TOutputMockProps = Pick;
+
+jest.mock('../../Attachments', () => ({
+ Attachments: jest.fn(() => null),
+}));
+
+jest.mock('../CheckboxOutput', () => ({ CheckboxOutput: () => null }));
+jest.mock('../RadioOutput', () => ({ RadioOutput: () => null }));
+jest.mock('../TextOutput', () => ({
+ TextOutput: jest.fn(({ apiName, name }: TOutputMockProps) => (
+ {name}
+ )),
+}));
+jest.mock('../UrlOutput', () => ({ UrlOutput: () => null }));
+jest.mock('../FileOutput', () => ({
+ FileOutput: jest.fn(({ apiName, name }: TOutputMockProps) => (
+ {name}
+ )),
+}));
+jest.mock('../UserOutput', () => ({ UserOutput: () => null }));
+jest.mock('../../icons', () => ({ EditIcon: () => null }));
+jest.mock('../../RichText', () => ({ RichText: () => null }));
+
+describe('KickoffOutputs', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+
+ const getAttachmentsProps = () => {
+ const mock = Attachments as jest.Mock;
+ const lastCall = mock.mock.calls[mock.mock.calls.length - 1];
+ return lastCall[0].attachments;
+ };
+
+ const baseProps = {
+ viewMode: EKickoffOutputsViewModes.Short,
+ isOnlyAttachmentsShown: true,
+ };
+
+ it('does not render the block when there are neither plain fields nor fieldsets', () => {
+ const { container } = render(React.createElement(KickoffOutputs, {
+ viewMode: EKickoffOutputsViewModes.Detailed,
+ outputs: [],
+ fieldsets: [],
+ }));
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('interleaves plain fields and fieldsets in a single list ordered by `order` descending', () => {
+ const fieldA = makeExtraField({ apiName: 'fa', order: 1, value: 'av' });
+ const fieldB = makeExtraField({ apiName: 'fb', order: 4, value: 'bv' });
+ const fsM = makeFieldsetRuntime({
+ apiNameBinding: 'fs-m',
+ order: 3,
+ fields: [makeExtraField({ apiName: 'fs-m-1', value: 'mv' })],
+ });
+ const fsN = makeFieldsetRuntime({
+ apiNameBinding: 'fs-n',
+ order: 2,
+ fields: [makeExtraField({ apiName: 'fs-n-1', value: 'nv' })],
+ });
+
+ render(React.createElement(KickoffOutputs, {
+ viewMode: EKickoffOutputsViewModes.Short,
+ outputs: [fieldA, fieldB],
+ fieldsets: [fsM, fsN],
+ }));
+
+ const rendered = screen.getAllByTestId(/^text-output-/);
+ expect(rendered.map((el) => el.getAttribute('data-testid'))).toEqual([
+ 'text-output-fb',
+ 'text-output-fs-m-1',
+ 'text-output-fs-n-1',
+ 'text-output-fa',
+ ]);
+ });
+
+ it('in full mode renders fieldset group: name, description and only non-empty fields', () => {
+ const fieldset = makeFieldsetRuntime({
+ name: 'Contacts',
+ description: 'Reachout details',
+ order: 1,
+ fields: [
+ makeExtraField({ apiName: 'email', name: 'Email', value: 'a@b.com', order: 1 }),
+ makeExtraField({ apiName: 'phone', name: 'Phone', order: 2 }),
+ makeExtraField({ apiName: 'city', name: 'City', value: 'NY', order: 3 }),
+ ],
+ });
+
+ render(React.createElement(KickoffOutputs, {
+ viewMode: EKickoffOutputsViewModes.Short,
+ outputs: [],
+ fieldsets: [fieldset],
+ }));
+
+ expect(screen.getByText('Contacts')).toBeInTheDocument();
+ expect(screen.getByText('Reachout details')).toBeInTheDocument();
+
+ expect(screen.getByTestId('text-output-email')).toBeInTheDocument();
+ expect(screen.getByTestId('text-output-city')).toBeInTheDocument();
+ expect(screen.queryByTestId('text-output-phone')).toBeNull();
+ });
+
+ it('renders a file field inside a fieldset that has no value but has attachments', () => {
+ const fileField = makeExtraField({
+ apiName: 'doc',
+ name: 'Document',
+ type: EExtraFieldType.File,
+ value: null,
+ attachments: [{ id: 1, url: 'doc.pdf', name: 'doc.pdf', size: 10 }],
+ });
+
+ const fieldset = makeFieldsetRuntime({
+ name: 'Docs',
+ fields: [fileField],
+ });
+
+ render(React.createElement(KickoffOutputs, {
+ viewMode: EKickoffOutputsViewModes.Short,
+ outputs: [],
+ fieldsets: [fieldset],
+ }));
+
+ expect(screen.getByTestId('file-output-doc')).toBeInTheDocument();
+ });
+
+ it('in truncated mode renders fieldset group title and only the first field', () => {
+ const fieldset = makeFieldsetRuntime({
+ name: 'Profile',
+ order: 5,
+ fields: [
+ makeExtraField({ apiName: 'first', name: 'First', value: 'one' }),
+ makeExtraField({ apiName: 'second', name: 'Second', value: 'two' }),
+ makeExtraField({ apiName: 'third', name: 'Third', value: 'three' }),
+ ],
+ });
+
+ render(React.createElement(KickoffOutputs, {
+ viewMode: EKickoffOutputsViewModes.Short,
+ outputs: [],
+ fieldsets: [fieldset],
+ isTruncated: true,
+ }));
+
+ expect(screen.getByText('Profile')).toBeInTheDocument();
+ expect(screen.getByTestId('text-output-first')).toBeInTheDocument();
+ expect(screen.queryByTestId('text-output-second')).toBeNull();
+ expect(screen.queryByTestId('text-output-third')).toBeNull();
+ });
+
+ it('in truncated mode does not crash on a fieldset with an empty fields list', () => {
+ const fieldset = makeFieldsetRuntime({
+ name: 'Empty Group',
+ order: 1,
+ });
+
+ expect(() =>
+ render(React.createElement(KickoffOutputs, {
+ viewMode: EKickoffOutputsViewModes.Short,
+ outputs: [],
+ fieldsets: [fieldset],
+ isTruncated: true,
+ })),
+ ).not.toThrow();
+
+ expect(screen.queryByTestId(/^text-output-/)).toBeNull();
+ expect(screen.queryByTestId(/^file-output-/)).toBeNull();
+ });
+
+ describe('isOnlyAttachmentsShown — collecting attachments from fieldsets', () => {
+ it('collects file attachments from both outputs and fieldsets respecting order', () => {
+ const attachment1 = { id: 1, url: 'file1.pdf', name: 'file1.pdf', size: 100 };
+ const attachment2 = { id: 2, url: 'file2.png', name: 'file2.png', size: 200 };
+ const attachment3 = { id: 3, url: 'file3.doc', name: 'file3.doc', size: 300 };
+
+ const fileOutput = makeExtraField({
+ type: EExtraFieldType.File,
+ order: 2,
+ attachments: [attachment1],
+ });
+
+ const stringOutput = makeExtraField({
+ value: 'some text',
+ });
+
+ const fieldset = makeFieldsetRuntime({
+ order: 1,
+ fields: [
+ makeExtraField({ type: EExtraFieldType.File, attachments: [attachment2] }),
+ makeExtraField({ value: 'ignored' }),
+ makeExtraField({ type: EExtraFieldType.File, attachments: [attachment3] }),
+ ],
+ });
+
+ render(React.createElement(KickoffOutputs, {
+ ...baseProps,
+ outputs: [stringOutput, fileOutput],
+ fieldsets: [fieldset],
+ }));
+
+ expect(Attachments as jest.Mock).toHaveBeenCalledTimes(1);
+
+ const attachments = getAttachmentsProps();
+ expect(attachments).toEqual([attachment1, attachment2, attachment3]);
+ });
+ });
+});
diff --git a/frontend/src/public/components/KickoffOutputs/__tests__/RadioOutput.test.tsx b/frontend/src/public/components/KickoffOutputs/__tests__/RadioOutput.test.tsx
index 014aad074..ca660f92d 100644
--- a/frontend/src/public/components/KickoffOutputs/__tests__/RadioOutput.test.tsx
+++ b/frontend/src/public/components/KickoffOutputs/__tests__/RadioOutput.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
diff --git a/frontend/src/public/components/KickoffOutputs/types.ts b/frontend/src/public/components/KickoffOutputs/types.ts
new file mode 100644
index 000000000..f46a638a3
--- /dev/null
+++ b/frontend/src/public/components/KickoffOutputs/types.ts
@@ -0,0 +1,6 @@
+import { IExtraField } from '../../types/template';
+import { IFieldsetRuntime } from '../../types/fieldset';
+
+export type TOutputItem =
+ | { kind: 'field'; order: number; data: IExtraField }
+ | { kind: 'fieldset'; order: number; data: IFieldsetRuntime };
\ No newline at end of file
diff --git a/frontend/src/public/components/MergedOutputList/MergedOutputList.tsx b/frontend/src/public/components/MergedOutputList/MergedOutputList.tsx
new file mode 100644
index 000000000..e2f8483be
--- /dev/null
+++ b/frontend/src/public/components/MergedOutputList/MergedOutputList.tsx
@@ -0,0 +1,74 @@
+import * as React from 'react';
+import { EExtraFieldMode } from '../../types/template';
+import { EFieldLabelPosition } from '../../types/fieldset';
+import { ExtraFieldIntl } from '../TemplateEdit/ExtraFields';
+import { FieldsetFieldGroup } from '../FieldsetFieldGroup';
+import { buildRuntimeMergedOutputParts } from '../TemplateEdit/TaskOutputFlow/mergeTaskOutputFlow';
+import { useCheckDevice } from '../../hooks/useCheckDevice';
+import { IMergedOutputListProps } from './types';
+
+export function MergedOutputList({
+ fields,
+ fieldsets,
+ onEditField,
+ onEditFieldsetField,
+ labelBackgroundColor,
+ fieldClassName,
+ accountId,
+}: IMergedOutputListProps) {
+ const { isDesktop } = useCheckDevice();
+ const mergedOutputs = buildRuntimeMergedOutputParts(fields, fieldsets);
+
+ return (
+ <>
+ {mergedOutputs.map((mergedOutput) => {
+ if (mergedOutput.kind === 'field') {
+ const fieldData = mergedOutput.field;
+ const { apiName: fieldApiName, name: fieldName, description: fieldDescription } = fieldData;
+
+ return (
+
+ );
+ }
+ if (mergedOutput.kind === 'fieldset') {
+ const {
+ apiNameBinding: fieldsetApiName,
+ name: fieldsetName,
+ description: fieldsetDescription,
+ fields: fieldsetFields,
+ labelPosition: fieldsetLabelPosition,
+ } = mergedOutput.data;
+
+ return (
+
+ );
+ }
+
+ return null;
+ })}
+ >
+ );
+}
diff --git a/frontend/src/public/components/MergedOutputList/__tests__/MergedOutputList.test.tsx b/frontend/src/public/components/MergedOutputList/__tests__/MergedOutputList.test.tsx
new file mode 100644
index 000000000..64b9ff837
--- /dev/null
+++ b/frontend/src/public/components/MergedOutputList/__tests__/MergedOutputList.test.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import { MergedOutputList } from '../MergedOutputList';
+import { makeExtraField } from '../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../__stubs__/fieldsets.factory';
+import { IExtraField } from '../../../types/template';
+import { EInputNameBackgroundColor } from '../../../types/workflow';
+
+jest.mock('../../TemplateEdit/ExtraFields', () => ({
+ ExtraFieldIntl: jest.fn(({ field }: { field: IExtraField }) => (
+ {field.apiName}
+ )),
+}));
+
+jest.mock('../../FieldsetFieldGroup', () => ({
+ FieldsetFieldGroup: jest.fn(({ title }: { title: string }) => (
+ {title}
+ )),
+}));
+
+
+
+const baseProps = {
+ onEditField: jest.fn(() => jest.fn()),
+ onEditFieldsetField: jest.fn(() => jest.fn()),
+ labelBackgroundColor: EInputNameBackgroundColor.White,
+ accountId: 1,
+};
+
+describe('MergedOutputList', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders fields and fieldsets interleaved by order', () => {
+ const fields = [
+ makeExtraField({ apiName: 'field-a', name: 'field-a', order: 1 }),
+ makeExtraField({ apiName: 'field-b', name: 'field-b', order: 3 }),
+ ];
+ const fieldsets = [makeFieldsetRuntime({ apiNameBinding: 'fs-10', name: 'Fieldset Middle', order: 2 })];
+
+ const { container } = render(
+ ,
+ );
+
+ const items = container.querySelectorAll('[data-testid="extra-field"], [data-testid="fieldset-group"]');
+ expect(items).toHaveLength(3);
+
+ expect(items[0].textContent).toBe('field-b');
+ expect(items[1].textContent).toBe('Fieldset Middle');
+ expect(items[2].textContent).toBe('field-a');
+ });
+
+ it('renders only fields when no fieldsets provided', () => {
+ const fields = [makeExtraField({ apiName: 'solo', name: 'solo' })];
+
+ render();
+
+ expect(screen.getAllByTestId('extra-field')).toHaveLength(1);
+ expect(screen.queryByTestId('fieldset-group')).toBeNull();
+ });
+
+ it('renders only fieldsets when fields array is empty', () => {
+ const fieldsets = [makeFieldsetRuntime({ name: 'Only FS' })];
+
+ render();
+
+ expect(screen.queryByTestId('extra-field')).toBeNull();
+ expect(screen.getAllByTestId('fieldset-group')).toHaveLength(1);
+ });
+
+ it('renders nothing when both arrays are empty', () => {
+ const { container } = render();
+
+ expect(container.childElementCount).toBe(0);
+ });
+});
diff --git a/frontend/src/public/components/MergedOutputList/index.ts b/frontend/src/public/components/MergedOutputList/index.ts
new file mode 100644
index 000000000..e5604e8d1
--- /dev/null
+++ b/frontend/src/public/components/MergedOutputList/index.ts
@@ -0,0 +1,2 @@
+export { MergedOutputList } from './MergedOutputList';
+export type { IMergedOutputListProps } from './types';
diff --git a/frontend/src/public/components/MergedOutputList/types.ts b/frontend/src/public/components/MergedOutputList/types.ts
new file mode 100644
index 000000000..03ba63cf6
--- /dev/null
+++ b/frontend/src/public/components/MergedOutputList/types.ts
@@ -0,0 +1,13 @@
+import { IExtraField } from '../../types/template';
+import { IFieldsetRuntime } from '../../types/fieldset';
+import { EInputNameBackgroundColor } from '../../types/workflow';
+
+export interface IMergedOutputListProps {
+ fields: IExtraField[];
+ fieldsets?: IFieldsetRuntime[];
+ onEditField(apiName: string): (changedProps: Partial) => void;
+ onEditFieldsetField(apiName: string): (changedProps: Partial) => void;
+ labelBackgroundColor: EInputNameBackgroundColor;
+ fieldClassName?: string;
+ accountId: number;
+}
diff --git a/frontend/src/public/components/NotificationsList/NotificationsList.tsx b/frontend/src/public/components/NotificationsList/NotificationsList.tsx
index 84c487edf..738f904b2 100644
--- a/frontend/src/public/components/NotificationsList/NotificationsList.tsx
+++ b/frontend/src/public/components/NotificationsList/NotificationsList.tsx
@@ -5,7 +5,7 @@ import OutsideClickHandler from 'react-outside-click-handler';
import InfiniteScroll from 'react-infinite-scroll-component';
import { useIntl } from 'react-intl';
-import { getTaskDetailRoute } from '../../utils/routes';
+import { getTaskDetailRoute, getWorkflowDetailedRoute } from '../../utils/routes';
import { Avatar } from '../UI/Avatar';
import { getUserFullName } from '../../utils/users';
import { TNotificationsListItem } from '../../types';
@@ -263,6 +263,17 @@ export const NotificationsList = ({
link: getTaskDetailRoute(notification.task.id),
};
}
+ case 'complete_workflow': {
+ return {
+ ...commonProps,
+ avatar: ,
+ icon: ,
+ title: formatMessage({ id: 'general.pneumatic' }),
+ subtitle: notification.workflow.name,
+ text: formatMessage({ id: 'notifications.workflow-completed' }),
+ link: getWorkflowDetailedRoute(notification.workflow.id),
+ };
+ }
default:
return null;
}
diff --git a/frontend/src/public/components/NotificationsList/__tests__/NotificationsList.test.tsx b/frontend/src/public/components/NotificationsList/__tests__/NotificationsList.test.tsx
new file mode 100644
index 000000000..2a0ba1935
--- /dev/null
+++ b/frontend/src/public/components/NotificationsList/__tests__/NotificationsList.test.tsx
@@ -0,0 +1,168 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { NotificationsList, INotificationsListProps } from '../NotificationsList';
+import { TNotificationsListItem } from '../../../types';
+import { intlMock } from '../../../__stubs__/intlMock';
+
+jest.mock('react-dom', () => {
+ const actual = jest.requireActual('react-dom');
+ return {
+ ...actual,
+ default: { ...actual.default, createPortal: (el: React.ReactNode) => el },
+ };
+});
+
+jest.mock('react-outside-click-handler', () => {
+ return {
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => children,
+ };
+});
+
+jest.mock('react-infinite-scroll-component', () => {
+ return {
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => children,
+ };
+});
+
+jest.mock('../../NavLink', () => ({
+ NavLink: ({ children, to }: { children: React.ReactNode; to: string }) =>
+ React.createElement('a', { href: to, 'data-testid': 'nav-link' }, children),
+}));
+
+jest.mock('../../UI/Avatar', () => ({
+ Avatar: () => null,
+}));
+
+jest.mock('../../UI/Loader', () => ({
+ Loader: () => null,
+}));
+
+jest.mock('../../RichText', () => ({
+ RichText: () => null,
+}));
+
+jest.mock('../../UI', () => ({
+ Placeholder: () => null,
+ SectionTitle: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
+}));
+
+jest.mock('../../icons', () => {
+ const stub = () => null;
+ return {
+ ClearIcon: stub,
+ CommentInfoIcon: stub,
+ PneumaticAvatarIcon: stub,
+ UrgentColorIcon: stub,
+ NotUrgentIcon: stub,
+ AlarmIcon: stub,
+ AlarmCrossedIcon: stub,
+ TaskCompleteIcon: stub,
+ TrashIcon: stub,
+ };
+});
+
+jest.mock('../../UserData/utils/getUserById', () => ({
+ getUserById: jest.fn(),
+}));
+
+jest.mock('../../../utils/helpers', () => {
+ const actual = jest.requireActual('../../../utils/helpers');
+ return { isArrayWithItems: actual.isArrayWithItems };
+});
+
+jest.mock('../../../utils/users', () => ({
+ getUserFullName: jest.fn(() => ''),
+}));
+
+jest.mock('../../../utils/routes', () => ({
+ getTaskDetailRoute: jest.fn((id: number) => `/tasks/${id}`),
+ getWorkflowDetailedRoute: jest.fn((id: number) => `/workflows/${id}`),
+}));
+
+jest.mock('../../../utils/reactElementToText', () => ({
+ reactElementToText: jest.fn(() => ''),
+}));
+
+jest.mock('../../UI/DateFormat', () => ({
+ DateFormat: () => null,
+}));
+
+describe('NotificationsList', () => {
+ const formatMsg = (id: string) => intlMock.formatMessage({ id });
+
+ const WORKFLOW_COMPLETED_TEXT = formatMsg('notifications.workflow-completed');
+ const PNEUMATIC_TITLE = formatMsg('general.pneumatic');
+
+ const makeCompleteWorkflowNotification = (
+ overrides: Partial = {},
+ ): TNotificationsListItem => ({
+ id: 1,
+ status: 'new',
+ datetime: '2026-05-11T05:01:30.049960Z',
+ type: 'complete_workflow',
+ workflow: {
+ id: 34,
+ name: 'Test Workflow',
+ },
+ ...overrides,
+ } as TNotificationsListItem);
+
+ const baseProps: INotificationsListProps = {
+ users: [],
+ notifications: [],
+ isLoading: false,
+ withPaywall: false,
+ totalNotificationsCount: 0,
+ isClosing: false,
+ setNotificationsListIsOpen: jest.fn(),
+ removeNotificationItem: jest.fn(),
+ changeNotificationsList: jest.fn(),
+ fetchNotificationsAsRead: jest.fn(),
+ markNotificationsAsRead: jest.fn(),
+ loadNotificationsList: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('complete_workflow notification rendering', () => {
+ it('renders workflow name, "Workflow completed" text and Pneumatic title', () => {
+ const notification = makeCompleteWorkflowNotification({
+ workflow: { id: 34, name: 'My Important Process' },
+ });
+
+ render(
+ React.createElement(NotificationsList, {
+ ...baseProps,
+ notifications: [notification],
+ totalNotificationsCount: 1,
+ }),
+ );
+
+ expect(screen.getByText('My Important Process')).toBeInTheDocument();
+ expect(screen.getByText(WORKFLOW_COMPLETED_TEXT)).toBeInTheDocument();
+ expect(screen.getByText(PNEUMATIC_TITLE)).toBeInTheDocument();
+ });
+
+ it('links to the workflow detail page', () => {
+ const notification = makeCompleteWorkflowNotification({
+ workflow: { id: 42, name: 'Linked Workflow' },
+ });
+
+ render(
+ React.createElement(NotificationsList, {
+ ...baseProps,
+ notifications: [notification],
+ totalNotificationsCount: 1,
+ }),
+ );
+
+ const link = screen.getByTestId('nav-link');
+ expect(link).toHaveAttribute('href', '/workflows/42');
+ });
+ });
+});
diff --git a/frontend/src/public/components/PageTitle/PageTitle.tsx b/frontend/src/public/components/PageTitle/PageTitle.tsx
index cff28bdb3..5d0d4ac73 100644
--- a/frontend/src/public/components/PageTitle/PageTitle.tsx
+++ b/frontend/src/public/components/PageTitle/PageTitle.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import * as React from 'react';
import { useIntl } from 'react-intl';
import classnames from 'classnames';
@@ -17,6 +17,7 @@ const tooltipTextMap: { [key in EPageTitle]: string } = {
[EPageTitle.Integrations]: 'integrations.title.tooltip',
[EPageTitle.Team]: 'team.title.tooltip',
[EPageTitle.Datasets]: 'datasets.title.tooltip',
+ [EPageTitle.Fieldsets]: 'fieldsets.title.tooltip',
};
const learnMoreLinkMap: { [key in EPageTitle]: ELearnMoreLinks } = {
@@ -28,6 +29,7 @@ const learnMoreLinkMap: { [key in EPageTitle]: ELearnMoreLinks } = {
[EPageTitle.Integrations]: ELearnMoreLinks.Integrations,
[EPageTitle.Team]: ELearnMoreLinks.Team,
[EPageTitle.Datasets]: ELearnMoreLinks.Datasets,
+ [EPageTitle.Fieldsets]: ELearnMoreLinks.Fieldsets,
};
export interface IPageTitleProps {
diff --git a/frontend/src/public/components/PageTitle/__tests__/PageTitle.test.tsx b/frontend/src/public/components/PageTitle/__tests__/PageTitle.test.tsx
new file mode 100644
index 000000000..7f73c005d
--- /dev/null
+++ b/frontend/src/public/components/PageTitle/__tests__/PageTitle.test.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { PageTitle } from '../PageTitle';
+import { EPageTitle } from '../../../constants/defaultValues';
+import { intlMock } from '../../../__stubs__/intlMock';
+
+type TTooltipMockProps = {
+ content: React.ReactNode;
+ children: React.ReactNode;
+};
+
+type THeaderMockProps = {
+ children: React.ReactNode;
+};
+
+jest.mock('../../UI', () => ({
+ Tooltip: ({ content, children }: TTooltipMockProps) => (
+
+ {children}
+
{content}
+
+ ),
+ Header: ({ children }: THeaderMockProps) => {children}
,
+}));
+
+jest.mock('../../icons', () => ({
+ FilledInfoIcon: () => null,
+}));
+
+describe('PageTitle for the Fieldsets page', () => {
+ it('renders the localized "Fieldsets" heading', () => {
+ render();
+
+ const expectedTitle = intlMock.formatMessage({ id: EPageTitle.Fieldsets });
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(expectedTitle);
+ });
+
+ it('renders the localized fieldsets tooltip content', () => {
+ render();
+
+ const expectedTooltip = intlMock.formatMessage({ id: 'fieldsets.title.tooltip' });
+ const tooltipContent = screen.getByTestId('tooltip-content');
+ expect(tooltipContent).toHaveTextContent(expectedTooltip);
+ });
+
+ it('does not render a "Learn more" link in the tooltip content', () => {
+ render();
+
+ expect(screen.queryByRole('link')).toBeNull();
+ expect(screen.queryByText(/learn more/i)).toBeNull();
+ });
+});
diff --git a/frontend/src/public/components/Profile/ProfileManagerWithFormik.test.tsx b/frontend/src/public/components/Profile/ProfileManagerWithFormik.test.tsx
index e99631b15..61415ce81 100644
--- a/frontend/src/public/components/Profile/ProfileManagerWithFormik.test.tsx
+++ b/frontend/src/public/components/Profile/ProfileManagerWithFormik.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
diff --git a/frontend/src/public/components/PublicFormsApp/common/PublicForm/PublicForm.tsx b/frontend/src/public/components/PublicFormsApp/common/PublicForm/PublicForm.tsx
index 154c86180..ae271649f 100644
--- a/frontend/src/public/components/PublicFormsApp/common/PublicForm/PublicForm.tsx
+++ b/frontend/src/public/components/PublicFormsApp/common/PublicForm/PublicForm.tsx
@@ -7,14 +7,16 @@ import classnames from 'classnames';
import { useDispatch } from 'react-redux';
import { NotificationManager } from '../../../UI/Notifications';
-import { ExtraFieldIntl } from '../../../TemplateEdit/ExtraFields';
-import { EExtraFieldMode, IExtraField } from '../../../../types/template';
+import { IExtraField } from '../../../../types/template';
import { Button } from '../../../UI/Buttons/Button';
import { getEditedFields } from '../../../TemplateEdit/ExtraFields/utils/getEditedFields';
import { EInputNameBackgroundColor } from '../../../../types/workflow';
import { getPublicForm } from '../../../../api/getPublicForm';
import { EPublicFormState, IPublicForm } from '../types';
import { logger } from '../../../../utils/logger';
+import { getErrorMessage } from '../../../../utils/getErrorMessage';
+import { MergedOutputList } from '../../../MergedOutputList';
+
import { runPublicForm } from '../../../../api/runPublicForm';
import { checkExtraFieldsAreValid } from '../../../WorkflowEditPopup/utils/areKickoffFieldsValid';
import { ExtraFieldsHelper } from '../../../TemplateEdit/ExtraFields/utils/ExtraFieldsHelper';
@@ -91,8 +93,14 @@ export function PublicForm({ type }: IPublicFormsAppProps) {
try {
setFormState(EPublicFormState.Submitting);
- await deleteRemovedFilesFromFields(publicForm.kickoff.fields);
- const normalizedKickoffFileds = getNormalizedKickoff(publicForm.kickoff);
+ const allFieldsetFields = publicForm.kickoff.fieldsets.flatMap((fieldset) => fieldset.fields);
+ const mergedKickoff = {
+ ...publicForm.kickoff,
+ fields: [...publicForm.kickoff.fields, ...allFieldsetFields],
+ };
+
+ await deleteRemovedFilesFromFields(mergedKickoff.fields);
+ const normalizedKickoffFileds = getNormalizedKickoff(mergedKickoff);
const runFormResult = await runPublicForm(captcha, normalizedKickoffFileds);
if (runFormResult?.redirectUrl) {
@@ -103,28 +111,32 @@ export function PublicForm({ type }: IPublicFormsAppProps) {
setFormState(EPublicFormState.Submitted);
} catch (error) {
- NotificationManager.notifyApiError(error, { message: 'public-form.submit-failed' });
+ NotificationManager.notifyApiError(error, { message: getErrorMessage(error) });
logger.error('Failed to run public form', error);
setFormState(EPublicFormState.WaitingForAction);
}
};
- const handleEditField = (apiName: string) => (changedProps: Partial) => {
- setPublicForm((prevPublicForm) => {
- if (!prevPublicForm) {
- return prevPublicForm;
- }
- const oldFields = prevPublicForm.kickoff.fields;
- const newFields = getEditedFields(oldFields, apiName, changedProps);
+ const updatePublicForm = (updater: (draft: IPublicForm) => void) => {
+ setPublicForm((prevPublicForm) => {
+ if (!prevPublicForm) return prevPublicForm;
+ return produce(prevPublicForm, updater);
+ });
+ };
- const newPublicForm = produce(prevPublicForm, (draftPublicForm) => {
- if (draftPublicForm) {
- draftPublicForm.kickoff.fields = newFields;
- }
- });
+ const handleEditField = (apiName: string) => (changedProps: Partial) => {
+ updatePublicForm((draft) => {
+ draft.kickoff.fields = getEditedFields(draft.kickoff.fields, apiName, changedProps);
+ });
+ };
- return newPublicForm;
+ const handleEditFieldsetField = (apiName: string) => (changedProps: Partial) => {
+ updatePublicForm((draft) => {
+ draft.kickoff.fieldsets = draft.kickoff.fieldsets.map((fieldset) => ({
+ ...fieldset,
+ fields: getEditedFields(fieldset.fields, apiName, changedProps),
+ }));
});
};
@@ -139,22 +151,18 @@ export function PublicForm({ type }: IPublicFormsAppProps) {
return (
<>
- {publicForm.kickoff.fields
- .filter((field) => !field.isHidden)
- .map((field) => (
-
- ))}
+ !field.isHidden)}
+ fieldsets={publicForm.kickoff.fieldsets.map((fieldset) => ({
+ ...fieldset,
+ fields: fieldset.fields.filter((field) => !field.isHidden),
+ }))}
+ onEditField={handleEditField}
+ onEditFieldsetField={handleEditFieldsetField}
+ labelBackgroundColor={EInputNameBackgroundColor.OrchidWhite}
+ fieldClassName={styles['output__field']}
+ accountId={publicForm.accountId}
+ />
{isEnvCaptcha && publicForm?.showCaptcha && (
@@ -224,6 +232,7 @@ export function PublicForm({ type }: IPublicFormsAppProps) {
const isCompleteDisabled = [
formState !== EPublicFormState.WaitingForAction,
!checkExtraFieldsAreValid(publicForm?.kickoff.fields),
+ publicForm?.kickoff.fieldsets?.some((fieldset) => !checkExtraFieldsAreValid(fieldset.fields)),
publicForm?.showCaptcha && !captcha,
].some(Boolean);
diff --git a/frontend/src/public/components/PublicFormsApp/common/PublicForm/__tests__/PublicForm.test.tsx b/frontend/src/public/components/PublicFormsApp/common/PublicForm/__tests__/PublicForm.test.tsx
index 0fc46e4d9..448b07244 100644
--- a/frontend/src/public/components/PublicFormsApp/common/PublicForm/__tests__/PublicForm.test.tsx
+++ b/frontend/src/public/components/PublicFormsApp/common/PublicForm/__tests__/PublicForm.test.tsx
@@ -1,17 +1,69 @@
import * as React from 'react';
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { useDispatch } from 'react-redux';
import { PublicForm } from '../PublicForm';
import { getPublicForm } from '../../../../../api/getPublicForm';
-import { EExtraFieldType } from '../../../../../types/template';
+import { runPublicForm } from '../../../../../api/runPublicForm';
+import { MergedOutputList } from '../../../../MergedOutputList';
+import { NotificationManager } from '../../../../UI/Notifications';
+import { makeExtraField } from '../../../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../../../__stubs__/fieldsets.factory';
+import { IExtraField } from '../../../../../types/template';
+import { IFieldsetRuntime } from '../../../../../types/fieldset';
+import { intlMock } from '../../../../../__stubs__/intlMock';
jest.mock('../../../../../api/getPublicForm', () => ({
getPublicForm: jest.fn(),
}));
-jest.mock('../../../../TemplateEdit/ExtraFields', () => ({
- ExtraFieldIntl: jest.fn(() =>
),
+jest.mock('../../../../../api/runPublicForm', () => ({
+ runPublicForm: jest.fn().mockResolvedValue({ redirectUrl: null }),
+}));
+
+jest.mock('../../../../../api/deleteRemovedFilesFromFields', () => ({
+ deleteRemovedFilesFromFields: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('../../../../../utils/mappers', () => ({
+ getNormalizedKickoff: jest.fn((kickoff: { fields: IExtraField[] }) => {
+ const result: Record
= {};
+ for (const field of kickoff.fields) {
+ result[field.apiName] = String(field.value || '');
+ }
+ return result;
+ }),
+}));
+
+jest.mock('../../../../MergedOutputList', () => ({
+ MergedOutputList: jest.fn(() => React.createElement('div', { 'data-testid': 'merged-output-list' })),
+}));
+
+jest.mock('../../../../UI/Buttons/Button', () => ({
+ Button: (props: { label: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', {
+ type: 'button',
+ disabled: props.disabled,
+ onClick: props.onClick,
+ }, props.label),
+}));
+
+jest.mock('../../../../UI/Typeography/Header', () => ({
+ Header: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('h1', null, children),
+}));
+
+jest.mock('../../../../RichText', () => ({
+ RichText: ({ text }: { text: string }) => React.createElement('span', null, text),
+}));
+
+jest.mock('../../Copyright', () => ({
+ Copyright: () => null,
+}));
+
+jest.mock('../../FormSkeleton', () => ({
+ FormSkeleton: () => React.createElement('div', { 'data-testid': 'skeleton' }),
}));
jest.mock('../../../../../utils/getConfig', () => ({
@@ -32,16 +84,46 @@ jest.mock('../../../../../constants/enviroment', () => ({
isEnvCaptcha: false,
}));
-const makeField = (overrides = {}) => ({
- apiName: `f-${Math.random()}`,
- name: 'Field',
- type: EExtraFieldType.String,
- order: 0,
- userId: null,
- groupId: null,
- ...overrides,
+jest.mock('../../../../../hooks/useShouldHideIntercom', () => ({
+ useShouldHideIntercom: jest.fn(),
+}));
+
+jest.mock('../../../../../utils/logger', () => ({
+ logger: { error: jest.fn(), info: jest.fn() },
+}));
+
+jest.mock('../../../../UI/Notifications', () => ({
+ NotificationManager: { notifyApiError: jest.fn(), success: jest.fn(), warning: jest.fn() },
+}));
+
+const formatMsg = (id: string) => intlMock.formatMessage({ id });
+const SUBMIT_LABEL = formatMsg('public-form.launch');
+
+const makePublicFormResponse = (overrides: {
+ fields?: IExtraField[];
+ fieldsets?: IFieldsetRuntime[];
+} = {}) => ({
+ accountId: 1,
+ name: 'Test Form',
+ description: '',
+ showCaptcha: false,
+ kickoff: {
+ description: '',
+ fields: overrides.fields || [],
+ fieldsets: overrides.fieldsets || [],
+ },
});
+const getLastMergedOutputListProps = () => {
+ const mock = MergedOutputList as jest.Mock;
+ return mock.mock.calls[mock.mock.calls.length - 1][0] as {
+ fields: IExtraField[];
+ fieldsets: IFieldsetRuntime[];
+ onEditField: (apiName: string) => (changedProps: Partial) => void;
+ onEditFieldsetField: (apiName: string) => (changedProps: Partial) => void;
+ };
+};
+
describe('PublicForm', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -50,26 +132,259 @@ describe('PublicForm', () => {
describe('Filtering fields by isHidden', () => {
it('renders only visible fields from a mixed list', async () => {
- (getPublicForm as jest.Mock).mockResolvedValue({
- accountId: 1,
- name: 'Test Form',
- description: '',
- showCaptcha: false,
- kickoff: {
- description: '',
+ (getPublicForm as jest.Mock).mockResolvedValue(
+ makePublicFormResponse({
fields: [
- makeField({ apiName: 'f1', isHidden: true }),
- makeField({ apiName: 'f2', isHidden: false }),
- makeField({ apiName: 'f3' }),
+ makeExtraField({ apiName: 'f1', isHidden: true }),
+ makeExtraField({ apiName: 'f2', isHidden: false }),
+ makeExtraField({ apiName: 'f3' }),
],
- },
+ }),
+ );
+
+ render(React.createElement(PublicForm, { type: 'shared' }));
+
+ await waitFor(() => {
+ expect(MergedOutputList).toHaveBeenCalled();
+ });
+
+ const props = getLastMergedOutputListProps();
+ expect(props.fields).toHaveLength(2);
+ expect(props.fields.map((field: IExtraField) => field.apiName)).toEqual(
+ expect.arrayContaining(['f2', 'f3']),
+ );
+ });
+ });
+
+ describe('Fieldsets: rendering', () => {
+ it('passes fieldsets to MergedOutputList', async () => {
+ const fieldset = makeFieldsetRuntime({
+ fields: [makeExtraField({ apiName: 'fs-field-1', order: 1 })],
+ order: 1,
+ });
+
+ (getPublicForm as jest.Mock).mockResolvedValue(
+ makePublicFormResponse({
+ fields: [makeExtraField({ apiName: 'k1', order: 0 })],
+ fieldsets: [fieldset],
+ }),
+ );
+
+ render(React.createElement(PublicForm, { type: 'shared' }));
+
+ await waitFor(() => {
+ expect(MergedOutputList).toHaveBeenCalled();
+ });
+
+ expect(MergedOutputList).toHaveBeenCalledTimes(1);
+ expect(MergedOutputList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fieldsets: expect.arrayContaining([
+ expect.objectContaining({ apiNameBinding: 'fs-1' }),
+ ]),
+ }),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('Fieldsets: isHidden filtering', () => {
+ it('filters out isHidden fields inside fieldsets', async () => {
+ const fieldset = makeFieldsetRuntime({
+ fields: [
+ makeExtraField({ apiName: 'fs-hidden', isHidden: true, order: 1 }),
+ makeExtraField({ apiName: 'fs-visible', isHidden: false, order: 2 }),
+ makeExtraField({ apiName: 'fs-default', order: 3 }),
+ ],
+ order: 1,
});
- render();
+ (getPublicForm as jest.Mock).mockResolvedValue(
+ makePublicFormResponse({ fieldsets: [fieldset] }),
+ );
+
+ render(React.createElement(PublicForm, { type: 'shared' }));
await waitFor(() => {
- expect(screen.getAllByTestId('extra-field')).toHaveLength(2);
+ expect(MergedOutputList).toHaveBeenCalled();
});
+
+ const props = getLastMergedOutputListProps();
+ const fieldsetFields = props.fieldsets[0].fields;
+ expect(fieldsetFields).toHaveLength(2);
+ expect(fieldsetFields.map((field: IExtraField) => field.apiName)).toEqual(
+ expect.arrayContaining(['fs-visible', 'fs-default']),
+ );
+ });
+ });
+
+ describe('Fieldsets: validation', () => {
+ it('disables Submit when fieldset has an empty required field', async () => {
+ (getPublicForm as jest.Mock).mockResolvedValue(
+ makePublicFormResponse({
+ fields: [makeExtraField({ apiName: 'k1', value: 'filled' })],
+ fieldsets: [
+ makeFieldsetRuntime({
+ fields: [makeExtraField({ apiName: 'fs-req', isRequired: true, value: '', order: 1 })],
+ order: 1,
+ }),
+ ],
+ }),
+ );
+
+ render(React.createElement(PublicForm, { type: 'shared' }));
+
+ const submitButton = await screen.findByRole('button', { name: SUBMIT_LABEL });
+ expect(submitButton).toBeDisabled();
+ });
+
+ it('enables Submit when all required fieldset fields are filled', async () => {
+ (getPublicForm as jest.Mock).mockResolvedValue(
+ makePublicFormResponse({
+ fields: [makeExtraField({ apiName: 'k1', value: 'filled' })],
+ fieldsets: [
+ makeFieldsetRuntime({
+ fields: [makeExtraField({ apiName: 'fs-req', isRequired: true, value: 'also filled', order: 1 })],
+ order: 1,
+ }),
+ ],
+ }),
+ );
+
+ render(React.createElement(PublicForm, { type: 'shared' }));
+
+ const submitButton = await screen.findByRole('button', { name: SUBMIT_LABEL });
+ expect(submitButton).not.toBeDisabled();
+ });
+
+ it('disables Submit when one of multiple fieldsets has an empty required field', async () => {
+ (getPublicForm as jest.Mock).mockResolvedValue(
+ makePublicFormResponse({
+ fieldsets: [
+ makeFieldsetRuntime({
+ apiNameBinding: 'fs-ok',
+ fields: [makeExtraField({ apiName: 'ok-field', isRequired: true, value: 'filled', order: 1 })],
+ order: 1,
+ }),
+ makeFieldsetRuntime({
+ apiNameBinding: 'fs-bad',
+ fields: [makeExtraField({ apiName: 'bad-field', isRequired: true, value: '', order: 1 })],
+ order: 2,
+ }),
+ ],
+ }),
+ );
+
+ render(React.createElement(PublicForm, { type: 'shared' }));
+
+ const submitButton = await screen.findByRole('button', { name: SUBMIT_LABEL });
+ expect(submitButton).toBeDisabled();
+ });
+ });
+
+ describe('Fieldsets: submit', () => {
+ it('sends fieldset fields merged into kickoff on submit', async () => {
+ (getPublicForm as jest.Mock).mockResolvedValue(
+ makePublicFormResponse({
+ fields: [makeExtraField({ apiName: 'k1', value: 'kickoff-val' })],
+ fieldsets: [
+ makeFieldsetRuntime({
+ fields: [makeExtraField({ apiName: 'fs1', value: 'fs-val', order: 1 })],
+ order: 1,
+ }),
+ ],
+ }),
+ );
+
+ render(React.createElement(PublicForm, { type: 'shared' }));
+
+ const submitButton = await screen.findByRole('button', { name: SUBMIT_LABEL });
+ userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(runPublicForm).toHaveBeenCalledTimes(1);
+ });
+
+ expect(runPublicForm).toHaveBeenCalledWith(
+ '',
+ expect.objectContaining({
+ 'k1': 'kickoff-val',
+ 'fs1': 'fs-val',
+ }),
+ );
+ });
+ });
+
+ describe('Fieldsets: editing', () => {
+ it('updates fieldset field value via onEditFieldsetField', async () => {
+ (getPublicForm as jest.Mock).mockResolvedValue(
+ makePublicFormResponse({
+ fieldsets: [
+ makeFieldsetRuntime({
+ fields: [makeExtraField({ apiName: 'fs-edit-1', value: 'old-value', order: 1 })],
+ order: 1,
+ }),
+ ],
+ }),
+ );
+
+ render(React.createElement(PublicForm, { type: 'shared' }));
+
+ await waitFor(() => {
+ expect(MergedOutputList).toHaveBeenCalled();
+ });
+
+ const props = getLastMergedOutputListProps();
+
+ act(() => {
+ props.onEditFieldsetField('fs-edit-1')({ value: 'new-value' });
+ });
+
+ const submitButton = screen.getByRole('button', { name: SUBMIT_LABEL });
+ userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(runPublicForm).toHaveBeenCalledTimes(1);
+ });
+
+ expect(runPublicForm).toHaveBeenCalledWith(
+ '',
+ expect.objectContaining({
+ 'fs-edit-1': 'new-value',
+ }),
+ );
+ });
+ });
+
+ describe('Submit: error handling', () => {
+ it('forwards backend validation error message to notification', async () => {
+ const backendError = {
+ code: 'validation_error',
+ message: 'The sum of the fields in this field set must equal "6"',
+ };
+
+ (getPublicForm as jest.Mock).mockResolvedValue(
+ makePublicFormResponse({
+ fields: [makeExtraField({ apiName: 'k1', value: 'filled' })],
+ }),
+ );
+ (runPublicForm as jest.Mock).mockRejectedValue(backendError);
+
+ render(React.createElement(PublicForm, { type: 'shared' }));
+
+ const submitButton = await screen.findByRole('button', { name: SUBMIT_LABEL });
+ userEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(NotificationManager.notifyApiError).toHaveBeenCalledTimes(1);
+ });
+
+ expect(NotificationManager.notifyApiError).toHaveBeenCalledWith(
+ backendError,
+ expect.objectContaining({
+ message: 'The sum of the fields in this field set must equal "6"',
+ }),
+ );
});
});
});
diff --git a/frontend/src/public/components/PublicFormsApp/common/types.ts b/frontend/src/public/components/PublicFormsApp/common/types.ts
index 796e55a8c..c08f78877 100644
--- a/frontend/src/public/components/PublicFormsApp/common/types.ts
+++ b/frontend/src/public/components/PublicFormsApp/common/types.ts
@@ -1,12 +1,15 @@
-/* eslint-disable */
-/* prettier-ignore */
-import { IKickoff } from '../../../types/template';
+import { IKickoffClient } from '../../../types/template';
+import { IFieldsetRuntime } from '../../../types/fieldset';
+
+export interface IPublicFormKickoff extends Omit {
+ fieldsets: IFieldsetRuntime[];
+}
export interface IPublicForm {
accountId: number;
name: string;
description: string;
- kickoff: IKickoff;
+ kickoff: IPublicFormKickoff;
showCaptcha: boolean;
}
diff --git a/frontend/src/public/components/RichEditor/RichEditor.tsx b/frontend/src/public/components/RichEditor/RichEditor.tsx
index 0926d8e2a..196520f20 100644
--- a/frontend/src/public/components/RichEditor/RichEditor.tsx
+++ b/frontend/src/public/components/RichEditor/RichEditor.tsx
@@ -45,6 +45,7 @@ export const RichEditor = forwardRef<
>(function RichEditor(
{
className,
+ editorClassName,
withChecklists = false,
withToolbar = true,
withMentions = true,
@@ -270,6 +271,7 @@ export const RichEditor = forwardRef<
onCancel={onCancel}
submitIcon={submitIcon}
cancelIcon={cancelIcon}
+ editorClassName={editorClassName}
withControls={Boolean(onSubmit)}
plainText={plainText}
templateVariables={templateVariables}
diff --git a/frontend/src/public/components/RichEditor/components/LexicalEditorContent/LexicalEditorContent.tsx b/frontend/src/public/components/RichEditor/components/LexicalEditorContent/LexicalEditorContent.tsx
index a346f34fe..f99cff6e9 100644
--- a/frontend/src/public/components/RichEditor/components/LexicalEditorContent/LexicalEditorContent.tsx
+++ b/frontend/src/public/components/RichEditor/components/LexicalEditorContent/LexicalEditorContent.tsx
@@ -53,6 +53,7 @@ export function LexicalEditorContent({
onCancel,
submitIcon,
cancelIcon,
+ editorClassName,
withControls = false,
plainText = false,
templateVariables,
@@ -67,6 +68,7 @@ export function LexicalEditorContent({
styles['editor'],
multiline && styles['editor-multiline'],
isUploading && styles['editor-uploading'],
+ editorClassName,
)}
>
diff --git a/frontend/src/public/components/RichEditor/types.ts b/frontend/src/public/components/RichEditor/types.ts
index 60a0351f9..6120d027a 100644
--- a/frontend/src/public/components/RichEditor/types.ts
+++ b/frontend/src/public/components/RichEditor/types.ts
@@ -26,6 +26,7 @@ export interface IRichEditorBaseProps {
accountId?: number;
placeholder?: string;
className?: string;
+ editorClassName?: string;
defaultValue?: string;
withToolbar?: boolean;
withChecklists?: boolean;
@@ -66,6 +67,7 @@ export interface ILexicalEditorContentProps {
withMentions: boolean;
mentions: TMentionData[] | undefined;
isModal: boolean | undefined;
+ editorClassName?: string;
isUploading?: boolean;
onUploadAttachments?: (e: React.ChangeEvent) => Promise;
onPasteFiles?: (files: File[]) => Promise;
diff --git a/frontend/src/public/components/TaskCard/TaskCard.tsx b/frontend/src/public/components/TaskCard/TaskCard.tsx
index ee8887e5c..0b4b5fe70 100644
--- a/frontend/src/public/components/TaskCard/TaskCard.tsx
+++ b/frontend/src/public/components/TaskCard/TaskCard.tsx
@@ -7,7 +7,8 @@ import { debounce } from 'throttle-debounce';
import { useSelector } from 'react-redux';
import { autoFocusFirstField } from '../../utils/autoFocusFirstField';
-import { EExtraFieldMode, ETaskPerformerType, ETemplateOwnerType, IExtraField } from '../../types/template';
+import { ETaskPerformerType, ETemplateOwnerType, IExtraField } from '../../types/template';
+import { IFieldsetRuntime } from '../../types/fieldset';
import { sanitizeText } from '../../utils/strings';
import { ITask } from '../../types/tasks';
import {
@@ -29,7 +30,7 @@ import { getTaskDetailRoute, getWorkflowDetailedRoute, isTaskDetailRoute } from
import { Header } from '../UI/Typeography/Header';
import { RichText } from '../RichText';
import { getUserFullName } from '../../utils/users';
-import { ExtraFieldIntl } from '../TemplateEdit/ExtraFields';
+import { MergedOutputList } from '../MergedOutputList';
import { isArrayWithItems, isEmptyArray } from '../../utils/helpers';
import { useCheckDevice } from '../../hooks/useCheckDevice';
import { history } from '../../utils/history';
@@ -50,7 +51,7 @@ import { EOptionTypes, TUsersDropdownOption, UsersDropdown, getUsersDropdownOpti
import { TUserListItem } from '../../types/user';
import { trackInviteTeamInPage } from '../../utils/analytics';
import { Tooltip } from '../UI';
-import { addOrUpdateStorageOutput, getOutputFromStorage } from './utils/storageOutputs';
+import { outputStorage, fieldsetsStorage } from './utils/storageOutputs';
import { TaskCarkSkeleton } from './TaskCarkSkeleton';
import { GuestController } from './GuestsController';
import { createChecklistExtension, createProgressbarExtension } from './checklist';
@@ -131,12 +132,14 @@ export function TaskCard({
const { isMobile } = useCheckDevice();
const groups = useSelector(getRegularGroupsList);
- const saveOutputsToStorageDebounced = debounce(300, addOrUpdateStorageOutput);
+ const saveOutputsToStorageDebounced = debounce(300, outputStorage.save);
+ const saveFieldsetsToStorageDebounced = debounce(300, fieldsetsStorage.save);
const guestsControllerRef = useRef | null>(null);
const wrapperRef = useRef(null);
const workflowLinkRef = useRef(null);
const [outputValues, setOutputValues] = useState([] as IExtraField[]);
+ const [fieldsetOutputValues, setFieldsetOutputValues] = useState(task.fieldsets || []);
const [isReturnModalOpen, setIsReturnModalOpen] = useState(false);
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
@@ -156,11 +159,17 @@ export function TaskCard({
}, [task.performers.length]);
useEffect(() => {
- const { output, id } = task;
- const storageOutput = getOutputFromStorage(id);
+ const { output, fieldsets, id } = task;
+ const storageOutput = outputStorage.get(id);
const outputFieldsWithValues = new ExtraFieldsHelper(output, storageOutput).getFieldsWithValues();
-
setOutputValues(outputFieldsWithValues);
+
+ const storageFieldsets = fieldsetsStorage.get(id);
+ const storageFieldsetsMap = new Map(storageFieldsets?.map((fieldset) => [fieldset.apiNameBinding, fieldset]) || []);
+ const mergedFieldsets = (fieldsets || []).map((fieldset) => {
+ return storageFieldsetsMap.get(fieldset.apiNameBinding) || fieldset;
+ });
+ setFieldsetOutputValues(mergedFieldsets);
}, [task.id]);
const handleOpenWorkflowPopup = (workflowId: number | null) => (e: MouseEvent) => {
@@ -401,33 +410,41 @@ export function TaskCard({
});
};
+ const handleEditFieldsetField = (apiName: string) => (changedProps: Partial) => {
+ setFieldsetOutputValues((prevFieldsets) => {
+ const newFieldsets = prevFieldsets.map((fs) => ({
+ ...fs,
+ fields: getEditedFields(fs.fields, apiName, changedProps),
+ }));
+
+ saveFieldsetsToStorageDebounced(task.id, newFieldsets);
+
+ return newFieldsets;
+ });
+ };
+
const renderOutputFields = () => {
const visibleOutputs = outputValues?.filter((field) => !field.isHidden);
- if (!isArrayWithItems(visibleOutputs) || status === ETaskStatus.Completed) {
+ if ((!isArrayWithItems(visibleOutputs) && !isArrayWithItems(fieldsetOutputValues)) || status === ETaskStatus.Completed) {
return null;
}
+
return (
- {visibleOutputs
- .map((field) => (
-
- ))}
+
);
};
@@ -467,6 +484,9 @@ export function TaskCard({
}
const renderCompleteButton = (isDisabled: boolean) => {
+ const allFieldsetFields = fieldsetOutputValues.flatMap((fs) => fs.fields);
+ const mergedOutput = [...outputValues, ...allFieldsetFields];
+
return (
;
-}
+}
\ No newline at end of file
diff --git a/frontend/src/public/components/TaskCard/__tests__/TaskCard.test.tsx b/frontend/src/public/components/TaskCard/__tests__/TaskCard.test.tsx
index 3d1e3814b..3dff8ab5b 100644
--- a/frontend/src/public/components/TaskCard/__tests__/TaskCard.test.tsx
+++ b/frontend/src/public/components/TaskCard/__tests__/TaskCard.test.tsx
@@ -1,18 +1,41 @@
import * as React from 'react';
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { TaskCard, ETaskCardViewMode } from '../TaskCard';
-import { EExtraFieldType, ETemplateOwnerType, IExtraField } from '../../../types/template';
+import { makeExtraField } from '../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../__stubs__/fieldsets.factory';
+import {ETemplateOwnerType, IExtraField } from '../../../types/template';
+import { IFieldsetRuntime } from '../../../types/fieldset';
import { ETaskStatus } from '../../../redux/actions';
import { EWorkflowStatus, EWorkflowsLogSorting } from '../../../types/workflow';
+import { IAuthUser, ELoggedState } from '../../../types/redux';
+import { EUserStatus } from '../../../types/user';
+import { ESubscriptionPlan } from '../../../types/account';
+import { MergedOutputList } from '../../MergedOutputList';
+import { intlMock } from '../../../__stubs__/intlMock';
+import type { TUsersDropdownOption } from '../../UI/form/UsersDropdown';
-jest.mock('../../TemplateEdit/ExtraFields', () => ({
- ExtraFieldIntl: jest.fn(() => ),
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: (selector: () => T) => selector(),
+}));
+
+jest.mock('../../MergedOutputList', () => ({
+ MergedOutputList: jest.fn(() => ),
}));
jest.mock('../utils/storageOutputs', () => ({
- getOutputFromStorage: jest.fn(() => undefined),
- addOrUpdateStorageOutput: jest.fn(),
+ outputStorage: {
+ get: jest.fn(() => undefined),
+ save: jest.fn(),
+ remove: jest.fn(),
+ },
+ fieldsetsStorage: {
+ get: jest.fn(() => undefined),
+ save: jest.fn(),
+ remove: jest.fn(),
+ },
}));
jest.mock('../../../utils/autoFocusFirstField', () => ({
@@ -23,18 +46,19 @@ jest.mock('../../../hooks/useCheckDevice', () => ({
useCheckDevice: () => ({ isMobile: false, isDesktop: true }),
}));
-jest.mock('react-redux', () => ({
- useSelector: (selector: () => unknown) => selector(),
-}));
-
jest.mock('../../../redux/selectors/groups', () => ({
getRegularGroupsList: () => [{ id: 5, name: 'Group Five', type: 'regular' }],
}));
-const mockUsersDropdown = jest.fn((_props?: unknown) => null);
+type TMockDropdownProps = {
+ value: TUsersDropdownOption[];
+ options: TUsersDropdownOption[];
+};
+
+const mockUsersDropdown = jest.fn((_props?: TMockDropdownProps) => null);
jest.mock('../../UI/form/UsersDropdown', () => ({
- UsersDropdown: (props: unknown) => {
+ UsersDropdown: (props: TMockDropdownProps) => {
mockUsersDropdown(props);
return null;
},
@@ -124,7 +148,19 @@ jest.mock('../../UI/Typeography/Header', () => ({
}));
jest.mock('../../UI/Buttons/Button', () => ({
- Button: () => ,
+ Button: ({
+ label,
+ onClick,
+ disabled,
+ }: {
+ label?: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ }) => (
+
+ ),
}));
jest.mock('../../IntlMessages', () => ({
@@ -143,15 +179,6 @@ jest.mock('../../icons', () => ({
ReturnToIcon: () => ,
}));
-const makeField = (overrides = {}) => ({
- apiName: `f-${Math.random()}`,
- name: 'Field',
- type: EExtraFieldType.String,
- order: 0,
- userId: null,
- groupId: null,
- ...overrides,
-});
const baseTask = {
id: 1,
@@ -192,24 +219,24 @@ const baseWorkflowLog = {
isLoading: false,
};
-const baseAuthUser = {
+const baseAuthUser: IAuthUser = {
id: 1,
email: 'test@test.com',
firstName: 'Test',
lastName: 'User',
phone: '',
photo: '',
- type: 'user' as const,
+ type: 'user',
token: '',
account: {
name: '',
isSubscribed: false,
billingSync: false,
tenantName: '',
- billingPlan: 'free' as any,
- plan: 'free' as any,
+ billingPlan: ESubscriptionPlan.Free,
+ plan: ESubscriptionPlan.Free,
planExpiration: null,
- leaseLevel: 'standard' as any,
+ leaseLevel: 'standard',
logoSm: null,
logoLg: null,
trialEnded: false,
@@ -223,9 +250,9 @@ const baseAuthUser = {
dateFmt: 'MM/dd/yyyy',
dateFdw: 'Monday',
language: 'en',
- status: 'active' as any,
- loggedState: 'logged' as any,
- invitedUser: {} as any,
+ status: EUserStatus.Active,
+ loggedState: ELoggedState.LoggedIn,
+ invitedUser: { id: '' },
isDigestSubscriber: false,
isTasksDigestSubscriber: false,
isCommentsMentionsSubscriber: false,
@@ -265,6 +292,360 @@ const baseProps = {
describe('TaskCard', () => {
beforeEach(() => {
jest.clearAllMocks();
+
+ const { fieldsetsStorage, outputStorage } = require('../utils/storageOutputs');
+ (fieldsetsStorage.get as jest.Mock).mockImplementation(() => undefined);
+ (outputStorage.get as jest.Mock).mockImplementation(() => undefined);
+ });
+
+ it('renders MergedOutputList and passes fields and fieldsets', async () => {
+ const fieldsets = [
+ makeFieldsetRuntime({ name: 'FS', order: 2 }),
+ ];
+ const task = {
+ ...baseTask,
+ output: [makeExtraField({ apiName: 'a', order: 1 })],
+ fieldsets,
+ };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+ });
+
+ const mock = MergedOutputList as jest.Mock;
+ expect(mock).toHaveBeenCalled();
+ const lastCallProps = mock.mock.calls[mock.mock.calls.length - 1][0];
+ expect(lastCallProps).toEqual(
+ expect.objectContaining({
+ fields: expect.arrayContaining([expect.objectContaining({ apiName: 'a' })]),
+ fieldsets,
+ }),
+ );
+ });
+
+ it('passes empty fieldsets when task has no fieldsets', async () => {
+ const task = {
+ ...baseTask,
+ output: [makeExtraField({ apiName: 'a' })],
+ };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+ });
+
+ const mock = MergedOutputList as jest.Mock;
+ expect(mock).toHaveBeenCalled();
+ const lastCallProps = mock.mock.calls[mock.mock.calls.length - 1][0];
+ expect(lastCallProps).toEqual(
+ expect.objectContaining({
+ fieldsets: [],
+ }),
+ );
+ });
+
+ it('filters out isHidden fields before passing to MergedOutputList', async () => {
+ const task = {
+ ...baseTask,
+ output: [
+ makeExtraField({ apiName: 'hidden', isHidden: true }),
+ makeExtraField({ apiName: 'visible-1' }),
+ makeExtraField({ apiName: 'visible-2' }),
+ ],
+ };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+ });
+
+ const callArgs = (MergedOutputList as jest.Mock).mock.calls[0][0];
+ expect(callArgs.fields).toHaveLength(2);
+ expect(callArgs.fields.map((field: IExtraField) => field.apiName)).toEqual(
+ expect.arrayContaining(['visible-1', 'visible-2']),
+ );
+ });
+
+ it('saves fieldsets to localStorage when a fieldset field is edited', async () => {
+ jest.useFakeTimers();
+ const { fieldsetsStorage } = require('../utils/storageOutputs');
+
+ const fieldsets = [
+ makeFieldsetRuntime({ name: 'FS', fields: [makeExtraField({ apiName: 'f-1' })] }),
+ ];
+ const task = { ...baseTask, output: [], fieldsets };
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+ });
+
+ const { onEditFieldsetField } = (MergedOutputList as jest.Mock).mock.calls[0][0];
+ act(() => {
+ onEditFieldsetField('f-1')({ value: 'new value' });
+ jest.advanceTimersByTime(300);
+ });
+ expect(fieldsetsStorage.save).toHaveBeenCalled();
+
+ jest.useRealTimers();
+ });
+
+ it('restores fieldset values from localStorage draft and merges them with server data', async () => {
+ const { fieldsetsStorage } = require('../utils/storageOutputs');
+
+ const storageFs: IFieldsetRuntime = makeFieldsetRuntime({
+ name: 'FS-1',
+ order: 1,
+ fields: [makeExtraField({ apiName: 'email', value: 'draft@x.com' })],
+ });
+ (fieldsetsStorage.get as jest.Mock).mockReturnValue([storageFs]);
+
+ const serverFs1: IFieldsetRuntime = makeFieldsetRuntime({
+ name: 'FS-1',
+ order: 1,
+ fields: [makeExtraField({ apiName: 'email', value: 'server@x.com' })],
+ });
+ const serverFs2: IFieldsetRuntime = makeFieldsetRuntime({
+ apiNameBinding: 'fs-2',
+ name: 'FS-2',
+ order: 2,
+ fields: [makeExtraField({ apiName: 'phone', value: 'server-phone' })],
+ });
+
+ const task = { ...baseTask, output: [], fieldsets: [serverFs1, serverFs2] };
+ render();
+
+ await waitFor(() => {
+ const lastCall = (MergedOutputList as jest.Mock).mock.calls[
+ (MergedOutputList as jest.Mock).mock.calls.length - 1
+ ];
+ expect(lastCall[0].fieldsets[0].fields[0].value).toBe('draft@x.com');
+ });
+
+ const lastCall = (MergedOutputList as jest.Mock).mock.calls[
+ (MergedOutputList as jest.Mock).mock.calls.length - 1
+ ];
+ const passedFieldsets: IFieldsetRuntime[] = lastCall[0].fieldsets;
+ expect(passedFieldsets).toHaveLength(2);
+ expect(passedFieldsets[1].fields[0].value).toBe('server-phone');
+ });
+
+ it('ignores a foreign fieldset from the draft that does not belong to the current task', async () => {
+ const { fieldsetsStorage } = require('../utils/storageOutputs');
+
+ const strangerFs: IFieldsetRuntime = makeFieldsetRuntime({
+ apiNameBinding: 'fs-stranger',
+ name: 'Stranger',
+ fields: [makeExtraField({ apiName: 'wrong', value: 'wrong' })],
+ });
+ (fieldsetsStorage.get as jest.Mock).mockReturnValue([strangerFs]);
+
+ const serverFs: IFieldsetRuntime = makeFieldsetRuntime({
+ name: 'FS-1',
+ order: 1,
+ fields: [makeExtraField({ apiName: 'email' })],
+ });
+
+ const task = { ...baseTask, output: [], fieldsets: [serverFs] };
+ render();
+
+ await waitFor(() => {
+ expect((MergedOutputList as jest.Mock).mock.calls.length).toBeGreaterThan(0);
+ });
+
+ const lastCall = (MergedOutputList as jest.Mock).mock.calls[
+ (MergedOutputList as jest.Mock).mock.calls.length - 1
+ ];
+ const passedFieldsets: IFieldsetRuntime[] = lastCall[0].fieldsets;
+ expect(passedFieldsets).toHaveLength(1);
+ expect(passedFieldsets[0].apiNameBinding).toBe('fs-1');
+ expect(
+ passedFieldsets.find((fs) => fs.apiNameBinding === 'fs-stranger'),
+ ).toBeUndefined();
+ });
+
+ it('reloads fieldsets from the new task draft when task.id changes', async () => {
+ const { fieldsetsStorage } = require('../utils/storageOutputs');
+
+ (fieldsetsStorage.get as jest.Mock).mockImplementation((id: number) => {
+ if (id === 100) {
+ return [
+ makeFieldsetRuntime({
+ apiNameBinding: 'fs',
+ name: 'FS',
+ fields: [makeExtraField({ apiName: 'k', value: 'draft-A' })],
+ }),
+ ];
+ }
+ if (id === 200) {
+ return [
+ makeFieldsetRuntime({
+ apiNameBinding: 'fs',
+ name: 'FS',
+ fields: [makeExtraField({ apiName: 'k', value: 'draft-B' })],
+ }),
+ ];
+ }
+ return undefined;
+ });
+
+ const serverFs = (value: string): IFieldsetRuntime => makeFieldsetRuntime({
+ apiNameBinding: 'fs',
+ name: 'FS',
+ fields: [makeExtraField({ apiName: 'k', value })],
+ });
+
+ const taskA = { ...baseTask, id: 100, output: [], fieldsets: [serverFs('server-A')] };
+ const { rerender } = render();
+
+ await waitFor(() => {
+ const lastCall = (MergedOutputList as jest.Mock).mock.calls[
+ (MergedOutputList as jest.Mock).mock.calls.length - 1
+ ];
+ expect(lastCall[0].fieldsets[0].fields[0].value).toBe('draft-A');
+ });
+
+ const taskB = { ...baseTask, id: 200, output: [], fieldsets: [serverFs('server-B')] };
+ rerender();
+
+ await waitFor(() => {
+ const lastCall = (MergedOutputList as jest.Mock).mock.calls[
+ (MergedOutputList as jest.Mock).mock.calls.length - 1
+ ];
+ expect(lastCall[0].fieldsets[0].fields[0].value).toBe('draft-B');
+ });
+ });
+
+ it('on Complete click submits a combined payload: plain outputs + fields of all fieldsets', async () => {
+ const setTaskCompleted = jest.fn();
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldsetRuntime({
+ name: 'FS-1',
+ order: 2,
+ fields: [makeExtraField({ apiName: 'a' }), makeExtraField({ apiName: 'b' })],
+ }),
+ makeFieldsetRuntime({
+ apiNameBinding: 'fs-2',
+ name: 'FS-2',
+ order: 3,
+ fields: [makeExtraField({ apiName: 'c' })],
+ }),
+ ];
+ const task = {
+ ...baseTask,
+ id: 42,
+ output: [makeExtraField({ apiName: 'top' })],
+ fieldsets,
+ };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+ });
+
+ const completeLabel = intlMock.formatMessage({ id: 'processes.complete-task' });
+ userEvent.click(screen.getByRole('button', { name: completeLabel }));
+
+ expect(setTaskCompleted).toHaveBeenCalledTimes(1);
+ expect(setTaskCompleted).toHaveBeenCalledWith({
+ taskId: 42,
+ viewMode: ETaskCardViewMode.Single,
+ output: expect.arrayContaining([
+ expect.objectContaining({ apiName: 'top' }),
+ expect.objectContaining({ apiName: 'a' }),
+ expect.objectContaining({ apiName: 'b' }),
+ expect.objectContaining({ apiName: 'c' }),
+ ]),
+ });
+ expect(setTaskCompleted.mock.calls[0][0].output).toHaveLength(4);
+ });
+
+ it('renders the outputs block when the task has only fieldsets and no plain fields', async () => {
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldsetRuntime({
+ name: 'FS',
+ fields: [makeExtraField({ apiName: 'k' })],
+ }),
+ ];
+ const task = { ...baseTask, output: [], fieldsets };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+ });
+ });
+
+ it('does not render the outputs block when the task has neither plain fields nor fieldsets', async () => {
+ const task = { ...baseTask, output: [], fieldsets: [] };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('merged-output-list')).toBeNull();
+ });
+ });
+
+ it('does not render the outputs block in Completed status even if fieldsets are present', async () => {
+ const task = {
+ ...baseTask,
+ output: [makeExtraField({ apiName: 'a' })],
+ fieldsets: [
+ makeFieldsetRuntime({
+ name: 'FS',
+ fields: [makeExtraField({ apiName: 'k' })],
+ }),
+ ] as IFieldsetRuntime[],
+ };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('merged-output-list')).toBeNull();
+ });
+ });
+
+ it('passes the latest fieldsets with the new value to fieldsetsStorage.save (not stale closure)', async () => {
+ jest.useFakeTimers();
+ const { fieldsetsStorage } = require('../utils/storageOutputs');
+
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldsetRuntime({
+ name: 'FS-1',
+ fields: [makeExtraField({ apiName: 'email', value: 'old@x.com' })],
+ }),
+ ];
+ const task = { ...baseTask, id: 42, output: [], fieldsets };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('merged-output-list')).not.toBeNull();
+ });
+
+ const calls = (MergedOutputList as jest.Mock).mock.calls;
+ const { onEditFieldsetField } = calls[calls.length - 1][0];
+
+ act(() => {
+ onEditFieldsetField('email')({ value: 'new@x.com' });
+ jest.advanceTimersByTime(300);
+ });
+
+ expect(fieldsetsStorage.save).toHaveBeenCalled();
+ const saveCalls = (fieldsetsStorage.save as jest.Mock).mock.calls;
+ const lastSaveArgs = saveCalls[saveCalls.length - 1];
+ expect(lastSaveArgs[0]).toBe(42);
+ const savedFieldsets: IFieldsetRuntime[] = lastSaveArgs[1];
+ expect(savedFieldsets).toHaveLength(1);
+ expect(savedFieldsets[0].apiNameBinding).toBe('fs-1');
+ expect(savedFieldsets[0].fields[0].value).toBe('new@x.com');
+
+ jest.useRealTimers();
});
describe('Performer dropdown', () => {
@@ -313,23 +694,4 @@ describe('TaskCard', () => {
expect(groupOption?.value).toBe('group-5');
});
});
-
- describe('Filtering output fields by isHidden', () => {
- it('renders only visible output fields from a mixed list', async () => {
- const task = {
- ...baseTask,
- output: [
- makeField({ apiName: 'a', isHidden: true }),
- makeField({ apiName: 'b', isHidden: false }),
- makeField({ apiName: 'c' }),
- ],
- };
-
- render();
-
- await waitFor(() => {
- expect(screen.getAllByTestId('extra-field')).toHaveLength(2);
- });
- });
- });
});
diff --git a/frontend/src/public/components/TaskCard/utils/__tests__/storageOutputs.test.ts b/frontend/src/public/components/TaskCard/utils/__tests__/storageOutputs.test.ts
new file mode 100644
index 000000000..4cbae23d1
--- /dev/null
+++ b/frontend/src/public/components/TaskCard/utils/__tests__/storageOutputs.test.ts
@@ -0,0 +1,109 @@
+import { outputStorage, fieldsetsStorage } from '../storageOutputs';
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../../__stubs__/fieldsets.factory';
+import { IExtraField } from '../../../../types/template';
+import { IFieldsetRuntime } from '../../../../types/fieldset';
+
+const OUTPUT_STORAGE_KEY = 'tasks_outputs';
+const FIELDSETS_STORAGE_KEY = 'tasks_fieldsets_outputs';
+
+
+describe('storageOutputs', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('keeps outputStorage and fieldsetsStorage isolated from each other', () => {
+ const outputs: IExtraField[] = [makeExtraField({ apiName: 'plain', name: 'Field plain', value: 'plain-value' })];
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldsetRuntime({ apiNameBinding: 'fs-1', name: 'Fieldset fs-1', fields: [makeExtraField({ apiName: 'fs-field', name: 'Field fs-field', value: 'fs-value' })] }),
+ ];
+
+ outputStorage.save(1, outputs);
+ fieldsetsStorage.save(1, fieldsets);
+
+ expect(outputStorage.get(1)).toEqual(outputs);
+ expect(fieldsetsStorage.get(1)).toEqual(fieldsets);
+
+ outputStorage.remove(1);
+
+ expect(outputStorage.get(1)).toBeUndefined();
+ expect(fieldsetsStorage.get(1)).toEqual(fieldsets);
+ });
+
+ it('save → get round-trip preserves the real fieldset structure 1-to-1', () => {
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldsetRuntime({
+ apiNameBinding: 'contacts',
+ name: 'Contacts',
+ description: 'Reachout details',
+ order: 1,
+ fields: [
+ makeExtraField({ apiName: 'email', name: 'Field email', value: 'a@b.com' }),
+ makeExtraField({ apiName: 'phone', name: 'Field phone', value: '+1', isRequired: true }),
+ ],
+ }),
+ makeFieldsetRuntime({
+ apiNameBinding: 'address',
+ name: 'Fieldset address',
+ order: 2,
+ fields: [makeExtraField({ apiName: 'city', name: 'Field city', value: 'NY' })],
+ }),
+ ];
+
+ fieldsetsStorage.save(7, fieldsets);
+
+ expect(fieldsetsStorage.get(7)).toEqual(fieldsets);
+ });
+
+ it('subsequent save for the same taskId replaces the entry without duplicating', () => {
+ fieldsetsStorage.save(1, [makeFieldsetRuntime({ apiNameBinding: 'fs', name: 'Fieldset fs', fields: [makeExtraField({ apiName: 'a', name: 'Field a', value: 'v1' })] })]);
+ fieldsetsStorage.save(1, [makeFieldsetRuntime({ apiNameBinding: 'fs', name: 'Fieldset fs', fields: [makeExtraField({ apiName: 'a', name: 'Field a', value: 'v2' })] })]);
+
+ const raw = localStorage.getItem(FIELDSETS_STORAGE_KEY);
+ if (raw === null) {
+ throw new Error('Expected localStorage to contain fieldsets entry');
+ }
+ const stored = JSON.parse(raw) as Array<{ taskId: number; data: IFieldsetRuntime[] }>;
+
+ expect(stored).toHaveLength(1);
+ expect(stored[0].taskId).toBe(1);
+ expect(stored[0].data[0].fields[0].value).toBe('v2');
+ expect(fieldsetsStorage.get(1)?.[0].fields[0].value).toBe('v2');
+ });
+
+ it('get returns undefined for a non-existent taskId', () => {
+ expect(fieldsetsStorage.get(999)).toBeUndefined();
+ expect(outputStorage.get(999)).toBeUndefined();
+ });
+
+ it('remove deletes only the entry for the given taskId, leaving other tasks intact', () => {
+ const fs1: IFieldsetRuntime[] = [makeFieldsetRuntime({ apiNameBinding: 'fs-1', name: 'Fieldset fs-1', fields: [makeExtraField({ apiName: 'a', name: 'Field a', value: 'task-1-value' })] })];
+ const fs2: IFieldsetRuntime[] = [makeFieldsetRuntime({ apiNameBinding: 'fs-2', name: 'Fieldset fs-2', fields: [makeExtraField({ apiName: 'b', name: 'Field b', value: 'task-2-value' })] })];
+
+ fieldsetsStorage.save(1, fs1);
+ fieldsetsStorage.save(2, fs2);
+
+ fieldsetsStorage.remove(1);
+
+ expect(fieldsetsStorage.get(1)).toBeUndefined();
+ expect(fieldsetsStorage.get(2)).toEqual(fs2);
+ });
+
+ describe('corrupted localStorage data', () => {
+ it.each([
+ 'not a json',
+ '{"taskId":1,"data":[]}',
+ 'null',
+ ])('get returns undefined for corrupted value %p and does not throw', (raw) => {
+ localStorage.setItem(FIELDSETS_STORAGE_KEY, raw);
+ localStorage.setItem(OUTPUT_STORAGE_KEY, raw);
+
+ expect(() => fieldsetsStorage.get(1)).not.toThrow();
+ expect(() => outputStorage.get(1)).not.toThrow();
+
+ expect(fieldsetsStorage.get(1)).toBeUndefined();
+ expect(outputStorage.get(1)).toBeUndefined();
+ });
+ });
+});
diff --git a/frontend/src/public/components/TaskCard/utils/storageOutputs.ts b/frontend/src/public/components/TaskCard/utils/storageOutputs.ts
index bac1f60ff..dc6cdad2d 100644
--- a/frontend/src/public/components/TaskCard/utils/storageOutputs.ts
+++ b/frontend/src/public/components/TaskCard/utils/storageOutputs.ts
@@ -1,69 +1,68 @@
/* eslint-disable */
/* prettier-ignore */
-import produce from 'immer';
-
import { IExtraField } from '../../../types/template';
+import { IFieldsetRuntime } from '../../../types/fieldset';
-const OUTPUT_LOCALSTORAGE_KEY = 'tasks_outputs';
-
-type TLocalStorageOutput = {
+type TStorageEntry = {
taskId: number;
- output: IExtraField[];
+ data: T;
};
-export function addOrUpdateStorageOutput(taskId: number, output: IExtraField[]) {
- const currentOutput: TLocalStorageOutput = { taskId, output };
- const savedOutputs = getOutputsFromStorage();
+function createTaskStorage(storageKey: string) {
+ function getAll(): TStorageEntry[] {
+ try {
+ const savedDataString = localStorage.getItem(storageKey);
- const newOutputs = produce(savedOutputs, draftOutputs => {
- const savedOutputIndex = draftOutputs.findIndex(output => output.taskId === taskId);
+ if (!savedDataString) {
+ throw new Error('no saved data');
+ }
- if (savedOutputIndex === -1) {
- draftOutputs.push(currentOutput);
-
- return;
- }
+ const savedData = JSON.parse(savedDataString) as TStorageEntry[];
- draftOutputs[savedOutputIndex] = currentOutput;
- });
+ if (!Array.isArray(savedData)) {
+ throw new Error('saved data is invalid');
+ }
- saveOutputsToStorage(newOutputs);
-}
+ return savedData;
+ } catch (error) {
+ return [];
+ }
+ }
-export function getOutputFromStorage(taskId: number) {
- const savedOutputs = getOutputsFromStorage();
+ function saveAll(entries: TStorageEntry[]) {
+ const dataJSON = JSON.stringify(entries);
- return savedOutputs.find(output => output.taskId === taskId)?.output;
-}
+ localStorage.setItem(storageKey, dataJSON);
+ }
-export function removeOutputFromLocalStorage(taskId: number) {
- const savedOutputs = getOutputsFromStorage();
- const newOutputs = savedOutputs.filter(output => output.taskId !== taskId);
- saveOutputsToStorage(newOutputs);
-}
+ return {
+ save(taskId: number, data: T) {
+ const currentEntry: TStorageEntry = { taskId, data };
+ const savedEntries = getAll();
+ const savedEntryIndex = savedEntries.findIndex(entry => entry.taskId === taskId);
-function getOutputsFromStorage(): TLocalStorageOutput[] {
- try {
- const savedOutputsString = localStorage.getItem(OUTPUT_LOCALSTORAGE_KEY);
+ if (savedEntryIndex === -1) {
+ saveAll([...savedEntries, currentEntry]);
- if (!savedOutputsString) {
- throw new Error('no saved outputs');
- }
+ return;
+ }
- const savedOutputs = JSON.parse(savedOutputsString) as TLocalStorageOutput[];
+ const newEntries = [...savedEntries];
+ newEntries[savedEntryIndex] = currentEntry;
+ saveAll(newEntries);
+ },
- if (!Array.isArray(savedOutputs)) {
- throw new Error('saved outputs are invalid');
- }
+ get(taskId: number): T | undefined {
+ return getAll().find(entry => entry.taskId === taskId)?.data;
+ },
- return savedOutputs;
- } catch (error) {
- return [];
- }
+ remove(taskId: number) {
+ const newEntries = getAll().filter(entry => entry.taskId !== taskId);
+ saveAll(newEntries);
+ },
+ };
}
-function saveOutputsToStorage(outputs: TLocalStorageOutput[]) {
- const ouputsJSON = JSON.stringify(outputs);
+export const outputStorage = createTaskStorage('tasks_outputs');
- localStorage.setItem(OUTPUT_LOCALSTORAGE_KEY, ouputsJSON);
-}
+export const fieldsetsStorage = createTaskStorage('tasks_fieldsets_outputs');
\ No newline at end of file
diff --git a/frontend/src/public/components/Team/SelectManagerModal/SelectManagerModal.test.tsx b/frontend/src/public/components/Team/SelectManagerModal/SelectManagerModal.test.tsx
index 560ba1011..0b730f088 100644
--- a/frontend/src/public/components/Team/SelectManagerModal/SelectManagerModal.test.tsx
+++ b/frontend/src/public/components/Team/SelectManagerModal/SelectManagerModal.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
diff --git a/frontend/src/public/components/Team/Users/CreateUserModal/__tests__/CreateUserModal.test.tsx b/frontend/src/public/components/Team/Users/CreateUserModal/__tests__/CreateUserModal.test.tsx
index 85105d580..03cd7280d 100644
--- a/frontend/src/public/components/Team/Users/CreateUserModal/__tests__/CreateUserModal.test.tsx
+++ b/frontend/src/public/components/Team/Users/CreateUserModal/__tests__/CreateUserModal.test.tsx
@@ -1,4 +1,3 @@
-///
import * as React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
diff --git a/frontend/src/public/components/TemplateAIModal/TemplateAIModal.tsx b/frontend/src/public/components/TemplateAIModal/TemplateAIModal.tsx
index 718fbb11d..596cf4437 100644
--- a/frontend/src/public/components/TemplateAIModal/TemplateAIModal.tsx
+++ b/frontend/src/public/components/TemplateAIModal/TemplateAIModal.tsx
@@ -3,7 +3,7 @@ import { useIntl } from 'react-intl';
import { Button, Header, InputField, Modal } from '../UI';
import { TGenerateAITemplatePayload } from '../../redux/actions';
-import { ITemplate, ITemplateTask, TAITemplateGenerationStatus } from '../../types/template';
+import { ITemplateClient, ITemplateTaskClient, TAITemplateGenerationStatus } from '../../types/template';
import { useDidUpdateEffect } from '../../hooks/useDidUpdateEffect';
import { InfiniteLoader } from '../InfiniteLoader';
@@ -12,7 +12,7 @@ import styles from './TemplateAIModal.css';
export interface ITemplateAIModalProps {
isOpen: boolean;
generationStatus: TAITemplateGenerationStatus;
- generatedTemplate: ITemplate | null;
+ generatedTemplate: ITemplateClient | null;
generateTemplate(payload: TGenerateAITemplatePayload): void;
stopTemplateGeneration(): void;
setIsModalOpened(isOpened: boolean): void;
@@ -33,7 +33,7 @@ export function TemplateAIModal({
const { formatMessage } = useIntl();
const inputRef = useRef(null);
const [description, setDescription] = useState('');
- const [renderedTemplate, setRenderedTemplate] = useState(null);
+ const [renderedTemplate, setRenderedTemplate] = useState(null);
const [heightContainer, setHeightContainer] = useState(0);
const [activeTask, setActiveTask] = useState(0);
@@ -199,7 +199,7 @@ export function TemplateAIModal({
interface IAnimationTaskProps {
order: number;
- task: ITemplateTask;
+ task: ITemplateTaskClient;
lastItem: boolean;
onActiveTask(index: number): void;
onChangeHeightContainer(height: number): void;
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/ExtraFieldCheckbox.css b/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/ExtraFieldCheckbox.css
index c5bc047ce..5a0b52cee 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/ExtraFieldCheckbox.css
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/ExtraFieldCheckbox.css
@@ -16,13 +16,20 @@
.kickoff-create-field-name {
position: relative;
display: inline-flex;
+ align-items: flex-end;
+ gap: 2px;
max-width: 100%;
}
.kickoff-create-field-name-input {
- padding: 0;
- line-height: 20px;
+ padding: 0 4px 0 0;
+ min-width: 0;
+ resize: none;
border: none;
+ field-sizing: content;
+ overflow-wrap: break-word;
+
+ @mixin text-base;
&:focus {
outline: none;
@@ -98,6 +105,7 @@
.kickoff-set-field-container {
display: block;
+ flex-grow: 1;
}
.kickoff-set-field-name {
@@ -134,3 +142,8 @@
position: relative;
top: -2px;
}
+
+.checkbox-options-wrapper_label-left {
+ flex: 1;
+ min-width: 0;
+}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/ExtraFieldCheckbox.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/ExtraFieldCheckbox.tsx
index f3c90314b..420526d43 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/ExtraFieldCheckbox.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/ExtraFieldCheckbox.tsx
@@ -2,13 +2,15 @@
/* prettier-ignore */
import * as React from 'react';
import classnames from 'classnames';
-import AutosizeInput from 'react-input-autosize';
+
import { getEmptySelection } from '../../KickoffRedux/utils/getEmptySelection';
import { validateCheckboxAndRadioField, validateKickoffFieldName } from '../../../../utils/validators';
import { handleSelectionBlur, recalculateDuplicateErrors } from '../utils/handleSelectionBlur';
import { IntlMessages } from '../../../IntlMessages';
import { EExtraFieldMode, IExtraFieldSelection } from '../../../../types/template';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
+import { FieldLabel } from '../utils/FieldLabel';
import { fitInputWidth } from '../utils/fitInputWidth';
import { PencilSmallIcon, RemoveIcon } from '../../../icons';
import { Checkbox } from '../../../UI/Fields/Checkbox';
@@ -21,7 +23,6 @@ import fieldStyles from './ExtraFieldCheckbox.css';
import { useState } from 'react';
const DEFAULT_OPTION_INPUT_WIDTH = 120;
-const DEFAULT_FIELD_INPUT_WIDTH = 120;
function normalizeCheckboxValue(value: unknown): string[] {
if (Array.isArray(value)) return value;
@@ -39,12 +40,14 @@ export function ExtraFieldCheckbox({
editField,
isDisabled = false,
datasetName,
-}: IWorkflowExtraFieldProps) {
+ labelPosition,
+ labelBackgroundColor,
+}: IWorkflowExtraFieldProps): JSX.Element {
const selectionItems = field.selections as IExtraFieldSelection[];
const selectionValues = field.selections as string[];
const selectedOptions = normalizeCheckboxValue(value);
- const fieldNameInputRef = React.useRef(null);
+ const fieldNameInputRef = React.useRef(null);
const optionInputsRefs = React.useRef([]);
const [isFocused, setIsFocused] = React.useState(false);
@@ -52,10 +55,6 @@ export function ExtraFieldCheckbox({
optionInputsRefs.current.forEach((input) => fitInputWidth(input, DEFAULT_OPTION_INPUT_WIDTH));
}, [selectionItems]);
- React.useEffect(() => {
- fitInputWidth(fieldNameInputRef.current, DEFAULT_FIELD_INPUT_WIDTH);
- }, []);
-
const [activeOptionIndex, setActiveOptionIndex] = useState(null);
const [duplicateErrors, setDuplicateErrors] = useState>(
() => recalculateDuplicateErrors(selectionItems || []),
@@ -78,38 +77,53 @@ export function ExtraFieldCheckbox({
);
return (
-
-
-
(fieldNameInputRef.current = ref)}
- inputClassName={classnames(
- fieldStyles['kickoff-create-field-name-input'],
- !isKickoffFieldNameValid && fieldStyles['kickoff-create-field-name-input_error'],
- )}
- onChange={handleChangeName}
- placeholder={namePlaceholder}
- type="text"
- value={name}
- disabled={isDisabled}
- onFocus={() => setIsFocused(true)}
- onBlur={() => setIsFocused(false)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- setIsFocused(false);
- event.currentTarget.blur();
- }
- }}
+
+ {labelPosition === EFieldLabelPosition.Left ? (
+
- {isRequired &&
}
- {!isFocused && mode === EExtraFieldMode.Kickoff && (
-
- )}
-
+ ) : (
+
+ )}
{!isKickoffFieldNameValid && (
@@ -117,7 +131,13 @@ export function ExtraFieldCheckbox({
)}
-
+
{customOptionsList}
{!isDisabled && addOptionButton}
@@ -179,8 +199,7 @@ export function ExtraFieldCheckbox({
};
const handleChangeName = React.useCallback(
- (e: React.ChangeEvent) => {
- fitInputWidth(e.target, DEFAULT_FIELD_INPUT_WIDTH);
+ (e: React.ChangeEvent) => {
editField({ name: e.target.value });
},
[editField],
@@ -232,16 +251,36 @@ export function ExtraFieldCheckbox({
return null;
}
+ const isLabelLeft = labelPosition === EFieldLabelPosition.Left;
const fieldNameClassName = classnames(fieldStyles['kickoff-set-field-name']);
return (
-
-
-
{name}
- {isRequired &&
}
+
+ {isLabelLeft ? (
+
+ ) : (
+
+
{name}
+ {isRequired &&
}
+
+ )}
+
+
{selectionValues.map(renderProcessRunOption)}
-
-
{selectionValues.map(renderProcessRunOption)}
);
};
@@ -265,5 +304,5 @@ export function ExtraFieldCheckbox({
return fieldsMap[mode];
};
- return renderField();
+ return <>{renderField()}>;
}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/__tests__/ExtraFieldCheckbox.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/__tests__/ExtraFieldCheckbox.test.tsx
index f21b78753..f458b4541 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/__tests__/ExtraFieldCheckbox.test.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Checkbox/__tests__/ExtraFieldCheckbox.test.tsx
@@ -1,28 +1,31 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import { ExtraFieldCheckbox } from '../ExtraFieldCheckbox';
import { OutputFieldContent } from '../../utils/OutputFieldContent';
+import { FieldLabel } from '../../utils/FieldLabel';
import { Checkbox } from '../../../../UI/Fields/Checkbox';
+import { IWorkflowExtraFieldProps } from '../../types';
import { intlMock } from '../../../../../__stubs__/intlMock';
-import { EExtraFieldMode } from '../../../../../types/template';
+import { makeExtraField } from '../../../../../__stubs__/fields.factory';
+import { EExtraFieldMode, EExtraFieldType, IExtraFieldSelection } from '../../../../../types/template';
+import { EFieldLabelPosition } from '../../../../../types/fieldset';
jest.mock('../../utils/OutputFieldContent', () => ({
- OutputFieldContent: jest.fn(({ children }: any) =>
+ OutputFieldContent: jest.fn(({ children }: { children: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'output-field-content' }, children),
),
}));
jest.mock('../../../../UI/Fields/Checkbox', () => ({
- Checkbox: jest.fn(({ title }: any) =>
+ Checkbox: jest.fn(({ title }: { title: string }) =>
React.createElement('span', { 'data-testid': `checkbox-${title}` }, title),
),
}));
jest.mock('react-input-autosize', () => ({
__esModule: true,
- default: jest.fn(({ value, onChange, placeholder }: any) =>
+ default: jest.fn(({ value, onChange, placeholder }: { value: string; onChange: () => void; placeholder: string }) =>
React.createElement('input', { value: value || '', onChange, placeholder }),
),
}));
@@ -54,38 +57,41 @@ jest.mock('../../../KickoffRedux/utils/getEmptySelection', () => ({
getEmptySelection: jest.fn(() => ({ id: 'empty', value: '', isSelected: false, apiName: 'empty' })),
}));
+jest.mock('../../utils/FieldLabel', () => ({
+ FieldLabel: jest.fn(() => null),
+}));
+
describe('ExtraFieldCheckbox', () => {
const mockEditField = jest.fn();
- const kickoffField = {
+ const kickoffSelections: IExtraFieldSelection[] = [
+ { key: 1, value: 'Red', isSelected: false, apiName: 'opt-1' },
+ { key: 2, value: 'Blue', isSelected: false, apiName: 'opt-2' },
+ ];
+
+ const processRunSelections = ['Red', 'Blue', 'Green'];
+
+ const kickoffField = makeExtraField({
name: 'Colors',
- value: '',
- type: 'checkbox' as any,
- apiName: 'colors',
- order: 0,
- isRequired: false,
- userId: 1,
- groupId: 1,
- dataset: null,
- selections: [
- { id: 1, value: 'Red', isSelected: false, apiName: 'opt-1' },
- { id: 2, value: 'Blue', isSelected: false, apiName: 'opt-2' },
- ],
- };
+ type: EExtraFieldType.Checkbox,
+ selections: kickoffSelections,
+ });
- const processRunField = {
- ...kickoffField,
- selections: ['Red', 'Blue', 'Green'],
+ const processRunField = makeExtraField({
+ name: 'Colors',
+ type: EExtraFieldType.Checkbox,
+ selections: processRunSelections,
value: 'Red',
- };
+ });
- const baseKickoffProps = {
- field: kickoffField as any,
- intl: intlMock as any,
+ const baseKickoffProps: IWorkflowExtraFieldProps = {
+ field: kickoffField,
+ intl: intlMock,
editField: mockEditField,
mode: EExtraFieldMode.Kickoff,
isDisabled: false,
accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
};
beforeEach(() => {
@@ -93,13 +99,13 @@ describe('ExtraFieldCheckbox', () => {
});
it('Kickoff: renders OutputFieldContent as wrapper for options', () => {
- render(React.createElement(ExtraFieldCheckbox as any, baseKickoffProps));
+ render(
);
expect(screen.getByTestId('output-field-content')).toBeInTheDocument();
});
it('Kickoff: passes field, editField, isDisabled to OutputFieldContent', () => {
- render(React.createElement(ExtraFieldCheckbox as any, baseKickoffProps));
+ render(
);
const mock = OutputFieldContent as jest.Mock;
expect(mock).toHaveBeenCalledTimes(1);
@@ -114,16 +120,82 @@ describe('ExtraFieldCheckbox', () => {
});
it('ProcessRun: renders Checkbox for each string selection', () => {
- render(React.createElement(ExtraFieldCheckbox as any, {
- ...baseKickoffProps,
- field: processRunField as any,
- mode: EExtraFieldMode.ProcessRun,
- }));
+ render(
+
,
+ );
- const mock = Checkbox as unknown as jest.Mock;
+ const mock = Checkbox as jest.Mock;
expect(mock).toHaveBeenCalledTimes(3);
expect(mock).toHaveBeenCalledWith(expect.objectContaining({ title: 'Red' }), {});
expect(mock).toHaveBeenCalledWith(expect.objectContaining({ title: 'Blue' }), {});
expect(mock).toHaveBeenCalledWith(expect.objectContaining({ title: 'Green' }), {});
});
+
+ describe('label-left support', () => {
+ it('Kickoff + labelPosition=Left: renders FieldLabel', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('Kickoff + labelPosition=Top: no FieldLabel', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).not.toHaveBeenCalled();
+ });
+
+ it('Kickoff + labelPosition=Left: passes className with label-left to OutputFieldContent', () => {
+ render(
);
+
+ const mock = OutputFieldContent as jest.Mock;
+ expect(mock).toHaveBeenCalledTimes(1);
+ expect(mock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ className: expect.stringContaining('label-left'),
+ }),
+ {},
+ );
+ });
+
+ it('ProcessRun + labelPosition=Left: renders FieldLabel with aligned-start class', () => {
+ render(
+
,
+ );
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).toHaveBeenCalledTimes(1);
+ expect(fieldLabelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ className: expect.stringContaining('aligned-start'),
+ }),
+ {},
+ );
+ });
+
+ it('ProcessRun + labelPosition=Top: renders static name div, no FieldLabel', () => {
+ render(
+
,
+ );
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).not.toHaveBeenCalled();
+ expect(screen.getByText('Colors')).toBeInTheDocument();
+ });
+ });
});
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/ExtraFieldCreatable.css b/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/ExtraFieldCreatable.css
index 04dfc68be..f9a0a3818 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/ExtraFieldCreatable.css
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/ExtraFieldCreatable.css
@@ -58,6 +58,12 @@
}
/* stylelint-enable */
}
+
+.dropdown-select-wrapper_label-left {
+ flex: 1;
+ min-width: 0;
+}
+
.kickoff-create-field-container {
flex-grow: 1;
max-width: calc(100% - 40px);
@@ -73,6 +79,12 @@
}
}
+.kickoff-create-field-options-wrapper_label-left {
+ margin-left: calc(45% + 3.2rem);
+ min-width: 0;
+ max-width: calc(55% - 3.2rem);
+}
+
.kickoff-create-field-name {
position: relative;
display: inline-block;
@@ -124,6 +136,8 @@
max-width: 100%;
border: none;
+ @mixin text-truncate;
+
&:focus {
outline: none;
}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/ExtraFieldCreatable.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/ExtraFieldCreatable.tsx
index 8aa3baee1..7da25506d 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/ExtraFieldCreatable.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/ExtraFieldCreatable.tsx
@@ -11,9 +11,11 @@ import { getInputNameBackground } from '../utils/getInputNameBackground';
import { RemoveIcon, ArrowDropdownIcon } from '../../../icons';
import { IntlMessages } from '../../../IntlMessages';
import { FieldWithName } from '../utils/FieldWithName';
+import { FieldLabel } from '../utils/FieldLabel';
import { OutputFieldContent } from '../utils/OutputFieldContent';
import { getFieldValidator } from '../utils/getFieldValidator';
import { EExtraFieldMode, IExtraFieldSelection } from '../../../../types/template';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
import { validateCheckboxAndRadioField } from '../../../../utils/validators';
import { handleSelectionBlur, recalculateDuplicateErrors } from '../utils/handleSelectionBlur';
@@ -44,9 +46,10 @@ export function ExtraFieldCreatable({
deleteField,
isDisabled = false,
labelBackgroundColor,
+ labelPosition,
innerRef,
datasetName,
-}: IWorkflowExtraFieldProps) {
+}: IWorkflowExtraFieldProps): JSX.Element {
const { isRequired } = field;
const optionInputsRefs = React.useRef
([]);
@@ -180,6 +183,8 @@ export function ExtraFieldCreatable({
validate={getFieldValidator(field, mode)}
isDisabled={isDisabled}
icon={}
+ labelPosition={labelPosition}
+ {...(labelPosition === EFieldLabelPosition.Left && { labelClassName: styles['kick-off-input__name_label-left_centered'] })}
innerRef={innerRef}
/>
);
@@ -195,35 +200,87 @@ export function ExtraFieldCreatable({
);
+ const optionsContent = (
+
+ {customOptionsList}
+ {!isDisabled && addOptionButton}
+
+ );
+
+ const isLabelLeft = labelPosition === EFieldLabelPosition.Left;
+
+ if (isLabelLeft) {
+ return (
+
+
}
+ labelPosition={labelPosition}
+ labelClassName={styles['kick-off-input__name_label-left_centered']}
+ innerRef={innerRef}
+ />
+
+ {optionsContent}
+
+
+ );
+ }
+
return (
{renderKickoffField()}
-
-
- {customOptionsList}
- {!isDisabled && addOptionButton}
-
+ {optionsContent}
);
};
const renderSelectableView = () => {
const displayValue = dropdownSelections.find((selection) => selection.value === field.value) || null;
+ const isLabelLeft = labelPosition === EFieldLabelPosition.Left;
return (
-
-
-
{field.name}
- {isRequired &&
}
+
+ {isLabelLeft ? (
+
+ ) : (
+
+
{field.name}
+ {isRequired &&
}
+
+ )}
+
+
-
);
};
@@ -237,5 +294,5 @@ export function ExtraFieldCreatable({
return fieldsMap[mode];
};
- return renderDropdownField();
+ return <>{renderDropdownField()}>;
}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/__tests__/ExtraFieldCreatable.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/__tests__/ExtraFieldCreatable.test.tsx
index 7eaefda1b..0733b4080 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/__tests__/ExtraFieldCreatable.test.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Creatable/__tests__/ExtraFieldCreatable.test.tsx
@@ -1,15 +1,18 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import { ExtraFieldCreatable } from '../ExtraFieldCreatable';
import { OutputFieldContent } from '../../utils/OutputFieldContent';
+import { FieldLabel } from '../../utils/FieldLabel';
import { DropdownList } from '../../../../UI/DropdownList';
+import { IWorkflowExtraFieldProps } from '../../types';
import { intlMock } from '../../../../../__stubs__/intlMock';
-import { EExtraFieldMode } from '../../../../../types/template';
+import { makeExtraField } from '../../../../../__stubs__/fields.factory';
+import { EExtraFieldMode, EExtraFieldType, IExtraFieldSelection } from '../../../../../types/template';
+import { EFieldLabelPosition } from '../../../../../types/fieldset';
jest.mock('../../utils/OutputFieldContent', () => ({
- OutputFieldContent: jest.fn(({ children }: any) =>
+ OutputFieldContent: jest.fn(({ children }: { children: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'output-field-content' }, children),
),
}));
@@ -48,8 +51,15 @@ jest.mock('../../../../IntlMessages', () => ({
IntlMessages: jest.fn(() => null),
}));
-jest.mock('../../utils/FieldWithName', () => ({
- FieldWithName: jest.fn(() => React.createElement('div', { 'data-testid': 'field-with-name' })),
+jest.mock('../../utils/FieldWithName', () => {
+ const React = require('react');
+ return {
+ FieldWithName: jest.fn(() => React.createElement('div', { 'data-testid': 'field-with-name' })),
+ };
+});
+
+jest.mock('../../utils/FieldLabel', () => ({
+ FieldLabel: jest.fn(() => null),
}));
jest.mock('../../../KickoffRedux/utils/getEmptySelection', () => ({
@@ -58,37 +68,38 @@ jest.mock('../../../KickoffRedux/utils/getEmptySelection', () => ({
describe('ExtraFieldCreatable', () => {
const mockEditField = jest.fn();
+ const getFieldWithNameMock = (): jest.Mock =>
+ require('../../utils/FieldWithName').FieldWithName;
+
+ const kickoffSelections: IExtraFieldSelection[] = [
+ { key: 1, value: 'High', isSelected: false, apiName: 'opt-1' },
+ { key: 2, value: 'Low', isSelected: false, apiName: 'opt-2' },
+ ];
- const kickoffField = {
+ const processRunSelections = ['High', 'Medium', 'Low'];
+
+ const kickoffField = makeExtraField({
name: 'Priority',
- value: '',
- type: 'creatable' as any,
- apiName: 'priority',
- order: 0,
- isRequired: false,
- userId: 1,
- groupId: 1,
- dataset: null,
+ type: EExtraFieldType.Creatable,
description: 'Select priority',
- selections: [
- { id: 1, value: 'High', isSelected: false, apiName: 'opt-1' },
- { id: 2, value: 'Low', isSelected: false, apiName: 'opt-2' },
- ],
- };
+ selections: kickoffSelections,
+ });
- const processRunField = {
- ...kickoffField,
- selections: ['High', 'Medium', 'Low'],
+ const processRunField = makeExtraField({
+ name: 'Priority',
+ type: EExtraFieldType.Creatable,
+ selections: processRunSelections,
value: 'High',
- };
+ });
- const baseKickoffProps = {
- field: kickoffField as any,
- intl: intlMock as any,
+ const baseKickoffProps: IWorkflowExtraFieldProps = {
+ field: kickoffField,
+ intl: intlMock,
editField: mockEditField,
mode: EExtraFieldMode.Kickoff,
isDisabled: false,
accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
};
beforeEach(() => {
@@ -96,13 +107,13 @@ describe('ExtraFieldCreatable', () => {
});
it('Kickoff: renders OutputFieldContent as wrapper for options', () => {
- render(React.createElement(ExtraFieldCreatable as any, baseKickoffProps));
+ render(
);
expect(screen.getByTestId('output-field-content')).toBeInTheDocument();
});
it('Kickoff: passes field, editField, isDisabled to OutputFieldContent', () => {
- render(React.createElement(ExtraFieldCreatable as any, baseKickoffProps));
+ render(
);
const mock = OutputFieldContent as jest.Mock;
expect(mock).toHaveBeenCalledTimes(1);
@@ -117,11 +128,13 @@ describe('ExtraFieldCreatable', () => {
});
it('ProcessRun: renders DropdownList with string selections as options', () => {
- render(React.createElement(ExtraFieldCreatable as any, {
- ...baseKickoffProps,
- field: processRunField as any,
- mode: EExtraFieldMode.ProcessRun,
- }));
+ render(
+
,
+ );
const mock = DropdownList as jest.Mock;
expect(mock).toHaveBeenCalledTimes(1);
@@ -136,4 +149,65 @@ describe('ExtraFieldCreatable', () => {
{},
);
});
+
+ describe('label-left support', () => {
+ it('Kickoff + labelPosition=Left: passes labelClassName with centered class to FieldWithName', () => {
+ render(
);
+
+ const mock = getFieldWithNameMock();
+ expect(mock).toHaveBeenCalledTimes(1);
+ expect(mock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ labelClassName: expect.stringContaining('centered'),
+ }),
+ {},
+ );
+ });
+
+ it('Kickoff + labelPosition=Top: does NOT pass labelClassName to FieldWithName', () => {
+ render(
);
+
+ const mock = getFieldWithNameMock();
+ expect(mock).toHaveBeenCalledTimes(1);
+ expect(mock).toHaveBeenCalledWith(
+ expect.not.objectContaining({ labelClassName: expect.anything() }),
+ {},
+ );
+ });
+
+ it('ProcessRun + labelPosition=Left: renders FieldLabel with centered class', () => {
+ render(
+
,
+ );
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).toHaveBeenCalledTimes(1);
+ expect(fieldLabelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ className: expect.stringContaining('centered'),
+ }),
+ {},
+ );
+ });
+
+ it('ProcessRun + labelPosition=Top: renders readonly name div, no FieldLabel', () => {
+ render(
+
,
+ );
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).not.toHaveBeenCalled();
+ expect(screen.getByText('Priority')).toBeInTheDocument();
+ });
+ });
});
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.css b/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.css
index 739cc6c98..080614914 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.css
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.css
@@ -8,6 +8,16 @@
position: relative;
}
+.date-input-wrapper_label-left {
+ flex: 1;
+ min-width: 0;
+
+ .icon {
+ bottom: auto;
+ height: 50px;
+ }
+}
+
.icon {
position: absolute;
top: 0;
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.stories.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.stories.tsx
index 9421fc9ec..bd172aade 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.stories.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.stories.tsx
@@ -1,68 +1,75 @@
import type { Meta, StoryObj } from '@storybook/react';
+import { createIntl } from 'react-intl';
+
import { ExtraFieldDate } from './ExtraFieldDate';
-import { EExtraFieldMode, IExtraField } from '../../../../types/template';
+import { EExtraFieldMode, EExtraFieldType, IExtraField } from '../../../../types/template';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
+import { IWorkflowExtraFieldProps } from '../types';
+import { enMessages } from '../../../../lang/locales/en_US';
+
+const intl = createIntl({ locale: 'en-US', defaultLocale: 'en-US', messages: enMessages });
-const meta = {
+const editField = (updates: Partial
): void => {
+ console.log('Field updated:', updates);
+};
+
+const defaultField: IExtraField = {
+ apiName: 'date-field',
+ name: 'Due Date',
+ type: EExtraFieldType.Date,
+ order: 0,
+ userId: null,
+ groupId: null,
+ description: 'Select a due date',
+ isRequired: false,
+ isHidden: false,
+ value: '',
+ selections: [],
+ attachments: [],
+ dataset: null,
+};
+
+const meta: Meta = {
title: 'UI/ExtraFieldDate',
- component: ExtraFieldDate as React.ComponentType,
+ component: ExtraFieldDate,
tags: ['autodocs'],
-} satisfies Meta;
+};
export default meta;
type Story = StoryObj;
-const formatMessage = ({ id }: { id: string }): string => id;
-const editField = (updates: Partial): void => {
- console.log('Field updated:', updates);
-};
-
export const Default: Story = {
args: {
- field: {
- value: null,
- name: 'Due Date',
- isRequired: false,
- description: 'Select a due date'
- },
- intl: {
- formatMessage
- },
+ field: defaultField,
+ intl,
mode: EExtraFieldMode.Kickoff,
editField,
- isDisabled: false
- }
+ isDisabled: false,
+ accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
+ },
};
export const Required: Story = {
args: {
- field: {
- value: '2024-01-01',
- name: 'Due Date',
- isRequired: true,
- description: 'Select a due date'
- },
- intl: {
- formatMessage
- },
+ field: { ...defaultField, value: '2024-01-01', isRequired: true },
+ intl,
mode: EExtraFieldMode.Kickoff,
editField,
- isDisabled: false
- }
+ isDisabled: false,
+ accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
+ },
};
export const Disabled: Story = {
args: {
- field: {
- value: '2024-01-01',
- name: 'Due Date',
- isRequired: false,
- description: 'Select a due date'
- },
- intl: {
- formatMessage
- },
+ field: { ...defaultField, value: '2024-01-01' },
+ intl,
mode: EExtraFieldMode.Kickoff,
editField,
- isDisabled: true
- }
+ isDisabled: true,
+ accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
+ },
};
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.tsx
index 19613b216..b92cf0908 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Date/ExtraFieldDate.tsx
@@ -6,8 +6,10 @@ import classnames from 'classnames';
import { getFieldValidator } from '../utils/getFieldValidator';
import { getInputNameBackground } from '../utils/getInputNameBackground';
import { EExtraFieldMode } from '../../../../types/template';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
import { DateIcon } from '../../../icons';
import { FieldWithName } from '../utils/FieldWithName';
+import { FieldLabel } from '../utils/FieldLabel';
import { IWorkflowExtraFieldProps } from '..';
import { DatePickerCustom } from '../../../UI/form/DatePicker';
import { toDate, toTspDate } from '../../../../utils/dateTime';
@@ -27,8 +29,9 @@ export function ExtraFieldDate({
editField,
isDisabled = false,
labelBackgroundColor,
+ labelPosition,
innerRef,
-}: IWorkflowExtraFieldProps) {
+}: IWorkflowExtraFieldProps): JSX.Element {
const handleChangeName = React.useCallback(
(e: React.ChangeEvent) => {
editField({ name: e.target.value });
@@ -56,18 +59,35 @@ export function ExtraFieldDate({
};
const renderProcessRunField = () => {
+ const isLabelLeft = labelPosition === EFieldLabelPosition.Left;
const fieldNameClassName = classnames(getInputNameBackground(labelBackgroundColor), styles['kick-off-input__name']);
return (
-
-
{name}
- {isRequired &&
}
-
-
+ {isLabelLeft ? (
+
+ ) : (
+
+
{name}
+ {isRequired &&
}
+
+ )}
+
}
@@ -105,5 +126,5 @@ export function ExtraFieldDate({
return fieldsMap[mode];
};
- return renderField();
+ return <>{renderField()}>;
}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Date/__tests__/ExtraFieldDate.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Date/__tests__/ExtraFieldDate.test.tsx
new file mode 100644
index 000000000..b4e7bcfa9
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Date/__tests__/ExtraFieldDate.test.tsx
@@ -0,0 +1,83 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { ExtraFieldDate } from '../ExtraFieldDate';
+import { FieldLabel } from '../../utils/FieldLabel';
+import { IWorkflowExtraFieldProps } from '../../types';
+import { intlMock } from '../../../../../__stubs__/intlMock';
+import { makeExtraField } from '../../../../../__stubs__/fields.factory';
+import { EExtraFieldMode, EExtraFieldType } from '../../../../../types/template';
+import { EFieldLabelPosition } from '../../../../../types/fieldset';
+
+jest.mock('../../utils/FieldLabel', () => ({
+ FieldLabel: jest.fn(() => null),
+}));
+
+jest.mock('../../utils/FieldWithName', () => {
+ const React = require('react');
+ return {
+ FieldWithName: jest.fn(() => React.createElement('div', { 'data-testid': 'field-with-name' })),
+ };
+});
+
+jest.mock('../../../../icons', () => ({
+ DateIcon: () => null,
+}));
+
+jest.mock('../../utils/getFieldValidator', () => ({
+ getFieldValidator: jest.fn(() => jest.fn(() => '')),
+}));
+
+jest.mock('../../utils/getInputNameBackground', () => ({
+ getInputNameBackground: jest.fn(() => ''),
+}));
+
+jest.mock('../../../../UI/form/DatePicker', () => ({
+ DatePickerCustom: jest.fn(() => null),
+}));
+
+jest.mock('../../../../../utils/dateTime', () => ({
+ toDate: jest.fn(() => null),
+ toTspDate: jest.fn(() => ''),
+}));
+
+describe('ExtraFieldDate', () => {
+ const mockEditField = jest.fn();
+
+ const baseProps: IWorkflowExtraFieldProps = {
+ field: makeExtraField({ name: 'Due Date', type: EExtraFieldType.Date }),
+ intl: intlMock,
+ editField: mockEditField,
+ mode: EExtraFieldMode.ProcessRun,
+ isDisabled: false,
+ accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('label-left support', () => {
+ it('ProcessRun + labelPosition=Left: renders FieldLabel with centered class', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).toHaveBeenCalledTimes(1);
+ expect(fieldLabelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ className: expect.stringContaining('centered'),
+ }),
+ {},
+ );
+ });
+
+ it('ProcessRun + labelPosition=Top: renders static name div, no FieldLabel', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).not.toHaveBeenCalled();
+ expect(screen.getByText('Due Date')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/ExtraField.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/ExtraField.tsx
index 4287b6b55..610b819d8 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/ExtraField.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/ExtraField.tsx
@@ -15,6 +15,7 @@ import { ExtraFieldUser } from './User';
import { ExtraFieldNumber } from './Number';
import { EExtraFieldType } from '../../../types/template';
+import { EFieldLabelPosition } from '../../../types/fieldset';
import { ExtraFieldDropdown } from './utils/ExtraFieldDropdown';
import { getInputNameBackground } from './utils/getInputNameBackground';
import { IExtraFieldProps } from './types';
@@ -28,7 +29,7 @@ function ExtraField(props: IExtraFieldProps) {
field,
field: { apiName, isRequired = false, isHidden = false },
fieldsCount,
- showDropdown = true,
+ showDropdown,
deleteField,
moveFieldUp,
moveFieldDown,
@@ -38,13 +39,16 @@ function ExtraField(props: IExtraFieldProps) {
wrapperClassName,
labelBackgroundColor,
innerRef,
- datasetOptions,
+ labelPosition,
} = props;
+ // eslint-disable-next-line react/destructuring-assignment
+ const datasetOptions = showDropdown ? (props.datasetOptions ?? []) : [];
+
const isDatasetField = DATASET_FIELD_TYPES.includes(field.type);
const datasetName = useMemo(
- () => datasetOptions?.find((option) => option.value === String(field.dataset))?.label,
+ () => datasetOptions.find((option) => option.value === String(field.dataset))?.label,
[datasetOptions, field.dataset],
);
@@ -147,7 +151,8 @@ function ExtraField(props: IExtraFieldProps) {
return classnames(
styles['kick-off-input__dropdown'],
(field.type === EExtraFieldType.Checkbox || field.type === EExtraFieldType.Radio) && styles['kick-off-input__dropdown_choices'],
- field.type === EExtraFieldType.Creatable && styles['kick-off-input__dropdown_creatable']
+ field.type === EExtraFieldType.Creatable && styles['kick-off-input__dropdown_creatable'],
+ labelPosition === EFieldLabelPosition.Left && styles['kick-off-input__dropdown_label-left'],
);
};
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/File/ExtraFieldFile.css b/frontend/src/public/components/TemplateEdit/ExtraFields/File/ExtraFieldFile.css
index 594add57b..f886d879c 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/File/ExtraFieldFile.css
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/File/ExtraFieldFile.css
@@ -130,6 +130,11 @@
height: 100%;
}
+.file-content-wrapper_label-left {
+ flex: 1;
+ min-width: 0;
+}
+
.extra-field-file__ref {
position: absolute;
z-index: -1;
@@ -157,16 +162,21 @@
.extra-field-file__input--template {
position: relative;
display: inline-flex;
- align-items: center;
- justify-content: center;
+ align-items: flex-end;
+ gap: 2px;
max-width: 100%;
}
.extra-field-file__input-name--template {
padding: 0 0.4rem 0 0;
- max-width: calc(100% - 0.5rem);
- line-height: 2rem;
+ min-width: 0;
+ resize: none;
border: none;
+ field-sizing: content;
+ overflow-wrap: break-word;
+
+ @mixin text-base;
+
&:focus {
outline: none;
}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/File/ExtraFieldFile.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/File/ExtraFieldFile.tsx
index 998be86c8..578431290 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/File/ExtraFieldFile.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/File/ExtraFieldFile.tsx
@@ -3,6 +3,8 @@
import * as React from 'react';
import classnames from 'classnames';
import { EExtraFieldMode } from '../../../../types/template';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
+import { FieldLabel } from '../utils/FieldLabel';
import { PencilSmallIcon } from '../../../icons';
import { TUploadedFile, uploadFiles } from '../../../../utils/uploadFiles';
import { NotificationManager } from '../../../UI/Notifications';
@@ -10,7 +12,6 @@ import { ExtraFieldFilesGrid } from './ExtraFieldFilesGrid';
import { logger } from '../../../../utils/logger';
import { IWorkflowExtraFieldProps } from '..';
-import AutosizeInput from 'react-input-autosize';
import { validateKickoffFieldName } from '../../../../utils/validators';
import { IntlMessages } from '../../../IntlMessages';
import kickoffStyles from '../../KickoffRedux/KickoffRedux.css';
@@ -27,11 +28,13 @@ export function ExtraFieldFile({
editField,
isDisabled = false,
accountId,
-}: IWorkflowExtraFieldProps) {
+ labelBackgroundColor,
+ labelPosition,
+}: IWorkflowExtraFieldProps): JSX.Element {
const { useCallback, useState, useEffect, createRef } = React;
const [isUploading, setUploadingState] = useState(false);
const [filesToUpload, setFilesToUploadState] = useState
(field.attachments || []);
- const fieldNameInputRef = React.useRef(null);
+ const fieldNameInputRef = React.useRef(null);
const [isFocused, setIsFocused] = React.useState(false);
const { formatMessage } = useIntl();
useEffect(() => {
@@ -45,7 +48,7 @@ export function ExtraFieldFile({
const uploadFieldRef = createRef();
const handleChangeName = useCallback(
- (e: React.ChangeEvent) => {
+ (e: React.ChangeEvent) => {
editField({ name: e.target.value });
},
[editField],
@@ -84,41 +87,55 @@ export function ExtraFieldFile({
const isKickoffFieldNameValid = !Boolean(fieldNameErrorMessage);
const renderKickoffView = () => (
-
-
-
(fieldNameInputRef.current = ref)}
- inputClassName={classnames(
- styles['extra-field-file__input-name--template'],
- !isKickoffFieldNameValid && styles['extra-field-file__input-name-error--template'],
- )}
- onChange={handleChangeName}
- placeholder={namePlaceholder}
- type="text"
- value={name}
- disabled={isDisabled}
- onFocus={() => setIsFocused(true)}
- onBlur={() => setIsFocused(false)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- setIsFocused(false);
- event.currentTarget.blur();
- }
- }}
+
+ {labelPosition === EFieldLabelPosition.Left ? (
+
- {isRequired &&
}
- {!isFocused && mode === EExtraFieldMode.Kickoff && (
-
- )}
-
+ ) : (
+
+ )}
{!isKickoffFieldNameValid && (
@@ -147,34 +164,55 @@ export function ExtraFieldFile({
};
const renderProcessView = () => {
- return (
-
-
-
{name}
- {isRequired &&
}
-
+ const isLabelLeft = labelPosition === EFieldLabelPosition.Left;
-
+ return (
+
+ {isLabelLeft ? (
+
+ ) : (
+
+
{name}
+ {isRequired &&
}
+
+ )}
+
);
@@ -189,5 +227,5 @@ export function ExtraFieldFile({
return fieldsMap[mode];
};
- return renderFileField();
+ return <>{renderFileField()}>;
}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/File/__tests__/ExtraFieldFile.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/File/__tests__/ExtraFieldFile.test.tsx
new file mode 100644
index 000000000..1ad7f51fd
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/File/__tests__/ExtraFieldFile.test.tsx
@@ -0,0 +1,114 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { ExtraFieldFile } from '../ExtraFieldFile';
+import { FieldLabel } from '../../utils/FieldLabel';
+import { IWorkflowExtraFieldProps } from '../../types';
+import { intlMock } from '../../../../../__stubs__/intlMock';
+import { makeExtraField } from '../../../../../__stubs__/fields.factory';
+import { EExtraFieldMode, EExtraFieldType } from '../../../../../types/template';
+import { EFieldLabelPosition } from '../../../../../types/fieldset';
+
+jest.mock('../../utils/FieldLabel', () => ({
+ FieldLabel: jest.fn(() => null),
+}));
+
+jest.mock('../../../../icons', () => ({
+ PencilSmallIcon: () => null,
+ RemoveIcon: () => null,
+}));
+
+jest.mock('../../../../../utils/validators', () => ({
+ validateKickoffFieldName: jest.fn(() => ''),
+}));
+
+jest.mock('../../../../../utils/uploadFiles', () => ({
+ uploadFiles: jest.fn(),
+}));
+
+jest.mock('../../../../IntlMessages', () => ({
+ IntlMessages: jest.fn(() => null),
+}));
+
+jest.mock('../../../../UI/Notifications', () => ({
+ NotificationManager: { warning: jest.fn(), success: jest.fn() },
+}));
+
+jest.mock('../ExtraFieldFilesGrid', () => ({
+ ExtraFieldFilesGrid: jest.fn(() => null),
+}));
+
+jest.mock('../../../../UI/Buttons/Button', () => ({
+ Button: jest.fn(() => null),
+}));
+
+jest.mock('../../../../../utils/logger', () => ({
+ logger: { error: jest.fn(), info: jest.fn() },
+}));
+
+describe('ExtraFieldFile', () => {
+ const mockEditField = jest.fn();
+
+ const baseProps: IWorkflowExtraFieldProps = {
+ field: makeExtraField({ name: 'Attachment', type: EExtraFieldType.File }),
+ intl: intlMock,
+ editField: mockEditField,
+ mode: EExtraFieldMode.Kickoff,
+ isDisabled: false,
+ accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('label-left support', () => {
+ it('Kickoff + labelPosition=Left: renders FieldLabel', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('Kickoff + labelPosition=Top: no FieldLabel', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).not.toHaveBeenCalled();
+ });
+
+ it('ProcessRun + labelPosition=Left: renders FieldLabel with aligned-start class', () => {
+ render(
+
,
+ );
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).toHaveBeenCalledTimes(1);
+ expect(fieldLabelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ className: expect.stringContaining('aligned-start'),
+ }),
+ {},
+ );
+ });
+
+ it('ProcessRun + labelPosition=Top: renders static name div, no FieldLabel', () => {
+ render(
+
,
+ );
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).not.toHaveBeenCalled();
+ expect(screen.getByText('Attachment')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Number/ExtraFieldNumber.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Number/ExtraFieldNumber.tsx
index d84280dc0..9a91b2a2f 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Number/ExtraFieldNumber.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Number/ExtraFieldNumber.tsx
@@ -18,6 +18,7 @@ export const ExtraFieldNumber = ({
editField,
isDisabled = false,
labelBackgroundColor,
+ labelPosition,
innerRef,
}: IWorkflowExtraFieldProps) => {
const handleChangeName = useCallback(
@@ -46,6 +47,7 @@ export const ExtraFieldNumber = ({
namePlaceholder={namePlaceholder}
mode={mode}
labelBackgroundColor={labelBackgroundColor}
+ labelPosition={labelPosition}
handleChangeName={handleChangeName}
handleChangeDescription={handleChangeDescription}
validate={getFieldValidator(field, mode)}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/ExtraFieldRadio.css b/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/ExtraFieldRadio.css
index 006a2566b..52874bbc7 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/ExtraFieldRadio.css
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/ExtraFieldRadio.css
@@ -16,14 +16,20 @@
.kickoff-create-field-name {
position: relative;
display: inline-flex;
+ align-items: flex-end;
+ gap: 2px;
max-width: 100%;
}
.kickoff-create-field-name-input {
padding: 0 4px 0 0;
- max-width: calc(100% - 5px);
- line-height: 20px;
+ min-width: 0;
+ resize: none;
border: none;
+ field-sizing: content;
+ overflow-wrap: break-word;
+
+ @mixin text-base;
&:focus {
outline: none;
@@ -106,6 +112,7 @@
.kickoff-set-field-container {
display: block;
+ flex-grow: 1;
}
.kickoff-set-field-name {
@@ -147,3 +154,8 @@
position: relative;
bottom: 15px;
}
+
+.radio-options-wrapper_label-left {
+ flex: 1;
+ min-width: 0;
+}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/ExtraFieldRadio.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/ExtraFieldRadio.tsx
index 86278abd6..1fd064988 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/ExtraFieldRadio.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/ExtraFieldRadio.tsx
@@ -2,13 +2,14 @@
/* prettier-ignore */
import * as React from 'react';
import classnames from 'classnames';
-import AutosizeInput from 'react-input-autosize';
import { getEmptySelection } from '../../KickoffRedux/utils/getEmptySelection';
import { validateCheckboxAndRadioField, validateKickoffFieldName } from '../../../../utils/validators';
import { handleSelectionBlur, recalculateDuplicateErrors } from '../utils/handleSelectionBlur';
import { IntlMessages } from '../../../IntlMessages';
import { EExtraFieldMode, IExtraFieldSelection } from '../../../../types/template';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
+import { FieldLabel } from '../utils/FieldLabel';
import { fitInputWidth } from '../utils/fitInputWidth';
import { PencilSmallIcon, RemoveIcon } from '../../../icons';
import { RadioButton } from '../../../UI/Fields/RadioButton';
@@ -21,7 +22,6 @@ import fieldStyles from './ExtraFieldRadio.css';
import { useState } from 'react';
const DEFAULT_OPTION_INPUT_WIDTH = 120;
-const DEFAULT_FIELD_INPUT_WIDTH = 120;
export function ExtraFieldRadio({
field,
@@ -33,10 +33,12 @@ export function ExtraFieldRadio({
editField,
isDisabled = false,
datasetName,
-}: IWorkflowExtraFieldProps) {
+ labelPosition,
+ labelBackgroundColor,
+}: IWorkflowExtraFieldProps): JSX.Element {
const selectionItems = field.selections as IExtraFieldSelection[];
const selectionValues = field.selections as string[];
- const fieldNameInputRef = React.useRef
(null);
+ const fieldNameInputRef = React.useRef(null);
const optionInputsRefs = React.useRef([]);
const [isFocused, setIsFocused] = React.useState(false);
@@ -44,10 +46,6 @@ export function ExtraFieldRadio({
optionInputsRefs.current.forEach((input) => fitInputWidth(input, DEFAULT_OPTION_INPUT_WIDTH));
}, [selectionItems]);
- React.useEffect(() => {
- fitInputWidth(fieldNameInputRef.current, DEFAULT_FIELD_INPUT_WIDTH);
- }, []);
-
const [activeOptionIndex, setActiveOptionIndex] = useState(null);
const [duplicateErrors, setDuplicateErrors] = useState>(
() => recalculateDuplicateErrors(selectionItems || []),
@@ -70,38 +68,53 @@ export function ExtraFieldRadio({
);
return (
-
-
-
(fieldNameInputRef.current = ref)}
- inputClassName={classnames(
- fieldStyles['kickoff-create-field-name-input'],
- !isKickoffFieldNameValid && fieldStyles['kickoff-create-field-name-input_error'],
- )}
- onChange={handleChangeName}
- placeholder={namePlaceholder}
- type="text"
- value={name}
- disabled={isDisabled}
- onFocus={() => setIsFocused(true)}
- onBlur={() => setIsFocused(false)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- setIsFocused(false);
- event.currentTarget.blur();
- }
- }}
+
+ {labelPosition === EFieldLabelPosition.Left ? (
+
- {isRequired &&
}
- {!isFocused && mode === EExtraFieldMode.Kickoff && (
-
- )}
-
+ ) : (
+
+ )}
{!isKickoffFieldNameValid && (
@@ -109,7 +122,13 @@ export function ExtraFieldRadio({
)}
-
+
{customOptionsList}
{!isDisabled && addOptionButton}
@@ -170,8 +189,7 @@ export function ExtraFieldRadio({
};
const handleChangeName = React.useCallback(
- (e: React.ChangeEvent) => {
- fitInputWidth(e.target, DEFAULT_FIELD_INPUT_WIDTH);
+ (e: React.ChangeEvent) => {
editField({ name: e.target.value });
},
[editField],
@@ -221,16 +239,36 @@ export function ExtraFieldRadio({
const renderProcessRunField = () => {
if (!selectionValues) return null;
+ const isLabelLeft = labelPosition === EFieldLabelPosition.Left;
const fieldNameClassName = classnames(fieldStyles['kickoff-set-field-name']);
return (
-
-
-
{name}
- {isRequired &&
}
+
+ {isLabelLeft ? (
+
+ ) : (
+
+
{name}
+ {isRequired &&
}
+
+ )}
+
+
{selectionValues.map(renderProcessRunOption)}
-
-
{selectionValues.map(renderProcessRunOption)}
);
};
@@ -251,5 +289,5 @@ export function ExtraFieldRadio({
return fieldsMap[mode];
};
- return renderField();
+ return <>{renderField()}>;
}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/__tests__/ExtraFieldRadio.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/__tests__/ExtraFieldRadio.test.tsx
index 826f07a32..96e9934b6 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/__tests__/ExtraFieldRadio.test.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Radio/__tests__/ExtraFieldRadio.test.tsx
@@ -1,30 +1,31 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import { ExtraFieldRadio } from '../ExtraFieldRadio';
import { OutputFieldContent } from '../../utils/OutputFieldContent';
+import { FieldLabel } from '../../utils/FieldLabel';
import { RadioButton } from '../../../../UI/Fields/RadioButton';
-import { intlMock } from '../../../../../__stubs__/intlMock';
-import { EExtraFieldMode, EExtraFieldType } from '../../../../../types/template';
import { IWorkflowExtraFieldProps } from '../../types';
+import { intlMock } from '../../../../../__stubs__/intlMock';
import { makeExtraField } from '../../../../../__stubs__/fields.factory';
+import { EExtraFieldMode, EExtraFieldType, IExtraFieldSelection } from '../../../../../types/template';
+import { EFieldLabelPosition } from '../../../../../types/fieldset';
jest.mock('../../utils/OutputFieldContent', () => ({
- OutputFieldContent: jest.fn(({ children }: any) =>
+ OutputFieldContent: jest.fn(({ children }: { children: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'output-field-content' }, children),
),
}));
jest.mock('../../../../UI/Fields/RadioButton', () => ({
- RadioButton: jest.fn(({ title }: any) =>
+ RadioButton: jest.fn(({ title }: { title: string }) =>
React.createElement('span', { 'data-testid': `radio-${title}` }, title),
),
}));
jest.mock('react-input-autosize', () => ({
__esModule: true,
- default: jest.fn(({ value, onChange, placeholder }: any) =>
+ default: jest.fn(({ value, onChange, placeholder }: { value: string; onChange: () => void; placeholder: string }) =>
React.createElement('input', { value: value || '', onChange, placeholder }),
),
}));
@@ -56,38 +57,41 @@ jest.mock('../../../KickoffRedux/utils/getEmptySelection', () => ({
getEmptySelection: jest.fn(() => ({ id: 'empty', value: '', isSelected: false, apiName: 'empty' })),
}));
+jest.mock('../../utils/FieldLabel', () => ({
+ FieldLabel: jest.fn(() => null),
+}));
+
describe('ExtraFieldRadio', () => {
const mockEditField = jest.fn();
- const kickoffField = {
+ const kickoffSelections: IExtraFieldSelection[] = [
+ { key: 1, value: 'Red', isSelected: false, apiName: 'opt-1' },
+ { key: 2, value: 'Blue', isSelected: false, apiName: 'opt-2' },
+ ];
+
+ const processRunSelections = ['Red', 'Blue', 'Green'];
+
+ const kickoffField = makeExtraField({
name: 'Color',
- value: '',
- type: 'radio' as any,
- apiName: 'color',
- order: 0,
- isRequired: false,
- userId: 1,
- groupId: 1,
- dataset: null,
- selections: [
- { id: 1, value: 'Red', isSelected: false, apiName: 'opt-1' },
- { id: 2, value: 'Blue', isSelected: false, apiName: 'opt-2' },
- ],
- };
+ type: EExtraFieldType.Radio,
+ selections: kickoffSelections,
+ });
- const processRunField = {
- ...kickoffField,
- selections: ['Red', 'Blue', 'Green'],
+ const processRunField = makeExtraField({
+ name: 'Color',
+ type: EExtraFieldType.Radio,
+ selections: processRunSelections,
value: 'Red',
- };
+ });
- const baseKickoffProps = {
- field: kickoffField as any,
- intl: intlMock as any,
+ const baseKickoffProps: IWorkflowExtraFieldProps = {
+ field: kickoffField,
+ intl: intlMock,
editField: mockEditField,
mode: EExtraFieldMode.Kickoff,
isDisabled: false,
accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
};
beforeEach(() => {
@@ -95,13 +99,13 @@ describe('ExtraFieldRadio', () => {
});
it('Kickoff: renders OutputFieldContent as wrapper for options', () => {
- render(React.createElement(ExtraFieldRadio as any, baseKickoffProps));
+ render(
);
expect(screen.getByTestId('output-field-content')).toBeInTheDocument();
});
it('Kickoff: passes field, editField, isDisabled to OutputFieldContent', () => {
- render(React.createElement(ExtraFieldRadio as any, baseKickoffProps));
+ render(
);
const mock = OutputFieldContent as jest.Mock;
expect(mock).toHaveBeenCalledTimes(1);
@@ -116,19 +120,92 @@ describe('ExtraFieldRadio', () => {
});
it('ProcessRun: renders RadioButton for each string selection', () => {
- render(React.createElement(ExtraFieldRadio as any, {
- ...baseKickoffProps,
- field: processRunField as any,
- mode: EExtraFieldMode.ProcessRun,
- }));
+ render(
+
,
+ );
- const mock = RadioButton as unknown as jest.Mock;
+ const mock = RadioButton as jest.Mock;
expect(mock).toHaveBeenCalledTimes(3);
expect(mock).toHaveBeenCalledWith(expect.objectContaining({ title: 'Red' }), {});
expect(mock).toHaveBeenCalledWith(expect.objectContaining({ title: 'Blue' }), {});
expect(mock).toHaveBeenCalledWith(expect.objectContaining({ title: 'Green' }), {});
});
+ describe('label-left support', () => {
+ it('Kickoff + labelPosition=Left: renders FieldLabel', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).toHaveBeenCalledTimes(1);
+ expect(fieldLabelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Color',
+ mode: EExtraFieldMode.Kickoff,
+ }),
+ {},
+ );
+ });
+
+ it('Kickoff + labelPosition=Top: no FieldLabel', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).not.toHaveBeenCalled();
+ });
+
+ it('Kickoff + labelPosition=Left: passes className with label-left to OutputFieldContent', () => {
+ render(
);
+
+ const mock = OutputFieldContent as jest.Mock;
+ expect(mock).toHaveBeenCalledTimes(1);
+ expect(mock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ className: expect.stringContaining('label-left'),
+ }),
+ {},
+ );
+ });
+
+ it('ProcessRun + labelPosition=Left: renders FieldLabel with aligned-start class', () => {
+ render(
+
,
+ );
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).toHaveBeenCalledTimes(1);
+ expect(fieldLabelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ className: expect.stringContaining('aligned-start'),
+ }),
+ {},
+ );
+ });
+
+ it('ProcessRun + labelPosition=Top: renders static name div, no FieldLabel', () => {
+ render(
+
,
+ );
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).not.toHaveBeenCalled();
+ expect(screen.getByText('Color')).toBeInTheDocument();
+ });
+ });
+
describe('unique radio grouping across multiple fields', () => {
it('ProcessRun: two fields with identical selections receive unique RadioButton ids', () => {
const sharedSelections = ['Red', 'Blue'];
@@ -155,11 +232,10 @@ describe('ExtraFieldRadio', () => {
mode: EExtraFieldMode.ProcessRun,
isDisabled: false,
accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
};
- // @ts-expect-error master version returns ReactNode; after fieldsets merge this becomes JSX.Element and labelPosition must be added
render(
);
- // @ts-expect-error master version returns ReactNode; after fieldsets merge this becomes JSX.Element and labelPosition must be added
render(
);
const mock = RadioButton as jest.Mock;
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/String/ExtraFieldString.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/String/ExtraFieldString.tsx
index 66d11d786..43df71099 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/String/ExtraFieldString.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/String/ExtraFieldString.tsx
@@ -19,6 +19,7 @@ export const ExtraFieldString = ({
editField,
isDisabled = false,
labelBackgroundColor,
+ labelPosition,
innerRef,
}: IWorkflowExtraFieldProps) => {
const handleChangeName = React.useCallback(
@@ -47,6 +48,7 @@ export const ExtraFieldString = ({
namePlaceholder={namePlaceholder}
mode={mode}
labelBackgroundColor={labelBackgroundColor}
+ labelPosition={labelPosition}
handleChangeName={handleChangeName}
handleChangeDescription={handleChangeDescription}
validate={getFieldValidator(field, mode)}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Text/ExtraFieldText.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Text/ExtraFieldText.tsx
index 59324acbe..5c7316330 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Text/ExtraFieldText.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Text/ExtraFieldText.tsx
@@ -2,11 +2,14 @@ import * as React from 'react';
import { getFieldValidator } from '../utils/getFieldValidator';
import { EExtraFieldMode } from '../../../../types/template';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
import { EFieldTagName } from '../../../Field';
import { FieldWithName } from '../utils/FieldWithName';
import { IWorkflowExtraFieldProps } from '..';
+import styles from '../../KickoffRedux/KickoffRedux.css';
+
export const ExtraFieldText = ({
field,
intl,
@@ -16,6 +19,7 @@ export const ExtraFieldText = ({
editField,
isDisabled = false,
labelBackgroundColor,
+ labelPosition,
innerRef,
accountId,
}: IWorkflowExtraFieldProps) => {
@@ -45,6 +49,9 @@ export const ExtraFieldText = ({
namePlaceholder={namePlaceholder}
mode={mode}
labelBackgroundColor={labelBackgroundColor}
+ labelPosition={labelPosition}
+ {...(labelPosition === EFieldLabelPosition.Left && { labelClassName: styles['kick-off-input__name_label-left_centered'] })}
+ {...(labelPosition === EFieldLabelPosition.Left && { editorClassName: styles['rich-editor_label-left'] })}
tagName={mode === EExtraFieldMode.ProcessRun ? EFieldTagName.RichText : EFieldTagName.Textarea}
handleChangeName={handleChangeName}
handleChangeDescription={handleChangeDescription}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/Url/ExtraFieldUrl.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/Url/ExtraFieldUrl.tsx
index 8a0b9cd80..769c955a3 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/Url/ExtraFieldUrl.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/Url/ExtraFieldUrl.tsx
@@ -18,6 +18,7 @@ export function ExtraFieldUrl({
editField,
isDisabled = false,
labelBackgroundColor,
+ labelPosition,
innerRef,
}: IWorkflowExtraFieldProps) {
const handleChangeName = React.useCallback((e: React.ChangeEvent
) => {
@@ -40,6 +41,7 @@ export function ExtraFieldUrl({
namePlaceholder={namePlaceholder}
mode={mode}
labelBackgroundColor={labelBackgroundColor}
+ labelPosition={labelPosition}
handleChangeName={handleChangeName}
handleChangeDescription={handleChangeDescription}
validate={getFieldValidator(field, mode)}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/User/ExtraFieldUser.css b/frontend/src/public/components/TemplateEdit/ExtraFields/User/ExtraFieldUser.css
index eebafcd58..38e74dfc4 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/User/ExtraFieldUser.css
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/User/ExtraFieldUser.css
@@ -59,6 +59,12 @@
}
/* stylelint-enable */
}
+
+.user-dropdown-wrapper_label-left {
+ flex: 1;
+ min-width: 0;
+}
+
.kickoff-create-field-container {
flex-grow: 1;
max-width: calc(100% - 40px);
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/User/ExtraFieldUser.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/User/ExtraFieldUser.tsx
index 2afa27ec5..c0f60cd3b 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/User/ExtraFieldUser.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/User/ExtraFieldUser.tsx
@@ -10,8 +10,10 @@ import { trackInviteTeamInPage } from '../../../../utils/analytics';
import { getInputNameBackground } from '../utils/getInputNameBackground';
import { ArrowDropdownIcon } from '../../../icons';
import { FieldWithName } from '../utils/FieldWithName';
+import { FieldLabel } from '../utils/FieldLabel';
import { getFieldValidator } from '../utils/getFieldValidator';
import { EExtraFieldMode, ETaskPerformerType } from '../../../../types/template';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
import { isArrayWithItems } from '../../../../utils/helpers';
import { IWorkflowExtraFieldProps } from '..';
import { getNotDeletedUsers, getUserFullName } from '../../../../utils/users';
@@ -35,8 +37,9 @@ export function ExtraFieldUser({
editField,
isDisabled = false,
labelBackgroundColor,
+ labelPosition,
innerRef,
-}: IExtraFieldUserProps) {
+}: IExtraFieldUserProps): JSX.Element {
const { formatMessage } = useIntl();
const users: ReturnType = getNotDeletedUsers(useSelector(getUsers));
@@ -72,6 +75,7 @@ export function ExtraFieldUser({
validate={getFieldValidator(field, mode)}
isDisabled={isDisabled}
icon={}
+ labelPosition={labelPosition}
innerRef={innerRef}
/>
);
@@ -119,26 +123,48 @@ export function ExtraFieldUser({
}
};
+ const isLabelLeft = labelPosition === EFieldLabelPosition.Left;
+
return (
-
-
-
{field.name}
- {isRequired &&
}
+
+ {isLabelLeft ? (
+
+ ) : (
+
+
{field.name}
+ {isRequired &&
}
+
+ )}
+
+
+ item.value === getUsersDropdownOptionValue(EOptionTypes.User, field.userId ?? '') ||
+ item.value === getUsersDropdownOptionValue(EOptionTypes.Group, field.groupId ?? ''),
+ )}
+ onClickInvite={() => trackInviteTeamInPage('From users field')}
+ inviteLabel={formatMessage({ id: 'template.invite-team-member' })}
+ onUsersInvited={onUsersInvited}
+ />
-
- item.value === getUsersDropdownOptionValue(EOptionTypes.User, field.userId ?? '') ||
- item.value === getUsersDropdownOptionValue(EOptionTypes.Group, field.groupId ?? ''),
- )}
- onClickInvite={() => trackInviteTeamInPage('From users field')}
- inviteLabel={formatMessage({ id: 'template.invite-team-member' })}
- onUsersInvited={onUsersInvited}
- />
);
};
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/User/__tests__/ExtraFieldUser.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/User/__tests__/ExtraFieldUser.test.tsx
index 76a0da01c..f88a3bced 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/User/__tests__/ExtraFieldUser.test.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/User/__tests__/ExtraFieldUser.test.tsx
@@ -1,12 +1,15 @@
-//
import * as React from 'react';
-import { render } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import { useSelector } from 'react-redux';
import { ExtraFieldUser, IExtraFieldUserProps } from '../ExtraFieldUser';
+import { makeExtraField } from '../../../../../__stubs__/fields.factory';
+import { intlMock } from '../../../../../__stubs__/intlMock';
import { EExtraFieldMode, EExtraFieldType } from '../../../../../types/template';
-import { EUserDropdownOptionType } from '../../../../../types/user';
-import { UsersDropdown } from '../../../../UI/form/UsersDropdown';
+import { EUserDropdownOptionType, TUserListItem, EUserStatus } from '../../../../../types/user';
+
+import { FieldLabel } from '../../utils/FieldLabel';
+import { EFieldLabelPosition } from '../../../../../types/fieldset';
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
@@ -14,6 +17,7 @@ jest.mock('react-redux', () => ({
}));
jest.mock('react-intl', () => ({
+ ...jest.requireActual('react-intl'),
useIntl: () => ({ formatMessage: ({ id }: { id: string }) => id }),
}));
@@ -34,17 +38,32 @@ jest.mock('react-input-autosize', () => ({
jest.mock('../../../../../utils/users', () => ({
getNotDeletedUsers: jest.fn((users) => users),
- getUserFullName: jest.fn((user) => `User ${user.id}`),
+ getUserFullName: jest.fn((user: { id: number }) => `User ${user.id}`),
}));
jest.mock('../../../../../utils/analytics', () => ({
trackInviteTeamInPage: jest.fn(),
}));
+jest.mock('../../utils/FieldLabel', () => ({
+ FieldLabel: jest.fn(() => null),
+}));
+
describe('ExtraFieldUser', () => {
const mockEditField = jest.fn();
- const mockUsers = [{ id: 1, firstName: 'User1', email: 'user1@test.com' }];
+ const mockUsers: TUserListItem[] = [{
+ id: 1,
+ firstName: 'User1',
+ lastName: '',
+ email: 'user1@test.com',
+ phone: '',
+ photo: '',
+ type: 'user',
+ status: EUserStatus.Active,
+ isAdmin: false,
+ isAccountOwner: false,
+ }];
const mockGroups = [{ id: 1, name: 'Group1' }];
beforeEach(() => {
@@ -55,34 +74,31 @@ describe('ExtraFieldUser', () => {
});
});
- const getDropdownProps = (): any => {
- const mock = UsersDropdown as unknown as jest.Mock;
+ const getDropdownProps = () => {
+ const { UsersDropdown: mock } = require('../../../../UI/form/UsersDropdown');
if (!mock.mock.calls.length) return null;
return mock.mock.calls[mock.mock.calls.length - 1][0];
};
- const createBaseField = (overrides = {}) => ({
- apiName: 'field-1',
+ const createBaseField = (overrides = {}) => makeExtraField({
name: 'User Field',
- description: '',
type: EExtraFieldType.User,
- userId: null,
- groupId: null,
- isRequired: false,
...overrides,
});
+ const baseProps: IExtraFieldUserProps = {
+ field: createBaseField(),
+ mode: EExtraFieldMode.ProcessRun,
+ editField: mockEditField,
+ users: mockUsers,
+ intl: intlMock,
+ accountId: 1,
+ labelPosition: EFieldLabelPosition.Top,
+ };
+
describe('Value matching logic', () => {
it('selects user option when field.userId matches and field.groupId is null, avoiding group ID collisions', () => {
- render(
- React.createElement(ExtraFieldUser, {
- field: createBaseField({ userId: 1 }),
- mode: EExtraFieldMode.ProcessRun,
- editField: mockEditField,
- users: mockUsers,
- intl: { formatMessage: () => '' },
- } as unknown as IExtraFieldUserProps)
- );
+ render(
);
const props = getDropdownProps();
expect(props.value).toBeDefined();
@@ -91,15 +107,7 @@ describe('ExtraFieldUser', () => {
});
it('selects group option when field.groupId matches and field.userId is null, avoiding user ID collisions', () => {
- render(
- React.createElement(ExtraFieldUser, {
- field: createBaseField({ groupId: 1 }),
- mode: EExtraFieldMode.ProcessRun,
- editField: mockEditField,
- users: mockUsers,
- intl: { formatMessage: () => '' },
- } as unknown as IExtraFieldUserProps)
- );
+ render(
);
const props = getDropdownProps();
expect(props.value).toBeDefined();
@@ -108,15 +116,7 @@ describe('ExtraFieldUser', () => {
});
it('returns undefined value when neither userId nor groupId are set', () => {
- render(
- React.createElement(ExtraFieldUser, {
- field: createBaseField(),
- mode: EExtraFieldMode.ProcessRun,
- editField: mockEditField,
- users: mockUsers,
- intl: { formatMessage: () => '' },
- } as unknown as IExtraFieldUserProps)
- );
+ render(
);
const props = getDropdownProps();
expect(props.value).toBeUndefined();
@@ -125,15 +125,7 @@ describe('ExtraFieldUser', () => {
describe('Selection handling', () => {
it('sets userId and clears groupId when a user is selected from dropdown', () => {
- render(
- React.createElement(ExtraFieldUser, {
- field: createBaseField(),
- mode: EExtraFieldMode.ProcessRun,
- editField: mockEditField,
- users: mockUsers,
- intl: { formatMessage: () => '' },
- } as unknown as IExtraFieldUserProps)
- );
+ render(
);
const props = getDropdownProps();
props.onChange({
@@ -150,15 +142,7 @@ describe('ExtraFieldUser', () => {
});
it('sets groupId and clears userId when a group is selected from dropdown', () => {
- render(
- React.createElement(ExtraFieldUser, {
- field: createBaseField(),
- mode: EExtraFieldMode.ProcessRun,
- editField: mockEditField,
- users: mockUsers,
- intl: { formatMessage: () => '' },
- } as unknown as IExtraFieldUserProps)
- );
+ render(
);
const props = getDropdownProps();
props.onChange({
@@ -174,4 +158,27 @@ describe('ExtraFieldUser', () => {
});
});
});
+
+ describe('label-left support', () => {
+ it('ProcessRun + labelPosition=Left: renders FieldLabel with centered class', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).toHaveBeenCalledTimes(1);
+ expect(fieldLabelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ className: expect.stringContaining('centered'),
+ }),
+ {},
+ );
+ });
+
+ it('ProcessRun + labelPosition=Top: renders static name div, no FieldLabel', () => {
+ render(
);
+
+ const fieldLabelMock = FieldLabel as jest.Mock;
+ expect(fieldLabelMock).not.toHaveBeenCalled();
+ expect(screen.getByText('User Field')).toBeInTheDocument();
+ });
+ });
});
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/__tests__/ExtraField.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/__tests__/ExtraField.test.tsx
index a8e4c39d7..7642d2b0b 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/__tests__/ExtraField.test.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/__tests__/ExtraField.test.tsx
@@ -5,7 +5,9 @@ import { enMessages } from '../../../../lang/locales/en_US';
import { ExtraFieldIntl } from '../index';
import { ExtraFieldDropdown } from '../utils/ExtraFieldDropdown';
-import { EExtraFieldMode, EExtraFieldType } from '../../../../types/template';
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { EExtraFieldMode } from '../../../../types/template';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
jest.mock('react-redux', () => ({
useSelector: jest.fn((selector) => {
@@ -37,20 +39,18 @@ jest.mock('../File', () => ({ ExtraFieldFile: () =>
}));
jest.mock('../User', () => ({ ExtraFieldUser: () =>
}));
jest.mock('../Number', () => ({ ExtraFieldNumber: () =>
}));
-const baseField = {
+const baseField = makeExtraField({
apiName: 'f1',
name: 'Field 1',
- type: EExtraFieldType.String,
- order: 0,
- userId: null,
- groupId: null,
-};
+});
const baseProps = {
field: baseField,
editField: jest.fn(),
accountId: 1,
showDropdown: true,
+ datasetOptions: [],
+ labelPosition: EFieldLabelPosition.Top,
mode: EExtraFieldMode.Kickoff,
};
@@ -89,4 +89,23 @@ describe('ExtraField', () => {
);
});
});
+
+ describe('Dataset options passing', () => {
+ it('does not pass undefined datasetOptions to ExtraFieldDropdown', () => {
+ const propsWithoutDatasets = {
+ field: baseField,
+ editField: jest.fn(),
+ accountId: 1,
+ showDropdown: true,
+ mode: EExtraFieldMode.Kickoff,
+ };
+
+ // @ts-ignore deliberately omitting datasetOptions to test runtime safety
+ renderWithIntl(
);
+
+ const dropdownCall = (ExtraFieldDropdown as jest.Mock).mock.calls[0]?.[0];
+ expect(dropdownCall.datasetOptions).toBeDefined();
+ expect(Array.isArray(dropdownCall.datasetOptions)).toBe(true);
+ });
+ });
});
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/types.ts b/frontend/src/public/components/TemplateEdit/ExtraFields/types.ts
index 66837d30b..2c2b5d6af 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/types.ts
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/types.ts
@@ -3,6 +3,7 @@ import { IntlShape } from 'react-intl';
import { EExtraFieldMode, IExtraField } from '../../../types/template';
import { EInputNameBackgroundColor } from '../../../types/workflow';
+import { EFieldLabelPosition } from '../../../types/fieldset';
export interface IWorkflowExtraFieldProps {
field: IExtraField;
@@ -12,6 +13,7 @@ export interface IWorkflowExtraFieldProps {
namePlaceholder?: string;
descriptionPlaceholder?: string;
labelBackgroundColor?: EInputNameBackgroundColor;
+ labelPosition: EFieldLabelPosition;
deleteField?(): void;
moveFieldUp?(): void;
moveFieldDown?(): void;
@@ -22,9 +24,18 @@ export interface IWorkflowExtraFieldProps {
datasetName?: string;
}
-export interface IExtraFieldProps extends IWorkflowExtraFieldProps {
+type IExtraFieldPropsBase = Omit
& {
wrapperClassName?: string;
fieldsCount?: number;
id?: number;
- datasetOptions?: { label: string; value: string }[];
-}
+};
+
+export type IExtraFieldProps = IExtraFieldPropsBase & (
+ | {
+ showDropdown: true;
+ datasetOptions: { label: string; value: string }[];
+ }
+ | {
+ showDropdown: false;
+ }
+);
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/ExtraFieldDropdown.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/ExtraFieldDropdown.tsx
index cc48d47be..2efbb99f0 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/ExtraFieldDropdown.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/ExtraFieldDropdown.tsx
@@ -34,7 +34,7 @@ export function ExtraFieldDropdown({
};
const getDatasetSubOptions = (): TDropdownOption[] | undefined => {
- if (!showDatasetOption || !datasetOptions?.length) {
+ if (!showDatasetOption || !datasetOptions.length) {
return undefined;
}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/FieldLabel.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/FieldLabel.tsx
new file mode 100644
index 000000000..d76b81bec
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/FieldLabel.tsx
@@ -0,0 +1,104 @@
+import classnames from 'classnames';
+import * as React from 'react';
+import { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react';
+import TextareaAutosize from 'react-textarea-autosize';
+
+import { validateKickoffFieldName } from '../../../../utils/validators';
+import { EInputNameBackgroundColor } from '../../../../types/workflow';
+import { getInputNameBackground } from './getInputNameBackground';
+import { EExtraFieldMode } from '../../../../types/template';
+import { PencilSmallIcon } from '../../../icons';
+
+import styles from '../../KickoffRedux/KickoffRedux.css';
+
+export interface IFieldLabelProps {
+ name: string;
+ isRequired: boolean;
+ isDisabled: boolean;
+ mode?: EExtraFieldMode;
+ labelBackgroundColor?: EInputNameBackgroundColor;
+ namePlaceholder?: string;
+ className?: string;
+ handleChangeName(e: ChangeEvent): void;
+}
+
+export function FieldLabel({
+ name,
+ isRequired,
+ isDisabled,
+ mode,
+ labelBackgroundColor,
+ namePlaceholder,
+ className,
+ handleChangeName,
+}: IFieldLabelProps) {
+ const editInputRef = useRef(null);
+ const [isFocused, setIsFocused] = useState(false);
+
+ const handleEditNameClick = useCallback(() => {
+ editInputRef.current?.focus();
+ }, []);
+
+ const handleNameTextareaRef = useCallback((element: HTMLTextAreaElement | null) => {
+ editInputRef.current = element;
+ }, []);
+
+ const handleNameFocus = useCallback(() => {
+ setIsFocused(true);
+ }, []);
+
+ const handleNameBlur = useCallback(() => {
+ setIsFocused(false);
+ }, []);
+
+ const fieldNameError = useMemo(() => validateKickoffFieldName(name), [name]);
+ const isKickoffEditorMode = mode === EExtraFieldMode.Kickoff;
+
+ const fieldNameClassName = useMemo(
+ () =>
+ classnames(
+ getInputNameBackground(labelBackgroundColor),
+ styles['kick-off-input__name'],
+ isKickoffEditorMode && styles['kick-off-input__name_kickoff-edit'],
+ fieldNameError && styles['kick-off-input__name_error'],
+ className,
+ ),
+ [labelBackgroundColor, fieldNameError, isKickoffEditorMode, className],
+ );
+
+ return (
+
+ {isKickoffEditorMode ? (
+ <>
+
+ {isRequired &&
}
+ {!isFocused && (
+
+ )}
+ >
+ ) : (
+ <>
+
{name}
+ {isRequired &&
}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/FieldWithName.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/FieldWithName.tsx
index 46da9374a..5f5233fb0 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/FieldWithName.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/FieldWithName.tsx
@@ -1,5 +1,6 @@
import classnames from 'classnames';
-import React, {
+import * as React from 'react';
+import {
ChangeEvent,
forwardRef,
KeyboardEvent,
@@ -8,17 +9,14 @@ import React, {
Ref,
useCallback,
useMemo,
- useRef,
- useState,
} from 'react';
-import TextareaAutosize from 'react-textarea-autosize';
import { Field, EFieldTagName } from '../../../Field';
import { validateKickoffFieldName } from '../../../../utils/validators';
import { EInputNameBackgroundColor } from '../../../../types/workflow';
-import { getInputNameBackground } from './getInputNameBackground';
import { EExtraFieldMode, IExtraField } from '../../../../types/template';
-import { PencilSmallIcon } from '../../../icons';
+import { EFieldLabelPosition } from '../../../../types/fieldset';
+import { FieldLabel } from './FieldLabel';
import styles from '../../KickoffRedux/KickoffRedux.css';
@@ -38,7 +36,10 @@ interface IKickoffFormFieldWithNameProps {
handleChangeName(e: ChangeEvent): void;
handleChangeDescription(e: ChangeEvent): void;
validate(value: string): string;
+ labelPosition: EFieldLabelPosition;
+ labelClassName?: string;
onClick?(): void;
+ editorClassName?: string;
onNumericKeyDown?(event: KeyboardEvent): void;
isNumericField?: boolean;
}
@@ -76,14 +77,14 @@ export const FieldWithName = forwardRef {
- const editInputRef = useRef(null);
- const [isFocused, setIsFocused] = useState(false);
-
const descriptionInputRef = useCallback(
(node: HTMLInputElement | null) => {
assignInputRef(innerRef, node);
@@ -92,6 +93,8 @@ export const FieldWithName = forwardRef validateKickoffFieldName(name), [name]);
+
const handleDescriptionWrapperKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!onClick) {
@@ -105,37 +108,6 @@ export const FieldWithName = forwardRef {
- editInputRef.current?.focus();
- }, []);
-
- const handleNameTextareaRef = useCallback((element: HTMLTextAreaElement | null) => {
- editInputRef.current = element;
- }, []);
-
- const handleNameFocus = useCallback(() => {
- setIsFocused(true);
- }, []);
-
- const handleNameBlur = useCallback(() => {
- setIsFocused(false);
- }, []);
-
- const fieldNameError = useMemo(() => validateKickoffFieldName(name), [name]);
-
- const isKickoffEditorMode = mode === EExtraFieldMode.Kickoff;
-
- const fieldNameClassName = useMemo(
- () =>
- classnames(
- getInputNameBackground(labelBackgroundColor),
- styles['kick-off-input__name'],
- isKickoffEditorMode && styles['kick-off-input__name_kickoff-edit'],
- fieldNameError && styles['kick-off-input__name_error'],
- ),
- [labelBackgroundColor, fieldNameError, isKickoffEditorMode],
- );
-
const labelReplacementClassName = useMemo(
() => classnames(shouldReplaceWithLabel && styles['label-replacement-class-name']),
[shouldReplaceWithLabel],
@@ -160,41 +132,23 @@ export const FieldWithName = forwardRef
-
- {isKickoffEditorMode ? (
- <>
-
- {isRequired &&
}
- {!isFocused && (
-
- )}
- >
- ) : (
- <>
-
{name}
- {isRequired &&
}
- >
- )}
-
+
+
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/OutputFieldContent.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/OutputFieldContent.tsx
index 2e850b0b6..2aeadd302 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/OutputFieldContent.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/OutputFieldContent.tsx
@@ -16,6 +16,7 @@ export function OutputFieldContent({
editField,
isDisabled = false,
datasetName,
+ className,
children,
}: IOutputFieldContentProps) {
const { dataset, selections } = field;
@@ -71,7 +72,7 @@ export function OutputFieldContent({
);
return (
-
+
{content}
);
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/__tests__/OutputFieldContent.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/__tests__/OutputFieldContent.test.tsx
index 1d2b78823..4b93d17a9 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/__tests__/OutputFieldContent.test.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/__tests__/OutputFieldContent.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/types.ts b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/types.ts
index 02badd39a..860b63eac 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/types.ts
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/OutputFieldContent/types.ts
@@ -7,6 +7,7 @@ export interface IOutputFieldContentProps {
editField: (changedProps: Partial
) => void;
isDisabled?: boolean;
datasetName?: string;
+ className?: string;
children?: React.ReactNode;
}
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/ExtraFieldDropdown.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/ExtraFieldDropdown.test.tsx
index 60a69822e..fa6b2027f 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/ExtraFieldDropdown.test.tsx
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/ExtraFieldDropdown.test.tsx
@@ -4,8 +4,17 @@ import userEvent from '@testing-library/user-event';
import { IntlProvider } from 'react-intl';
import { ExtraFieldDropdown } from '../ExtraFieldDropdown';
+import { Dropdown } from '../../../../UI';
import { enMessages } from '../../../../../lang/locales/en_US';
+jest.mock('../../../../UI', () => {
+ const actual = jest.requireActual('../../../../UI');
+ return {
+ ...actual,
+ Dropdown: jest.fn(actual.Dropdown),
+ };
+});
+
jest.mock('react-outside-click-handler', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <>{children}>,
@@ -19,6 +28,9 @@ const baseProps = {
onDeleteField: jest.fn(),
onMoveFieldUp: jest.fn(),
onMoveFieldDown: jest.fn(),
+ showDatasetOption: false,
+ datasetOptions: [],
+ onDatasetSelect: jest.fn(),
};
const renderWithIntl = (ui: React.ReactElement) =>
@@ -81,4 +93,44 @@ describe('ExtraFieldDropdown', () => {
expect(onEditField).toHaveBeenCalledWith({ isHidden: false });
});
});
+
+ describe('Dataset option for list field types', () => {
+ const onDatasetSelect = jest.fn();
+ const datasetProps = {
+ ...baseProps,
+ showDatasetOption: true,
+ datasetOptions: [
+ { label: 'Dataset A', value: '1' },
+ { label: 'Dataset B', value: '2' },
+ ],
+ onDatasetSelect,
+ };
+
+ it('calls onDatasetSelect with dataset id when dataset option is clicked', () => {
+ renderWithIntl();
+
+ userEvent.click(screen.getByText('Dataset A'));
+
+ expect(onDatasetSelect).toHaveBeenCalledWith(1);
+ });
+ it('renders dataset sub-options when datasetOptions is provided', () => {
+ renderWithIntl();
+
+ expect(screen.getByText('Dataset A')).toBeInTheDocument();
+ expect(screen.getByText('Dataset B')).toBeInTheDocument();
+ });
+
+ it('passes dataset subOptions to Dropdown when datasetOptions is provided', () => {
+ (Dropdown as jest.Mock).mockClear();
+
+ renderWithIntl();
+
+ const dropdownProps = (Dropdown as jest.Mock).mock.calls[0]?.[0];
+ const datasetMenuItem = dropdownProps.options.find(
+ (opt: any) => opt.className && opt.className.includes('dataset-submenu'),
+ );
+ expect(datasetMenuItem.subOptions).toBeDefined();
+ expect(datasetMenuItem.subOptions.length).toBeGreaterThan(0);
+ });
+ });
});
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/FieldLabel.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/FieldLabel.test.tsx
new file mode 100644
index 000000000..e5f923993
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/FieldLabel.test.tsx
@@ -0,0 +1,134 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { FieldLabel, IFieldLabelProps } from '../FieldLabel';
+import { EExtraFieldMode } from '../../../../../types/template';
+
+jest.mock('react-textarea-autosize', () => {
+ const ReactInsideMock = require('react');
+
+ return {
+ __esModule: true,
+ default: ReactInsideMock.forwardRef(
+ (
+ props: {
+ value: string;
+ onChange: React.ChangeEventHandler;
+ placeholder: string;
+ disabled: boolean;
+ onFocus: () => void;
+ onBlur: () => void;
+ },
+ ref: React.Ref,
+ ) =>
+ ReactInsideMock.createElement('textarea', {
+ value: props.value || '',
+ onChange: props.onChange,
+ placeholder: props.placeholder,
+ disabled: props.disabled,
+ onFocus: props.onFocus,
+ onBlur: props.onBlur,
+ ref,
+ 'aria-label': 'field name',
+ }),
+ ),
+ };
+});
+
+jest.mock('../../../../icons', () => ({
+ PencilSmallIcon: () => null,
+}));
+
+jest.mock('../getInputNameBackground', () => ({
+ getInputNameBackground: jest.fn(() => ''),
+}));
+
+jest.mock('../../../../../utils/validators', () => ({
+ validateKickoffFieldName: jest.fn(() => ''),
+}));
+
+describe('FieldLabel', () => {
+ const mockHandleChangeName = jest.fn();
+
+ const baseProps: Omit = {
+ name: 'My field',
+ isRequired: false,
+ isDisabled: false,
+ handleChangeName: mockHandleChangeName,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Kickoff mode', () => {
+ const kickoffProps: IFieldLabelProps = { ...baseProps, mode: EExtraFieldMode.Kickoff };
+
+ it('renders editable textarea with field name', () => {
+ render();
+
+ const textarea = screen.getByRole('textbox', { name: 'field name' });
+ expect(textarea).toBeInTheDocument();
+ expect(textarea).toHaveValue('My field');
+ });
+
+ it('renders required sign when isRequired=true', () => {
+ render();
+
+ expect(screen.getByLabelText('required')).toBeInTheDocument();
+ });
+
+ it('hides required sign when isRequired=false', () => {
+ render();
+
+ expect(screen.queryByLabelText('required')).not.toBeInTheDocument();
+ });
+
+ it('renders edit button when textarea is not focused', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: 'Edit field name' })).toBeInTheDocument();
+ });
+
+ it('hides edit button when textarea is focused', () => {
+ render();
+
+ const textarea = screen.getByRole('textbox', { name: 'field name' });
+ userEvent.click(textarea);
+
+ expect(screen.queryByRole('button', { name: 'Edit field name' })).not.toBeInTheDocument();
+ });
+
+ it('calls handleChangeName on textarea input', () => {
+ render();
+
+ const textarea = screen.getByRole('textbox', { name: 'field name' });
+ userEvent.type(textarea, 'X');
+
+ expect(mockHandleChangeName).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Non-kickoff mode', () => {
+ const processRunProps: IFieldLabelProps = { ...baseProps, mode: EExtraFieldMode.ProcessRun };
+ it('renders readonly name text', () => {
+ render();
+
+ expect(screen.getByText('My field')).toBeInTheDocument();
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+ });
+
+ it('renders required sign when isRequired=true', () => {
+ render();
+
+ expect(screen.getByLabelText('required')).toBeInTheDocument();
+ });
+
+ it('hides required sign when isRequired=false', () => {
+ render();
+
+ expect(screen.queryByLabelText('required')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/FieldWithName.test.tsx b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/FieldWithName.test.tsx
new file mode 100644
index 000000000..0a08bd72e
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/FieldWithName.test.tsx
@@ -0,0 +1,124 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { FieldWithName } from '../FieldWithName';
+import { FieldLabel } from '../FieldLabel';
+import { Field } from '../../../../Field';
+import { EExtraFieldMode } from '../../../../../types/template';
+import { EFieldLabelPosition } from '../../../../../types/fieldset';
+import { makeExtraField } from '../../../../../__stubs__/fields.factory';
+
+jest.mock('../FieldLabel', () => ({
+ FieldLabel: jest.fn(() => null),
+}));
+
+jest.mock('../../../../Field', () => ({
+ Field: jest.fn(() => null),
+ EFieldTagName: {},
+}));
+
+jest.mock('../../../../../utils/validators', () => ({
+ validateKickoffFieldName: jest.fn(() => ''),
+}));
+
+describe('FieldWithName', () => {
+ const mockHandleChangeName = jest.fn();
+ const mockHandleChangeDescription = jest.fn();
+ const mockValidate = jest.fn(() => '');
+
+ const baseProps = {
+ field: makeExtraField({ name: 'Test', description: 'Desc text', value: 'Val text' }),
+ handleChangeName: mockHandleChangeName,
+ handleChangeDescription: mockHandleChangeDescription,
+ validate: mockValidate,
+ isDisabled: false,
+ labelPosition: EFieldLabelPosition.Top,
+ mode: EExtraFieldMode.Kickoff,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('labelPosition → CSS class', () => {
+ it('labelPosition=Top: container does NOT get label-left class', () => {
+ render(React.createElement(FieldWithName, baseProps));
+
+ const container = screen.getByLabelText('field-container');
+ expect(container.className).not.toContain('label-left');
+ });
+
+ it('labelPosition=Left: container gets label-left class', () => {
+ render(
+ React.createElement(FieldWithName, {
+ ...baseProps,
+ labelPosition: EFieldLabelPosition.Left,
+ }),
+ );
+
+ const container = screen.getByLabelText('field-container');
+ expect(container.className).toContain('label-left');
+ });
+ });
+
+ describe('labelClassName → FieldLabel', () => {
+ it('passes labelClassName to FieldLabel when provided', () => {
+ render(
+ React.createElement(FieldWithName, {
+ ...baseProps,
+ labelClassName: 'custom-label-class',
+ }),
+ );
+
+ const mock = FieldLabel as jest.Mock;
+ expect(mock).toHaveBeenCalledTimes(1);
+ expect(mock).toHaveBeenCalledWith(
+ expect.objectContaining({ className: 'custom-label-class' }),
+ {},
+ );
+ });
+
+ it('does NOT pass className to FieldLabel when labelClassName is undefined', () => {
+ render(React.createElement(FieldWithName, baseProps));
+
+ const mock = FieldLabel as jest.Mock;
+ expect(mock).toHaveBeenCalledTimes(1);
+ const calledProps = mock.mock.calls[0][0];
+ expect(calledProps).not.toHaveProperty('className');
+ });
+ });
+
+ describe('description field value by mode', () => {
+ it('uses description in Kickoff mode', () => {
+ render(
+ React.createElement(FieldWithName, {
+ ...baseProps,
+ mode: EExtraFieldMode.Kickoff,
+ }),
+ );
+
+ const fieldMock = Field as jest.Mock;
+ expect(fieldMock).toHaveBeenCalledTimes(1);
+ expect(fieldMock).toHaveBeenCalledWith(
+ expect.objectContaining({ value: 'Desc text' }),
+ {},
+ );
+ });
+
+ it('uses value in ProcessRun mode', () => {
+ render(
+ React.createElement(FieldWithName, {
+ ...baseProps,
+ mode: EExtraFieldMode.ProcessRun,
+ }),
+ );
+
+ const fieldMock = Field as jest.Mock;
+ expect(fieldMock).toHaveBeenCalledTimes(1);
+ expect(fieldMock).toHaveBeenCalledWith(
+ expect.objectContaining({ value: 'Val text' }),
+ {},
+ );
+ });
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/handleSelectionBlur.test.ts b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/handleSelectionBlur.test.ts
index bfa6284d8..8ac211f26 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/handleSelectionBlur.test.ts
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/__tests__/handleSelectionBlur.test.ts
@@ -1,4 +1,3 @@
-//
import { recalculateDuplicateErrors, handleSelectionBlur } from '../handleSelectionBlur';
import { IExtraFieldSelection } from '../../../../../types/template';
diff --git a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/types.ts b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/types.ts
index d10031d61..7f45c9c73 100644
--- a/frontend/src/public/components/TemplateEdit/ExtraFields/utils/types.ts
+++ b/frontend/src/public/components/TemplateEdit/ExtraFields/utils/types.ts
@@ -16,8 +16,8 @@ export interface IKickoffDropdownProps {
onDeleteField(): void;
onMoveFieldUp(): void;
onMoveFieldDown(): void;
- showDatasetOption?: boolean;
- datasetOptions?: IDatasetOption[];
+ showDatasetOption: boolean;
+ datasetOptions: IDatasetOption[];
selectedDatasetId?: number;
- onDatasetSelect?: (datasetId: number) => void;
+ onDatasetSelect: (datasetId: number) => void;
}
diff --git a/frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/FieldsetOutputsPreview.css b/frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/FieldsetOutputsPreview.css
new file mode 100644
index 000000000..d080bdc65
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/FieldsetOutputsPreview.css
@@ -0,0 +1,18 @@
+.fieldset-outputs-preview__group {
+ margin: 0 0.4rem 0.4rem 0;
+ padding: 0.1rem 0.8rem;
+ display: inline-block;
+ overflow: hidden;
+ max-width: 20ch;
+ font-size: 1.3rem;
+ line-height: 1.6rem;
+ vertical-align: middle;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ user-select: all;
+ color: var(--pneumatic-color-black72);
+ background: var(--pneumatic-color-black16);
+ border: 0;
+ border-radius: 1.4rem;
+ justify-content: space-between;
+}
diff --git a/frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/FieldsetOutputsPreview.tsx b/frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/FieldsetOutputsPreview.tsx
new file mode 100644
index 000000000..91d73299c
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/FieldsetOutputsPreview.tsx
@@ -0,0 +1,39 @@
+import * as React from 'react';
+import { useIntl } from 'react-intl';
+import classNames from 'classnames';
+
+import { IFieldsetBindingClient } from '../../../types/template';
+import { isArrayWithItems } from '../../../utils/helpers';
+
+import styles from './FieldsetOutputsPreview.css';
+
+export interface IFieldsetOutputsPreviewProps {
+ fieldsets: IFieldsetBindingClient[];
+ onGroupClick?(): void;
+}
+
+export function FieldsetOutputsPreview({ fieldsets, onGroupClick }: IFieldsetOutputsPreviewProps) {
+ const { formatMessage } = useIntl();
+
+ if (!fieldsets.length) {
+ return null;
+ }
+
+ const fieldsetsWithFields = fieldsets.filter((fieldset) => isArrayWithItems(fieldset.fields));
+
+ if (!fieldsetsWithFields.length) {
+ return null;
+ }
+
+ return (
+
+ {fieldsetsWithFields.map(({ apiNameBinding, name }) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/__tests__/FieldsetOutputsPreview.test.tsx b/frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/__tests__/FieldsetOutputsPreview.test.tsx
new file mode 100644
index 000000000..f0f064dc7
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/__tests__/FieldsetOutputsPreview.test.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { makeFieldsetBindingClient, makeFieldsetField } from '../../../../__stubs__/fieldsets.factory';
+import { FieldsetOutputsPreview } from '../FieldsetOutputsPreview';
+
+describe('FieldsetOutputsPreview', () => {
+ const getFieldsetButton = (fieldsetName: string) =>
+ screen.getByRole('button', { name: new RegExp(fieldsetName) });
+
+ it('returns null when fieldsets array is empty', () => {
+ const { container } = render(
+ React.createElement(FieldsetOutputsPreview, {
+ fieldsets: [],
+ }),
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('returns null when no fieldset has fields', () => {
+ const { container } = render(
+ React.createElement(FieldsetOutputsPreview, {
+ fieldsets: [
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-1', fields: [] }),
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-2', fields: [] }),
+ ],
+ }),
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders one button per fieldset that has fields', () => {
+ render(
+ React.createElement(FieldsetOutputsPreview, {
+ fieldsets: [
+ makeFieldsetBindingClient({
+ apiNameBinding: 'fs-a',
+ name: 'Fieldset A',
+ fields: [makeFieldsetField({ apiName: 'f-1' }), makeFieldsetField({ apiName: 'f-2' })],
+ }),
+ makeFieldsetBindingClient({
+ apiNameBinding: 'fs-b',
+ name: 'Fieldset B',
+ fields: [makeFieldsetField({ apiName: 'f-3' })],
+ }),
+ ],
+ onGroupClick: jest.fn(),
+ }),
+ );
+
+ expect(screen.getAllByRole('button')).toHaveLength(2);
+ expect(getFieldsetButton('Fieldset A')).toBeInTheDocument();
+ expect(getFieldsetButton('Fieldset B')).toBeInTheDocument();
+ });
+
+ it('click on button calls onGroupClick exactly once', () => {
+ const onGroupClick = jest.fn();
+
+ render(
+ React.createElement(FieldsetOutputsPreview, {
+ fieldsets: [
+ makeFieldsetBindingClient({
+ apiNameBinding: 'fs-a',
+ name: 'Fieldset A',
+ fields: [makeFieldsetField({ apiName: 'f-1' })],
+ }),
+ ],
+ onGroupClick,
+ }),
+ );
+
+ userEvent.click(screen.getByRole('button'));
+
+ expect(onGroupClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/InputWithVariables/InputWithVariables.tsx b/frontend/src/public/components/TemplateEdit/InputWithVariables/InputWithVariables.tsx
index ebcb9974c..8e26960fb 100644
--- a/frontend/src/public/components/TemplateEdit/InputWithVariables/InputWithVariables.tsx
+++ b/frontend/src/public/components/TemplateEdit/InputWithVariables/InputWithVariables.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import * as React from 'react';
import classnames from 'classnames';
import { TTaskVariable } from '../types';
@@ -14,6 +14,7 @@ export interface IEditorWithVariablesProps {
placeholder?: string;
listVariables: TTaskVariable[];
templateVariables: TTaskVariable[];
+ showInsertButton: boolean;
value?: string;
title?: string;
className?: string;
@@ -26,6 +27,7 @@ export const InputWithVariables: React.FC = ({
placeholder,
listVariables,
templateVariables,
+ showInsertButton,
value,
className,
title,
@@ -77,17 +79,19 @@ export const InputWithVariables: React.FC = ({
withMentions={false}
multiline={false}
plainText
- className={classnames(className, styles['input-with-variables'])}
+ className={classnames(className, showInsertButton && styles['input-with-variables'])}
foregroundColor={foregroundColor}
templateVariables={templateVariables}
>
- editorRef.current?.focus()}
- />
+ {showInsertButton && (
+ editorRef.current?.focus()}
+ />
+ )}
);
};
diff --git a/frontend/src/public/components/TemplateEdit/InputWithVariables/__tests__/InputWithVariables.test.tsx b/frontend/src/public/components/TemplateEdit/InputWithVariables/__tests__/InputWithVariables.test.tsx
new file mode 100644
index 000000000..89dc2c453
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/InputWithVariables/__tests__/InputWithVariables.test.tsx
@@ -0,0 +1,54 @@
+import * as React from 'react';
+import { render } from '@testing-library/react';
+
+import { InputWithVariables } from '../InputWithVariables';
+import { VariableList } from '../../VariableList';
+
+jest.mock('../../VariableList', () => ({
+ VariableList: jest.fn(() => React.createElement('div', { 'data-testid': 'variable-list' })),
+}));
+
+jest.mock('../../../RichEditor', () => ({
+ RichEditor: React.forwardRef(
+ ({ children }: { children?: React.ReactNode }, _ref: React.Ref) =>
+ React.createElement('div', { 'data-testid': 'rich-editor' }, children),
+ ),
+}));
+
+
+describe('InputWithVariables', () => {
+ const baseProps = {
+ listVariables: [],
+ templateVariables: [],
+ value: '',
+ toolipText: 'tooltip',
+ onChange: jest.fn(() => Promise.resolve('')),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders VariableList when showInsertButton=true', () => {
+ render(React.createElement(InputWithVariables, { ...baseProps, showInsertButton: true }));
+
+ expect(VariableList as jest.Mock).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not render VariableList when showInsertButton=false', () => {
+ render(React.createElement(InputWithVariables, { ...baseProps, showInsertButton: false }));
+
+ expect(VariableList as jest.Mock).toHaveBeenCalledTimes(0);
+ });
+
+ it('does not render VariableList when showInsertButton=undefined', () => {
+ render(
+ React.createElement(InputWithVariables, {
+ ...baseProps,
+ showInsertButton: undefined as unknown as boolean,
+ }),
+ );
+
+ expect(VariableList as jest.Mock).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.css b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.css
index 98aef9e0f..77f1035bf 100644
--- a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.css
+++ b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.css
@@ -95,6 +95,83 @@
min-width: 0;
}
+ .kick-off-input__field_label-left {
+ display: flex;
+ flex-direction: row;
+ gap: 3.2rem;
+
+ .kick-off-input__name {
+ margin: 0;
+ flex-shrink: 0;
+ width: 40%;
+ align-self: center;
+
+ /* stylelint-disable-next-line selector-max-compound-selectors */
+ input,
+ .kick-off-input__name-textarea,
+ .kick-off-input__name-readonly {
+ padding-left: 0;
+
+ @mixin text-base;
+ }
+ }
+
+ .kick-off-input__name_label-left {
+ align-self: flex-start;
+ margin-top: 1.6rem;
+ }
+
+ .kick-off-input__name_label-left_centered {
+ align-self: center;
+ }
+
+ .kick-off-input__name_label-left_aligned-start {
+ align-self: flex-start;
+ }
+
+ .kick-off-input__description {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .kick-off-input__options-content_label-left {
+ flex: 1;
+ min-width: 0;
+ }
+ }
+
+ .rich-editor_label-left {
+ padding: 0.4rem;
+ min-width: 0;
+
+ & div {
+ min-width: 0;
+ }
+
+ & [role='toolbar'] {
+ max-width: 100%;
+ gap: 0.2rem;
+
+ /* stylelint-disable-next-line selector-max-compound-selectors */
+ & div {
+ flex-shrink: 1;
+ gap: 0.2rem;
+ }
+
+ /* stylelint-disable-next-line selector-max-compound-selectors */
+ & svg {
+ width: 100%;
+ height: 100%;
+ }
+ }
+ }
+
+ .kick-off-input__dropdown_label-left {
+ align-self: flex-start;
+ margin-top: 1.4rem;
+ height: auto;
+ }
+
svg {
position: relative;
top: -0.2rem;
@@ -578,3 +655,8 @@
.workflow-name-field {
margin-bottom: 1.6rem;
}
+
+.fieldsets-section {
+ margin-top: 2.4rem;
+ margin-bottom: 1.6rem;
+}
diff --git a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.tsx b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.tsx
index 4232bdfbf..e7ae718f9 100644
--- a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.tsx
+++ b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffRedux.tsx
@@ -1,26 +1,38 @@
import * as React from 'react';
+import { useMemo } from 'react';
import { IntlShape } from 'react-intl';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { getEmptyField } from './utils/getEmptyField';
import { KickoffShareForm } from './KickoffShareForm';
import { isKickoffCleared } from './utils/isKickoffCleared';
+import { FieldsetIconPicker } from '../TaskOutputFlow/FieldsetIconPicker';
+import {
+ buildMergedTaskOutputRows,
+ buildRowsWithAddedFieldset,
+ buildRowsWithRemovedFieldset,
+ createFieldsetBinding,
+ moveMergedRow,
+ normalizeMergedTaskOutputOrders,
+ TMergedTaskOutputRow,
+} from '../TaskOutputFlow/mergeTaskOutputFlow';
+import { MergedOutputRows } from '../TaskOutputFlow/MergedOutputRows';
import { KickoffMenu } from './KickoffMenu';
import { IntlMessages } from '../../IntlMessages';
-import { EMoveDirections, EInputNameBackgroundColor } from '../../../types/workflow';
-import { EExtraFieldMode, EExtraFieldType, IKickoff, IExtraField, ITemplate, ETemplateParts } from '../../../types/template';
+import { EExtraFieldType, IExtraField, ETemplateParts, IKickoffClient, ITemplateClient } from '../../../types/template';
+import { IFieldsetCatalogItem } from '../../../types/fieldset';
import { isArrayWithItems } from '../../../utils/helpers';
-import { getNormalizeFieldsOrders, moveWorkflowField } from '../../../utils/workflows';
import { ExtraFieldsMap } from '../ExtraFields/utils/ExtraFieldsMap';
import { ExtraFieldIcon } from '../ExtraFields/utils/ExtraFieldIcon';
-import { ExtraFieldIntl } from '../ExtraFields';
import { getEditedFields } from '../ExtraFields/utils/getEditedFields';
import { ETemplateStatus } from '../../../types/redux';
import { ExtraFieldsLabels } from '../ExtraFields/utils/ExtraFieldsLabels';
import { getEmptyKickoff } from '../../../utils/template';
import { useHashLink } from '../../../hooks/useHashLink';
import { useWorkflowNameVariables } from '../TaskForm/utils/getTaskVariables';
+import { getFieldsetsCatalogIsLoading } from '../../../redux/selectors/fieldsets';
+import { FieldsetOutputsPreview } from '../FieldsetOutputsPreview/FieldsetOutputsPreview';
import styles from './KickoffRedux.css';
import { patchTemplate } from '../../../redux/actions';
@@ -28,11 +40,11 @@ import { InputWithVariables } from '../InputWithVariables';
import { useDatasetOptions } from '../ExtraFields/utils/useDatasetOptions';
export interface IKickoffReduxProps {
- template: ITemplate;
+ template: ITemplateClient;
intl: IntlShape;
accountId: number;
templateStatus: ETemplateStatus;
- setKickoff(value: IKickoff): void;
+ setKickoff(value: IKickoffClient): void;
}
export function KickoffRedux({
@@ -46,8 +58,14 @@ export function KickoffRedux({
const containerRef = React.useRef(null);
const variables = useWorkflowNameVariables(kickoff);
const datasetOptions = useDatasetOptions(kickoff.fields);
+ const fieldsetsCatalogLoading = useSelector(getFieldsetsCatalogIsLoading);
+
+ const mergedRows = useMemo(
+ () => buildMergedTaskOutputRows(kickoff.fields || [], kickoff.fieldsets || []),
+ [kickoff.fields, kickoff.fieldsets],
+ );
- const editTemplate = (templateFields: Partial) => {
+ const editTemplate = (templateFields: Partial) => {
dispatch(patchTemplate({ changedFields: templateFields }));
};
@@ -61,15 +79,12 @@ export function KickoffRedux({
},
]);
- const getSortedFields = () => {
- return [...kickoff.fields].sort((a, b) => b.order - a.order);
- };
const toggleExpanded = () => {
setIsOpen(!isOpen);
};
- const handleChangeKickoff = (newKickoff: IKickoff) => {
+ const handleChangeKickoff = (newKickoff: IKickoffClient) => {
setKickoff(newKickoff);
};
@@ -79,41 +94,45 @@ export function KickoffRedux({
};
const handleCreateField = (type: EExtraFieldType) => {
- const newKickoff = {
- ...kickoff,
- fields: getNormalizeFieldsOrders([...kickoff.fields, getEmptyField(type, formatMessage)]),
- };
-
- handleChangeKickoff(newKickoff!);
+ const newField = getEmptyField(type, formatMessage, -1);
+ const nextFields = [...kickoff.fields, newField];
+ const rows = buildMergedTaskOutputRows(nextFields, kickoff.fieldsets || []);
+ saveOutputOrders(rows, nextFields);
};
const handleEditField = (apiName: string) => (changedProps: Partial) => {
- const newFields = getEditedFields(getSortedFields(), apiName, changedProps);
+ const newFields = getEditedFields(kickoff.fields, apiName, changedProps);
handleChangeKickoff({ ...kickoff, fields: newFields });
};
- const handleDeleteField = (idx: number) => {
- const newKickoffFields = getSortedFields().filter((_, index) => index !== idx);
- const newOrderedKickoff = {
- ...kickoff,
- fields: getNormalizeFieldsOrders(newKickoffFields),
- };
+ const saveOutputOrders = (rows: TMergedTaskOutputRow[], allFields?: IExtraField[]) => {
+ const { nextFields, nextFieldsets } = normalizeMergedTaskOutputOrders(rows, allFields ?? kickoff.fields);
+ handleChangeKickoff({ ...kickoff, fields: nextFields, fieldsets: nextFieldsets });
+ };
- handleChangeKickoff(newOrderedKickoff);
+ const handleAddKickoffFieldset = (fieldsetCatalogItem: IFieldsetCatalogItem) => {
+ const newFieldsetBinding = createFieldsetBinding(fieldsetCatalogItem);
+ const rows = buildRowsWithAddedFieldset(kickoff.fields, kickoff.fieldsets || [], newFieldsetBinding);
+ if (rows) saveOutputOrders(rows);
};
- const handleMoveField = (from: number, direction: EMoveDirections) => {
- const to = direction === EMoveDirections.Up ? from - 1 : from + 1;
- const newKickoffFields = moveWorkflowField(from, to, getSortedFields());
+ const handleRemoveFieldset = (sharedFieldsetId: number) => {
+ const rows = buildRowsWithRemovedFieldset(kickoff.fields, kickoff.fieldsets || [], sharedFieldsetId);
+ saveOutputOrders(rows);
+ };
+ const handleDeleteField = (apiName: string) => {
+ const nextFields = (kickoff.fields || []).filter((f) => f.apiName !== apiName);
+ const rows = buildMergedTaskOutputRows(nextFields, kickoff.fieldsets || []);
+ saveOutputOrders(rows, nextFields);
+ };
- handleChangeKickoff({
- ...kickoff,
- fields: newKickoffFields,
- });
+ const handleMoveMergedIndex = (index: number, direction: 'up' | 'down') => {
+ const moved = moveMergedRow(mergedRows, index, direction);
+ saveOutputOrders(moved);
};
const renderKickoffForm = () => {
- const isFormEmpty = !isArrayWithItems(kickoff.fields) && !kickoff.description;
+ const isFormEmpty = !isArrayWithItems(kickoff.fields) && !(kickoff.fieldsets || []).length && !kickoff.description;
return (
<>
@@ -123,6 +142,7 @@ export function KickoffRedux({
{
editTemplate({
@@ -144,26 +164,25 @@ export function KickoffRedux({
{ExtraFieldsMap.map((x) => (
handleCreateField(x.id)} />
))}
+ fieldset.sharedFieldsetId)}
+ onSelectFieldset={handleAddKickoffFieldset}
+ onRemoveFieldset={handleRemoveFieldset}
+ />
-
{!isFormEmpty && (
- {getSortedFields().map((field, index) => (
- handleDeleteField(index)}
- moveFieldUp={() => handleMoveField(index, EMoveDirections.Up)}
- moveFieldDown={() => handleMoveField(index, EMoveDirections.Down)}
- editField={handleEditField(field.apiName)}
- mode={EExtraFieldMode.Kickoff}
- datasetOptions={datasetOptions}
- accountId={accountId}
- />
- ))}
+
)}
@@ -173,9 +192,12 @@ export function KickoffRedux({
};
const renderKickoffLabels = () => {
- const { fields } = kickoff;
+ const { fields, fieldsets } = kickoff;
+ const hasFieldsetChips = (fieldsets || []).some(
+ (taskFieldset) => isArrayWithItems(taskFieldset.fields),
+ );
- if (!isArrayWithItems(fields)) {
+ if (!isArrayWithItems(fields) && !hasFieldsetChips) {
return null;
}
@@ -192,7 +214,8 @@ export function KickoffRedux({
role="button"
aria-label="Toggle expand"
>
-
+ {isArrayWithItems(fields) &&
}
+
);
};
diff --git a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffShareForm/KickoffShareForm.tsx b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffShareForm/KickoffShareForm.tsx
index f4ca2db94..3163f604e 100644
--- a/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffShareForm/KickoffShareForm.tsx
+++ b/frontend/src/public/components/TemplateEdit/KickoffRedux/KickoffShareForm/KickoffShareForm.tsx
@@ -15,7 +15,7 @@ import { copyToClipboard } from '../../../../utils/helpers';
import { NotificationManager } from '../../../UI/Notifications';
import { Button, InputField, Loader, Tabs } from '../../../UI';
import { useDidUpdateEffect } from '../../../../hooks/useDidUpdateEffect';
-import { ITemplate } from '../../../../types/template';
+import { ITemplateClient } from '../../../../types/template';
import { noop } from '../../../../utils/noop';
import { trackShareKickoffForm } from '../../../../utils/analytics';
import { TPublicFormType } from '../../../../types/publicForms';
@@ -61,7 +61,7 @@ export function KickoffShareForm({ className }: IKickoffShareFormProps) {
return generateEmbedCode(embedUrl);
}, [embedUrl]);
- const editTemplate = (templateFields: Partial
) => {
+ const editTemplate = (templateFields: Partial) => {
dispatch(patchTemplate({ changedFields: templateFields }));
};
diff --git a/frontend/src/public/components/TemplateEdit/KickoffRedux/__tests__/KickoffRedux.test.tsx b/frontend/src/public/components/TemplateEdit/KickoffRedux/__tests__/KickoffRedux.test.tsx
new file mode 100644
index 000000000..d9cb12e71
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/KickoffRedux/__tests__/KickoffRedux.test.tsx
@@ -0,0 +1,337 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { makeFieldsetBindingClient, makeFieldsetField } from '../../../../__stubs__/fieldsets.factory';
+import {
+ IExtraField,
+ IKickoffClient,
+ ITemplateClient,
+} from '../../../../types/template';
+import { IFieldsetCatalogItem } from '../../../../types/fieldset';
+import { ETemplateStatus } from '../../../../types/redux';
+
+jest.mock('../../../../redux/selectors/fieldsets', () => ({
+ getFieldsetsCatalogIsLoading: jest.fn(() => false),
+}));
+
+jest.mock('../../../../redux/actions', () => ({
+ patchTemplate: jest.fn((payload: unknown) => ({ type: 'PATCH_TEMPLATE', payload })),
+}));
+
+jest.mock('../../../../hooks/useHashLink', () => ({
+ useHashLink: jest.fn(),
+}));
+
+jest.mock('../../TaskForm/utils/getTaskVariables', () => ({
+ useWorkflowNameVariables: jest.fn(() => []),
+}));
+
+jest.mock('../../ExtraFields/utils/useDatasetOptions', () => ({
+ useDatasetOptions: jest.fn(() => []),
+}));
+
+jest.mock('../utils/getEmptyField', () => ({
+ getEmptyField: jest.fn(),
+}));
+
+jest.mock('../../ExtraFields/utils/ExtraFieldsMap', () => ({
+ ExtraFieldsMap: [{ id: 'string', label: 'String' }],
+}));
+
+jest.mock('../../ExtraFields/utils/ExtraFieldIcon', () => ({
+ ExtraFieldIcon: (props: { id: string; onClick: () => void }) =>
+ React.createElement(
+ 'button',
+ { type: 'button', onClick: props.onClick },
+ `Add field ${props.id}`,
+ ),
+}));
+
+jest.mock('../../ExtraFields/utils/ExtraFieldsLabels', () => ({
+ ExtraFieldsLabels: (props: { extraFields: { apiName: string }[] }) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': 'extra-fields-labels' },
+ props.extraFields.map((f) =>
+ React.createElement('span', { key: f.apiName }, f.apiName),
+ ),
+ ),
+}));
+
+jest.mock('../../TaskOutputFlow/FieldsetIconPicker', () => ({
+ FieldsetIconPicker: (props: {
+ selectedFieldsetIds: number[];
+ onSelectFieldset: (item: IFieldsetCatalogItem) => void;
+ onRemoveFieldset: (sharedFieldsetId: number) => void;
+ }) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': 'fieldset-icon-picker' },
+ React.createElement(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': 'add-fieldset-btn',
+ onClick: () =>
+ props.onSelectFieldset({
+ id: 200,
+ apiName: 'fs-new',
+ name: 'New Set',
+ description: '',
+ fields: [],
+ rules: [],
+ order: 0,
+ labelPosition: 'top',
+ layout: 'vertical',
+ title: '',
+ } as IFieldsetCatalogItem),
+ },
+ 'Add fieldset New Set',
+ ),
+ ),
+}));
+
+jest.mock('../../TaskOutputFlow/MergedOutputRows', () => ({
+ MergedOutputRows: () =>
+ React.createElement('div', { 'data-testid': 'merged-rows' }),
+}));
+
+jest.mock('../../FieldsetOutputsPreview/FieldsetOutputsPreview', () => ({
+ FieldsetOutputsPreview: (props: {
+ fieldsets: { apiNameBinding: string; fields?: unknown[] }[];
+ }) => {
+ const groups = props.fieldsets.filter(
+ (fieldset) => fieldset.fields && fieldset.fields.length > 0,
+ );
+ if (!groups.length) return null;
+ return React.createElement(
+ 'div',
+ { 'data-testid': 'fieldset-outputs-preview' },
+ groups.map((fieldset) =>
+ React.createElement('span', { key: fieldset.apiNameBinding }, fieldset.apiNameBinding),
+ ),
+ );
+ },
+}));
+
+jest.mock('../KickoffMenu', () => ({
+ KickoffMenu: (props: {
+ isKickoffOpen: boolean;
+ isClearDisabled: boolean;
+ toggleKickoff: () => void;
+ clearForm: () => void;
+ }) =>
+ React.createElement(
+ 'div',
+ null,
+ React.createElement(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': 'kickoff-toggle',
+ onClick: props.toggleKickoff,
+ },
+ props.isKickoffOpen ? 'Close' : 'Open',
+ ),
+ React.createElement(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': 'kickoff-clear',
+ disabled: props.isClearDisabled,
+ onClick: props.clearForm,
+ },
+ 'Clear',
+ ),
+ ),
+}));
+
+jest.mock('../KickoffShareForm', () => ({
+ KickoffShareForm: () => null,
+}));
+
+jest.mock('../../InputWithVariables', () => ({
+ InputWithVariables: () => null,
+}));
+
+jest.mock('../../../IntlMessages', () => ({
+ IntlMessages: (props: { id: string }) =>
+ React.createElement('span', null, props.id),
+}));
+
+import { KickoffRedux } from '../KickoffRedux';
+import { getEmptyField } from '../utils/getEmptyField';
+import { useSelector } from 'react-redux';
+import {
+ getFieldsetsCatalogIsLoading,
+} from '../../../../redux/selectors/fieldsets';
+
+describe('KickoffRedux', () => {
+ const makeField = (overrides: Partial = {}) => makeExtraField({
+ name: 'Field 1',
+ ...overrides,
+ });
+
+ const makeKickoff = (overrides: Partial = {}): IKickoffClient => ({
+ description: '',
+ fields: [],
+ fieldsets: [],
+ ...overrides,
+ });
+
+ const makeTemplate = (kickoff: IKickoffClient): ITemplateClient => ({
+ id: 1,
+ kickoff,
+ wfNameTemplate: '',
+ } as unknown as ITemplateClient);
+
+ const NEW_FIELD: IExtraField = makeExtraField({
+ apiName: 'new-field',
+ name: 'New Field',
+ order: -1,
+ });
+
+ const renderKickoff = (params: {
+ kickoff: IKickoffClient;
+ setKickoff?: jest.Mock;
+ }) => {
+ const setKickoff = params.setKickoff ?? jest.fn();
+
+ (getFieldsetsCatalogIsLoading as jest.Mock).mockReturnValue(false);
+
+ render(
+ React.createElement(KickoffRedux, {
+ template: makeTemplate(params.kickoff),
+ intl: intlMock,
+ accountId: 1,
+ templateStatus: ETemplateStatus.Saved,
+ setKickoff,
+ }),
+ );
+ return { setKickoff };
+ };
+
+ const EMPTY_STATE = {};
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (getEmptyField as jest.Mock).mockReturnValue(NEW_FIELD);
+ (useSelector as jest.Mock).mockImplementation((selector: unknown) =>
+ (selector as (s: unknown) => unknown)(EMPTY_STATE),
+ );
+ });
+
+ describe('collapsed kickoff: labels block', () => {
+ it('renders FieldsetOutputsPreview when a fieldset binding has fields', () => {
+ renderKickoff({
+ kickoff: makeKickoff({
+ fieldsets: [makeFieldsetBindingClient({
+ apiNameBinding: 'fs-1',
+ order: 0,
+ fields: [makeFieldsetField({ apiName: 'fs-field-1' })],
+ })],
+ }),
+ });
+
+ expect(screen.getByTestId('fieldset-outputs-preview')).toBeInTheDocument();
+ expect(screen.queryByTestId('extra-fields-labels')).not.toBeInTheDocument();
+ });
+
+ it('renders no labels block at all when no own fields and fieldset binding has empty fields', () => {
+ renderKickoff({
+ kickoff: makeKickoff({
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 0 })],
+ }),
+ });
+
+ expect(screen.queryByTestId('fieldset-outputs-preview')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('extra-fields-labels')).not.toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: 'Toggle expand' })).toHaveLength(1);
+ });
+ });
+
+ describe('expanded kickoff: isFormEmpty accounts for fieldsets', () => {
+ it('renders MergedOutputRows when only fieldsets are present (no own fields or description)', () => {
+ renderKickoff({
+ kickoff: makeKickoff({
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 0 })],
+ }),
+ });
+
+ userEvent.click(screen.getByTestId('kickoff-toggle'));
+
+ expect(screen.getByTestId('merged-rows')).toBeInTheDocument();
+ });
+ });
+
+ describe('add fieldset', () => {
+ it('calls setKickoff with the new fieldset added to the array', () => {
+ const { setKickoff } = renderKickoff({
+ kickoff: makeKickoff(),
+ });
+
+ userEvent.click(screen.getByTestId('kickoff-toggle'));
+ userEvent.click(screen.getByTestId('add-fieldset-btn'));
+
+ expect(setKickoff).toHaveBeenCalledTimes(1);
+ expect(setKickoff).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fieldsets: expect.arrayContaining([
+ expect.objectContaining({ sharedFieldsetId: 200 }),
+ ]),
+ }),
+ );
+ });
+ });
+
+ describe('create field when fieldsets are connected', () => {
+ it('calls setKickoff with updated fields AND fieldset order patches', () => {
+ const { setKickoff } = renderKickoff({
+ kickoff: makeKickoff({
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 0 })],
+ }),
+ });
+
+ userEvent.click(screen.getByTestId('kickoff-toggle'));
+ userEvent.click(screen.getByRole('button', { name: 'Add field string' }));
+
+ expect(setKickoff).toHaveBeenCalledTimes(1);
+ expect(setKickoff).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fields: expect.arrayContaining([
+ expect.objectContaining({ apiName: 'new-field' }),
+ ]),
+ fieldsets: expect.arrayContaining([
+ expect.objectContaining({ apiNameBinding: 'fs-1' }),
+ ]),
+ }),
+ );
+ });
+ });
+
+ describe('clear kickoff', () => {
+ it('calls setKickoff with empty fields AND empty fieldsets', () => {
+ const { setKickoff } = renderKickoff({
+ kickoff: makeKickoff({
+ fields: [makeField({ apiName: 'f-1' })],
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 0 })],
+ description: 'desc',
+ }),
+ });
+
+ userEvent.click(screen.getByTestId('kickoff-clear'));
+
+ expect(setKickoff).toHaveBeenCalledTimes(1);
+ expect(setKickoff).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fields: [],
+ fieldsets: [],
+ description: '',
+ }),
+ );
+ });
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/KickoffRedux/utils/isKickoffCleared.ts b/frontend/src/public/components/TemplateEdit/KickoffRedux/utils/isKickoffCleared.ts
index 2443eb768..a43b9601c 100644
--- a/frontend/src/public/components/TemplateEdit/KickoffRedux/utils/isKickoffCleared.ts
+++ b/frontend/src/public/components/TemplateEdit/KickoffRedux/utils/isKickoffCleared.ts
@@ -1,9 +1,9 @@
/* eslint-disable */
/* prettier-ignore */
-import { IKickoff } from '../../../../types/template';
+import { IKickoffClient } from '../../../../types/template';
import { isArrayWithItems } from '../../../../utils/helpers';
-export const isKickoffCleared = (kickoff: IKickoff) => {
+export const isKickoffCleared = (kickoff: IKickoffClient) => {
const hasFields = isArrayWithItems(kickoff.fields);
return !hasFields && !kickoff.description;
diff --git a/frontend/src/public/components/TemplateEdit/OutputForm/OutputForm.css b/frontend/src/public/components/TemplateEdit/OutputForm/OutputForm.css
index 3dac1aca1..6cb85150b 100644
--- a/frontend/src/public/components/TemplateEdit/OutputForm/OutputForm.css
+++ b/frontend/src/public/components/TemplateEdit/OutputForm/OutputForm.css
@@ -9,3 +9,72 @@
.components::-webkit-scrollbar {
display: none;
}
+
+.fields {
+ display: flex;
+ flex-direction: column;
+}
+
+.flow__components {
+ display: flex;
+ flex-flow: row nowrap;
+ overflow-x: scroll;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.flow__components::-webkit-scrollbar {
+ display: none;
+}
+
+.flow__fieldset-icon-slot {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.flow__fieldset-dropdown {
+ max-width: min(28rem, 90vw);
+}
+
+.flow__fieldset-icon-dropdown {
+ display: flex;
+ flex-shrink: 0;
+}
+
+.flow__fieldset-icon-dropdown-menu {
+ padding: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ width: max-content;
+ min-width: 18rem;
+ max-height: 24rem;
+}
+
+.flow__fieldset-dropdown-panel {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.flow__fieldset-icon-trigger_open {
+ opacity: 0.92;
+}
+
+.flow__fieldset-readonly-description {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ font-family: Nunito, sans-serif;
+ font-size: 1.3rem;
+ line-height: 2rem;
+ word-break: break-word;
+ color: var(--pneumatic-color-black48);
+}
+
+.flow__fieldset-nested-fields {
+ margin-top: 0.5rem;
+}
+
+.flow__fieldset-name {
+ @mixin text-small;
+}
diff --git a/frontend/src/public/components/TemplateEdit/OutputForm/OutputForm.tsx b/frontend/src/public/components/TemplateEdit/OutputForm/OutputForm.tsx
index b69daad99..f3b2b3a6d 100644
--- a/frontend/src/public/components/TemplateEdit/OutputForm/OutputForm.tsx
+++ b/frontend/src/public/components/TemplateEdit/OutputForm/OutputForm.tsx
@@ -1,11 +1,10 @@
-/* eslint-disable */
-/* prettier-ignore */
import * as React from 'react';
import { injectIntl, IntlShape } from 'react-intl';
import classNames from 'classnames';
import { EMoveDirections, EInputNameBackgroundColor } from '../../../types/workflow';
import { EExtraFieldMode, EExtraFieldType, IExtraField } from '../../../types/template';
+import { EFieldLabelPosition } from '../../../types/fieldset';
import { isArrayWithItems } from '../../../utils/helpers';
import { getNormalizeFieldsOrders, moveWorkflowField } from '../../../utils/workflows';
@@ -15,27 +14,46 @@ import { ExtraFieldIntl } from '../ExtraFields';
import { getEditedFields } from '../ExtraFields/utils/getEditedFields';
import { getEmptyField } from '../KickoffRedux/utils/getEmptyField';
+import { OutputFormTaskMerged, IOutputFormTaskMergedOwnProps } from './OutputFormTaskMerged';
+
import styles from './OutputForm.css';
import stylesTaskForm from '../TaskForm/TaskForm.css';
import { useDatasetOptions } from '../ExtraFields/utils/useDatasetOptions';
-export interface IOutputFormOwnProps {
+export interface IOutputFormSimpleOwnProps {
+ mode?: 'simple';
fields: IExtraField[];
- intl: IntlShape;
+ onOutputChange(value: IExtraField[]): void;
show?: boolean;
isDisabled: boolean;
accountId: number;
- onOutputChange(value: IExtraField[]): void;
}
-export function OutputForm({ fields, onOutputChange, intl, isDisabled, show, accountId }: IOutputFormOwnProps) {
+export type IOutputFormTaskMergedExternalProps = Omit & {
+ mode: 'taskMerged';
+};
+
+export type IOutputFormExternalProps = IOutputFormSimpleOwnProps | IOutputFormTaskMergedExternalProps;
+
+export type ITaskOutputFlowFormProps = Omit;
+
+type TOutputFormInjectedProps = IOutputFormExternalProps & { intl: IntlShape };
+
+function OutputFormSimple({
+ fields,
+ onOutputChange,
+ intl,
+ isDisabled,
+ show,
+ accountId,
+}: IOutputFormSimpleOwnProps & { intl: IntlShape }) {
const outputRef = React.useRef(null);
React.useEffect(() => {
if (show) {
outputRef.current?.focus();
}
- }, [outputRef]);
+ }, [show]);
const sortedFields = [...fields].sort((a, b) => b.order - a.order);
const datasetOptions = useDatasetOptions(fields);
@@ -91,7 +109,7 @@ export function OutputForm({ fields, onOutputChange, intl, isDisabled, show, acc
{sortedFields.map((field, index) => (
handleMoveField(index, EMoveDirections.Down)}
editField={handleEditField(field.apiName)}
mode={EExtraFieldMode.Kickoff}
+ showDropdown
datasetOptions={datasetOptions}
isDisabled={isDisabled}
innerRef={outputRef}
accountId={accountId}
+ labelPosition={EFieldLabelPosition.Top}
/>
))}
@@ -113,4 +133,16 @@ export function OutputForm({ fields, onOutputChange, intl, isDisabled, show, acc
);
}
+function OutputForm(props: TOutputFormInjectedProps) {
+ const { intl, ...rest } = props;
+ if (rest.mode === 'taskMerged') {
+ const { mode, ...merged } = rest as IOutputFormTaskMergedExternalProps;
+ if (mode !== 'taskMerged') {
+ return null;
+ }
+ return ;
+ }
+ return ;
+}
+
export const OutputFormIntl = injectIntl(OutputForm);
diff --git a/frontend/src/public/components/TemplateEdit/OutputForm/OutputFormTaskMerged.tsx b/frontend/src/public/components/TemplateEdit/OutputForm/OutputFormTaskMerged.tsx
new file mode 100644
index 000000000..d901febcb
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/OutputForm/OutputFormTaskMerged.tsx
@@ -0,0 +1,168 @@
+import * as React from 'react';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import classNames from 'classnames';
+import { IntlShape } from 'react-intl';
+
+import { EExtraFieldType, IExtraField, ITemplateTaskClient } from '../../../types/template';
+import { IFieldsetCatalogItem } from '../../../types/fieldset';
+import { isArrayWithItems } from '../../../utils/helpers';
+import { useDatasetOptions } from '../ExtraFields/utils/useDatasetOptions';
+import { getEditedFields } from '../ExtraFields/utils/getEditedFields';
+import { getEmptyField } from '../KickoffRedux/utils/getEmptyField';
+import { ExtraFieldsMap } from '../ExtraFields/utils/ExtraFieldsMap';
+import { ExtraFieldIcon } from '../ExtraFields/utils/ExtraFieldIcon';
+import { FieldsetIconPicker } from '../TaskOutputFlow/FieldsetIconPicker';
+import { MergedOutputRows } from '../TaskOutputFlow/MergedOutputRows';
+import { TPatchTaskPayload } from '../../../redux/actions';
+
+import {
+ buildMergedTaskOutputRows,
+ buildRowsWithAddedFieldset,
+ buildRowsWithRemovedFieldset,
+ createFieldsetBinding,
+ moveMergedRow,
+ normalizeMergedTaskOutputOrders,
+ TMergedTaskOutputRow,
+} from '../TaskOutputFlow/mergeTaskOutputFlow';
+
+import styles from './OutputForm.css';
+import stylesTaskForm from '../TaskForm/TaskForm.css';
+
+export interface IOutputFormTaskMergedOwnProps {
+ task: ITemplateTaskClient;
+ fieldsetsCatalogLoading: boolean;
+ accountId: number;
+ show?: boolean;
+ patchTask: (args: TPatchTaskPayload) => void;
+ intl: IntlShape;
+}
+
+
+export function OutputFormTaskMerged({
+ task,
+ fieldsetsCatalogLoading,
+ accountId,
+ show,
+ patchTask,
+ intl,
+}: IOutputFormTaskMergedOwnProps) {
+ const { formatMessage } = intl;
+ const outputRef = useRef(null);
+
+ useEffect(() => {
+ if (show) {
+ outputRef.current?.focus();
+ }
+ }, [show]);
+
+ const mergedRows = useMemo(
+ () => buildMergedTaskOutputRows(task.fields || [], task.fieldsets || []),
+ [task.fields, task.fieldsets],
+ );
+ const datasetOptions = useDatasetOptions(task.fields || []);
+
+ const saveOutputOrders = useCallback(
+ async (
+ rows: TMergedTaskOutputRow[],
+ allFieldsSource?: IExtraField[],
+ ) => {
+ const allFields = allFieldsSource ?? task.fields ?? [];
+ const { nextFields, nextFieldsets } = normalizeMergedTaskOutputOrders(rows, allFields);
+ patchTask({
+ taskUUID: task.uuid,
+ changedFields: { fields: nextFields, fieldsets: nextFieldsets },
+ });
+ },
+ [patchTask, task.fields, task.fieldsets, task.uuid],
+ );
+
+ const handleCreateField = useCallback(
+ (type: EExtraFieldType) => {
+ const newField = getEmptyField(type, formatMessage, -1);
+ const mergedTaskFields = [...(task.fields || []), newField];
+ const rowsWithNew = buildMergedTaskOutputRows(
+ mergedTaskFields,
+ task.fieldsets || [],
+ );
+ saveOutputOrders(rowsWithNew, mergedTaskFields).catch(() => undefined);
+ },
+ [formatMessage, saveOutputOrders, task.fieldsets, task.fields],
+ );
+
+ const handleEditField = useCallback(
+ (apiName: string) => (changedProps: Partial) => {
+ const newFields = getEditedFields(task.fields || [], apiName, changedProps);
+ patchTask({ taskUUID: task.uuid, changedFields: { fields: newFields } });
+ },
+ [patchTask, task.fields, task.uuid],
+ );
+
+ const handleDeleteField = useCallback(
+ (apiName: string) => {
+ const nextFields = (task.fields || []).filter((f) => f.apiName !== apiName);
+ const rows = buildMergedTaskOutputRows(nextFields, task.fieldsets || []);
+ saveOutputOrders(rows, nextFields).catch(() => undefined);
+ },
+ [saveOutputOrders, task.fieldsets, task.fields],
+ );
+
+ const handleMoveMergedIndex = useCallback(
+ (index: number, direction: 'up' | 'down') => {
+ const moved = moveMergedRow(mergedRows, index, direction);
+ saveOutputOrders(moved).catch(() => undefined);
+ },
+ [mergedRows, saveOutputOrders],
+ );
+
+ const handleAddFieldset = useCallback(
+ (fieldsetCatalogItem: IFieldsetCatalogItem) => {
+ const newFieldsetBinding = createFieldsetBinding(fieldsetCatalogItem);
+ const rows = buildRowsWithAddedFieldset(task.fields || [], task.fieldsets || [], newFieldsetBinding);
+ if (rows) saveOutputOrders(rows).catch(() => undefined);
+ },
+ [saveOutputOrders, task.fieldsets, task.fields],
+ );
+
+ const handleRemoveFieldset = useCallback(
+ (sharedFieldsetId: number) => {
+ const rows = buildRowsWithRemovedFieldset(task.fields || [], task.fieldsets || [], sharedFieldsetId);
+ saveOutputOrders(rows).catch(() => undefined);
+ },
+ [saveOutputOrders, task.fieldsets, task.fields],
+ );
+
+
+ const isEmpty = !isArrayWithItems(task.fields) && !(task.fieldsets || []).length;
+
+ return (
+ <>
+
+ {ExtraFieldsMap.map((field) => (
+ handleCreateField(field.id)} />
+ ))}
+ fieldset.sharedFieldsetId)}
+ onSelectFieldset={handleAddFieldset}
+ onRemoveFieldset={handleRemoveFieldset}
+ />
+
+
+ {!isEmpty && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/public/components/TemplateEdit/OutputForm/__tests__/OutputFormTaskMerged.test.tsx b/frontend/src/public/components/TemplateEdit/OutputForm/__tests__/OutputFormTaskMerged.test.tsx
new file mode 100644
index 000000000..7ba858621
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/OutputForm/__tests__/OutputFormTaskMerged.test.tsx
@@ -0,0 +1,338 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { makeFieldsetBindingClient, makeFieldsetCatalogItem } from '../../../../__stubs__/fieldsets.factory';
+import { makeTemplateTaskClient } from '../../../../__stubs__/templates.factory';
+import {
+ IExtraField,
+ ITemplateTaskClient,
+} from '../../../../types/template';
+import { IFieldsetCatalogItem } from '../../../../types/fieldset';
+
+jest.mock('../../../../redux/selectors/fieldsets', () => ({
+ getFieldsetsCatalogItems: jest.fn(() => []),
+}));
+
+import { getFieldsetsCatalogItems } from '../../../../redux/selectors/fieldsets';
+
+jest.mock('../../ExtraFields/utils/useDatasetOptions', () => ({
+ useDatasetOptions: jest.fn(() => []),
+}));
+
+jest.mock('../../KickoffRedux/utils/getEmptyField', () => ({
+ getEmptyField: jest.fn(),
+}));
+
+jest.mock('../../ExtraFields/utils/ExtraFieldsMap', () => ({
+ ExtraFieldsMap: [{ id: 'string', label: 'String' }],
+}));
+
+jest.mock('../../ExtraFields/utils/ExtraFieldIcon', () => ({
+ ExtraFieldIcon: (props: { id: string; onClick: () => void }) =>
+ React.createElement(
+ 'button',
+ { type: 'button', onClick: props.onClick },
+ `Add field ${props.id}`,
+ ),
+}));
+
+jest.mock('../../TaskOutputFlow/FieldsetIconPicker', () => ({
+ FieldsetIconPicker: (props: {
+ selectedFieldsetIds: number[];
+ onSelectFieldset: (item: IFieldsetCatalogItem) => void;
+ onRemoveFieldset: (sharedFieldsetId: number) => void;
+ }) => {
+ const catalogItems = (getFieldsetsCatalogItems as jest.Mock)();
+ return React.createElement(
+ 'div',
+ null,
+ catalogItems.map((item: IFieldsetCatalogItem) =>
+ React.createElement(
+ 'button',
+ {
+ key: `add-${item.id}`,
+ type: 'button',
+ onClick: () => props.onSelectFieldset(item),
+ },
+ `Add fieldset ${item.name}`,
+ ),
+ ),
+ props.selectedFieldsetIds.map((id: number) =>
+ React.createElement(
+ 'button',
+ {
+ key: `remove-${id}`,
+ type: 'button',
+ onClick: () => props.onRemoveFieldset(id),
+ },
+ `Remove fieldset ${id}`,
+ ),
+ ),
+ );
+ },
+}));
+
+jest.mock('../../TaskOutputFlow/MergedOutputRows', () => ({
+ MergedOutputRows: (props: {
+ mergedRows: Array<
+ | { kind: 'field'; field: IExtraField }
+ | { kind: 'fieldset'; apiName: string; order: number }
+ >;
+ onDeleteField: (apiName: string) => void;
+ onMoveRow: (index: number, direction: 'up' | 'down') => void;
+ onEditField: (apiName: string) => (changed: Partial) => void;
+ onRemoveFieldset: (apiName: string) => void;
+ }) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': 'merged-rows' },
+ props.mergedRows.map((row, index) => {
+ if (row.kind === 'field') {
+ const apiName = row.field.apiName;
+ return React.createElement(
+ 'div',
+ { key: `field-${apiName}` },
+ React.createElement(
+ 'button',
+ { type: 'button', onClick: () => props.onDeleteField(apiName) },
+ `Delete ${apiName}`,
+ ),
+ React.createElement(
+ 'button',
+ {
+ type: 'button',
+ onClick: () => props.onEditField(apiName)({ name: 'EditedName' }),
+ },
+ `Edit ${apiName}`,
+ ),
+ React.createElement(
+ 'button',
+ { type: 'button', onClick: () => props.onMoveRow(index, 'up') },
+ `Move row ${index} up`,
+ ),
+ React.createElement(
+ 'button',
+ { type: 'button', onClick: () => props.onMoveRow(index, 'down') },
+ `Move row ${index} down`,
+ ),
+ );
+ }
+ return React.createElement(
+ 'div',
+ { key: `fieldset-${row.apiName}` },
+ React.createElement(
+ 'button',
+ { type: 'button', onClick: () => props.onMoveRow(index, 'up') },
+ `Move row ${index} up`,
+ ),
+ React.createElement(
+ 'button',
+ { type: 'button', onClick: () => props.onMoveRow(index, 'down') },
+ `Move row ${index} down`,
+ ),
+ );
+ }),
+ ),
+}));
+
+import { OutputFormTaskMerged } from '../OutputFormTaskMerged';
+import { getEmptyField } from '../../KickoffRedux/utils/getEmptyField';
+
+describe('OutputFormTaskMerged', () => {
+ const makeTask = makeTemplateTaskClient;
+
+ const makeField = (overrides: Partial = {}) => makeExtraField({
+ name: 'Field 1',
+ ...overrides,
+ });
+
+ const NEW_FIELD: IExtraField = makeExtraField({
+ apiName: 'new-field',
+ name: 'New Field',
+ order: -1,
+ });
+
+ const renderForm = (props: {
+ task: ITemplateTaskClient;
+ catalogItems?: IFieldsetCatalogItem[];
+ patchTask?: jest.Mock;
+ }) => {
+ const patchTask = props.patchTask ?? jest.fn();
+ const catalogItems = props.catalogItems ?? [];
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue(catalogItems);
+
+ render(
+ React.createElement(OutputFormTaskMerged, {
+ task: props.task,
+ fieldsetsCatalogLoading: false,
+ accountId: 1,
+ patchTask,
+ intl: intlMock,
+ } as React.ComponentProps),
+ );
+ return { patchTask };
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (getEmptyField as jest.Mock).mockReturnValue(NEW_FIELD);
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue([]);
+ });
+
+ describe('rows table visibility', () => {
+ it('does not render rows table when there are no fields and no fieldsets', () => {
+ renderForm({ task: makeTask({ fields: [], fieldsets: [] }) });
+ expect(screen.queryByTestId('merged-rows')).not.toBeInTheDocument();
+ });
+
+ it('renders rows table when there are fieldsets but no own fields', () => {
+ renderForm({
+ task: makeTask({ fields: [], fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 0 })] }),
+ });
+ expect(screen.getByTestId('merged-rows')).toBeInTheDocument();
+ });
+ });
+
+ describe('field actions', () => {
+ it('adding a field sends PATCH with new field and fieldset order recalculated', () => {
+ const existingField = makeField({ apiName: 'f-1', order: 0 });
+ const { patchTask } = renderForm({
+ task: makeTask({
+ fields: [existingField],
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 1 })],
+ }),
+ });
+
+ userEvent.click(screen.getByRole('button', { name: 'Add field string' }));
+
+ expect(patchTask).toHaveBeenCalledTimes(1);
+ const arg = patchTask.mock.calls[0][0];
+ expect(arg.taskUUID).toBe('task-uuid');
+ expect(arg.changedFields.fields).toBeDefined();
+ expect(arg.changedFields.fieldsets).toBeDefined();
+ const fieldApiNames = arg.changedFields.fields.map((f: IExtraField) => f.apiName);
+ expect(fieldApiNames).toContain('new-field');
+ expect(fieldApiNames).toContain('f-1');
+ const patchBindingNames = arg.changedFields.fieldsets.map((p: { apiNameBinding: string }) => p.apiNameBinding);
+ expect(patchBindingNames).toContain('fs-1');
+ });
+
+ it('editing a field sends PATCH with fields only, without fieldsets payload', () => {
+ const existingField = makeField({ apiName: 'f-1', name: 'Old' });
+ const { patchTask } = renderForm({
+ task: makeTask({
+ fields: [existingField],
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 0 })],
+ }),
+ });
+
+ userEvent.click(screen.getByRole('button', { name: 'Edit f-1' }));
+
+ expect(patchTask).toHaveBeenCalledTimes(1);
+ const arg = patchTask.mock.calls[0][0];
+ expect(arg.changedFields.fields).toBeDefined();
+ expect(arg.changedFields.fieldsets).toBeUndefined();
+ });
+
+ it('deleting a field sends PATCH without that field and recomputes order including fieldsets', () => {
+ const fieldA = makeField({ apiName: 'f-a', order: 1 });
+ const fieldB = makeField({ apiName: 'f-b', order: 2 });
+ const { patchTask } = renderForm({
+ task: makeTask({
+ fields: [fieldA, fieldB],
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 0 })],
+ }),
+ });
+
+ userEvent.click(screen.getByRole('button', { name: 'Delete f-a' }));
+
+ expect(patchTask).toHaveBeenCalledTimes(1);
+ const arg = patchTask.mock.calls[0][0];
+ const fieldApiNames = arg.changedFields.fields.map((f: IExtraField) => f.apiName);
+ expect(fieldApiNames).not.toContain('f-a');
+ expect(fieldApiNames).toContain('f-b');
+ expect(arg.changedFields.fieldsets).toBeDefined();
+ const patchBindingNames = arg.changedFields.fieldsets.map((p: { apiNameBinding: string }) => p.apiNameBinding);
+ expect(patchBindingNames).toContain('fs-1');
+ });
+ });
+
+ describe('fieldset actions', () => {
+ it('adding a fieldset sends PATCH with both fields and fieldset patches', () => {
+ const catalogItem = makeFieldsetCatalogItem({ id: 99, apiName: 'fs-new', name: 'New Set' });
+ const existingField = makeField({ apiName: 'f-1' });
+ const { patchTask } = renderForm({
+ task: makeTask({ fields: [existingField], fieldsets: [] }),
+ catalogItems: [catalogItem],
+ });
+
+ userEvent.click(screen.getByRole('button', { name: 'Add fieldset New Set' }));
+
+ expect(patchTask).toHaveBeenCalledTimes(1);
+ const arg = patchTask.mock.calls[0][0];
+ expect(arg.changedFields.fields).toBeDefined();
+ expect(arg.changedFields.fieldsets).toBeDefined();
+ });
+
+ it('does not send PATCH when adding an already-connected fieldset', () => {
+ const catalogItem = makeFieldsetCatalogItem({ id: 1, apiName: 'fs-1', name: 'Fieldset 1' });
+ const { patchTask } = renderForm({
+ task: makeTask({ fields: [], fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 0, sharedFieldsetId: 1 })] }),
+ catalogItems: [catalogItem],
+ });
+
+ userEvent.click(screen.getByRole('button', { name: 'Add fieldset Fieldset 1' }));
+
+ expect(patchTask).not.toHaveBeenCalled();
+ });
+
+ it('removing a fieldset sends PATCH without it and recomputes order of remaining ones', () => {
+ const { patchTask } = renderForm({
+ task: makeTask({
+ fields: [],
+ fieldsets: [
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-a', order: 0, sharedFieldsetId: 10 }),
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-b', order: 1, sharedFieldsetId: 20 }),
+ ],
+ }),
+ });
+
+ userEvent.click(screen.getByRole('button', { name: 'Remove fieldset 10' }));
+
+ expect(patchTask).toHaveBeenCalledTimes(1);
+ const arg = patchTask.mock.calls[0][0];
+ expect(arg.changedFields.fieldsets).toBeDefined();
+ const patchBindingNames = arg.changedFields.fieldsets.map((p: { apiNameBinding: string }) => p.apiNameBinding);
+ expect(patchBindingNames).not.toContain('fs-a');
+ expect(patchBindingNames).toContain('fs-b');
+ });
+ });
+
+ describe('merged rows reordering', () => {
+ it('reordering a row sends PATCH with both updated fields and fieldset patches', () => {
+ const fieldA = makeField({ apiName: 'f-a', order: 1 });
+ const { patchTask } = renderForm({
+ task: makeTask({
+ fields: [fieldA],
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 0 })],
+ }),
+ });
+
+ userEvent.click(screen.getByRole('button', { name: 'Move row 0 down' }));
+
+ expect(patchTask).toHaveBeenCalledTimes(1);
+ const arg = patchTask.mock.calls[0][0];
+ expect(arg.changedFields.fields).toBeDefined();
+ expect(arg.changedFields.fieldsets).toBeDefined();
+ const field = arg.changedFields.fields.find((f: IExtraField) => f.apiName === 'f-a');
+ expect(field).toBeDefined();
+ const fsPatch = arg.changedFields.fieldsets.find((p: { apiNameBinding: string }) => p.apiNameBinding === 'fs-1');
+ expect(fsPatch).toBeDefined();
+ expect(fsPatch.order).toBe(1);
+ expect(field.order).toBe(0);
+ });
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/OutputForm/index.ts b/frontend/src/public/components/TemplateEdit/OutputForm/index.ts
index fe2fede57..b18bb9df6 100644
--- a/frontend/src/public/components/TemplateEdit/OutputForm/index.ts
+++ b/frontend/src/public/components/TemplateEdit/OutputForm/index.ts
@@ -1,3 +1,4 @@
/* eslint-disable */
/* prettier-ignore */
export * from './OutputForm';
+export * from './OutputFormTaskMerged';
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/Conditions/utils/__tests__/shouldLoadDatasetForVariable.test.ts b/frontend/src/public/components/TemplateEdit/TaskForm/Conditions/utils/__tests__/shouldLoadDatasetForVariable.test.ts
index be35760b7..572bc98ed 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/Conditions/utils/__tests__/shouldLoadDatasetForVariable.test.ts
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/Conditions/utils/__tests__/shouldLoadDatasetForVariable.test.ts
@@ -1,4 +1,3 @@
-//
import { shouldLoadDatasetForVariable } from '../shouldLoadDatasetForVariable';
import { EExtraFieldType } from '../../../../../../types/template';
import { TTaskVariable } from '../../../../types';
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/DueDate.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/DueDate.tsx
index 62288e41b..f9aac65db 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/DueDate.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/DueDate.tsx
@@ -5,7 +5,7 @@ import classnames from 'classnames';
import { DropdownList, Duration } from '../../../UI';
import { TrashIcon } from '../../../icons';
-import { IDueDate, IKickoff, ITemplateTask } from '../../../../types/template';
+import { IDueDate, IKickoffClient, ITemplateTaskClient } from '../../../../types/template';
import { START_DURATION } from '../../constants';
import { getRuleTargetOptions, TRuleTargetOption } from './utils/getRuleTargetOptions';
import { getRulePrepositionOptions, TRulePrepositionOption } from './utils/getRulePrepositionOptions';
@@ -15,14 +15,14 @@ import styles from './DueDate.css';
import stylesTaskForm from '../TaskForm.css';
interface IDueInProps {
- currentTask: ITemplateTask;
- tasks: ITemplateTask[];
- kickoff: IKickoff;
+ currentTask: ITemplateTaskClient;
+ tasks: ITemplateTaskClient[];
+ kickoff: IKickoffClient;
onChange(value: IDueDate): void;
- dueDate: ITemplateTask['rawDueDate'];
+ dueDate: ITemplateTaskClient['rawDueDate'];
}
-type TDueDateKeys = keyof ITemplateTask['rawDueDate'];
+type TDueDateKeys = keyof ITemplateTaskClient['rawDueDate'];
export function DueDate({ dueDate, currentTask, tasks, kickoff, onChange }: IDueInProps) {
const { formatMessage } = useIntl();
@@ -35,7 +35,7 @@ export function DueDate({ dueDate, currentTask, tasks, kickoff, onChange }: IDue
const [systemRules, dateFieldsRules, tasksRules] = useMemo(() => {
return getRuleTargetOptions(currentTask, tasks, kickoff);
- }, [tasks, kickoff]);
+ }, [currentTask, kickoff, tasks]);
const currentPrepositionOption = useMemo(() => {
return prepositionOptions.find((rule) => rule.rulePreposition === rulePreposition) || null;
@@ -47,7 +47,7 @@ export function DueDate({ dueDate, currentTask, tasks, kickoff, onChange }: IDue
(rule) => rule.sourceId === sourceId && rule.ruleTarget === ruleTarget,
) || null
);
- }, [sourceId, ruleTarget]);
+ }, [dateFieldsRules, ruleTarget, sourceId, systemRules, tasksRules]);
useUpdatePreposition(prepositionOptions, currentPrepositionOption, currentTargetOption, (option) =>
onChange({
@@ -58,7 +58,7 @@ export function DueDate({ dueDate, currentTask, tasks, kickoff, onChange }: IDue
const handleChange =
(field: T) =>
- (value: ITemplateTask['rawDueDate'][T]) => {
+ (value: ITemplateTaskClient['rawDueDate'][T]) => {
onChange({ ...dueDate, [field]: value });
};
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/utils/__tests__/getRuleTargetOptions.test.ts b/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/utils/__tests__/getRuleTargetOptions.test.ts
new file mode 100644
index 000000000..72acbac90
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/utils/__tests__/getRuleTargetOptions.test.ts
@@ -0,0 +1,89 @@
+import { EExtraFieldType, IKickoffClient, ITemplateTaskClient } from '../../../../../../types/template';
+import { makeFieldsetField, makeFieldsetBindingClient } from '../../../../../../__stubs__/fieldsets.factory';
+import { createEmptyTaskDueDate } from '../../../../../../utils/dueDate/createEmptyTaskDueDate';
+import { getRuleTargetOptions } from '../getRuleTargetOptions';
+
+jest.mock('../../../../../UI', () => ({
+ DropdownList: jest.fn(() => null),
+}));
+
+const makeTask = (overrides: Partial = {}): ITemplateTaskClient => ({
+ id: 1,
+ apiName: 'task-1',
+ name: 'Task 1',
+ description: '',
+ number: 1,
+ rawPerformers: [],
+ requireCompletionByAll: false,
+ skipForStarter: false,
+ fields: [],
+ delay: null,
+ rawDueDate: createEmptyTaskDueDate(),
+ conditions: [],
+ uuid: 'uuid-1',
+ checklists: [],
+ revertTask: null,
+ ancestors: [],
+ fieldsets: [],
+ ...overrides,
+});
+
+const makeKickoff = (overrides: Partial = {}): IKickoffClient => ({
+ description: '',
+ fields: [],
+ fieldsets: [],
+ ...overrides,
+});
+
+
+describe('getRuleTargetOptions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns 2 system rules (workflow started, task started) even without fieldsets', () => {
+ const currentTask = makeTask();
+ const [systemRules, dateFieldsRules, tasksRules] = getRuleTargetOptions(
+ currentTask,
+ [currentTask],
+ makeKickoff(),
+ );
+
+ expect(systemRules).toHaveLength(2);
+ expect(systemRules.find((r) => r.ruleTarget === 'workflow started')).toBeDefined();
+ expect(systemRules.find((r) => r.ruleTarget === 'task started')).toBeDefined();
+ expect(dateFieldsRules).toHaveLength(0);
+ expect(tasksRules).toHaveLength(0);
+ });
+
+ it('only Date fields from kickoff fieldset appear in dateFieldsRules, String fields are filtered out', () => {
+ const fieldsetApiName = 'fs-kickoff';
+
+ const kickoff = makeKickoff({
+ fieldsets: [makeFieldsetBindingClient({
+ apiNameBinding: fieldsetApiName,
+ fields: [
+ makeFieldsetField({ apiName: 'date-field', name: 'Date Field', type: EExtraFieldType.Date }),
+ makeFieldsetField({ apiName: 'string-field', name: 'String Field', order: 1 }),
+ ],
+ })],
+ });
+
+ const currentTask = makeTask();
+ const [systemRules, dateFieldsRules] = getRuleTargetOptions(
+ currentTask,
+ [currentTask],
+ kickoff,
+ );
+
+ expect(systemRules).toHaveLength(2);
+
+ expect(dateFieldsRules).toHaveLength(1);
+ const dateRule = dateFieldsRules.find((r) => r.sourceId === 'date-field');
+ expect(dateRule).toBeDefined();
+ expect(dateRule?.ruleTarget).toBe('field');
+
+ const stringInRules = dateFieldsRules.find((r) => r.sourceId === 'string-field');
+ expect(stringInRules).toBeUndefined();
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/utils/getRuleTargetOptions.ts b/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/utils/getRuleTargetOptions.ts
index 44e7a637a..6e196be1b 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/utils/getRuleTargetOptions.ts
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/DueDate/utils/getRuleTargetOptions.ts
@@ -1,4 +1,9 @@
-import { EExtraFieldType, IKickoff, ITemplateTask, TDueDateRuleTarget } from '../../../../../types/template';
+import {
+ EExtraFieldType,
+ IKickoffClient,
+ ITemplateTaskClient,
+ TDueDateRuleTarget,
+} from '../../../../../types/template';
import { TDropdownOptionBase } from '../../../../UI';
import { getPreviousTasks } from '../../utils/getPreviousTasks';
import { getTaskVariables } from '../../utils/getTaskVariables';
@@ -9,9 +14,9 @@ export type TRuleTargetOption = TDropdownOptionBase & {
};
export function getRuleTargetOptions(
- currentTask: ITemplateTask,
- tasks: ITemplateTask[],
- kickoff: IKickoff,
+ currentTask: ITemplateTaskClient,
+ tasks: ITemplateTaskClient[],
+ kickoff: IKickoffClient,
): readonly [TRuleTargetOption[], TRuleTargetOption[], TRuleTargetOption[]] {
const prevTasks = getPreviousTasks(currentTask, tasks);
const prevDateVariables = getTaskVariables(kickoff, tasks, currentTask).filter(
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/ReturnTo/ReturnTo.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/ReturnTo/ReturnTo.tsx
index f97239330..9beb46561 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/ReturnTo/ReturnTo.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/ReturnTo/ReturnTo.tsx
@@ -10,16 +10,16 @@ import { getFormattedDropdownOption } from '../Conditions/utils/getFormattedDrop
import { useCheckDevice } from '../../../../hooks/useCheckDevice';
import { TTaskVariable } from '../../types';
-import { ITemplateTask } from '../../../../types/template';
+import { ITemplateTaskClient } from '../../../../types/template';
import styles from './ReturnTo.css';
import stylesTaskForm from '../TaskForm.css';
interface IReturnToProps {
variables: TTaskVariable[];
- tasks: ITemplateTask[];
+ tasks: ITemplateTaskClient[];
currentTaskRevertTask: string | null;
- setCurrentTask(changedFields: Partial): void;
+ setCurrentTask(changedFields: Partial): void;
taskAncestors: Set;
}
@@ -57,7 +57,7 @@ export function ReturnTo({ variables, tasks, currentTaskRevertTask, setCurrentTa
const dropdownTaskList = useMemo(
() => [
...tasks
- .filter((task: ITemplateTask) => taskAncestors.has(task.apiName))
+ .filter((task: ITemplateTaskClient) => taskAncestors.has(task.apiName))
.map(({ name, apiName }) => {
return {
label: name,
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx
index 93e16629a..2814e5c0c 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskForm.tsx
@@ -1,13 +1,14 @@
import * as React from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { useIntl } from 'react-intl';
+import { useSelector } from 'react-redux';
import { TaskDescriptionEditor } from './TaskDescriptionEditor';
import { scrollToElement } from '../../../utils/helpers';
import { TUserListItem } from '../../../types/user';
-import { IKickoff, ITemplateTask } from '../../../types/template';
+import { IKickoffClient, ITemplateTaskClient } from '../../../types/template';
import { TTaskVariable, TTaskFormPart, ETaskFormParts } from '../types';
-import { OutputFormIntl } from '../OutputForm';
+import { OutputFormIntl } from '../OutputForm/OutputForm';
import { ShowMore } from '../../UI/ShowMore';
import { TaskPerformers } from './TaskPerformers';
import { CheckIfConditions, ICondition, removeDeletedTasks, StartAfterCondition } from './Conditions';
@@ -16,7 +17,8 @@ import { TPatchTaskPayload } from '../../../redux/actions';
import { ReturnTo } from './ReturnTo';
import { DueDate } from './DueDate';
-import { getSingleLineVariables, getSystemVariables } from './utils/getTaskVariables';
+import { getFieldsetsCatalogIsLoading } from '../../../redux/selectors/fieldsets';
+import { getSingleLineVariables, getSystemVariables, getTaskVariables, getVariables } from './utils/getTaskVariables';
import styles from '../TemplateEdit.css';
@@ -29,22 +31,18 @@ import { TaskRenderReturnInfo } from '../TaskRenderReturnInfo';
import { StepName } from '../../StepName';
export interface ITaskFormProps {
- listVariables: TTaskVariable[];
- templateVariables: TTaskVariable[];
- task: ITemplateTask;
+ task: ITemplateTaskClient;
users: TUserListItem[];
isSubscribed: boolean;
scrollTarget: TTaskFormPart;
accountId: number;
isTeamInvitesModalOpen: boolean;
- tasks: ITemplateTask[];
- kickoff: IKickoff;
+ tasks: ITemplateTaskClient[];
+ kickoff: IKickoffClient;
patchTask(args: TPatchTaskPayload): void;
}
export function TaskForm({
- listVariables,
- templateVariables,
task,
users,
isSubscribed,
@@ -58,8 +56,17 @@ export function TaskForm({
}: ITaskFormProps & { templateId: number | undefined }) {
if (!task) return null;
const { formatMessage } = useIntl();
+ const fieldsetsCatalogLoading = useSelector(getFieldsetsCatalogIsLoading);
const wrapperRef = useRef(null);
const taskName = task.name || '';
+ const listVariables = useMemo(
+ () => getTaskVariables(kickoff, tasks, task, templateId),
+ [kickoff, task, tasks, templateId],
+ );
+ const templateVariables = useMemo(
+ () => getVariables({ kickoff, tasks, templateId }),
+ [kickoff, tasks, templateId],
+ );
const listSystemVariables = useMemo(() => [
...getSystemVariables(),
...listVariables,
@@ -68,6 +75,7 @@ export function TaskForm({
[ETaskFormParts.AssignPerformers]: useRef(null),
[ETaskFormParts.DueIn]: useRef(null),
[ETaskFormParts.Fields]: useRef(null),
+ [ETaskFormParts.Fieldsets]: useRef(null),
[ETaskFormParts.StartsAfter]: useRef(null),
[ETaskFormParts.CheckIf]: useRef(null),
[ETaskFormParts.ReturnTo]: useRef(null),
@@ -102,16 +110,18 @@ export function TaskForm({
}, [startingOrder, task.conditions, onEdit]);
useLayoutEffect(() => {
- const scrollTo = (scrollTarget && taskFormPartsRefs[scrollTarget]?.current) || wrapperRef.current;
+ const scrollKey =
+ scrollTarget === ETaskFormParts.Fieldsets ? ETaskFormParts.Fields : scrollTarget;
+ const scrollTo = (scrollKey && taskFormPartsRefs[scrollKey]?.current) || wrapperRef.current;
if (scrollTo) scrollToElement(scrollTo);
}, []);
- const setCurrentTask = (changedFields: Partial) => {
+ const setCurrentTask = (changedFields: Partial) => {
patchTask({ taskUUID: task.uuid, changedFields });
};
- const handleTaskFieldChange = (field: keyof ITemplateTask) => (value: ITemplateTask[keyof ITemplateTask]) => {
+ const handleTaskFieldChange = (field: keyof ITemplateTaskClient) => (value: ITemplateTaskClient[keyof ITemplateTaskClient]) => {
setCurrentTask({ [field]: value });
};
@@ -166,14 +176,15 @@ export function TaskForm({
title: 'tasks.task-outputs-create-help',
component: (
),
- widget: createWidget(TaskRenderExtraFieldsInfo, { task }),
+ widget: createWidget(TaskRenderExtraFieldsInfo, { task })
},
{
formPartId: ETaskFormParts.StartsAfter,
@@ -233,6 +244,7 @@ export function TaskForm({
placeholder={formatMessage({ id: 'tasks.task-task-name-placeholder' })}
listVariables={getSingleLineVariables(listSystemVariables)}
templateVariables={templateVariables}
+ showInsertButton
value={taskName}
onChange={(value: string) => {
handleTaskFieldChange('name')(value);
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/TaskPerformers.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/TaskPerformers.tsx
index cba309c93..3349f3169 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/TaskPerformers.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/TaskPerformers.tsx
@@ -5,7 +5,7 @@ import classNames from 'classnames';
import { Checkbox } from '../../UI/Fields/Checkbox';
import { TUserListItem } from '../../../types/user';
-import { ETaskPerformerType, ITemplateTask, ITemplateTaskPerformer } from '../../../types/template';
+import { ETaskPerformerType, ITemplateTaskClient, ITemplateTaskPerformer } from '../../../types/template';
import { TTaskVariable } from '../types';
import { trackInviteTeamInPage } from '../../../utils/analytics';
import { EOptionTypes, TUsersDropdownOption, UsersDropdown, getUsersDropdownOptionValue } from '../../UI/form/UsersDropdown';
@@ -19,12 +19,12 @@ import stylesTaskForm from './TaskForm.css';
import { createPerformerApiName } from '../../../utils/createId';
export interface ITaskPerformersProps {
- task: ITemplateTask;
- tasks: ITemplateTask[];
+ task: ITemplateTaskClient;
+ tasks: ITemplateTaskClient[];
users: TUserListItem[];
variables: TTaskVariable[];
isTeamInvitesModalOpen: boolean;
- setCurrentTask(changedFields: Partial): void;
+ setCurrentTask(changedFields: Partial): void;
}
export function TaskPerformers({ task, tasks, users, variables, setCurrentTask }: ITaskPerformersProps) {
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx
index 0e5baf7e1..ce58a5d80 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx
@@ -1,14 +1,11 @@
-///
import * as React from 'react';
import { render } from '@testing-library/react';
import { intlMock } from '../../../../__stubs__/intlMock';
+import { makeTemplateTask } from '../../../../__stubs__/templates.factory';
import { Checkbox } from '../../../UI/Fields/Checkbox';
import { ITemplateTask } from '../../../../types/template';
-// TaskPerformers uses `import React from 'react'` (default import),
-// which doesn't work with ts-jest without esModuleInterop.
-// Mocking the react module so that default === module
jest.mock('react', () => {
const actual = jest.requireActual('react');
return { ...actual, default: actual, __esModule: true };
@@ -41,6 +38,7 @@ jest.mock('../../../../utils/users', () => ({
jest.mock('../../../../utils/createId', () => ({
createPerformerApiName: jest.fn(() => 'perf-mock'),
+ createDueDateApiName: jest.fn(() => 'due-date-mock'),
}));
// Deferred import after react mock
@@ -48,27 +46,13 @@ jest.mock('../../../../utils/createId', () => ({
const { TaskPerformers } = require('../TaskPerformers');
describe('TaskPerformers', () => {
- const t = (id: string) => intlMock.formatMessage({ id });
- const SKIP_LABEL = t('templates.task-skip-for-starter');
+ const formatMsg = (id: string) => intlMock.formatMessage({ id });
+ const SKIP_LABEL = formatMsg('templates.task-skip-for-starter');
const mockSetCurrentTask = jest.fn();
- const makeTask = (overrides: Partial = {}): ITemplateTask => ({
- apiName: 'task-1',
- number: 1,
+ const makeTask = (overrides: Partial = {}) => makeTemplateTask({
name: 'Test Task',
- description: '',
- delay: null,
- rawDueDate: null as any,
- requireCompletionByAll: false,
- skipForStarter: false,
- rawPerformers: [],
- fields: [],
- uuid: 'uuid-1',
- conditions: [],
- checklists: [],
- revertTask: null,
- ancestors: [],
...overrides,
});
@@ -86,18 +70,15 @@ describe('TaskPerformers', () => {
);
};
- const getSkipCheckboxCall = () => {
- const calls = (Checkbox as jest.Mock).mock.calls;
- return calls.find(
- (c: any[]) => c[0].checkboxId === 'skipForStarter-task-1',
- );
- };
+ type TCheckboxProps = Record;
- const getCompleteByAllCall = () => {
+ const findCheckboxProps = (checkboxId: string): TCheckboxProps => {
const calls = (Checkbox as jest.Mock).mock.calls;
- return calls.find(
- (c: any[]) => c[0].checkboxId === 'completeByAll-task-1',
+ const match = calls.find(
+ (c: [TCheckboxProps, Record]) => c[0].checkboxId === checkboxId,
);
+ if (!match) throw new Error(`Checkbox call with checkboxId="${checkboxId}" not found`);
+ return match[0];
};
const renderTwoTasks = () => {
@@ -139,21 +120,25 @@ describe('TaskPerformers', () => {
renderComponent({ apiName: 'custom-task-123' });
const calls = (Checkbox as jest.Mock).mock.calls;
- const completeByAllCall = calls.find((c: any[]) => c[0].checkboxId?.startsWith('completeByAll-'));
- const skipForStarterCall = calls.find((c: any[]) => c[0].checkboxId?.startsWith('skipForStarter-'));
+ const completeByAllCall = calls.find(
+ (c: [TCheckboxProps, Record]) => (c[0].checkboxId as string)?.startsWith('completeByAll-'),
+ );
+ const skipForStarterCall = calls.find(
+ (c: [TCheckboxProps, Record]) => (c[0].checkboxId as string)?.startsWith('skipForStarter-'),
+ );
- expect(completeByAllCall).toBeDefined();
- expect(skipForStarterCall).toBeDefined();
+ if (!completeByAllCall) throw new Error('completeByAll checkbox call not found');
+ if (!skipForStarterCall) throw new Error('skipForStarter checkbox call not found');
- expect(completeByAllCall![0].checkboxId).toBe('completeByAll-custom-task-123');
- expect(skipForStarterCall![0].checkboxId).toBe('skipForStarter-custom-task-123');
+ expect(completeByAllCall[0].checkboxId).toBe('completeByAll-custom-task-123');
+ expect(skipForStarterCall[0].checkboxId).toBe('skipForStarter-custom-task-123');
});
it('two tasks produce different checkboxIds (no DOM id collisions)', () => {
renderTwoTasks();
const calls = (Checkbox as jest.Mock).mock.calls;
- const checkboxIds = calls.map((c: any[]) => c[0].checkboxId);
+ const checkboxIds = calls.map((c: [TCheckboxProps, Record]) => c[0].checkboxId);
const completeByAllIds = checkboxIds.filter((id: string) => id?.startsWith('completeByAll-'));
const skipForStarterIds = checkboxIds.filter((id: string) => id?.startsWith('skipForStarter-'));
@@ -167,7 +152,7 @@ describe('TaskPerformers', () => {
const calls = (Checkbox as jest.Mock).mock.calls;
- calls.forEach((c: any[]) => {
+ calls.forEach((c: [TCheckboxProps, Record]) => {
expect(c[0].checkboxId).toBeDefined();
expect(c[0].id).toBeUndefined();
});
@@ -178,33 +163,32 @@ describe('TaskPerformers', () => {
it('passes title with correct localization text', () => {
renderComponent();
- const call = getCompleteByAllCall();
+ const props = findCheckboxProps('completeByAll-task-1');
- expect(call).toBeDefined();
- expect(call![0].title).toBe(t('templates.task-require-completion-by-all'));
+ expect(props.title).toBe(formatMsg('templates.task-require-completion-by-all'));
});
it('passes checked=true when requireCompletionByAll=true', () => {
renderComponent({ requireCompletionByAll: true });
- const call = getCompleteByAllCall();
+ const props = findCheckboxProps('completeByAll-task-1');
- expect(call![0].checked).toBe(true);
+ expect(props.checked).toBe(true);
});
it('passes checked=false when requireCompletionByAll=false', () => {
renderComponent({ requireCompletionByAll: false });
- const call = getCompleteByAllCall();
+ const props = findCheckboxProps('completeByAll-task-1');
- expect(call![0].checked).toBe(false);
+ expect(props.checked).toBe(false);
});
it('calls setCurrentTask with requireCompletionByAll on onChange', () => {
renderComponent({ requireCompletionByAll: false });
- const call = getCompleteByAllCall();
- const onChangeFn = call![0].onChange;
+ const props = findCheckboxProps('completeByAll-task-1');
+ const onChangeFn = props.onChange as (e: { currentTarget: { checked: boolean } }) => void;
onChangeFn({ currentTarget: { checked: true } });
expect(mockSetCurrentTask).toHaveBeenCalledWith(
@@ -217,33 +201,32 @@ describe('TaskPerformers', () => {
it('passes title with correct localization text', () => {
renderComponent();
- const skipCall = getSkipCheckboxCall();
+ const props = findCheckboxProps('skipForStarter-task-1');
- expect(skipCall).toBeDefined();
- expect(skipCall![0].title).toBe(SKIP_LABEL);
+ expect(props.title).toBe(SKIP_LABEL);
});
it('passes checked=true when skipForStarter=true', () => {
renderComponent({ skipForStarter: true });
- const skipCall = getSkipCheckboxCall();
+ const props = findCheckboxProps('skipForStarter-task-1');
- expect(skipCall![0].checked).toBe(true);
+ expect(props.checked).toBe(true);
});
it('passes checked=false when skipForStarter=false', () => {
renderComponent({ skipForStarter: false });
- const skipCall = getSkipCheckboxCall();
+ const props = findCheckboxProps('skipForStarter-task-1');
- expect(skipCall![0].checked).toBe(false);
+ expect(props.checked).toBe(false);
});
it('calls setCurrentTask with true on onChange', () => {
renderComponent({ skipForStarter: false });
- const skipCall = getSkipCheckboxCall();
- const onChangeFn = skipCall![0].onChange;
+ const props = findCheckboxProps('skipForStarter-task-1');
+ const onChangeFn = props.onChange as (e: { currentTarget: { checked: boolean } }) => void;
onChangeFn({ currentTarget: { checked: true } });
expect(mockSetCurrentTask).toHaveBeenCalledWith(
@@ -254,8 +237,8 @@ describe('TaskPerformers', () => {
it('calls setCurrentTask with false on checkbox uncheck', () => {
renderComponent({ skipForStarter: true });
- const skipCall = getSkipCheckboxCall();
- const onChangeFn = skipCall![0].onChange;
+ const props = findCheckboxProps('skipForStarter-task-1');
+ const onChangeFn = props.onChange as (e: { currentTarget: { checked: boolean } }) => void;
onChangeFn({ currentTarget: { checked: false } });
expect(mockSetCurrentTask).toHaveBeenCalledWith(
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/container.ts b/frontend/src/public/components/TemplateEdit/TaskForm/container.ts
index 199e0a4dc..6440561ee 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/container.ts
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/container.ts
@@ -2,18 +2,16 @@ import { connect } from 'react-redux';
import { TaskForm, ITaskFormProps } from './TaskForm';
import { IApplicationState } from '../../../types/redux';
-import { getTaskVariables, getVariables } from './utils/getTaskVariables';
import { patchTask } from '../../../redux/actions';
import { getIsUserSubsribed } from '../../../redux/selectors/user';
type TStoreProps = Pick<
ITaskFormProps,
- 'listVariables' | 'templateVariables' | 'isSubscribed' | 'accountId' | 'isTeamInvitesModalOpen' | 'kickoff' | 'tasks'
+ 'isSubscribed' | 'accountId' | 'isTeamInvitesModalOpen' | 'kickoff' | 'tasks'
> & { templateId: number | undefined };
type TDispatchProps = Pick;
-type TOwnProps = Pick;
-const mapStateToProps = (state: IApplicationState, { task }: TOwnProps): TStoreProps => {
+const mapStateToProps = (state: IApplicationState): TStoreProps => {
const {
template: {
data: { kickoff, tasks, id },
@@ -24,8 +22,6 @@ const mapStateToProps = (state: IApplicationState, { task }: TOwnProps): TStoreP
const isSubscribed = getIsUserSubsribed(state);
return {
- listVariables: getTaskVariables(kickoff, tasks, task, id),
- templateVariables: getVariables({ kickoff, tasks, templateId: id }),
isSubscribed,
accountId: account.id || -1,
isTeamInvitesModalOpen,
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getPerformersForDropdown.test.ts b/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getPerformersForDropdown.test.ts
index ca3e5bc06..a4410fd44 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getPerformersForDropdown.test.ts
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getPerformersForDropdown.test.ts
@@ -1,33 +1,22 @@
-///
import { intlMock } from '../../../../../__stubs__/intlMock';
-import { ETaskPerformerType, ITemplateTask } from '../../../../../types/template';
+import { makeTemplateTaskClient } from '../../../../../__stubs__/templates.factory';
+import { ETaskPerformerType, ITemplateTaskClient } from '../../../../../types/template';
+import { TUserListItem } from '../../../../../types/user';
+import { IGroup } from '../../../../../redux/team/types';
+import { makeUser } from '../../../../../__stubs__/users.factory';
+import { makeGroup } from '../../../../../__stubs__/team.factory';
import { getPerformersForDropdown } from '../getPerformersForDropdown';
import { EOptionTypes } from '../../../../UI/form/UsersDropdown';
jest.mock('../../../../../utils/users', () => ({
- getUserFullName: jest.fn((u: any) => `${u.firstName} ${u.lastName}`),
+ getUserFullName: jest.fn((u: { firstName?: string; lastName?: string }) => `${u.firstName} ${u.lastName}`),
}));
-
describe('getPerformersForDropdown', () => {
- const t = (id: string, values?: Record) => intlMock.formatMessage({ id }, values);
- const MANAGER_LABEL = (step: string) => t('tasks.task-manager-of-step', { step });
+ const formatMsg = (id: string, values?: Record) => intlMock.formatMessage({ id }, values);
+ const MANAGER_LABEL = (step: string) => formatMsg('tasks.task-manager-of-step', { step });
- const makeTask = (overrides: Partial = {}): ITemplateTask => ({
- apiName: 'task-1',
- number: 1,
+ const makeTask = (overrides: Partial = {}) => makeTemplateTaskClient({
name: 'First Step',
- description: '',
- delay: null,
- rawDueDate: null as any,
- requireCompletionByAll: false,
- skipForStarter: false,
- rawPerformers: [],
- fields: [],
- uuid: 'uuid-1',
- conditions: [],
- checklists: [],
- revertTask: null,
- ancestors: [],
...overrides,
});
@@ -96,8 +85,8 @@ describe('getPerformersForDropdown', () => {
});
it('generates unique values for user and group with the same id', () => {
- const users = [{ id: 5, firstName: 'John', lastName: 'Doe' } as any];
- const groups = [{ id: 5, name: 'Team A', type: 'regular' } as any];
+ const users: TUserListItem[] = [makeUser({ id: 5, firstName: 'John', lastName: 'Doe' })];
+ const groups: IGroup[] = [makeGroup({ id: 5, name: 'Team A' })];
const result = getPerformersForDropdown(users, groups, [], intlMock.formatMessage);
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getTaskVariables.test.ts b/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getTaskVariables.test.ts
index e4eb5e412..3e7e1a38e 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getTaskVariables.test.ts
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getTaskVariables.test.ts
@@ -1,35 +1,39 @@
-import { EExtraFieldType, IKickoff, ITemplateTask } from '../../../../../types/template';
+import { createElement } from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { EExtraFieldType, IKickoffClient, ITemplateTaskClient } from '../../../../../types/template';
+import { IFieldsetRuntime } from '../../../../../types/fieldset';
+import { makeExtraField } from '../../../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime, makeFieldsetBindingClient, makeFieldsetField } from '../../../../../__stubs__/fieldsets.factory';
import { createEmptyTaskDueDate } from '../../../../../utils/dueDate/createEmptyTaskDueDate';
import { TTaskVariable } from '../../../types';
import {
+ getKickoffVariables,
getTaskVariables,
getSystemVariables,
getVariables,
getSingleLineVariables,
isSystemVariable,
+ useWorkflowNameVariables,
WORKFLOW_STARTER_VARIABLE_API_NAME,
WORKFLOW_STARTER_VARIABLE_TITLE,
SYSTEM_VARIABLE_SUBTITLE,
} from '../getTaskVariables';
-const mockKikoff: IKickoff = {
+const mockKikoff: IKickoffClient = {
description: 'Kickoff description',
fields: [
- {
+ makeExtraField({
name: 'Client name',
- type: EExtraFieldType.String,
- isRequired: false,
description: 'Enter client name',
apiName: 'client-name-3967',
- selections: [],
order: 1,
- userId: null,
- groupId: null,
- },
+ }),
],
+ fieldsets: [],
};
-const mockTask1: ITemplateTask = {
+const mockTask1: ITemplateTaskClient = {
id: 3048,
apiName: 'task-1',
name: 'Task 1',
@@ -39,17 +43,11 @@ const mockTask1: ITemplateTask = {
requireCompletionByAll: true,
skipForStarter: false,
fields: [
- {
+ makeExtraField({
name: 'Large Text Field',
type: EExtraFieldType.Text,
- isRequired: false,
- description: '',
apiName: 'large-text-field-8622',
- selections: [],
- order: 0,
- userId: null,
- groupId: null,
- },
+ }),
],
delay: null,
rawDueDate: createEmptyTaskDueDate(),
@@ -58,9 +56,10 @@ const mockTask1: ITemplateTask = {
checklists: [],
revertTask: null,
ancestors: [],
+ fieldsets: [],
};
-const mockTask2: ITemplateTask = {
+const mockTask2: ITemplateTaskClient = {
id: 3049,
apiName: 'task-2',
name: 'Task2',
@@ -70,17 +69,13 @@ const mockTask2: ITemplateTask = {
requireCompletionByAll: false,
skipForStarter: false,
fields: [
- {
+ makeExtraField({
name: 'Reasons',
type: EExtraFieldType.Text,
isRequired: true,
description: 'Enter reasons of client requesting',
apiName: 'reasons-3969',
- selections: [],
- order: 0,
- userId: null,
- groupId: null,
- },
+ }),
],
delay: null,
rawDueDate: createEmptyTaskDueDate(),
@@ -89,11 +84,45 @@ const mockTask2: ITemplateTask = {
checklists: [],
revertTask: null,
ancestors: ['task-1'],
+ fieldsets: [],
};
+const mockFieldsetData: IFieldsetRuntime = makeFieldsetRuntime({
+ apiNameBinding: 'fs-99',
+ name: 'Extra Set',
+ fields: [
+ makeExtraField({
+ name: 'Assignee',
+ type: EExtraFieldType.User,
+ apiName: 'assignee-fs',
+ }),
+ makeExtraField({
+ name: 'Kickoff date',
+ type: EExtraFieldType.Date,
+ apiName: 'kickoff-date-fs',
+ order: 1,
+ }),
+ ],
+});
+
+const mockBindingFields = [
+ makeFieldsetField({
+ name: 'Assignee',
+ type: EExtraFieldType.User,
+ apiName: 'assignee-fs',
+ }),
+ makeFieldsetField({
+ name: 'Kickoff date',
+ type: EExtraFieldType.Date,
+ apiName: 'kickoff-date-fs',
+ order: 1,
+ }),
+];
+
+
describe('getTaskVariables', () => {
it("correctly gets 1st task's variables", () => {
- const tasks: ITemplateTask[] = [mockTask1, mockTask2];
+ const tasks: ITemplateTaskClient[] = [mockTask1, mockTask2];
const expectedFirstTaskVariables: TTaskVariable[] = [
{
apiName: 'client-name-3967',
@@ -112,7 +141,7 @@ describe('getTaskVariables', () => {
});
it("correctly gets 2nd task's variables", () => {
- const tasks: ITemplateTask[] = [mockTask1, mockTask2];
+ const tasks: ITemplateTaskClient[] = [mockTask1, mockTask2];
const expectedSecondTaskVariables: TTaskVariable[] = [
{
apiName: 'client-name-3967',
@@ -137,6 +166,99 @@ describe('getTaskVariables', () => {
expect(actualResult).toStrictEqual(expectedResult);
});
+
+ it('appends variables from selected task fieldsets with combined subtitles', () => {
+ const taskWithFieldset: ITemplateTaskClient = {
+ ...mockTask1,
+ fieldsets: [makeFieldsetBindingClient({
+ apiNameBinding: mockFieldsetData.apiNameBinding,
+ name: mockFieldsetData.name,
+ fields: mockBindingFields,
+ })],
+ };
+ const tasks: ITemplateTaskClient[] = [taskWithFieldset, mockTask2];
+ const actualResult = getTaskVariables(mockKikoff, tasks, mockTask2);
+
+ expect(actualResult.map((v) => v.apiName)).toEqual([
+ 'client-name-3967',
+ 'large-text-field-8622',
+ 'assignee-fs',
+ 'kickoff-date-fs',
+ ]);
+ expect(actualResult.find((v) => v.apiName === 'assignee-fs')).toMatchObject({
+ title: 'Assignee',
+ subtitle: 'Task 1 · Extra Set',
+ type: EExtraFieldType.User,
+ });
+ });
+
+ it('expands task-fieldset with inline `fields` without using the catalog', () => {
+ const inlineFieldset = makeFieldsetBindingClient({
+ apiNameBinding: 'fs-inline',
+ name: 'Inline Set',
+ fields: [
+ makeFieldsetField({
+ apiName: 'inline-field-1',
+ name: 'Inline Field',
+ }),
+ ],
+ });
+ const taskWithInline: ITemplateTaskClient = {
+ ...mockTask1,
+ fieldsets: [inlineFieldset],
+ };
+ const tasks: ITemplateTaskClient[] = [taskWithInline, mockTask2];
+
+ const actualResult = getTaskVariables(mockKikoff, tasks, mockTask2);
+
+ const inlineVar = actualResult.find((v) => v.apiName === 'inline-field-1');
+ expect(inlineVar).toBeDefined();
+ expect(inlineVar).toMatchObject({
+ title: 'Inline Field',
+ subtitle: 'Task 1 · Inline Set',
+ type: EExtraFieldType.String,
+ });
+ });
+});
+
+describe('getKickoffVariables with fieldsets', () => {
+ it('adds kickoff fieldset fields after regular kickoff fields', () => {
+ const kickoff: IKickoffClient = {
+ ...mockKikoff,
+ fieldsets: [makeFieldsetBindingClient({
+ apiNameBinding: mockFieldsetData.apiNameBinding,
+ name: mockFieldsetData.name,
+ fields: mockBindingFields,
+ })],
+ };
+ const vars = getKickoffVariables(kickoff);
+
+ expect(vars.map((v) => v.apiName)).toEqual(['client-name-3967', 'assignee-fs', 'kickoff-date-fs']);
+ expect(vars[1]).toMatchObject({
+ subtitle: 'Kick-off form · Extra Set',
+ title: 'Assignee',
+ type: EExtraFieldType.User,
+ });
+ });
+
+ it('skips fieldset missing from catalog without crashing or producing phantom options', () => {
+ const kickoff: IKickoffClient = {
+ ...mockKikoff,
+ fieldsets: [
+ makeFieldsetBindingClient({ apiNameBinding: 'missing-fs' }),
+ makeFieldsetBindingClient({
+ apiNameBinding: mockFieldsetData.apiNameBinding,
+ name: mockFieldsetData.name,
+ fields: mockBindingFields,
+ order: 1,
+ }),
+ ],
+ };
+
+ const vars = getKickoffVariables(kickoff);
+
+ expect(vars.map((v) => v.apiName)).toEqual(['client-name-3967', 'assignee-fs', 'kickoff-date-fs']);
+ });
});
describe('getSystemVariables', () => {
@@ -182,8 +304,8 @@ describe('getVariables', () => {
expect(result).toHaveLength(2);
});
- it('returns only system variables when no kickoff or tasks provided', () => {
- const result = getVariables({});
+ it('returns only system variables when kickoff and tasks are empty', () => {
+ const result = getVariables({ kickoff: { fields: [], fieldsets: [] }, tasks: [] });
expect(result).toHaveLength(1);
expect(result[0].apiName).toBe(WORKFLOW_STARTER_VARIABLE_API_NAME);
@@ -234,3 +356,80 @@ describe('getSingleLineVariables', () => {
expect(getSingleLineVariables(variables)).toHaveLength(6);
});
});
+
+describe('useWorkflowNameVariables', () => {
+ function TestWrapper({
+ kickoff,
+ }: {
+ kickoff?: Parameters[0];
+ }) {
+ const vars = useWorkflowNameVariables(kickoff);
+ return createElement(
+ 'div',
+ null,
+ vars.map((v) =>
+ createElement(
+ 'span',
+ { key: v.apiName, 'data-testid': `var-${v.apiName}`, 'data-type': v.type },
+ v.title,
+ ),
+ ),
+ );
+ }
+
+ it('includes 4 system variables plus single-line kickoff and fieldset fields, filters out multi-line types', () => {
+
+ const kickoff: IKickoffClient = {
+ description: '',
+ fields: [
+ makeExtraField({
+ apiName: 'kickoff-string',
+ name: 'Kickoff String',
+ }),
+ makeExtraField({
+ apiName: 'kickoff-text',
+ name: 'Kickoff Text',
+ type: EExtraFieldType.Text,
+ order: 1,
+ }),
+ ],
+ fieldsets: [makeFieldsetBindingClient({
+ apiNameBinding: 'fs-name',
+ fields: [
+ makeFieldsetField({
+ apiName: 'fs-date',
+ name: 'FS Date',
+ type: EExtraFieldType.Date,
+ }),
+ makeFieldsetField({
+ apiName: 'fs-number',
+ name: 'FS Number',
+ type: EExtraFieldType.Number,
+ order: 1,
+ }),
+ makeFieldsetField({
+ apiName: 'fs-text',
+ name: 'FS Text',
+ type: EExtraFieldType.Text,
+ order: 2,
+ }),
+ ],
+ })],
+ };
+
+ render(createElement(TestWrapper, { kickoff }));
+
+ expect(screen.getByTestId('var-date')).toBeInTheDocument();
+ expect(screen.getByTestId('var-template-name')).toBeInTheDocument();
+ expect(screen.getByTestId('var-workflow-id')).toBeInTheDocument();
+ expect(screen.getByTestId('var-workflow-starter')).toBeInTheDocument();
+
+ expect(screen.getByTestId('var-kickoff-string')).toBeInTheDocument();
+
+ expect(screen.getByTestId('var-fs-date')).toBeInTheDocument();
+ expect(screen.getByTestId('var-fs-number')).toBeInTheDocument();
+
+ expect(screen.queryByTestId('var-kickoff-text')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('var-fs-text')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getVariableFromField.test.ts b/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getVariableFromField.test.ts
index f6eb959bd..c65a412fd 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getVariableFromField.test.ts
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/utils/__tests__/getVariableFromField.test.ts
@@ -1,15 +1,11 @@
-//
import { getVariableFromField } from '../getTaskVariables';
+import { makeExtraField } from '../../../../../__stubs__/fields.factory';
import { EExtraFieldType, IExtraField } from '../../../../../types/template';
describe('getVariableFromField', () => {
- const makeField = (overrides: Partial = {}): IExtraField => ({
- apiName: 'field-1',
+ const makeField = (overrides: Partial = {}) => makeExtraField({
name: 'Test Field',
type: EExtraFieldType.Checkbox,
- order: 0,
- userId: null,
- groupId: null,
...overrides,
});
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPerformersForDropdown.ts b/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPerformersForDropdown.ts
index 9fb656f3f..228f77ee2 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPerformersForDropdown.ts
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPerformersForDropdown.ts
@@ -1,5 +1,5 @@
import { IntlShape } from 'react-intl';
-import { EExtraFieldType, ETaskPerformerType, ITemplateTask, ITemplateTaskPerformer } from '../../../../types/template';
+import { EExtraFieldType, ETaskPerformerType, ITemplateTaskClient, ITemplateTaskPerformer } from '../../../../types/template';
import { TUserListItem } from '../../../../types/user';
import { getUserFullName } from '../../../../utils/users';
import { TTaskVariable } from '../../types';
@@ -11,8 +11,8 @@ export function getPerformersForDropdown(
groups: IGroup[],
variables: TTaskVariable[],
formatMessage: IntlShape['formatMessage'],
- currentTask?: ITemplateTask,
- tasks?: ITemplateTask[],
+ currentTask?: ITemplateTaskClient,
+ tasks?: ITemplateTaskClient[],
): TUsersDropdownOption[] {
const userPeformers: (Omit & TUsersDropdownOption)[] = users.map((user) => ({
id: user.id,
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPreviousTask.ts b/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPreviousTask.ts
index 4005d1789..3b6a2a8e8 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPreviousTask.ts
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPreviousTask.ts
@@ -1,6 +1,6 @@
-import { ITemplateTask } from "../../../../types/template";
+import { ITemplateTaskClient } from "../../../../types/template";
-export function getPreviousTask(currentTask: ITemplateTask, tasks: ITemplateTask[]): ITemplateTask | null {
+export function getPreviousTask(currentTask: ITemplateTaskClient, tasks: ITemplateTaskClient[]): ITemplateTaskClient | null {
if (currentTask.number === 1) {
return null;
}
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPreviousTasks.ts b/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPreviousTasks.ts
index 5df07b42b..123b36cd1 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPreviousTasks.ts
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/utils/getPreviousTasks.ts
@@ -1,5 +1,5 @@
-import { ITemplateTask } from "../../../../types/template";
+import { ITemplateTaskClient } from "../../../../types/template";
-export function getPreviousTasks(currentTask: ITemplateTask, tasks: ITemplateTask[]) {
+export function getPreviousTasks(currentTask: ITemplateTaskClient, tasks: ITemplateTaskClient[]) {
return tasks.filter(task => task.number < currentTask.number);
}
diff --git a/frontend/src/public/components/TemplateEdit/TaskForm/utils/getTaskVariables.tsx b/frontend/src/public/components/TemplateEdit/TaskForm/utils/getTaskVariables.tsx
index f6f36a21c..052e8ae0d 100644
--- a/frontend/src/public/components/TemplateEdit/TaskForm/utils/getTaskVariables.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskForm/utils/getTaskVariables.tsx
@@ -1,8 +1,17 @@
+/* eslint-disable max-len */
import * as React from 'react';
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
-import { IKickoff, IExtraField, ITemplateTask, EExtraFieldType } from '../../../../types/template';
+import {
+ IKickoffClient,
+ IExtraField,
+ ITemplateTaskClient,
+ IFieldsetBindingClient,
+ EExtraFieldType,
+ TTemplateFieldFieldset,
+} from '../../../../types/template';
+import { IFieldsetField, IFieldsetRuntime } from '../../../../types/fieldset';
import { normalizeSelections } from '../../utils/normalizeSelections';
import { TTaskVariable } from '../../types';
import { IGetLocalizedSystemVariableParams, IGetLocalizedSystemVariableReturn } from './types';
@@ -37,11 +46,31 @@ export function getLocalizedSystemVariable({
}
type TGetVariablesParam = {
- kickoff?: Pick;
- tasks?: (Pick & { name?: ITemplateTask['name'] })[];
+ kickoff: Pick & { fieldsets?: (IFieldsetBindingClient | TTemplateFieldFieldset)[] };
+ tasks: (Pick & {
+ name?: ITemplateTaskClient['name'];
+ fieldsets?: (IFieldsetBindingClient | TTemplateFieldFieldset)[];
+ })[];
templateId?: number;
};
+function getVariablesFromSelectedFieldsets(
+ fieldsets: (IFieldsetBindingClient | TTemplateFieldFieldset)[] | undefined,
+ getSubtitle: (fieldset: Pick) => string,
+ getRichSubtitle: (fieldset: Pick) => React.ReactNode,
+): TTaskVariable[] {
+ if (!fieldsets?.length) {
+ return [];
+ }
+
+ return fieldsets.flatMap((taskFieldset) => {
+ const subtitle = getSubtitle(taskFieldset);
+ const richSubtitle = getRichSubtitle(taskFieldset);
+
+ return taskFieldset.fields.map((field) => getVariableFromField(field, subtitle, richSubtitle));
+ });
+}
+
/**
* This function duplicates the system variables defined in useWorkflowNameVariables hook
* with hardcoded (non-localized) titles. It exists because getVariables() is called from
@@ -60,40 +89,64 @@ export function getSystemVariables(): TTaskVariable[] {
];
}
-export function getFieldVariables({ kickoff, tasks, templateId }: TGetVariablesParam): TTaskVariable[] {
- const tasksVariables = tasks
- ?.reduce((acc, task) => {
- const fieldsWithTasks = task.fields.map((field) => [task, field] as const);
-
- return [...acc, ...fieldsWithTasks];
- }, [])
- .map(([task, field]) => {
+export function getFieldVariables({
+ kickoff,
+ tasks,
+ templateId,
+}: TGetVariablesParam): TTaskVariable[] {
+ const tasksVariables =
+ tasks.flatMap((task) => {
const taskName = task.name || '';
+ const richTaskName = templateId ? (
+
+ ) : (
+ taskName
+ );
+
+ const fromTaskFields = task.fields.map((field) =>
+ getVariableFromField(field, taskName, richTaskName),
+ );
- return getVariableFromField(
- field,
- taskName,
- templateId ? : taskName,
+ const fromTaskFieldsets = getVariablesFromSelectedFieldsets(
+ task.fieldsets,
+ (fieldset) => `${taskName} · ${fieldset.name}`,
+ (fieldset) => (
+ <>
+ {richTaskName}
+ {` · ${fieldset.name}`}
+ >
+ ),
);
+
+ return [...fromTaskFields, ...fromTaskFieldsets];
});
const kickoffVariables = getKickoffVariables(kickoff);
- return [...(kickoffVariables || []), ...(tasksVariables || [])];
+ return [...kickoffVariables, ...tasksVariables];
}
export function getVariables(params: TGetVariablesParam): TTaskVariable[] {
return [...getSystemVariables(), ...getFieldVariables(params)];
}
-export function getKickoffVariables(kickoff?: Pick) {
- return kickoff?.fields.map((field) => getVariableFromField(field, 'Kick-off form')) || [];
+export function getKickoffVariables(
+ kickoff?: Pick & { fieldsets?: (IFieldsetBindingClient | TTemplateFieldFieldset)[] },
+) {
+ const fromFields = kickoff?.fields.map((field) => getVariableFromField(field, 'Kick-off form')) ?? [];
+ const fromFieldsets = getVariablesFromSelectedFieldsets(
+ kickoff?.fieldsets,
+ (fieldset) => `Kick-off form · ${fieldset.name}`,
+ (fieldset) => `Kick-off form · ${fieldset.name}`,
+ );
+
+ return [...fromFields, ...fromFieldsets];
}
export function getTaskVariables(
- kickoff: IKickoff,
- tasks: ITemplateTask[],
- currentTask: ITemplateTask,
+ kickoff: IKickoffClient,
+ tasks: ITemplateTaskClient[],
+ currentTask: ITemplateTaskClient,
templateId?: number,
): TTaskVariable[] {
return getFieldVariables({
@@ -104,7 +157,7 @@ export function getTaskVariables(
}
export function getVariableFromField(
- field: IExtraField,
+ field: IExtraField | IFieldsetField,
subtitle: string,
richSubtitle?: React.ReactNode,
): TTaskVariable {
@@ -113,7 +166,7 @@ export function getVariableFromField(
title: field.name,
subtitle,
richSubtitle: richSubtitle || subtitle,
- type: field.type,
+ type: field.type as EExtraFieldType,
selections: normalizeSelections(field.selections),
...(field.dataset && { datasetId: field.dataset }),
};
@@ -135,7 +188,9 @@ export const getSingleLineVariables = (variables: TTaskVariable[]) => {
return variables.filter((variable) => SINGLE_LINE_VARIBALE_TIPES.includes(variable.type));
};
-export const useWorkflowNameVariables = (kickoff?: Pick) => {
+export const useWorkflowNameVariables = (
+ kickoff?: Pick,
+) => {
const { formatMessage } = useIntl();
const CUSTOM_VARIABLES: TTaskVariable[] = useMemo(
@@ -172,7 +227,13 @@ export const useWorkflowNameVariables = (kickoff?: Pick) =>
[formatMessage],
);
- const kickoffSingleLineVriables = getSingleLineVariables(getKickoffVariables(kickoff));
+ const kickoffSingleLineVriables = useMemo(
+ () => getSingleLineVariables(getKickoffVariables(kickoff)),
+ [kickoff],
+ );
- return [...CUSTOM_VARIABLES, ...kickoffSingleLineVriables];
+ return useMemo(() => [...CUSTOM_VARIABLES, ...kickoffSingleLineVriables], [
+ CUSTOM_VARIABLES,
+ kickoffSingleLineVriables,
+ ]);
};
diff --git a/frontend/src/public/components/TemplateEdit/TaskItem/TaskItem.tsx b/frontend/src/public/components/TemplateEdit/TaskItem/TaskItem.tsx
index 601e9e145..6db4c3ecb 100644
--- a/frontend/src/public/components/TemplateEdit/TaskItem/TaskItem.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskItem/TaskItem.tsx
@@ -2,12 +2,13 @@
import React from 'react';
import { useSelector } from 'react-redux';
-import { getKickoff, getTemplateTasks } from '../../../redux/selectors/template';
+import { getKickoff, getTemplateData, getTemplateTasks } from '../../../redux/selectors/template';
import { ExtraFieldsLabels } from '../ExtraFields/utils/ExtraFieldsLabels';
import { getVariables } from '../TaskForm/utils/getTaskVariables';
+import { FieldsetOutputsPreview } from '../FieldsetOutputsPreview/FieldsetOutputsPreview';
-import { ITemplateTask } from '../../../types/template';
+import { ITemplateTaskClient } from '../../../types/template';
import { ETaskFormParts } from '../types';
import { TaskItemUsers } from './TaskItemUsers';
@@ -19,7 +20,7 @@ import styles from '../TemplateEdit.css';
import { TaskRenderConditionsInfo } from '../TaskRenderConditionsInfo';
export interface ITaskItemProps {
- task: ITemplateTask;
+ task: ITemplateTaskClient;
toggleIsOpenTask(): void;
setScrollTarget(target: ETaskFormParts): void;
}
@@ -29,7 +30,12 @@ export const TaskItem = ({ task, toggleIsOpenTask, setScrollTarget }: ITaskItemP
const kickoff = useSelector(getKickoff);
const tasks = useSelector(getTemplateTasks);
- const allVariables = getVariables({ kickoff, tasks });
+ const template = useSelector(getTemplateData);
+ const allVariables = getVariables({
+ kickoff,
+ tasks,
+ templateId: template.id,
+ });
const handleClickOnLabel = (taskFormParts: ETaskFormParts) => {
setScrollTarget(taskFormParts);
@@ -58,6 +64,10 @@ export const TaskItem = ({ task, toggleIsOpenTask, setScrollTarget }: ITaskItemP
handleClickOnLabel(ETaskFormParts.Fields)} />
+ handleClickOnLabel(ETaskFormParts.Fields)}
+ />
{TaskRenderConditionsInfo({
task,
onClick: () => handleClickOnLabel(ETaskFormParts.StartsAfter),
diff --git a/frontend/src/public/components/TemplateEdit/TaskItem/TaskItemUsers/TaskItemUsers.tsx b/frontend/src/public/components/TemplateEdit/TaskItem/TaskItemUsers/TaskItemUsers.tsx
index d88560b33..c0abea4f8 100644
--- a/frontend/src/public/components/TemplateEdit/TaskItem/TaskItemUsers/TaskItemUsers.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskItem/TaskItemUsers/TaskItemUsers.tsx
@@ -4,13 +4,13 @@ import * as React from 'react';
import { Avatar, TAvatarUser } from '../../../UI/Avatar';
import { isArrayWithItems } from '../../../../utils/helpers';
-import { ETaskPerformerType, ITemplateTask, ITemplateTaskPerformer } from '../../../../types/template';
+import { ETaskPerformerType, ITemplateTaskClient, ITemplateTaskPerformer } from '../../../../types/template';
import { UserData } from '../../../UserData';
import styles from './TaskItemUsers.css';
export interface ITaskItemUsersProps {
- task: ITemplateTask;
+ task: ITemplateTaskClient;
maxUsers?: number;
onClick(): void;
}
diff --git a/frontend/src/public/components/TemplateEdit/TaskItem/__tests__/TaskItem.test.tsx b/frontend/src/public/components/TemplateEdit/TaskItem/__tests__/TaskItem.test.tsx
new file mode 100644
index 000000000..2e26dd303
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskItem/__tests__/TaskItem.test.tsx
@@ -0,0 +1,107 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ITemplateTaskClient } from '../../../../types/template';
+import { makeFieldsetBindingClient } from '../../../../__stubs__/fieldsets.factory';
+import { makeTemplateTaskClient } from '../../../../__stubs__/templates.factory';
+import { ETaskFormParts } from '../../types';
+
+jest.mock('react', () => {
+ const actual = jest.requireActual('react');
+ return { ...actual, default: actual, __esModule: true };
+});
+
+jest.mock('../../../../redux/selectors/template', () => ({
+ getKickoff: jest.fn(),
+ getTemplateTasks: jest.fn(),
+ getTemplateData: jest.fn(),
+}));
+
+jest.mock('../../TaskForm/utils/getTaskVariables', () => ({
+ getVariables: jest.fn(() => []),
+}));
+
+jest.mock('../../FieldsetOutputsPreview/FieldsetOutputsPreview', () => ({
+ FieldsetOutputsPreview: (props: {
+ fieldsets: Array<{ apiNameBinding: string }>;
+ onGroupClick?: () => void;
+ }) =>
+ React.createElement(
+ 'div',
+ null,
+ props.fieldsets.map((fieldset) =>
+ React.createElement(
+ 'button',
+ {
+ key: fieldset.apiNameBinding,
+ type: 'button',
+ onClick: props.onGroupClick,
+ },
+ fieldset.apiNameBinding,
+ ),
+ ),
+ ),
+}));
+
+jest.mock('../../ExtraFields/utils/ExtraFieldsLabels', () => ({
+ ExtraFieldsLabels: () => null,
+}));
+jest.mock('../TaskItemUsers', () => ({
+ TaskItemUsers: () => null,
+}));
+jest.mock('../../../RichText', () => ({
+ RichText: () => null,
+}));
+jest.mock('../../TaskRenderDueInInfo', () => ({
+ TaskRenderDueIn: jest.fn(() => null),
+}));
+jest.mock('../../TaskRenderConditionsInfo', () => ({
+ TaskRenderConditionsInfo: jest.fn(() => null),
+}));
+jest.mock('../../TaskRenderReturnInfo', () => ({
+ TaskRenderReturnInfo: jest.fn(() => null),
+}));
+
+import { TaskItem } from '../TaskItem';
+import {
+ getKickoff,
+ getTemplateTasks,
+ getTemplateData,
+} from '../../../../redux/selectors/template';
+
+describe('TaskItem', () => {
+ const makeTask = (overrides: Partial = {}) => makeTemplateTaskClient({
+ name: 'Task 1',
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1' })],
+ ...overrides,
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (getKickoff as jest.Mock).mockReturnValue({ fields: [], fieldsets: [] });
+ (getTemplateTasks as jest.Mock).mockReturnValue([]);
+ (getTemplateData as jest.Mock).mockReturnValue({ id: 1 });
+ });
+
+ describe('click on fieldset preview', () => {
+ it('sets scrollTarget to Fields (not Fieldsets) and opens the task form', () => {
+ const setScrollTarget = jest.fn();
+ const toggleIsOpenTask = jest.fn();
+
+ render(
+ React.createElement(TaskItem, {
+ task: makeTask(),
+ setScrollTarget,
+ toggleIsOpenTask,
+ }),
+ );
+
+ userEvent.click(screen.getByRole('button', { name: 'fs-1' }));
+
+ expect(setScrollTarget).toHaveBeenCalledTimes(1);
+ expect(setScrollTarget).toHaveBeenCalledWith(ETaskFormParts.Fields);
+ expect(toggleIsOpenTask).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/TaskMenu/TaskMenu.tsx b/frontend/src/public/components/TemplateEdit/TaskMenu/TaskMenu.tsx
index a051ab314..f30b59af0 100644
--- a/frontend/src/public/components/TemplateEdit/TaskMenu/TaskMenu.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskMenu/TaskMenu.tsx
@@ -1,8 +1,8 @@
import * as React from 'react';
-import * as classnames from 'classnames';
+import classnames from 'classnames';
import { useIntl } from 'react-intl';
-import { ITemplateTask } from '../../../types/template';
+import { ITemplateTaskClient } from '../../../types/template';
import { ArrowDownIcon, ArrowUpIcon, ClockIcon, EditIcon, MoreIcon, TrashIcon, UnionIcon } from '../../icons';
import { Dropdown, TDropdownOption } from '../../UI';
import { TTaskFormPart } from '../types';
@@ -11,14 +11,14 @@ import styles from './TaskMenu.css';
interface ITaskMenuProps {
tasksCount?: number;
- task: ITemplateTask;
+ task: ITemplateTaskClient;
isTaskOpen: boolean;
removeTask(): void;
cloneTask(): void;
addDelay(): void;
moveTaskDown(): void;
moveTaskUp(): void;
- toggleIsOpenTask(task: ITemplateTask): void;
+ toggleIsOpenTask(task: ITemplateTaskClient): void;
setScrollTarget(defaultOpenTaskFormPart: TTaskFormPart): void;
}
diff --git a/frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetFlowRowDropdown.tsx b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetFlowRowDropdown.tsx
new file mode 100644
index 000000000..17e0e2624
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetFlowRowDropdown.tsx
@@ -0,0 +1,91 @@
+import * as React from 'react';
+import { useIntl } from 'react-intl';
+import classnames from 'classnames';
+
+import { ArrowDownIcon, ArrowUpIcon, BurgerIcon, DropdownCrossIcon, TrashIcon } from '../../icons';
+import { Dropdown, type TDropdownOption } from '../../UI';
+
+import styles from '../KickoffRedux/KickoffRedux.css';
+
+export interface IFieldsetFlowRowDropdownProps {
+ headerTitle: string;
+ isFirstItem: boolean;
+ isLastItem: boolean;
+ onMoveUp(): void;
+ onMoveDown(): void;
+ onRemove(): void;
+ onOpenDetail?: () => void;
+}
+
+export const FieldsetFlowRowDropdown = ({
+ headerTitle,
+ isFirstItem,
+ isLastItem,
+ onMoveUp,
+ onMoveDown,
+ onRemove,
+ onOpenDetail,
+}: IFieldsetFlowRowDropdownProps) => {
+ const { formatMessage } = useIntl();
+
+ const handleOptionClick = (handler: () => void) => (closeDropdown: () => void) => {
+ closeDropdown();
+ handler();
+ };
+
+ const openDetailOption: TDropdownOption | null = onOpenDetail
+ ? {
+ mapKey: 'fieldset-flow-open-detail',
+ label: formatMessage({ id: 'fieldsets.title' }),
+ onClick: handleOptionClick(onOpenDetail),
+ }
+ : null;
+
+ const menuOptions: TDropdownOption[] = [
+ {
+ label: formatMessage({ id: 'template.move-up' }),
+ onClick: handleOptionClick(onMoveUp),
+ Icon: ArrowUpIcon,
+ isHidden: isFirstItem,
+ },
+ {
+ label: formatMessage({ id: 'template.move-down' }),
+ onClick: handleOptionClick(onMoveDown),
+ Icon: ArrowDownIcon,
+ isHidden: isLastItem,
+ },
+ ...(openDetailOption ? [openDetailOption] : []),
+ {
+ label: formatMessage({ id: 'user.avatar.delete' }),
+ onClick: handleOptionClick(onRemove),
+ Icon: TrashIcon,
+ withConfirmation: true,
+ withUpperline: true,
+ color: 'red',
+ },
+ ];
+
+ return (
+ {
+ const DropdownIcon = isOpen ? DropdownCrossIcon : BurgerIcon;
+
+ return (
+
+
+
+ );
+ }}
+ options={menuOptions}
+ renderMenuContent={(renderedOptions) => (
+ <>
+ {headerTitle ? {headerTitle}
: null}
+ {renderedOptions}
+ >
+ )}
+ menuClassName={styles['dropdown-menu']}
+ />
+ );
+};
diff --git a/frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetIconPicker.css b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetIconPicker.css
new file mode 100644
index 000000000..6ceb68d16
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetIconPicker.css
@@ -0,0 +1,63 @@
+.fieldset-picker__option {
+ padding: 0.8rem 1.6rem;
+ display: flex;
+ width: 100%;
+ font-family: Nunito, sans-serif;
+ font-size: 1.4rem;
+ line-height: 2rem;
+ text-align: left;
+ cursor: pointer;
+ color: var(--pneumatic-color-black100);
+ background: none;
+ border: none;
+ transition: background-color 0.1s ease;
+ align-items: center;
+}
+
+.fieldset-picker__option:hover {
+ background-color: var(--pneumatic-color-beige);
+}
+
+.fieldset-picker__checkbox {
+ margin-right: 1.2rem;
+ width: 1.6rem;
+ height: 1.6rem;
+ flex-shrink: 0;
+ accent-color: var(--pneumatic-color-link);
+}
+
+.fieldset-picker__option-info {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.fieldset-picker__option-name {
+ overflow: hidden;
+ font-weight: 600;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.fieldset-picker__option-meta {
+ font-size: 1.2rem;
+ line-height: 1.6rem;
+ color: var(--pneumatic-color-black48);
+}
+
+.fieldset-picker__empty {
+ padding: 1.6rem;
+ font-family: Nunito, sans-serif;
+ font-size: 1.3rem;
+ line-height: 2rem;
+ text-align: center;
+ color: var(--pneumatic-color-black48);
+}
+
+.fieldset-picker__loading {
+ padding: 1.6rem;
+ font-family: Nunito, sans-serif;
+ font-size: 1.3rem;
+ text-align: center;
+ color: var(--pneumatic-color-black48);
+}
diff --git a/frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetIconPicker.tsx b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetIconPicker.tsx
new file mode 100644
index 000000000..bdbb61d08
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetIconPicker.tsx
@@ -0,0 +1,161 @@
+import * as React from 'react';
+import { useCallback, useMemo, useRef } from 'react';
+import { useIntl } from 'react-intl';
+import { useSelector } from 'react-redux';
+import classNames from 'classnames';
+
+import { IFieldsetCatalogItem } from '../../../types/fieldset';
+import { getFieldsetsCatalogItems } from '../../../redux/selectors/fieldsets';
+import { CustomTooltip } from '../../UI/CustomTooltip';
+import { Dropdown, type TDropdownOption } from '../../UI';
+import { FieldsetIcon } from '../../icons/FieldsetIcon';
+
+import pickerStyles from './FieldsetIconPicker.css';
+import kickoffStyles from '../KickoffRedux/KickoffRedux.css';
+import flowStyles from '../OutputForm/OutputForm.css';
+
+interface IFieldsetCatalogPickerRow {
+ id: number;
+ apiName: string;
+ name: string;
+ fieldsCount: number;
+ rulesCount: number;
+ order: number;
+}
+
+export interface IFieldsetIconPickerProps {
+ fieldsetsCatalogLoading: boolean;
+ selectedFieldsetIds: number[];
+ onSelectFieldset: (fieldsetCatalogItem: IFieldsetCatalogItem) => void;
+ onRemoveFieldset: (sharedFieldsetId: number) => void;
+}
+
+const buildCatalogPickerRows = (catalogFieldsetItems: IFieldsetCatalogItem[]): IFieldsetCatalogPickerRow[] => {
+ const rows = catalogFieldsetItems.map((catalogFieldsetItem) => ({
+ id: catalogFieldsetItem.id,
+ apiName: catalogFieldsetItem.apiName,
+ name: catalogFieldsetItem.name,
+ fieldsCount: catalogFieldsetItem.fields.length,
+ rulesCount: catalogFieldsetItem.rules.length,
+ order: catalogFieldsetItem.order,
+ }));
+ rows.sort((a, b) => {
+ if (a.order !== b.order) {
+ return a.order - b.order;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ return rows;
+};
+
+export const FieldsetIconPicker = ({
+ fieldsetsCatalogLoading,
+ selectedFieldsetIds,
+ onSelectFieldset,
+ onRemoveFieldset,
+}: IFieldsetIconPickerProps) => {
+ const { formatMessage } = useIntl();
+ const triggerRef = useRef(null);
+ const fieldsetsCatalogItems = useSelector(getFieldsetsCatalogItems);
+
+ const catalogRows = useMemo(() => buildCatalogPickerRows(fieldsetsCatalogItems), [fieldsetsCatalogItems]);
+ const showListLoading = fieldsetsCatalogLoading && catalogRows.length === 0;
+
+ const handleToggleFieldset = useCallback(
+ (sharedFieldsetId: number) => {
+ if (selectedFieldsetIds.includes(sharedFieldsetId)) {
+ onRemoveFieldset(sharedFieldsetId);
+ return;
+ }
+ const fieldsetCatalogItem = fieldsetsCatalogItems.find((item) => item.id === sharedFieldsetId);
+ if (fieldsetCatalogItem) {
+ onSelectFieldset(fieldsetCatalogItem);
+ }
+ },
+ [fieldsetsCatalogItems, onRemoveFieldset, onSelectFieldset, selectedFieldsetIds],
+ );
+
+ const catalogDropdownOption = useMemo((): TDropdownOption => {
+ return {
+ mapKey: 'template.fieldset-icon-picker.catalog',
+ label: '\u00a0',
+ customSubOption: (
+
+ {showListLoading ? (
+
+ {formatMessage({ id: 'template.fieldset-picker.loading', defaultMessage: 'Loading…' })}
+
+ ) : null}
+
+ {!showListLoading && catalogRows.length === 0 ? (
+
+ {formatMessage({ id: 'template.fieldset-picker.empty' })}
+
+ ) : null}
+
+ {!showListLoading
+ && catalogRows.map((row) => {
+ const isSelected = selectedFieldsetIds.includes(row.id);
+ return (
+
+ );
+ })}
+
+ ),
+ };
+ }, [
+ catalogRows,
+ formatMessage,
+ handleToggleFieldset,
+ selectedFieldsetIds,
+ showListLoading,
+ ]);
+
+ return (
+
+ (
+
+
+
+ )}
+ options={catalogDropdownOption}
+ />
+
+
+ );
+};
diff --git a/frontend/src/public/components/TemplateEdit/TaskOutputFlow/MergedOutputRows.tsx b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/MergedOutputRows.tsx
new file mode 100644
index 000000000..6ad111db0
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/MergedOutputRows.tsx
@@ -0,0 +1,106 @@
+import * as React from 'react';
+import classNames from 'classnames';
+
+import { EInputNameBackgroundColor } from '../../../types/workflow';
+import { EExtraFieldMode, IExtraField } from '../../../types/template';
+import { mapFieldsetBindingClientToRuntime } from '../../../utils/mapFieldsetBindingClientToRuntime';
+import { EFieldLabelPosition } from '../../../types/fieldset';
+import { isArrayWithItems } from '../../../utils/helpers';
+import { ExtraFieldIntl } from '../ExtraFields';
+import { ExtraFieldsLabels } from '../ExtraFields/utils/ExtraFieldsLabels';
+import { FieldsetFlowRowDropdown } from './FieldsetFlowRowDropdown';
+import { TMergedTaskOutputRow } from './mergeTaskOutputFlow';
+
+import styles from '../OutputForm/OutputForm.css';
+import kickoffStyles from '../KickoffRedux/KickoffRedux.css';
+
+export interface IMergedOutputRowsProps {
+ mergedRows: TMergedTaskOutputRow[];
+ onDeleteField: (apiName: string) => void;
+ onMoveRow: (index: number, direction: 'up' | 'down') => void;
+ onEditField: (apiName: string) => (changedProps: Partial) => void;
+ onRemoveFieldset: (sharedFieldsetId: number) => void;
+ datasetOptions: { value: string; label: string }[];
+ accountId: number;
+ formatMessage: (descriptor: { id: string }) => string;
+ innerRef?: React.RefObject;
+}
+
+export function MergedOutputRows({
+ mergedRows,
+ onDeleteField,
+ onMoveRow,
+ onEditField,
+ onRemoveFieldset,
+ datasetOptions,
+ accountId,
+ formatMessage,
+ innerRef,
+}: IMergedOutputRowsProps) {
+ return (
+ <>
+ {mergedRows.map((row, index) => {
+ const isFirst = index === 0;
+ const isLast = index === mergedRows.length - 1;
+ if (row.kind === 'field') {
+ return (
+ onDeleteField(row.field.apiName)}
+ moveFieldUp={isFirst ? undefined : () => onMoveRow(index, 'up')}
+ moveFieldDown={isLast ? undefined : () => onMoveRow(index, 'down')}
+ editField={onEditField(row.field.apiName)}
+ isDisabled={false}
+ innerRef={innerRef}
+ accountId={accountId}
+ mode={EExtraFieldMode.Kickoff}
+ showDropdown
+ datasetOptions={datasetOptions}
+ labelPosition={EFieldLabelPosition.Top}
+ />
+ );
+ }
+ const { name, fields, apiNameBinding } = mapFieldsetBindingClientToRuntime(row);
+ const fieldsetTitle = name || formatMessage({ id: 'tasks.task-fieldsets' });
+ return (
+
+
+
+ {formatMessage({ id: 'fieldsets.title' })}: {fieldsetTitle}
+
+ {isArrayWithItems(fields) &&
+
+
+
+ }
+
+
+ {/* sharedFieldsetId is synced with FieldsetIconPicker — do not replace with apiNameBinding */}
+ onMoveRow(index, 'up')}
+ onMoveDown={() => onMoveRow(index, 'down')}
+ onRemove={() => onRemoveFieldset(row.sharedFieldsetId)}
+ />
+
+
+ );
+ })}
+ >
+ );
+}
diff --git a/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/FieldsetFlowRowDropdown.test.tsx b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/FieldsetFlowRowDropdown.test.tsx
new file mode 100644
index 000000000..a895d52d4
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/FieldsetFlowRowDropdown.test.tsx
@@ -0,0 +1,201 @@
+import * as React from 'react';
+import { render } from '@testing-library/react';
+
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { Dropdown } from '../../../UI';
+import { TDropdownOption } from '../../../UI/Dropdown/Dropdown';
+import { FieldsetFlowRowDropdown, IFieldsetFlowRowDropdownProps } from '../FieldsetFlowRowDropdown';
+
+jest.mock('../../../UI', () => ({
+ Dropdown: jest.fn(() => null),
+}));
+
+jest.mock('../../../icons', () => ({
+ ArrowDownIcon: () => null,
+ ArrowUpIcon: () => null,
+ BurgerIcon: () => null,
+ DropdownCrossIcon: () => null,
+ TrashIcon: () => null,
+}));
+
+const formatMsg = (id: string) => intlMock.formatMessage({ id });
+
+const getDropdownOptions = (): TDropdownOption[] => {
+ const calls = (Dropdown as jest.Mock).mock.calls;
+ const options = calls[calls.length - 1][0].options;
+ return Array.isArray(options) ? options : [];
+};
+
+describe('FieldsetFlowRowDropdown', () => {
+ const baseProps: IFieldsetFlowRowDropdownProps = {
+ headerTitle: 'FS Title',
+ isFirstItem: false,
+ isLastItem: false,
+ onMoveUp: jest.fn(),
+ onMoveDown: jest.fn(),
+ onRemove: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('isFirstItem=true → move-up option has isHidden: true', () => {
+ render(React.createElement(FieldsetFlowRowDropdown, { ...baseProps, isFirstItem: true }));
+
+ expect(getDropdownOptions()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ label: formatMsg('template.move-up'),
+ isHidden: true,
+ }),
+ ]),
+ );
+ });
+
+ it('isFirstItem=false → move-up option has isHidden: false', () => {
+ render(React.createElement(FieldsetFlowRowDropdown, { ...baseProps, isFirstItem: false }));
+
+ expect(getDropdownOptions()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ label: formatMsg('template.move-up'),
+ isHidden: false,
+ }),
+ ]),
+ );
+ });
+
+ it('isLastItem=true → move-down option has isHidden: true', () => {
+ render(React.createElement(FieldsetFlowRowDropdown, { ...baseProps, isLastItem: true }));
+
+ expect(getDropdownOptions()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ label: formatMsg('template.move-down'),
+ isHidden: true,
+ }),
+ ]),
+ );
+ });
+
+ it('isLastItem=false → move-down option has isHidden: false', () => {
+ render(React.createElement(FieldsetFlowRowDropdown, { ...baseProps, isLastItem: false }));
+
+ expect(getDropdownOptions()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ label: formatMsg('template.move-down'),
+ isHidden: false,
+ }),
+ ]),
+ );
+ });
+
+ it('onOpenDetail provided → options contain fieldset-flow-open-detail', () => {
+ render(
+ React.createElement(FieldsetFlowRowDropdown, {
+ ...baseProps,
+ onOpenDetail: jest.fn(),
+ }),
+ );
+
+ expect(getDropdownOptions()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ mapKey: 'fieldset-flow-open-detail' }),
+ ]),
+ );
+ });
+
+ it('onOpenDetail not provided → options do not contain fieldset-flow-open-detail', () => {
+ render(React.createElement(FieldsetFlowRowDropdown, { ...baseProps }));
+
+ expect(getDropdownOptions()).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ mapKey: 'fieldset-flow-open-detail' }),
+ ]),
+ );
+ });
+
+ it('every option calls closeDropdown strictly before the parent handler', () => {
+ const onMoveUp = jest.fn();
+ const onMoveDown = jest.fn();
+ const onRemove = jest.fn();
+ const onOpenDetail = jest.fn();
+ render(
+ React.createElement(FieldsetFlowRowDropdown, {
+ ...baseProps,
+ onMoveUp,
+ onMoveDown,
+ onRemove,
+ onOpenDetail,
+ }),
+ );
+
+ const options = getDropdownOptions();
+ const findOption = (predicate: (o: TDropdownOption) => boolean): TDropdownOption => {
+ const found = options.find(predicate);
+ if (!found) throw new Error('Option not found by predicate');
+ return found;
+ };
+
+ const cases: Array<{ option: TDropdownOption; handler: jest.Mock }> = [
+ { option: findOption((o) => o.label === formatMsg('template.move-up')), handler: onMoveUp },
+ { option: findOption((o) => o.label === formatMsg('template.move-down')), handler: onMoveDown },
+ { option: findOption((o) => o.mapKey === 'fieldset-flow-open-detail'), handler: onOpenDetail },
+ { option: findOption((o) => o.label === formatMsg('user.avatar.delete')), handler: onRemove },
+ ];
+
+ cases.forEach(({ option, handler }) => {
+ const optionOnClick = option.onClick;
+ if (!optionOnClick) {
+ throw new Error(`Option "${String(option.label)}" has no onClick`);
+ }
+ const closeDropdown = jest.fn();
+ optionOnClick(closeDropdown);
+
+ expect(closeDropdown).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(closeDropdown.mock.invocationCallOrder[0])
+ .toBeLessThan(handler.mock.invocationCallOrder[0]);
+ });
+ });
+
+ it('Delete option has withConfirmation, withUpperline and red color', () => {
+ render(React.createElement(FieldsetFlowRowDropdown, { ...baseProps }));
+
+ expect(getDropdownOptions()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ label: formatMsg('user.avatar.delete'),
+ withConfirmation: true,
+ withUpperline: true,
+ color: 'red',
+ }),
+ ]),
+ );
+ });
+
+ it('Delete option is present regardless of onOpenDetail presence', () => {
+ render(React.createElement(FieldsetFlowRowDropdown, { ...baseProps }));
+ expect(getDropdownOptions()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ label: formatMsg('user.avatar.delete') }),
+ ]),
+ );
+
+ (Dropdown as jest.Mock).mockClear();
+
+ render(
+ React.createElement(FieldsetFlowRowDropdown, {
+ ...baseProps,
+ onOpenDetail: jest.fn(),
+ }),
+ );
+ expect(getDropdownOptions()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ label: formatMsg('user.avatar.delete') }),
+ ]),
+ );
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/FieldsetIconPicker.test.tsx b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/FieldsetIconPicker.test.tsx
new file mode 100644
index 000000000..a8c9f2b79
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/FieldsetIconPicker.test.tsx
@@ -0,0 +1,265 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useSelector } from 'react-redux';
+
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { makeFieldsetCatalogItem } from '../../../../__stubs__/fieldsets.factory';
+import { IFieldsetCatalogItem } from '../../../../types/fieldset';
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { Dropdown } from '../../../UI';
+import { IDropdownProps, TDropdownOption } from '../../../UI/Dropdown/Dropdown';
+import { FieldsetIconPicker, IFieldsetIconPickerProps } from '../FieldsetIconPicker';
+import {
+ getFieldsetsCatalogItems,
+} from '../../../../redux/selectors/fieldsets';
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
+
+jest.mock('../../../../redux/selectors/fieldsets', () => ({
+ getFieldsetsCatalogItems: jest.fn(() => []),
+}));
+
+jest.mock('../../../UI', () => ({
+ Dropdown: jest.fn(({ options }: IDropdownProps) => {
+ const single = !Array.isArray(options) ? (options as TDropdownOption) : null;
+ return single?.customSubOption ?? null;
+ }),
+ CustomTooltip: () => null,
+}));
+
+jest.mock('../../../UI/CustomTooltip', () => ({
+ CustomTooltip: () => null,
+}));
+
+jest.mock('../../../icons/FieldsetIcon', () => ({
+ FieldsetIcon: () => null,
+}));
+
+
+const makeCatalogItem = (
+ id: number,
+ apiName: string,
+ name: string,
+ order: number,
+): IFieldsetCatalogItem => makeFieldsetCatalogItem({
+ id,
+ apiName,
+ name,
+ order,
+});
+
+const getDropdownProps = (): IDropdownProps => {
+ const calls = (Dropdown as jest.Mock).mock.calls;
+ return calls[calls.length - 1][0];
+};
+
+const makeField = (apiName: string) => makeExtraField({
+ apiName,
+ name: `Field ${apiName}`,
+});
+
+const formatMsg = (id: string, defaultMessage?: string) =>
+ intlMock.formatMessage({ id, defaultMessage });
+
+const EMPTY_STATE = {};
+
+describe('FieldsetIconPicker', () => {
+ const makeProps = (overrides: Partial = {}): IFieldsetIconPickerProps => ({
+ fieldsetsCatalogLoading: false,
+ selectedFieldsetIds: [],
+ onSelectFieldset: jest.fn(),
+ onRemoveFieldset: jest.fn(),
+ ...overrides,
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useSelector as jest.Mock).mockImplementation((selector: unknown) =>
+ (selector as (s: unknown) => unknown)(EMPTY_STATE),
+ );
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue([]);
+ });
+
+ it('Dropdown does not receive isDisabled prop', () => {
+ render(React.createElement(FieldsetIconPicker, makeProps()));
+ expect(getDropdownProps().isDisabled).toBeUndefined();
+ });
+
+ it('fieldsetsCatalogLoading=true and empty catalog → loading text is rendered', () => {
+ render(
+ React.createElement(
+ FieldsetIconPicker,
+ makeProps({ fieldsetsCatalogLoading: true }),
+ ),
+ );
+ expect(
+ screen.getByText(formatMsg('template.fieldset-picker.loading', 'Loading…')),
+ ).toBeInTheDocument();
+ });
+
+ it('fieldsetsCatalogLoading=false and empty catalog → empty text is rendered', () => {
+ render(
+ React.createElement(
+ FieldsetIconPicker,
+ makeProps({ fieldsetsCatalogLoading: false }),
+ ),
+ );
+ expect(screen.getByText(formatMsg('template.fieldset-picker.empty'))).toBeInTheDocument();
+ });
+
+ it('renders fieldsets sorted by order property', () => {
+ // Бизнес: филдсеты отображаются в порядке, заданном в каталоге (поле order).
+ // Идентификатор: каталожный id (1, 2, 3). Order определяет визуальный порядок.
+ const catalogItems: IFieldsetCatalogItem[] = [
+ makeCatalogItem(1, 'fs-a', 'Alpha', 2),
+ makeCatalogItem(2, 'fs-b', 'Zeta', 0),
+ makeCatalogItem(3, 'fs-c', 'Beta', 1),
+ ];
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue(catalogItems);
+
+ render(React.createElement(FieldsetIconPicker, makeProps()));
+
+ const buttons = screen.getAllByRole('button');
+ expect(buttons[0]).toHaveTextContent('Zeta');
+ expect(buttons[1]).toHaveTextContent('Beta');
+ expect(buttons[2]).toHaveTextContent('Alpha');
+ });
+
+ it('click on unselected fieldset calls onSelectFieldset, onRemoveFieldset not called', () => {
+ const onSelectFieldset = jest.fn();
+ const onRemoveFieldset = jest.fn();
+ const catalogItems: IFieldsetCatalogItem[] = [
+ makeCatalogItem(1, 'fs-1', 'My Fieldset', 0),
+ ];
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue(catalogItems);
+
+ render(
+ React.createElement(
+ FieldsetIconPicker,
+ makeProps({
+ selectedFieldsetIds: [],
+ onSelectFieldset,
+ onRemoveFieldset,
+ }),
+ ),
+ );
+
+ userEvent.click(screen.getByRole('button'));
+
+ expect(onSelectFieldset).toHaveBeenCalledTimes(1);
+ expect(onSelectFieldset).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
+ expect(onRemoveFieldset).not.toHaveBeenCalled();
+ });
+
+ it('click on selected fieldset calls onRemoveFieldset, onSelectFieldset not called', () => {
+ const onSelectFieldset = jest.fn();
+ const onRemoveFieldset = jest.fn();
+ const catalogItems: IFieldsetCatalogItem[] = [
+ makeCatalogItem(1, 'fs-1', 'My Fieldset', 0),
+ ];
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue(catalogItems);
+
+ render(
+ React.createElement(
+ FieldsetIconPicker,
+ makeProps({
+ selectedFieldsetIds: [1],
+ onSelectFieldset,
+ onRemoveFieldset,
+ }),
+ ),
+ );
+
+ userEvent.click(screen.getByRole('button'));
+
+ expect(onRemoveFieldset).toHaveBeenCalledTimes(1);
+ expect(onRemoveFieldset).toHaveBeenCalledWith(1);
+ expect(onSelectFieldset).not.toHaveBeenCalled();
+ });
+
+ it('selected fieldset renders checkbox in checked state', () => {
+ const catalogItems: IFieldsetCatalogItem[] = [
+ makeCatalogItem(1, 'fs-1', 'My Fieldset', 0),
+ ];
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue(catalogItems);
+
+ render(
+ React.createElement(
+ FieldsetIconPicker,
+ makeProps({
+ selectedFieldsetIds: [1],
+ }),
+ ),
+ );
+
+ const checkbox = screen.getByRole('checkbox', { hidden: true });
+ expect(checkbox).toBeChecked();
+ });
+
+ it('unselected fieldset renders checkbox in unchecked state', () => {
+ const catalogItems: IFieldsetCatalogItem[] = [
+ makeCatalogItem(1, 'fs-1', 'My Fieldset', 0),
+ ];
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue(catalogItems);
+
+ render(
+ React.createElement(
+ FieldsetIconPicker,
+ makeProps({
+ selectedFieldsetIds: [],
+ }),
+ ),
+ );
+
+ const checkbox = screen.getByRole('checkbox', { hidden: true });
+ expect(checkbox).not.toBeChecked();
+ });
+
+ it('background catalog load does not show loading text when list is not empty', () => {
+ const catalogItems: IFieldsetCatalogItem[] = [
+ makeCatalogItem(1, 'fs-1', 'My Set', 0),
+ ];
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue(catalogItems);
+
+ render(
+ React.createElement(
+ FieldsetIconPicker,
+ makeProps({ fieldsetsCatalogLoading: true }),
+ ),
+ );
+
+ expect(
+ screen.queryByText(formatMsg('template.fieldset-picker.loading', 'Loading…')),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText('My Set')).toBeInTheDocument();
+ });
+
+ it('meta line shows " fields · rules" with real numbers', () => {
+ const catalogItem = makeFieldsetCatalogItem({
+ name: 'My Set',
+ fields: [makeField('a'), makeField('b'), makeField('c')] as any,
+ rules: [{}, {}, {}, {}, {}] as any,
+ });
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue([catalogItem]);
+
+ render(React.createElement(FieldsetIconPicker, makeProps()));
+
+ expect(screen.getByText('3 fields · 5 rules')).toBeInTheDocument();
+ });
+
+ it('meta line shows "0 fields · 0 rules" when fields and rules are empty', () => {
+ const catalogItem = makeFieldsetCatalogItem({
+ name: 'My Set',
+ fields: [],
+ rules: [],
+ });
+ (getFieldsetsCatalogItems as jest.Mock).mockReturnValue([catalogItem]);
+
+ render(React.createElement(FieldsetIconPicker, makeProps()));
+
+ expect(screen.getByText('0 fields · 0 rules')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/MergedOutputRows.test.tsx b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/MergedOutputRows.test.tsx
new file mode 100644
index 000000000..90c1b3d77
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/MergedOutputRows.test.tsx
@@ -0,0 +1,236 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { makeFieldsetBindingClient, makeFieldsetField } from '../../../../__stubs__/fieldsets.factory';
+import { IExtraField } from '../../../../types/template';
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { MergedOutputRows, IMergedOutputRowsProps } from '../MergedOutputRows';
+import { TMergedTaskOutputRow } from '../mergeTaskOutputFlow';
+
+type TExtraFieldIntlMockProps = {
+ field: IExtraField;
+ moveFieldUp?: () => void;
+ moveFieldDown?: () => void;
+};
+
+type TFieldsetFlowRowDropdownMockProps = {
+ onMoveUp: () => void;
+ onMoveDown: () => void;
+ onRemove: () => void;
+};
+
+jest.mock('../../ExtraFields', () => ({
+ ExtraFieldIntl: ({ field, moveFieldUp, moveFieldDown }: TExtraFieldIntlMockProps) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': 'extra-field-intl' },
+ field.name,
+ moveFieldUp
+ && React.createElement(
+ 'button',
+ { 'data-testid': `field-up-${field.apiName}`, onClick: moveFieldUp },
+ 'FieldUp',
+ ),
+ moveFieldDown
+ && React.createElement(
+ 'button',
+ { 'data-testid': `field-down-${field.apiName}`, onClick: moveFieldDown },
+ 'FieldDown',
+ ),
+ ),
+}));
+
+jest.mock('../../ExtraFields/utils/ExtraFieldsLabels', () => ({
+ ExtraFieldsLabels: () =>
+ React.createElement('div', { 'data-testid': 'extra-fields-labels' }),
+}));
+
+jest.mock('../FieldsetFlowRowDropdown', () => ({
+ FieldsetFlowRowDropdown: ({
+ onMoveUp,
+ onMoveDown,
+ onRemove,
+ }: TFieldsetFlowRowDropdownMockProps) =>
+ React.createElement(
+ 'div',
+ null,
+ React.createElement('button', { onClick: onMoveUp }, 'Up'),
+ React.createElement('button', { onClick: onMoveDown }, 'Down'),
+ React.createElement('button', { onClick: onRemove }, 'Remove'),
+ ),
+}));
+
+const makeField = (apiName: string) => makeExtraField({
+ apiName,
+ name: `Field ${apiName}`,
+});
+
+const fieldRow = (apiName: string): TMergedTaskOutputRow => ({
+ kind: 'field',
+ field: makeField(apiName),
+});
+
+const fieldsetRow = (apiNameBinding: string, name = 'Test Fieldset', fieldsCount = 0): TMergedTaskOutputRow => ({
+ ...makeFieldsetBindingClient({
+ apiNameBinding,
+ name,
+ fields: Array.from({ length: fieldsCount }, (_, index) =>
+ makeFieldsetField({ apiName: `${apiNameBinding}-field-${index}` }),
+ ),
+ }),
+ kind: 'fieldset',
+});
+
+describe('MergedOutputRows', () => {
+ const makeProps = (overrides: Partial = {}): IMergedOutputRowsProps => ({
+ mergedRows: [],
+ onDeleteField: jest.fn(),
+ onMoveRow: jest.fn(),
+ onEditField: jest.fn(() => jest.fn()),
+ onRemoveFieldset: jest.fn(),
+ datasetOptions: [],
+ accountId: 1,
+ formatMessage: intlMock.formatMessage,
+ ...overrides,
+ });
+
+ it('field row renders ExtraFieldIntl with the field name', () => {
+ render(
+ React.createElement(MergedOutputRows, makeProps({ mergedRows: [fieldRow('f-1')] })),
+ );
+ expect(screen.getByText('Field f-1')).toBeInTheDocument();
+ });
+
+ it('fieldset with name → renders ": " with exact text', () => {
+ const prefix = intlMock.formatMessage({ id: 'fieldsets.title' });
+
+ render(
+ React.createElement(
+ MergedOutputRows,
+ makeProps({ mergedRows: [fieldsetRow('fs-1', 'Fieldset Alpha')] }),
+ ),
+ );
+
+ expect(screen.getByText(`${prefix}: Fieldset Alpha`)).toBeInTheDocument();
+ });
+
+ it('fieldset with empty name → renders ": " with exact text', () => {
+ const prefix = intlMock.formatMessage({ id: 'fieldsets.title' });
+ const fallback = intlMock.formatMessage({ id: 'tasks.task-fieldsets' });
+
+ render(
+ React.createElement(
+ MergedOutputRows,
+ makeProps({ mergedRows: [fieldsetRow('fs-empty', '')] }),
+ ),
+ );
+
+ expect(screen.getByText(`${prefix}: ${fallback}`)).toBeInTheDocument();
+ });
+
+ it('renders ExtraFieldsLabels when fieldset has fields', () => {
+ render(
+ React.createElement(
+ MergedOutputRows,
+ makeProps({ mergedRows: [fieldsetRow('fs-1', 'Fieldset A', 2)] }),
+ ),
+ );
+
+ expect(screen.getByTestId('extra-fields-labels')).toBeInTheDocument();
+ });
+
+ it('does NOT render ExtraFieldsLabels when fieldset has no fields', () => {
+ render(
+ React.createElement(
+ MergedOutputRows,
+ makeProps({ mergedRows: [fieldsetRow('fs-1', 'Fieldset A', 0)] }),
+ ),
+ );
+
+ expect(screen.queryByTestId('extra-fields-labels')).not.toBeInTheDocument();
+ });
+
+ it('renders FieldsetFlowRowDropdown for kind=fieldset rows', () => {
+ render(
+ React.createElement(
+ MergedOutputRows,
+ makeProps({ mergedRows: [fieldsetRow('fs-1')] }),
+ ),
+ );
+
+ expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
+ });
+
+ it('click Remove → onRemoveFieldset(sharedFieldsetId) called once', () => {
+ const onRemoveFieldset = jest.fn();
+
+ render(
+ React.createElement(
+ MergedOutputRows,
+ makeProps({ mergedRows: [fieldsetRow('fs-1')], onRemoveFieldset }),
+ ),
+ );
+
+ userEvent.click(screen.getByRole('button', { name: 'Remove' }));
+
+ expect(onRemoveFieldset).toHaveBeenCalledTimes(1);
+ expect(onRemoveFieldset).toHaveBeenCalledWith(1);
+ });
+
+ it('click Up → onMoveRow(0, "up") called once, "down" not called', () => {
+ const onMoveRow = jest.fn();
+
+ render(
+ React.createElement(
+ MergedOutputRows,
+ makeProps({ mergedRows: [fieldsetRow('fs-1')], onMoveRow }),
+ ),
+ );
+
+ userEvent.click(screen.getByRole('button', { name: 'Up' }));
+
+ expect(onMoveRow).toHaveBeenCalledTimes(1);
+ expect(onMoveRow).toHaveBeenCalledWith(0, 'up');
+ expect(onMoveRow).not.toHaveBeenCalledWith(expect.anything(), 'down');
+ });
+
+ it('click Down → onMoveRow(0, "down") called once, "up" not called', () => {
+ const onMoveRow = jest.fn();
+
+ render(
+ React.createElement(
+ MergedOutputRows,
+ makeProps({ mergedRows: [fieldsetRow('fs-1')], onMoveRow }),
+ ),
+ );
+
+ userEvent.click(screen.getByRole('button', { name: 'Down' }));
+
+ expect(onMoveRow).toHaveBeenCalledTimes(1);
+ expect(onMoveRow).toHaveBeenCalledWith(0, 'down');
+ expect(onMoveRow).not.toHaveBeenCalledWith(expect.anything(), 'up');
+ });
+
+ it('field rows: first has only Down, middle has both, last has only Up', () => {
+ const rows: TMergedTaskOutputRow[] = [
+ fieldRow('f-0'),
+ fieldRow('f-1'),
+ fieldRow('f-2'),
+ ];
+
+ render(
+ React.createElement(MergedOutputRows, makeProps({ mergedRows: rows })),
+ );
+
+ expect(screen.queryByTestId('field-up-f-0')).not.toBeInTheDocument();
+ expect(screen.getByTestId('field-down-f-0')).toBeInTheDocument();
+
+ expect(screen.getByTestId('field-up-f-1')).toBeInTheDocument();
+ expect(screen.getByTestId('field-down-f-1')).toBeInTheDocument();
+
+ expect(screen.getByTestId('field-up-f-2')).toBeInTheDocument();
+ expect(screen.queryByTestId('field-down-f-2')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/mergeTaskOutputFlow.test.ts b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/mergeTaskOutputFlow.test.ts
new file mode 100644
index 000000000..3e54e448c
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/mergeTaskOutputFlow.test.ts
@@ -0,0 +1,154 @@
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { makeFieldsetBindingClient, makeFieldsetRuntime } from '../../../../__stubs__/fieldsets.factory';
+import {
+ buildMergedTaskOutputRows,
+ buildRuntimeMergedOutputParts,
+ buildRowsWithAddedFieldset,
+ buildRowsWithRemovedFieldset,
+ moveMergedRow,
+ normalizeMergedTaskOutputOrders,
+ TMergedTaskOutputRow,
+} from '../mergeTaskOutputFlow';
+
+const assertRowsDefined = (
+ rows: TMergedTaskOutputRow[] | null,
+): TMergedTaskOutputRow[] => {
+ if (rows === null) {
+ throw new Error('expected rows to be non-null');
+ }
+ return rows;
+};
+
+const field = (apiName: string, order: number) =>
+ makeExtraField({ apiName, name: apiName, order });
+
+const fs = (id: number, order: number) =>
+ makeFieldsetRuntime({ apiNameBinding: `fs-${id}`, name: `FS ${id}`, order });
+
+describe('mergeTaskOutputFlow', () => {
+ it('buildMergedTaskOutputRows sorts by order descending with stable tie-break', () => {
+ const rows = buildMergedTaskOutputRows(
+ [field('a', 0), field('b', 3)],
+ [
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-10', order: 1 }),
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-20', order: 2 }),
+ ],
+ );
+ expect(rows.map((row) => (row.kind === 'field' ? row.field.apiName : row.apiNameBinding))).toEqual(['b', 'fs-20', 'fs-10', 'a']);
+ });
+
+ it('normalizeMergedTaskOutputOrders assigns contiguous orders', () => {
+ const fields = [field('a', 10), field('b', 5)];
+ const rows = buildMergedTaskOutputRows(fields, [
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 7 }),
+ ]);
+ const { nextFields, nextFieldsets } = normalizeMergedTaskOutputOrders(rows, fields);
+ const byApi = Object.fromEntries(nextFields.map((f) => [f.apiName, f.order]));
+ expect(byApi.a).toBe(2);
+ expect(byApi.b).toBe(0);
+ expect(nextFieldsets).toContainEqual(expect.objectContaining({ apiNameBinding: 'fs-1', order: 1 }));
+ });
+
+ it('moveMergedRow swaps adjacent items and does not mutate the input', () => {
+ const original = buildMergedTaskOutputRows(
+ [field('a', 4), field('b', 3), field('c', 2), field('d', 1)],
+ [],
+ );
+ const apiNamesOf = (rows: ReturnType) =>
+ rows.map((row) => (row.kind === 'field' ? row.field.apiName : row.apiNameBinding));
+ const snapshotBefore = apiNamesOf(original);
+ expect(snapshotBefore).toEqual(['a', 'b', 'c', 'd']);
+
+ const moved = moveMergedRow(original, 1, 'down');
+
+ expect(apiNamesOf(moved)).toEqual(['a', 'c', 'b', 'd']);
+ expect(moved).not.toBe(original);
+ expect(apiNamesOf(original)).toEqual(snapshotBefore);
+ });
+
+ it('buildRuntimeMergedOutputParts interleaves fields and fieldsets', () => {
+ const parts = buildRuntimeMergedOutputParts(
+ [field('x', 1), field('y', 3)],
+ [fs(9, 2)],
+ );
+ expect(parts.map((output) => (output.kind === 'fieldset' ? output.data.apiNameBinding : output.field.apiName))).toEqual(['y', 'fs-9', 'x']);
+ });
+
+ it('buildRuntimeMergedOutputParts returns only fields when fieldsets is undefined', () => {
+ const parts = buildRuntimeMergedOutputParts(
+ [field('x', 1), field('y', 3)],
+ undefined,
+ );
+ expect(parts.every((p) => p.kind === 'field')).toBe(true);
+ expect(parts.map((p) => (p.kind === 'field' ? p.field.apiName : ''))).toEqual(['y', 'x']);
+ });
+
+ describe('buildRowsWithAddedFieldset', () => {
+ it('adds fieldset to rows when it is not already present', () => {
+ const result = buildRowsWithAddedFieldset(
+ [field('a', 0)],
+ [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 1 })],
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-new', order: 0, sharedFieldsetId: 99 }),
+ );
+ const rows = assertRowsDefined(result);
+ const apiNames = rows.map((row) => (row.kind === 'field' ? row.field.apiName : row.apiNameBinding));
+ expect(apiNames).toContain('fs-new');
+ });
+
+ it('returns null when fieldset with that apiName is already present', () => {
+ const result = buildRowsWithAddedFieldset(
+ [field('a', 0)],
+ [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 1 })],
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-1-dup', order: 0, sharedFieldsetId: 1 }),
+ );
+ expect(result).toBeNull();
+ });
+
+ it('places the newly added fieldset as the last row in the merged list', () => {
+ const result = buildRowsWithAddedFieldset(
+ [field('a', 5), field('b', 3)],
+ [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 1 })],
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-new', order: 0, sharedFieldsetId: 99 }),
+ );
+ const rows = assertRowsDefined(result);
+ const lastRow = rows[rows.length - 1];
+ expect(lastRow.kind).toBe('fieldset');
+ expect(lastRow.kind === 'fieldset' && lastRow.apiNameBinding).toBe('fs-new');
+ });
+ });
+
+ describe('buildRowsWithRemovedFieldset', () => {
+ it('removes fieldset by sharedFieldsetId, other rows are preserved', () => {
+ const rows = buildRowsWithRemovedFieldset(
+ [field('a', 0)],
+ [
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 1 }),
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-2', order: 2, sharedFieldsetId: 2 }),
+ ],
+ 1,
+ );
+ const apiNames = rows.map((row) => (row.kind === 'field' ? row.field.apiName : row.apiNameBinding));
+ expect(apiNames).not.toContain('fs-1');
+ expect(apiNames).toContain('fs-2');
+ expect(apiNames).toContain('a');
+ });
+
+ it('does not throw when fieldset is absent — returns rows unchanged', () => {
+ const rows = buildRowsWithRemovedFieldset(
+ [field('a', 0)],
+ [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', order: 1 })],
+ 999,
+ );
+ const apiNames = rows.map((row) => (row.kind === 'field' ? row.field.apiName : row.apiNameBinding));
+ expect(apiNames).toContain('fs-1');
+ expect(apiNames).toContain('a');
+ });
+ });
+
+ it('normalizeMergedTaskOutputOrders with empty rowsInDisplayOrder returns fields unchanged and empty patches', () => {
+ const originalFields = [field('a', 5), field('b', 3)];
+ const { nextFields, nextFieldsets } = normalizeMergedTaskOutputOrders([], originalFields);
+ expect(nextFieldsets).toEqual([]);
+ expect(nextFields).toEqual(originalFields);
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/TaskOutputFlow/mergeTaskOutputFlow.ts b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/mergeTaskOutputFlow.ts
new file mode 100644
index 000000000..41b49cafd
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskOutputFlow/mergeTaskOutputFlow.ts
@@ -0,0 +1,152 @@
+import { IExtraField, TRuntimeMergedOutputPart, TTemplateFieldFieldset } from '../../../types/template';
+import { IFieldsetRuntime, IFieldsetCatalogItem, IFieldsetBindingClient } from '../../../types/fieldset';
+import { createFieldsetBindingApiName } from '../../../utils/createId';
+
+export type TMergedTaskOutputRow =
+ | { kind: 'field'; field: IExtraField }
+ | (IFieldsetBindingClient & { kind: 'fieldset' });
+
+function rowOrder(row: TMergedTaskOutputRow): number {
+ return row.kind === 'field' ? row.field.order : row.order;
+}
+
+export function buildMergedTaskOutputRows(
+ fields: IExtraField[],
+ fieldsets: IFieldsetBindingClient[],
+): TMergedTaskOutputRow[] {
+ const rows: TMergedTaskOutputRow[] = [
+ ...fields.map((field) => ({ kind: 'field' as const, field })),
+ ...fieldsets.map((fieldset) => ({ ...fieldset, kind: 'fieldset' as const })),
+ ];
+ return rows.sort((a, b) => {
+ const delta = rowOrder(b) - rowOrder(a);
+ if (delta !== 0) {
+ return delta;
+ }
+ if (a.kind === 'fieldset' && b.kind === 'fieldset') {
+ return b.apiNameBinding.localeCompare(a.apiNameBinding);
+ }
+ if (a.kind === 'field' && b.kind === 'field') {
+ return b.field.apiName.localeCompare(a.field.apiName);
+ }
+ return a.kind === 'fieldset' ? 1 : -1;
+ });
+}
+
+export interface INormalizeMergedTaskOutputResult {
+ nextFields: IExtraField[];
+ nextFieldsets: IFieldsetBindingClient[];
+}
+
+export function normalizeMergedTaskOutputOrders(
+ rowsInDisplayOrder: TMergedTaskOutputRow[],
+ allTaskFields: IExtraField[],
+): INormalizeMergedTaskOutputResult {
+ if (rowsInDisplayOrder.length === 0) {
+ return { nextFields: [...allTaskFields], nextFieldsets: [] };
+ }
+ const total = rowsInDisplayOrder.length;
+ const orderByFieldApiName = new Map();
+ const nextFieldsets: IFieldsetBindingClient[] = [];
+
+ rowsInDisplayOrder.forEach((row, index) => {
+ const order = total - index - 1;
+ if (row.kind === 'field') {
+ orderByFieldApiName.set(row.field.apiName, order);
+ } else {
+ const { kind, ...fieldsetBinding } = row;
+ nextFieldsets.push({ ...fieldsetBinding, order });
+ }
+ });
+
+ const nextFields = allTaskFields.map((field) => ({
+ ...field,
+ order: orderByFieldApiName.get(field.apiName) ?? field.order,
+ }));
+
+ return { nextFields, nextFieldsets };
+}
+
+function runtimeOrder(part: TRuntimeMergedOutputPart): number {
+ if (part.kind === 'fieldset') return part.data.order ?? 0;
+ if (part.kind === 'field') return part.field.order;
+
+ return 0;
+}
+
+export function buildRuntimeMergedOutputParts(
+ output: IExtraField[],
+ fieldsets: IFieldsetRuntime[] | TTemplateFieldFieldset[] | undefined,
+): TRuntimeMergedOutputPart[] {
+ const rows: TRuntimeMergedOutputPart[] = [
+ ...output.map((field) => ({ kind: 'field' as const, field })),
+ ...(fieldsets || []).map((data) => ({ kind: 'fieldset' as const, data })),
+ ];
+ return rows.sort((a, b) => {
+ const delta = runtimeOrder(b) - runtimeOrder(a);
+ if (delta !== 0) {
+ return delta;
+ }
+ if (a.kind === 'fieldset' && b.kind === 'fieldset') {
+ return b.data.apiNameBinding.localeCompare(a.data.apiNameBinding);
+ }
+ if (a.kind === 'field' && b.kind === 'field') {
+ return b.field.apiName.localeCompare(a.field.apiName);
+ }
+ return a.kind === 'fieldset' ? 1 : -1;
+ });
+}
+
+export function moveMergedRow(
+ rows: TMergedTaskOutputRow[],
+ index: number,
+ direction: 'up' | 'down',
+): TMergedTaskOutputRow[] {
+ const copy = [...rows];
+ const to = direction === 'up' ? index - 1 : index + 1;
+ if (to < 0 || to >= copy.length) {
+ return copy;
+ }
+ const tmp = copy[index];
+ copy[index] = copy[to];
+ copy[to] = tmp;
+ return copy;
+}
+
+export function buildRowsWithAddedFieldset(
+ fields: IExtraField[],
+ fieldsets: IFieldsetBindingClient[],
+ newFieldsetBinding: IFieldsetBindingClient,
+): TMergedTaskOutputRow[] | null {
+ if (fieldsets.some((fieldset) => fieldset.sharedFieldsetId === newFieldsetBinding.sharedFieldsetId)) {
+ return null;
+ }
+ const nextFieldsets = [...fieldsets, newFieldsetBinding];
+ return buildMergedTaskOutputRows(fields, nextFieldsets);
+}
+
+export function buildRowsWithRemovedFieldset(
+ fields: IExtraField[],
+ fieldsets: IFieldsetBindingClient[],
+ sharedFieldsetId: number,
+): TMergedTaskOutputRow[] {
+ const nextFieldsets = fieldsets.filter((fieldset) => fieldset.sharedFieldsetId !== sharedFieldsetId);
+ return buildMergedTaskOutputRows(fields, nextFieldsets);
+}
+
+export function createFieldsetBinding(catalogItem: IFieldsetCatalogItem): IFieldsetBindingClient {
+ const apiNameBinding = createFieldsetBindingApiName();
+
+ return {
+ sharedFieldsetId: catalogItem.id,
+ apiNameBinding,
+ name: catalogItem.name,
+ title: catalogItem.title,
+ description: catalogItem.description,
+ order: -1,
+ labelPosition: catalogItem.labelPosition,
+ layout: catalogItem.layout,
+ rules: catalogItem.rules,
+ fields: catalogItem.fields,
+ };
+}
diff --git a/frontend/src/public/components/TemplateEdit/TaskRenderConditionsInfo/TaskRenderConditionsInfo.tsx b/frontend/src/public/components/TemplateEdit/TaskRenderConditionsInfo/TaskRenderConditionsInfo.tsx
index a9322d1aa..774b06784 100644
--- a/frontend/src/public/components/TemplateEdit/TaskRenderConditionsInfo/TaskRenderConditionsInfo.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskRenderConditionsInfo/TaskRenderConditionsInfo.tsx
@@ -6,14 +6,14 @@ import classNames from 'classnames';
import { getSubscriptionPlan, getIsUserSubsribed } from '../../../redux/selectors/user';
import { getConditionsCount } from '../TaskItem/utlils/getConditionsCount';
import { ESubscriptionPlan } from '../../../types/account';
-import { ITemplateTask } from '../../../types/template';
+import { ITemplateTaskClient } from '../../../types/template';
import { EConditionAction } from '../TaskForm/Conditions/types';
import styles from '../TemplateEdit.css';
import { getTriplePlural } from '../../../utils/helpers';
interface ITaskRenderConditionsInfoProps {
- task: ITemplateTask;
+ task: ITemplateTaskClient;
onClick: () => void;
isInTaskForm?: boolean;
isStartTask?: boolean;
diff --git a/frontend/src/public/components/TemplateEdit/TaskRenderDueInInfo/TaskRenderDueInInfo.tsx b/frontend/src/public/components/TemplateEdit/TaskRenderDueInInfo/TaskRenderDueInInfo.tsx
index 5289d9379..02a636f6c 100644
--- a/frontend/src/public/components/TemplateEdit/TaskRenderDueInInfo/TaskRenderDueInInfo.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskRenderDueInInfo/TaskRenderDueInInfo.tsx
@@ -4,12 +4,12 @@ import classNames from 'classnames';
import { formatDuration, formatDurationMonths, getZeroDuration } from '../../../utils/dateTime';
import { ClockIcon } from '../../icons';
-import { ITemplateTask } from '../../../types/template';
+import { ITemplateTaskClient } from '../../../types/template';
import styles from '../TemplateEdit.css';
interface ITaskRenderDueInProps {
- task: ITemplateTask;
+ task: ITemplateTaskClient;
onClick: () => void;
isInTaskForm?: boolean;
}
diff --git a/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/TaskRenderExtraFieldsInfo.tsx b/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/TaskRenderExtraFieldsInfo.tsx
index da813c38b..f9bf2077e 100644
--- a/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/TaskRenderExtraFieldsInfo.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/TaskRenderExtraFieldsInfo.tsx
@@ -1,25 +1,40 @@
-import React from 'react';
+import * as React from 'react';
+import { useMemo } from 'react';
import { useIntl } from 'react-intl';
-import { ITemplateTask } from '../../../types/template';
+import { ITemplateTaskClient, IFieldsetBindingClient } from '../../../types/template';
import styles from '../ExtraFields/utils/ExtraFieldsLabels/ExtraFieldsLabels.css';
import { getTriplePlural } from '../../../utils/helpers';
interface ITaskRenderExtraFieldsInfoProps {
- task: ITemplateTask;
+ task: ITemplateTaskClient;
onClick: () => void;
}
-export const TaskRenderExtraFieldsInfo = ({ task: { fields }, onClick }: ITaskRenderExtraFieldsInfoProps) => {
+function countFieldsetOutputFields(
+ fieldsets: IFieldsetBindingClient[] | undefined,
+): number {
+ if (!fieldsets?.length) {
+ return 0;
+ }
+
+ return fieldsets.reduce((acc, taskFieldset) => acc + taskFieldset.fields.length, 0);
+}
+
+export const TaskRenderExtraFieldsInfo = ({ task: { fields, fieldsets }, onClick }: ITaskRenderExtraFieldsInfoProps) => {
const { formatMessage } = useIntl();
+ const totalCount = useMemo(() => {
+ return fields.length + countFieldsetOutputFields(fieldsets);
+ }, [fieldsets, fields.length]);
+
const extraFieldsText = getTriplePlural({
- counter: fields.length,
+ counter: totalCount,
forms: ['tasks.task-extra-field-single', 'tasks.task-extra-field-plural-1', 'tasks.task-extra-field-plural-2'],
formatMessage,
});
return (
- fields.length > 0 && (
+ totalCount > 0 && (
diff --git a/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/__tests__/TaskRenderExtraFieldsInfo.test.tsx b/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/__tests__/TaskRenderExtraFieldsInfo.test.tsx
new file mode 100644
index 000000000..e811007f4
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TaskRenderExtraFieldsInfo/__tests__/TaskRenderExtraFieldsInfo.test.tsx
@@ -0,0 +1,134 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { makeFieldsetBindingClient, makeFieldsetField } from '../../../../__stubs__/fieldsets.factory';
+import { IExtraField, IFieldsetBindingClient } from '../../../../types/template';
+import { TaskRenderExtraFieldsInfo } from '../TaskRenderExtraFieldsInfo';
+
+const makeField = (apiName: string) => makeExtraField({
+ apiName,
+ name: apiName,
+});
+
+const makeTask = (fields: IExtraField[], fieldsets: IFieldsetBindingClient[]) =>
+ ({ fields, fieldsets } as any);
+
+describe('TaskRenderExtraFieldsInfo', () => {
+ it('does not render when fields are empty and fieldsets are empty', () => {
+ render(
+ React.createElement(TaskRenderExtraFieldsInfo as React.FC, {
+ task: makeTask([], []),
+ onClick: jest.fn(),
+ }),
+ );
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('does not render when fieldsets have no fields', () => {
+ render(
+ React.createElement(TaskRenderExtraFieldsInfo as React.FC, {
+ task: makeTask(
+ [],
+ [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', fields: [] })],
+ ),
+ onClick: jest.fn(),
+ }),
+ );
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('counts only own fields when fieldsets have no fields', () => {
+ render(
+ React.createElement(TaskRenderExtraFieldsInfo as React.FC, {
+ task: makeTask(
+ [makeField('field-1'), makeField('field-2')],
+ [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', fields: [] })],
+ ),
+ onClick: jest.fn(),
+ }),
+ );
+
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('sums own fields and fieldset fields', () => {
+ render(
+ React.createElement(TaskRenderExtraFieldsInfo as React.FC, {
+ task: makeTask(
+ [makeField('field-1')],
+ [makeFieldsetBindingClient({
+ apiNameBinding: 'fs-1',
+ fields: [makeFieldsetField({ apiName: 'f-1' }), makeFieldsetField({ apiName: 'f-2' })],
+ })],
+ ),
+ onClick: jest.fn(),
+ }),
+ );
+
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('sums fields from multiple fieldsets when task has no own fields', () => {
+ render(
+ React.createElement(TaskRenderExtraFieldsInfo as React.FC, {
+ task: makeTask(
+ [],
+ [
+ makeFieldsetBindingClient({
+ apiNameBinding: 'fs-1',
+ fields: [makeFieldsetField({ apiName: 'f-1' }), makeFieldsetField({ apiName: 'f-2' })],
+ }),
+ makeFieldsetBindingClient({
+ apiNameBinding: 'fs-2',
+ fields: [
+ makeFieldsetField({ apiName: 'f-3' }),
+ makeFieldsetField({ apiName: 'f-4' }),
+ makeFieldsetField({ apiName: 'f-5' }),
+ ],
+ }),
+ ],
+ ),
+ onClick: jest.fn(),
+ }),
+ );
+
+ expect(screen.getByRole('button')).toHaveTextContent('5');
+ });
+
+ it('recomputes counter when fieldsets prop changes', () => {
+ const task = makeTask([], [makeFieldsetBindingClient({ apiNameBinding: 'fs-1', fields: [] })]);
+
+ const { rerender } = render(
+ React.createElement(TaskRenderExtraFieldsInfo as React.FC, {
+ task,
+ onClick: jest.fn(),
+ }),
+ );
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+
+ const updatedTask = makeTask([], [
+ makeFieldsetBindingClient({
+ apiNameBinding: 'fs-1',
+ fields: [
+ makeFieldsetField({ apiName: 'f-1' }),
+ makeFieldsetField({ apiName: 'f-2' }),
+ makeFieldsetField({ apiName: 'f-3' }),
+ makeFieldsetField({ apiName: 'f-4' }),
+ ],
+ }),
+ ]);
+
+ rerender(
+ React.createElement(TaskRenderExtraFieldsInfo as React.FC, {
+ task: updatedTask,
+ onClick: jest.fn(),
+ }),
+ );
+
+ expect(screen.getByRole('button')).toHaveTextContent('4');
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/TaskRenderReturnInfo/TaskRenderReturnInfo.tsx b/frontend/src/public/components/TemplateEdit/TaskRenderReturnInfo/TaskRenderReturnInfo.tsx
index 9c3e0b928..06c61b070 100644
--- a/frontend/src/public/components/TemplateEdit/TaskRenderReturnInfo/TaskRenderReturnInfo.tsx
+++ b/frontend/src/public/components/TemplateEdit/TaskRenderReturnInfo/TaskRenderReturnInfo.tsx
@@ -1,10 +1,10 @@
import React from 'react';
import { useIntl } from 'react-intl';
-import { ITemplateTask } from '../../../types/template';
+import { ITemplateTaskClient } from '../../../types/template';
import styles from '../TemplateEdit.css';
interface ITaskRenderReturnInfoProps {
- task: ITemplateTask;
+ task: ITemplateTaskClient;
onClick: () => void;
}
diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx
index 5fcb94e9f..798390e20 100644
--- a/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx
+++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/TemplateControlls.tsx
@@ -1,4 +1,5 @@
-import React, { useEffect, useState } from 'react';
+import * as React from 'react';
+import { useEffect, useState } from 'react';
import Switch from 'rc-switch';
import { Link } from 'react-router-dom';
import classnames from 'classnames';
@@ -14,7 +15,7 @@ import { ShowMore } from '../../UI/ShowMore';
import { getLinkToWorkflows } from '../../../utils/routes/getLinkToWorkflows';
import { getLinkToHighlightsByTemplate } from '../../../utils/routes/getLinkToHighlightsByTemplate';
import { Button } from '../../UI/Buttons/Button';
-import { ETemplateOwnerRole, ITemplateOwner, ITemplate } from '../../../types/template';
+import { ETemplateOwnerRole, ITemplateOwner, ITemplateClient } from '../../../types/template';
import {
TCloneTemplatePayload,
TDeleteTemplatePayload,
@@ -22,6 +23,7 @@ import {
discardTemplateChanges,
} from '../../../redux/actions';
import { getRunnableWorkflow, loadDatasetsMap } from '../utils/getRunnableWorkflow';
+import { mapFieldsetBindingClientToRuntime } from '../../../utils/mapFieldsetBindingClientToRuntime';
import { ETemplateStatus } from '../../../types/redux';
import { IRunWorkflow } from '../../WorkflowEditPopup/types';
import { WarningPopup } from '../../UI/WarningPopup';
@@ -40,7 +42,7 @@ import { getSubscriptionPlan } from '../../../redux/selectors/user';
import { ESubscriptionPlan } from '../../../types/account';
export interface ITemplateControllsProps {
- template: ITemplate;
+ template: ITemplateClient;
templateStatus: ETemplateStatus;
isSubscribed: boolean;
cloneTemplate(payload: TCloneTemplatePayload): void;
@@ -102,15 +104,18 @@ export function TemplateControlls({
const isSavedTemplate = React.useMemo(() => Boolean(templateId), [templateId]);
+
const handleRunProcess = async () => {
- const datasetsMap = await loadDatasetsMap(template.kickoff);
- const runnableWorkflow = getRunnableWorkflow(template, datasetsMap);
+ if (!template.id) return;
+ const loadedFieldsets = template.kickoff.fieldsets.map(mapFieldsetBindingClientToRuntime);
+ const datasetsMap = await loadDatasetsMap(template.kickoff, loadedFieldsets);
+ const runnableWorkflow = getRunnableWorkflow(template, datasetsMap, loadedFieldsets);
if (runnableWorkflow) {
openRunWorkflowModal(runnableWorkflow);
}
};
- const handleChangeIsActive = (value: ITemplate['isActive'], redirectUrl?: string) => {
+ const handleChangeIsActive = (value: ITemplateClient['isActive'], redirectUrl?: string) => {
if (!value) {
patchTemplate({ changedFields: { isActive: false } });
return;
@@ -212,7 +217,9 @@ export function TemplateControlls({
@@ -323,6 +330,7 @@ export function TemplateControlls({
{formatMessage({ id: 'template.more-show-activity' })}
+
+
{showDraftWarning && (
diff --git a/frontend/src/public/components/TemplateEdit/TemplateControlls/__tests__/TemplateControlls.test.tsx b/frontend/src/public/components/TemplateEdit/TemplateControlls/__tests__/TemplateControlls.test.tsx
new file mode 100644
index 000000000..5d657c831
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TemplateControlls/__tests__/TemplateControlls.test.tsx
@@ -0,0 +1,182 @@
+import * as React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { TemplateControlls, ITemplateControllsProps } from '../TemplateControlls';
+import { ETemplateStatus } from '../../../../types/redux';
+import {
+ getRunnableWorkflow,
+ loadDatasetsMap,
+} from '../../utils/getRunnableWorkflow';
+import { mapFieldsetBindingClientToRuntime } from '../../../../utils/mapFieldsetBindingClientToRuntime';
+import { getTemplate } from '../../../../__stubs__/templates';
+import { makeFieldsetBindingClient, makeFieldsetRuntime } from '../../../../__stubs__/fieldsets.factory';
+
+jest.mock('../../../../utils/history', () => ({
+ history: { push: jest.fn() },
+ isCreateTemplate: jest.fn(() => false),
+ checkSomeRouteMatchesLocation: jest.fn(() => false),
+}));
+
+jest.mock('../../../../redux/actions', () => ({
+ discardTemplateChanges: jest.fn((p) => ({ type: 'template/discardChanges', payload: p })),
+}));
+
+jest.mock('react-router-dom', () => ({
+ Link: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+jest.mock('rc-switch', () => ({ __esModule: true, default: () => null }));
+
+jest.mock('../../../icons', () => ({
+ ActivityIcon: () => null,
+ BoxesIcon: () => null,
+ EnableIcon: () => null,
+ TrashIcon: () => null,
+ UnionIcon: () => null,
+ WarningIcon: () => null,
+}));
+
+jest.mock('../../../IntlMessages', () => ({
+ IntlMessages: () => null,
+}));
+
+jest.mock('../../../UI/ShowMore', () => ({
+ ShowMore: () => null,
+}));
+
+jest.mock('../../../UI/Buttons/Button', () => ({
+ Button: jest.fn(
+ (props: { label: string; onClick?: () => void; disabled?: boolean }) =>
+ React.createElement(
+ 'button',
+ { type: 'button', onClick: props.onClick, disabled: props.disabled },
+ props.label,
+ ),
+ ),
+}));
+
+jest.mock('../../../UI/WarningPopup', () => ({
+ WarningPopup: () => null,
+}));
+
+jest.mock('../../../UI/Notifications', () => ({
+ NotificationManager: { warning: jest.fn() },
+}));
+
+jest.mock('../../../UI', () => ({
+ RouteLeavingGuard: jest.fn(
+ (props: {
+ onConfirm: (path: string) => void;
+ onReject: (path: string) => void;
+ renderControlls: (confirm: (p: string) => void, reject: (p: string) => void) => React.ReactNode;
+ }) => props.renderControlls(props.onConfirm, props.onReject),
+ ),
+}));
+
+jest.mock('../../TemplateOwners', () => ({
+ TemplateOwners: () => null,
+}));
+
+jest.mock('../../TemplateViewers', () => ({
+ TemplateViewers: () => null,
+}));
+
+jest.mock('../../TemplateStarters', () => ({
+ TemplateStarters: () => null,
+}));
+
+jest.mock('../../../TemplateIntegrationsStats', () => ({
+ useTemplateIntegrationsList: jest.fn(() => []),
+}));
+
+jest.mock('../../../Templates', () => ({
+ checkShowDraftTemplateWarning: jest.fn(() => false),
+}));
+
+jest.mock('../../utils/getRunnableWorkflow', () => ({
+ getRunnableWorkflow: jest.fn(),
+ loadDatasetsMap: jest.fn(),
+}));
+
+jest.mock('../../../../utils/mapFieldsetBindingClientToRuntime', () => ({
+ mapFieldsetBindingClientToRuntime: jest.fn(),
+}));
+
+jest.mock('../../utils/validateTemplate', () => ({
+ validateTemplate: jest.fn(() => ({ commonWarnings: [], infoWarnings: [] })),
+}));
+
+jest.mock('../../../../utils/routes/getLinkToWorkflows', () => ({
+ getLinkToWorkflows: jest.fn(() => '/'),
+}));
+
+jest.mock('../../../../utils/routes/getLinkToHighlightsByTemplate', () => ({
+ getLinkToHighlightsByTemplate: jest.fn(() => '/'),
+}));
+
+describe('TemplateControlls — fieldset logic', () => {
+ const makeProps = (overrides: Partial
= {}) => ({
+ template: getTemplate('5'),
+ templateStatus: ETemplateStatus.Saved,
+ isSubscribed: false,
+ cloneTemplate: jest.fn(),
+ patchTemplate: jest.fn(),
+ deleteTemplate: jest.fn(),
+ openRunWorkflowModal: jest.fn(),
+ setInfoWarnings: jest.fn(),
+ ...overrides,
+ });
+
+ const formatMsg = (id: string) => intlMock.formatMessage({ id });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('clicking Run on an active saved template runs the chain mapFieldsets → loadDatasets → getRunnable → openModal', async () => {
+ const openRunWorkflowModal = jest.fn();
+ const runnableWorkflow = { kickoff: {}, tasks: [] };
+ const mappedFieldset = makeFieldsetRuntime({ apiNameBinding: 'fs-1' });
+
+ (mapFieldsetBindingClientToRuntime as jest.Mock).mockReturnValue(mappedFieldset);
+ (loadDatasetsMap as jest.Mock).mockResolvedValue({});
+ (getRunnableWorkflow as jest.Mock).mockReturnValue(runnableWorkflow);
+
+ const template = getTemplate('5');
+ template.isActive = true;
+ template.kickoff = {
+ description: '',
+ fields: [],
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-1' })],
+ };
+
+ render(
+ React.createElement(
+ TemplateControlls,
+ makeProps({ template, openRunWorkflowModal, templateStatus: ETemplateStatus.Saved }),
+ ),
+ );
+
+ userEvent.click(screen.getByRole('button', { name: formatMsg('templates.run-workflow') }));
+
+ await waitFor(() => expect(openRunWorkflowModal).toHaveBeenCalledTimes(1));
+
+ expect(loadDatasetsMap).toHaveBeenCalledTimes(1);
+ expect(loadDatasetsMap).toHaveBeenCalledWith(
+ template.kickoff,
+ [mappedFieldset],
+ );
+
+ expect(getRunnableWorkflow).toHaveBeenCalledTimes(1);
+ expect(getRunnableWorkflow).toHaveBeenCalledWith(
+ template,
+ {},
+ [mappedFieldset],
+ );
+ expect(openRunWorkflowModal).toHaveBeenCalledWith(runnableWorkflow);
+ });
+
+
+});
diff --git a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx
index 520b2bf69..dffe33883 100644
--- a/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx
+++ b/frontend/src/public/components/TemplateEdit/TemplateEdit.tsx
@@ -1,5 +1,6 @@
-import React, { useState, useEffect } from 'react';
-import { useSelector } from 'react-redux';
+import * as React from 'react';
+import { useState, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from 'react-intl';
import { RouteComponentProps } from 'react-router-dom';
import { debounce } from 'throttle-debounce';
@@ -10,7 +11,6 @@ import { AutoSaveStatusContainer } from './AutoSaveStatus';
import { TemplateEntity } from './TemplateEntity';
import { AddEntityButton, EEntityTitle } from './AddEntityButton';
import { START_DURATION, DEFAULT_TEMPLATE_NAME } from './constants';
-import { getVariables } from './TaskForm/utils/getTaskVariables';
import { TemplateIntegrations } from './Integrations';
import { ERoutes } from '../../constants/routes';
import { TUserListItem } from '../../types/user';
@@ -22,7 +22,7 @@ import { NotificationManager } from '../UI/Notifications';
import { isArrayWithItems } from '../../utils/helpers';
import { createOwnerApiName, createPerformerApiName, createTaskApiName, createUUID } from '../../utils/createId';
import { EMoveDirections } from '../../types/workflow';
-import { ETaskPerformerType, ETemplateOwnerRole, ETemplateOwnerType, ITemplate, ITemplateTask } from '../../types/template';
+import { ETaskPerformerType, ETemplateOwnerRole, ETemplateOwnerType, ITemplateClient, ITemplateTaskClient } from '../../types/template';
import { TLoadTemplateVariablesSuccessPayload } from '../../redux/actions';
import { ETemplateStatus, IAuthUser } from '../../types/redux';
import { getKickoffConditions } from './TaskForm/Conditions/utils/getKickoffConditions';
@@ -32,9 +32,13 @@ import { usePrevious } from '../../hooks/usePrevious';
import { ConditionsBanner } from './ConditionsBanner';
import { getUserFullName } from '../../utils/users';
import { getSubscriptionPlan } from '../../redux/selectors/user';
+import { getIsCatalogLoaded } from '../../redux/selectors/fieldsets';
+import { loadFieldsetsCatalog } from '../../redux/fieldsets/slice';
import { ESubscriptionPlan } from '../../types/account';
import { TemplateSettings } from './TemplateSettings';
+import { TemplateEditVariablesSync } from './TemplateEditVariablesSync';
+
import styles from './TemplateEdit.css';
import { getEmptyConditions } from './TaskForm/Conditions/utils/getEmptyConditions';
@@ -42,8 +46,8 @@ export interface ITemplateEditProps {
match: any;
location: any;
authUser: IAuthUser;
- template: ITemplate;
- aiTemplate: ITemplate | null;
+ template: ITemplateClient;
+ aiTemplate: ITemplateClient | null;
templateStatus: ETemplateStatus;
users: TUserListItem[];
isSubscribed: boolean;
@@ -51,7 +55,7 @@ export interface ITemplateEditProps {
loadTemplateFromSystem(id: string): void;
resetTemplateStore(): void;
saveTemplate(): void;
- setTemplate(payload: ITemplate): void;
+ setTemplate(payload: ITemplateClient): void;
setTemplateStatus(status: ETemplateStatus): void;
loadTemplateVariablesSuccess(payload: TLoadTemplateVariablesSuccessPayload): void;
}
@@ -87,8 +91,11 @@ export function TemplateEdit({
loadTemplateVariablesSuccess,
}: TTemplateEditProps) {
const { formatMessage } = useIntl();
+ const dispatch = useDispatch();
const { tasks, owners } = template;
const billingPlan = useSelector(getSubscriptionPlan);
+ const isCatalogLoaded = useSelector(getIsCatalogLoaded);
+
const isFreePlan = billingPlan === ESubscriptionPlan.Free;
const accessConditions = isSubscribed || isFreePlan;
@@ -102,6 +109,10 @@ export function TemplateEdit({
useEffect(() => {
initPage();
+ if (!isCatalogLoaded) {
+ dispatch(loadFieldsetsCatalog());
+ }
+
return () => {
resetTemplateStore();
};
@@ -114,14 +125,6 @@ export function TemplateEdit({
}, [template.tasks, prevLocation?.pathname]);
useEffect(() => {
- const variables = getVariables(template);
- const prevVariables = prevTemplate ? getVariables(prevTemplate) : [];
- if (variables.length !== prevVariables.length) {
- if (template.id) {
- loadTemplateVariablesSuccess({ templateId: template.id, variables });
- }
- }
-
const [pathName, prevPathName] = [location.pathname, prevLocation?.pathname];
const isPreviousPathIsCreate = prevPathName === ERoutes.TemplatesCreate;
const isCurrentPathIsEdit = checkSomeRouteIsActive(ERoutes.TemplatesEdit);
@@ -184,7 +187,7 @@ export function TemplateEdit({
const sortedTasks = () => [...tasks].sort((a, b) => a.number - b.number);
- const getNewTask = (templateTask?: Partial): ITemplateTask => {
+ const getNewTask = (templateTask?: Partial): ITemplateTaskClient => {
const taskApiName = createTaskApiName();
return {
@@ -194,6 +197,7 @@ export function TemplateEdit({
name: 'New Step',
number: 1,
fields: [],
+ fieldsets: [],
rawPerformers: [
{
apiName: createPerformerApiName(),
@@ -214,7 +218,7 @@ export function TemplateEdit({
};
};
- const getEmptyTemplate = () => {
+ const getEmptyTemplate = (): ITemplateClient => {
return {
description: '',
kickoff: getEmptyKickoff(),
@@ -250,15 +254,17 @@ export function TemplateEdit({
users,
),
wfNameTemplate: '{{date}} — {{template-name}}',
- } as ITemplate;
+ completionNotification: false,
+ reminderNotification: false,
+ };
};
- const handleChangeTemplateField = (field: keyof ITemplate) => (value: ITemplate[keyof ITemplate]) => {
+ const handleChangeTemplateField = (field: keyof ITemplateClient) => (value: ITemplateClient[keyof ITemplateClient]) => {
const workflow = template;
setTemplateStatus(ETemplateStatus.Saving);
if (field === 'isActive') {
- const newWorkflow: ITemplate = {
+ const newWorkflow: ITemplateClient = {
...workflow,
isActive: value as boolean,
};
@@ -269,7 +275,7 @@ export function TemplateEdit({
return;
}
- const updatedWorkflow: ITemplate = {
+ const updatedWorkflow: ITemplateClient = {
...workflow,
[field]: value,
isActive: false,
@@ -283,11 +289,11 @@ export function TemplateEdit({
submitDebounced();
};
- const changeTasks = (newTasks: ITemplateTask[]) => {
+ const changeTasks = (newTasks: ITemplateTaskClient[]) => {
handleChangeTemplateField('tasks')(newTasks);
};
- const handleRemoveTask = (targetTask: ITemplateTask) => () => {
+ const handleRemoveTask = (targetTask: ITemplateTaskClient) => () => {
const newTasks = tasks
.filter((task) => task.uuid !== targetTask.uuid)
.map((task, index) => ({ ...task, number: index + 1 }));
@@ -326,7 +332,7 @@ export function TemplateEdit({
changeTasks(newTasks);
};
- const getTasksWithNewTask = (newTask: ITemplateTask, newTaskIndex: number) => {
+ const getTasksWithNewTask = (newTask: ITemplateTaskClient, newTaskIndex: number) => {
const newTasks = [...tasks.slice(0, newTaskIndex), newTask, ...tasks.slice(newTaskIndex)].map((task, index) => ({
...task,
number: index + 1,
@@ -335,14 +341,14 @@ export function TemplateEdit({
return newTasks;
};
- const handleCloneTask = (targetTask: ITemplateTask) => () => {
+ const handleCloneTask = (targetTask: ITemplateTaskClient) => () => {
const newTask = getClonedTask(targetTask);
const newTasks = getTasksWithNewTask(newTask, targetTask.number);
changeTasks(newTasks);
toggleIsOpenTask(newTask.uuid);
};
- const handleAddTaskBefore = (targetTask: ITemplateTask) => (previousTaskApiName?: string) => {
+ const handleAddTaskBefore = (targetTask: ITemplateTaskClient) => (previousTaskApiName?: string) => {
const newTaskName = `New Step ${tasks.length + 1}`;
const newTask = getNewTask({
name: newTaskName,
@@ -369,7 +375,7 @@ export function TemplateEdit({
};
const handleEditTaskField =
- (targetTask: ITemplateTask) => (field: keyof ITemplateTask) => (value: ITemplateTask[keyof ITemplateTask]) => {
+ (targetTask: ITemplateTaskClient) => (field: keyof ITemplateTaskClient) => (value: ITemplateTaskClient[keyof ITemplateTaskClient]) => {
const newTasks = tasks.map((task) => {
if (targetTask.uuid === task.uuid) {
return {
@@ -384,7 +390,7 @@ export function TemplateEdit({
handleChangeTemplateField('tasks')(newTasks);
};
- const addDelay = (targetTask: ITemplateTask) => () => {
+ const addDelay = (targetTask: ITemplateTaskClient) => () => {
if (targetTask.delay) {
const message = formatMessage({ id: 'template.delay-task-has-delay-error' });
NotificationManager.warning({ message });
@@ -414,7 +420,7 @@ export function TemplateEdit({
changeTasks(newTasks);
};
- const editDelay = (targetTask: ITemplateTask) => (delay: string) => {
+ const editDelay = (targetTask: ITemplateTaskClient) => (delay: string) => {
const newTasks = tasks.map((task) => {
if (task.uuid === targetTask.uuid) return { ...targetTask, delay };
@@ -424,7 +430,7 @@ export function TemplateEdit({
changeTasks(newTasks);
};
- const deleteDelay = (targetTask: ITemplateTask) => () => {
+ const deleteDelay = (targetTask: ITemplateTaskClient) => () => {
if (!targetTask.delay) return;
handleEditTaskField(targetTask)('delay')('');
@@ -438,7 +444,7 @@ export function TemplateEdit({
const submitDebounced = debounce(350, saveTemplate);
- const getTaskListItem = (task: ITemplateTask, index: number, tasksLocal: ITemplateTask[]) => {
+ const getTaskListItem = (task: ITemplateTaskClient, index: number, tasksLocal: ITemplateTaskClient[]) => {
const isTaskOpen = Boolean(openedTasks[task.uuid]);
const isDelayOpen = Boolean(openedDelays[task.uuid]);
const previousTask = index > 0 ? tasksLocal[index - 1] : null;
@@ -467,37 +473,42 @@ export function TemplateEdit({
);
};
- if (templateStatus === ETemplateStatus.Loading) {
- return ;
- }
-
- return (
-
-
+ return templateStatus === ETemplateStatus.Loading ? (
+
+ ) : (
+ <>
+
+
+
-
-
-
-
-
- {!accessConditions &&
}
-
-
-
+
+
+
+
+
+ {!accessConditions &&
}
+
+
+
+
+ {sortedTasks().map(getTaskListItem)}
+
+
- {sortedTasks().map(getTaskListItem)}
-
-
-
+ >
);
}
diff --git a/frontend/src/public/components/TemplateEdit/TemplateEditVariablesSync.tsx b/frontend/src/public/components/TemplateEdit/TemplateEditVariablesSync.tsx
new file mode 100644
index 000000000..149c5b1e6
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/TemplateEditVariablesSync.tsx
@@ -0,0 +1,39 @@
+import { useEffect } from 'react';
+
+import { ITemplateClient } from '../../types/template';
+import { TLoadTemplateVariablesSuccessPayload } from '../../redux/actions';
+import { getVariables } from './TaskForm/utils/getTaskVariables';
+
+export interface ITemplateEditVariablesSyncProps {
+ template: ITemplateClient;
+ prevTemplate: ITemplateClient | undefined;
+ loadTemplateVariablesSuccess(payload: TLoadTemplateVariablesSuccessPayload): void;
+}
+
+export function TemplateEditVariablesSync({
+ template,
+ prevTemplate,
+ loadTemplateVariablesSuccess,
+}: ITemplateEditVariablesSyncProps) {
+ useEffect(() => {
+ const variables = getVariables({
+ kickoff: template.kickoff,
+ tasks: template.tasks,
+ templateId: template.id,
+ });
+ let prevVariables = [];
+ if (prevTemplate) {
+ prevVariables = getVariables({
+ kickoff: prevTemplate.kickoff,
+ tasks: prevTemplate.tasks,
+ templateId: prevTemplate.id,
+ });
+ }
+
+ if (variables.length !== prevVariables.length && template.id) {
+ loadTemplateVariablesSuccess({ templateId: template.id, variables });
+ }
+ }, [loadTemplateVariablesSuccess, prevTemplate, template]);
+
+ return null;
+}
diff --git a/frontend/src/public/components/TemplateEdit/TemplateEntity/TemplateEntity.props.tsx b/frontend/src/public/components/TemplateEdit/TemplateEntity/TemplateEntity.props.tsx
index c341cf340..f2d6119b1 100644
--- a/frontend/src/public/components/TemplateEdit/TemplateEntity/TemplateEntity.props.tsx
+++ b/frontend/src/public/components/TemplateEdit/TemplateEntity/TemplateEntity.props.tsx
@@ -1,10 +1,10 @@
-import { ITemplateTask } from '../../../types/template';
+import { ITemplateTaskClient } from '../../../types/template';
import { TUserListItem } from '../../../types/user';
import { EMoveDirections } from '../../../types/workflow';
export interface ITemplateEntityProps {
index: number;
- task: ITemplateTask;
+ task: ITemplateTaskClient;
users: TUserListItem[];
tasksCount: number;
isSubscribed: boolean;
@@ -13,7 +13,7 @@ export interface ITemplateEntityProps {
actualPreviousTaskApiName?: string;
addDelay(): void;
addTaskBefore(): void;
- deleteDelay(targetTask: ITemplateTask): () => void;
+ deleteDelay(targetTask: ITemplateTaskClient): () => void;
editDelay(delay: string): void;
toggleDelay(): void;
handleMoveTask(from: number, direction: EMoveDirections): () => void;
diff --git a/frontend/src/public/components/TemplateEdit/TemplateOwners/TemplateOwners.tsx b/frontend/src/public/components/TemplateEdit/TemplateOwners/TemplateOwners.tsx
index bb31e4a49..33f4cfa46 100644
--- a/frontend/src/public/components/TemplateEdit/TemplateOwners/TemplateOwners.tsx
+++ b/frontend/src/public/components/TemplateEdit/TemplateOwners/TemplateOwners.tsx
@@ -9,14 +9,14 @@ import { trackInviteTeamInPage } from '../../../utils/analytics';
import { getNotDeletedUsers, getUserFullName } from '../../../utils/users';
import { EOptionTypes, TUsersDropdownOption, UsersDropdown, getUsersDropdownOptionValue } from '../../UI/form/UsersDropdown';
import { getIsUserSubsribed, getSubscriptionPlan, getUsers } from '../../../redux/selectors/user';
-import { ETaskPerformerType, ETemplateOwnerRole, ETemplateOwnerType, ITemplate, ITemplateOwner } from '../../../types/template';
+import { ETaskPerformerType, ETemplateOwnerRole, ETemplateOwnerType, ITemplateClient, ITemplateOwner } from '../../../types/template';
import OwnerItem from './components';
import styles from './TemplateOwners.css';
import UserDataWithGroup from '../../UserDataWithGroup';
interface ITemplateOwnersProps {
- templateOwners: ITemplate['owners'];
+ templateOwners: ITemplateClient['owners'];
onChangeTemplateOwners(templateOwners: ITemplateOwner[]): void;
}
diff --git a/frontend/src/public/components/TemplateEdit/TemplateSettings/TemplateSettings.tsx b/frontend/src/public/components/TemplateEdit/TemplateSettings/TemplateSettings.tsx
index 2854dc40e..4f4661a45 100644
--- a/frontend/src/public/components/TemplateEdit/TemplateSettings/TemplateSettings.tsx
+++ b/frontend/src/public/components/TemplateEdit/TemplateSettings/TemplateSettings.tsx
@@ -9,7 +9,7 @@ import { NAVBAR_HEIGHT } from '../../../constants/defaultValues';
import { getTemplateData } from '../../../redux/selectors/template';
import { saveTemplate, setTemplate, setTemplateStatus } from '../../../redux/actions';
import { ETemplateStatus } from '../../../types/redux';
-import { ITemplate } from '../../../types/template';
+import { ITemplateClient } from '../../../types/template';
import { TemplateControllsContainer } from '../TemplateControlls';
import { isArrayWithItems } from '../../../utils/helpers';
import { IInfoWarningProps, InfoWarningsModal } from '../InfoWarningsModal';
@@ -25,9 +25,9 @@ export function TemplateSettings() {
const [isInfoWarningsModaOpen, setIsInfoWarningsModaOpen] = useState(false);
const [infoWarnings, setInfoWarnings] = useState
([]);
- const handleChangeTemplateField = (field: keyof ITemplate) => (value: ITemplate[keyof ITemplate]) => {
+ const handleChangeTemplateField = (field: keyof ITemplateClient) => (value: ITemplateClient[keyof ITemplateClient]) => {
const workflow = template;
- let newWorkflow: ITemplate;
+ let newWorkflow: ITemplateClient;
dispatch(setTemplateStatus(ETemplateStatus.Saving));
if (field === 'isActive') {
@@ -47,7 +47,7 @@ export function TemplateSettings() {
submitDebounced();
};
- const handleChangeTextField = (field: keyof ITemplate) => (value: string) => handleChangeTemplateField(field)(value);
+ const handleChangeTextField = (field: keyof ITemplateClient) => (value: string) => handleChangeTemplateField(field)(value);
const submitDebounced = debounce(350, () => dispatch(saveTemplate()));
diff --git a/frontend/src/public/components/TemplateEdit/VariableList/__tests__/VariableList.test.tsx b/frontend/src/public/components/TemplateEdit/VariableList/__tests__/VariableList.test.tsx
index 8ff6b729c..c72246452 100644
--- a/frontend/src/public/components/TemplateEdit/VariableList/__tests__/VariableList.test.tsx
+++ b/frontend/src/public/components/TemplateEdit/VariableList/__tests__/VariableList.test.tsx
@@ -1,4 +1,3 @@
-///
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
diff --git a/frontend/src/public/components/TemplateEdit/__tests__/TemplateEdit.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/TemplateEdit.test.tsx
new file mode 100644
index 000000000..6fadaea96
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/__tests__/TemplateEdit.test.tsx
@@ -0,0 +1,214 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useSelector, useDispatch } from 'react-redux';
+
+import { TemplateEdit } from '../TemplateEdit';
+import { cleanTemplateReferences } from '../../../utils/template';
+import { KickoffReduxContainer } from '../KickoffRedux';
+import { getSubscriptionPlan } from '../../../redux/selectors/user';
+import { getIsCatalogLoaded } from '../../../redux/selectors/fieldsets';
+import { ETemplateStatus } from '../../../types/redux';
+
+jest.mock('../../../utils/template', () => ({
+ cleanTemplateReferences: jest.fn((tpl: any) => tpl),
+ getEmptyKickoff: jest.fn(() => ({ description: '', fields: [], fieldsets: [] })),
+ getNormalizedTemplateOwners: jest.fn((owners: any) => owners),
+ getTemplateIdFromUrl: jest.fn(() => null),
+}));
+
+jest.mock('../../../utils/history', () => ({
+ checkSomeRouteIsActive: jest.fn(() => false),
+ isCreateTemplate: jest.fn(() => false),
+}));
+
+jest.mock('../../../utils/users', () => ({
+ getUserFullName: jest.fn(() => 'Test User'),
+}));
+
+jest.mock('../../../utils/createId', () => ({
+ createOwnerApiName: jest.fn(() => 'owner-api'),
+ createPerformerApiName: jest.fn(() => 'performer-api'),
+ createTaskApiName: jest.fn(() => 'task-api-new'),
+ createUUID: jest.fn(() => 'uuid-new'),
+}));
+
+jest.mock('../../../utils/dueDate/createEmptyTaskDueDate', () => ({
+ createEmptyTaskDueDate: jest.fn(() => ({})),
+}));
+
+jest.mock('../../../utils/workflows', () => ({
+ moveTask: jest.fn((_from: number, _to: number, tasks: any[]) => tasks),
+}));
+
+jest.mock('throttle-debounce', () => ({
+ debounce: (_ms: number, fn: (...args: any[]) => any) => fn,
+}));
+
+jest.mock('../AutoSaveStatus', () => ({
+ AutoSaveStatusContainer: jest.fn(() => null),
+}));
+jest.mock('../TemplateEntity', () => ({
+ TemplateEntity: jest.fn(() => null),
+}));
+jest.mock('../AddEntityButton', () => ({
+ AddEntityButton: jest.fn(
+ (props: { entities: { title: string; onAddEntity: (apiName?: string) => void }[] }) =>
+ React.createElement(
+ 'button',
+ {
+ type: 'button',
+ onClick: () => props.entities[0].onAddEntity(),
+ },
+ 'Add',
+ ),
+ ),
+ EEntityTitle: { Task: 'task', Delay: 'delay' },
+}));
+jest.mock('../Integrations', () => ({
+ TemplateIntegrations: jest.fn(() => null),
+}));
+jest.mock('../KickoffRedux', () => ({
+ KickoffReduxContainer: jest.fn(() => null),
+}));
+jest.mock('../TemplateSettings', () => ({
+ TemplateSettings: jest.fn(() => null),
+}));
+jest.mock('../TemplateEditVariablesSync', () => ({
+ TemplateEditVariablesSync: jest.fn(() => null),
+}));
+jest.mock('../ConditionsBanner', () => ({
+ ConditionsBanner: jest.fn(() => null),
+}));
+
+jest.mock('../TaskForm/Conditions/utils/getKickoffConditions', () => ({
+ getKickoffConditions: jest.fn(() => ({ predicates: [] })),
+}));
+jest.mock('../TaskForm/Conditions/utils/getStartTaskConditions', () => ({
+ getStartTaskConditions: jest.fn(() => ({ predicates: [] })),
+}));
+jest.mock('../TaskForm/Conditions/utils/getEmptyConditions', () => ({
+ getEmptyConditions: jest.fn(() => ({ predicates: [] })),
+}));
+
+jest.mock('../utils/getClonedTask', () => ({
+ getClonedTask: jest.fn((task: any) => task),
+}));
+
+jest.mock('../../../redux/selectors/fieldsets', () => ({
+ getIsCatalogLoaded: jest.fn(),
+}));
+jest.mock('../../../redux/selectors/user', () => ({
+ getSubscriptionPlan: jest.fn(),
+}));
+
+jest.mock('../../UI/Notifications', () => ({
+ NotificationManager: { warning: jest.fn() },
+}));
+
+const SUBSCRIPTION_PLAN = 'unlimited_month';
+
+describe('TemplateEdit', () => {
+ const makeTemplate = (overrides: any = {}) => ({
+ id: 1,
+ name: 'Tpl',
+ description: '',
+ isActive: false,
+ finalizable: false,
+ owners: [],
+ tasks: [
+ { uuid: 'u-task-1', apiName: 'task-1', number: 1, fields: [], fieldsets: [] } as any,
+ ],
+ kickoff: { description: '', fields: [], fieldsets: [] },
+ isPublic: false,
+ publicUrl: null,
+ publicSuccessUrl: null,
+ isEmbedded: false,
+ embedUrl: null,
+ wfNameTemplate: null,
+ tasksCount: 1,
+ performersCount: 0,
+ dateUpdated: null,
+ updatedBy: null,
+ ...overrides,
+ });
+
+ const baseProps = () => ({
+ match: { params: { id: '1' } },
+ location: { pathname: '/templates/edit/1', search: '' },
+ authUser: { id: 1, isSuperuser: false } as any,
+ template: makeTemplate(),
+ aiTemplate: null,
+ templateStatus: ETemplateStatus.Saved,
+ users: [{ id: 1 }] as any,
+ isSubscribed: true,
+ loadTemplate: jest.fn(),
+ loadTemplateFromSystem: jest.fn(),
+ resetTemplateStore: jest.fn(),
+ saveTemplate: jest.fn(),
+ setTemplate: jest.fn(),
+ setTemplateStatus: jest.fn(),
+ loadTemplateVariablesSuccess: jest.fn(),
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useDispatch as jest.Mock).mockReturnValue(jest.fn());
+ (useSelector as jest.Mock).mockImplementation((selector: unknown) => {
+ if (selector === getSubscriptionPlan) return SUBSCRIPTION_PLAN;
+ if (selector === getIsCatalogLoaded) return true;
+ return undefined;
+ });
+ });
+
+ describe('cleanup of references to fieldset fields', () => {
+ it('changing tasks runs cleanTemplateReferences and pipes the result into setTemplate', () => {
+ const props = baseProps();
+ render(React.createElement(TemplateEdit, props as any));
+
+ userEvent.click(screen.getByRole('button', { name: 'Add' }));
+
+ expect(cleanTemplateReferences).toHaveBeenCalledTimes(1);
+ const [updatedWorkflow] = (cleanTemplateReferences as jest.Mock).mock.calls[0];
+ expect(updatedWorkflow.tasks).toHaveLength(2);
+ expect(props.setTemplate).toHaveBeenCalledTimes(1);
+ expect(props.setTemplate).toHaveBeenCalledWith(updatedWorkflow);
+ });
+
+ it('changing kickoff runs cleanTemplateReferences and pipes the result into setTemplate', () => {
+ const props = baseProps();
+ render(React.createElement(TemplateEdit, props as any));
+
+ const kickoffMock = KickoffReduxContainer as unknown as jest.Mock;
+ const lastCall = kickoffMock.mock.calls[kickoffMock.mock.calls.length - 1];
+ const setKickoff = lastCall[0].setKickoff as (kickoff: any) => void;
+
+ const newKickoff = {
+ description: 'new',
+ fields: [],
+ fieldsets: [{ apiName: 'fs-1' }],
+ };
+ setKickoff(newKickoff);
+
+ expect(cleanTemplateReferences).toHaveBeenCalledTimes(1);
+ const [updatedWorkflow] = (cleanTemplateReferences as jest.Mock).mock.calls[0];
+ expect(updatedWorkflow.kickoff).toBe(newKickoff);
+ expect(props.setTemplate).toHaveBeenCalledTimes(1);
+ expect(props.setTemplate).toHaveBeenCalledWith(updatedWorkflow);
+ });
+ });
+
+ describe('newly added task', () => {
+ it('a freshly added task has fieldsets as an empty array, not undefined', () => {
+ const props = { ...baseProps(), template: makeTemplate({ tasks: [] }) };
+ render(React.createElement(TemplateEdit, props as any));
+
+ userEvent.click(screen.getByRole('button', { name: 'Add' }));
+
+ expect(cleanTemplateReferences).toHaveBeenCalledTimes(1);
+ const newTemplate = (cleanTemplateReferences as jest.Mock).mock.calls[0][0];
+ expect(newTemplate.tasks).toHaveLength(1);
+ expect(newTemplate.tasks[0].fieldsets).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/__tests__/TemplateEditVariablesSync.test.tsx b/frontend/src/public/components/TemplateEdit/__tests__/TemplateEditVariablesSync.test.tsx
new file mode 100644
index 000000000..c982abbd5
--- /dev/null
+++ b/frontend/src/public/components/TemplateEdit/__tests__/TemplateEditVariablesSync.test.tsx
@@ -0,0 +1,86 @@
+import * as React from 'react';
+import { render } from '@testing-library/react';
+
+import { getVariables } from '../TaskForm/utils/getTaskVariables';
+import {
+ ITemplateEditVariablesSyncProps,
+ TemplateEditVariablesSync,
+} from '../TemplateEditVariablesSync';
+
+jest.mock('../TaskForm/utils/getTaskVariables', () => ({
+ getVariables: jest.fn(),
+}));
+
+const makeTemplate = (id: number | undefined) =>
+ ({ id, kickoff: { fields: [], fieldsets: [] }, tasks: [] } as any);
+
+describe('TemplateEditVariablesSync', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('calls loadTemplateVariablesSuccess when variable count changes', () => {
+ const loadTemplateVariablesSuccess = jest.fn();
+ const mockVariables = [{ apiName: 'v1' }, { apiName: 'v2' }] as any[];
+
+ (getVariables as jest.Mock)
+ .mockReturnValueOnce(mockVariables)
+ .mockReturnValueOnce([{ apiName: 'v1' }]);
+
+ render(
+ React.createElement(TemplateEditVariablesSync, {
+ template: makeTemplate(10),
+ prevTemplate: makeTemplate(10),
+ loadTemplateVariablesSuccess,
+ } as ITemplateEditVariablesSyncProps),
+ );
+
+ expect(loadTemplateVariablesSuccess).toHaveBeenCalledTimes(1);
+ expect(loadTemplateVariablesSuccess).toHaveBeenCalledWith({
+ templateId: 10,
+ variables: mockVariables,
+ });
+
+ expect(getVariables).toHaveBeenCalledTimes(2);
+ expect(getVariables).toHaveBeenCalledWith(
+ expect.objectContaining({ templateId: 10 }),
+ );
+ });
+
+ it('does NOT call loadTemplateVariablesSuccess when variable count is unchanged', () => {
+ const loadTemplateVariablesSuccess = jest.fn();
+ const sameVars = [{ apiName: 'v1' }, { apiName: 'v2' }] as any[];
+
+ (getVariables as jest.Mock)
+ .mockReturnValueOnce(sameVars)
+ .mockReturnValueOnce(sameVars);
+
+ render(
+ React.createElement(TemplateEditVariablesSync, {
+ template: makeTemplate(10),
+ prevTemplate: makeTemplate(10),
+ loadTemplateVariablesSuccess,
+ } as ITemplateEditVariablesSyncProps),
+ );
+
+ expect(loadTemplateVariablesSuccess).not.toHaveBeenCalled();
+ });
+
+ it('does NOT call loadTemplateVariablesSuccess when template.id is undefined', () => {
+ const loadTemplateVariablesSuccess = jest.fn();
+
+ (getVariables as jest.Mock)
+ .mockReturnValueOnce([{ apiName: 'v1' }, { apiName: 'v2' }])
+ .mockReturnValueOnce([{ apiName: 'v1' }]);
+
+ render(
+ React.createElement(TemplateEditVariablesSync, {
+ template: makeTemplate(undefined),
+ prevTemplate: makeTemplate(10),
+ loadTemplateVariablesSuccess,
+ } as ITemplateEditVariablesSyncProps),
+ );
+
+ expect(loadTemplateVariablesSuccess).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/public/components/TemplateEdit/types.ts b/frontend/src/public/components/TemplateEdit/types.ts
index bc13e02a6..c59eea712 100644
--- a/frontend/src/public/components/TemplateEdit/types.ts
+++ b/frontend/src/public/components/TemplateEdit/types.ts
@@ -26,6 +26,7 @@ export enum ETaskFormParts {
CheckIf = 'checkIf',
DueIn = 'dueIn',
Fields = 'fields',
+ Fieldsets = 'fieldsets',
AssignPerformers = 'assignPerformers',
ReturnTo = 'returnTo',
StartsAfter = 'startsAfter',
diff --git a/frontend/src/public/components/TemplateEdit/utils/__tests__/areExtraFieldsValid.test.ts b/frontend/src/public/components/TemplateEdit/utils/__tests__/areExtraFieldsValid.test.ts
index b09022fda..b2e12c325 100644
--- a/frontend/src/public/components/TemplateEdit/utils/__tests__/areExtraFieldsValid.test.ts
+++ b/frontend/src/public/components/TemplateEdit/utils/__tests__/areExtraFieldsValid.test.ts
@@ -1,15 +1,11 @@
-//
import { areExtraFieldsValid } from '../areExtraFieldsValid';
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
import { EExtraFieldType, IExtraField } from '../../../../types/template';
describe('areExtraFieldsValid', () => {
- const makeField = (overrides: Partial = {}): IExtraField => ({
- apiName: 'field-1',
+ const makeField = (overrides: Partial = {}) => makeExtraField({
name: 'Test Field',
type: EExtraFieldType.Checkbox,
- order: 0,
- userId: null,
- groupId: null,
...overrides,
});
diff --git a/frontend/src/public/components/TemplateEdit/utils/__tests__/getClonedTask.test.ts b/frontend/src/public/components/TemplateEdit/utils/__tests__/getClonedTask.test.ts
index 1b6959d39..c78ca94f0 100644
--- a/frontend/src/public/components/TemplateEdit/utils/__tests__/getClonedTask.test.ts
+++ b/frontend/src/public/components/TemplateEdit/utils/__tests__/getClonedTask.test.ts
@@ -1,4 +1,5 @@
-import { EExtraFieldType, ITemplateTask } from '../../../../types/template';
+import { EExtraFieldType, ITemplateTaskClient } from '../../../../types/template';
+import { makeFieldsetBindingClient } from '../../../../__stubs__/fieldsets.factory';
import { createEmptyTaskDueDate } from '../../../../utils/dueDate/createEmptyTaskDueDate';
import { omit } from '../../../../utils/helpers';
import { EConditionAction, EConditionLogicOperations, EConditionOperators } from '../../TaskForm/Conditions';
@@ -6,7 +7,7 @@ import { getClonedTask } from '../getClonedTask';
describe('getClonedTask', () => {
it("returns initial task's copy", () => {
- const mockTask: ITemplateTask = {
+ const mockTask: ITemplateTaskClient = {
id: 3048,
apiName: 'task-1',
name: 'Task 1',
@@ -83,11 +84,12 @@ describe('getClonedTask', () => {
checklists: [],
revertTask: null,
ancestors: [],
+ fieldsets: [],
};
const clonedTask = getClonedTask(mockTask);
- const diffFields: (keyof ITemplateTask)[] = [
+ const diffFields: (keyof ITemplateTaskClient)[] = [
'id',
'apiName',
'uuid',
@@ -108,4 +110,39 @@ describe('getClonedTask', () => {
expect(clonedTask.conditions[0].rules[0].ruleId).toBe(undefined);
expect(clonedTask.conditions[0].rules[0].ruleApiName).toBe(clonedTask.conditions[0].rules[1].ruleApiName);
});
+
+ it('regenerates apiNameBinding for each fieldset on clone', () => {
+ const mockTask: ITemplateTaskClient = {
+ id: 1,
+ apiName: 'task-source',
+ name: 'Task',
+ description: '',
+ number: 1,
+ requireCompletionByAll: false,
+ skipForStarter: false,
+ fields: [],
+ rawPerformers: [],
+ delay: null,
+ rawDueDate: createEmptyTaskDueDate(),
+ conditions: [],
+ uuid: 'uuid-source',
+ checklists: [],
+ revertTask: null,
+ ancestors: [],
+ fieldsets: [
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-a', order: 0 }),
+ makeFieldsetBindingClient({ apiNameBinding: 'fs-b', order: 5 }),
+ ],
+ };
+
+ const clonedTask = getClonedTask(mockTask);
+
+ expect(clonedTask.fieldsets).toHaveLength(2);
+ expect(clonedTask.fieldsets[0].order).toBe(0);
+ expect(clonedTask.fieldsets[1].order).toBe(5);
+ expect(clonedTask.fieldsets[0].apiNameBinding).not.toBe('fs-a');
+ expect(clonedTask.fieldsets[1].apiNameBinding).not.toBe('fs-b');
+ expect(clonedTask.fieldsets[0].apiNameBinding).toMatch(/^fieldset-bind-/);
+ expect(clonedTask.fieldsets[1].apiNameBinding).toMatch(/^fieldset-bind-/);
+ });
});
diff --git a/frontend/src/public/components/TemplateEdit/utils/__tests__/getRunnableWorkflow.test.ts b/frontend/src/public/components/TemplateEdit/utils/__tests__/getRunnableWorkflow.test.ts
index 4638d1667..17efc4b70 100644
--- a/frontend/src/public/components/TemplateEdit/utils/__tests__/getRunnableWorkflow.test.ts
+++ b/frontend/src/public/components/TemplateEdit/utils/__tests__/getRunnableWorkflow.test.ts
@@ -1,24 +1,20 @@
-//
-import { ETaskPerformerType, EExtraFieldType, ITemplateResponse, ETemplateOwnerType, ETemplateOwnerRole } from '../../../../types/template';
-import { getRunnableWorkflow } from '../getRunnableWorkflow';
+import { ETaskPerformerType, EExtraFieldType, IKickoffClient } from '../../../../types/template';
+import { IFieldsetRuntime } from '../../../../types/fieldset';
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../../__stubs__/fieldsets.factory';
+import { getRunnableWorkflow, loadDatasetsMap, TTemplateToRunWorkflow } from '../getRunnableWorkflow';
+import { getDataset } from '../../../../api/datasets/getDataset';
-const templateResponseMock: ITemplateResponse = {
+jest.mock('../../../../api/datasets/getDataset', () => ({
+ getDataset: jest.fn(),
+}));
+
+const templateResponseMock: TTemplateToRunWorkflow = {
id: 4654,
name: 'End Template',
description: '12346789',
tasks: [
{
- id: 14702,
- name: 'First Step',
- number: 1,
- description: '12',
- requireCompletionByAll: false,
- skipForStarter: false,
- delay: null,
- rawDueDate: null,
- fields: [],
- conditions: [],
- apiName: 'task-059819',
rawPerformers: [
{
sourceId: '306',
@@ -33,34 +29,8 @@ const templateResponseMock: ITemplateResponse = {
apiName: 'raw-performer-024t43',
},
],
- checklists: [],
- revertTask: null,
- ancestors: [],
},
{
- id: 14703,
- name: 'New Step 2',
- number: 2,
- description: '1233',
- requireCompletionByAll: false,
- skipForStarter: false,
- delay: null,
- rawDueDate: null,
- fields: [
- {
- id: 21478,
- type: EExtraFieldType.String,
- name: '12 21 12 12 12222222222',
- description: '',
- isRequired: false,
- order: 0,
- apiName: 'field-27fb3d',
- userId: null,
- groupId: null,
- },
- ],
- conditions: [],
- apiName: 'task-4e99a7',
rawPerformers: [
{
sourceId: '306',
@@ -69,22 +39,8 @@ const templateResponseMock: ITemplateResponse = {
apiName: 'raw-performer-024t43',
},
],
- checklists: [],
- revertTask: null,
- ancestors: ['task-059819'],
},
{
- id: 14707,
- name: 'New Step 3',
- number: 3,
- description: '',
- requireCompletionByAll: false,
- skipForStarter: false,
- delay: null,
- rawDueDate: null,
- fields: [],
- conditions: [],
- apiName: 'task-a889d4',
rawPerformers: [
{
sourceId: '306',
@@ -93,22 +49,8 @@ const templateResponseMock: ITemplateResponse = {
apiName: 'raw-performer-024t43',
},
],
- checklists: [],
- revertTask: null,
- ancestors: ['task-059819', 'task-4e99a7'],
},
{
- id: 14708,
- name: 'New Step 4',
- number: 4,
- description: '',
- requireCompletionByAll: false,
- skipForStarter: false,
- delay: null,
- rawDueDate: null,
- fields: [],
- conditions: [],
- apiName: 'task-1e9ba6',
rawPerformers: [
{
sourceId: '306',
@@ -117,23 +59,8 @@ const templateResponseMock: ITemplateResponse = {
apiName: 'raw-performer-024t43',
},
],
- checklists: [],
- revertTask: null,
- ancestors: ['task-059819', 'task-4e99a7', 'task-a889d4'],
},
{
- id: 14709,
- name: 'New Step 5',
- number: 5,
- description: '',
- requireCompletionByAll: false,
- skipForStarter: false,
- delay: null,
- dueIn: null,
- rawDueDate: null,
- fields: [],
- conditions: [],
- apiName: 'task-05a887',
rawPerformers: [
{
sourceId: '306',
@@ -142,40 +69,14 @@ const templateResponseMock: ITemplateResponse = {
apiName: 'raw-performer-024t43',
},
],
- checklists: [],
- revertTask: null,
- ancestors: ['task-059819', 'task-4e99a7', 'task-a889d4', 'task-1e9ba6'],
},
],
kickoff: {
description: '',
fields: [],
+ fieldsets: [],
},
- owners: [
- {
- sourceId: '306',
- type: ETemplateOwnerType.User,
- apiName: 'owner-024t43',
- role: ETemplateOwnerRole.Owner,
- },
- {
- sourceId: '1896',
- type: ETemplateOwnerType.User,
- apiName: 'owner-024t12',
- role: ETemplateOwnerRole.Owner,
- },
- ],
isActive: true,
- isPublic: false,
- publicUrl: null,
- publicSuccessUrl: null,
- embedUrl: null,
- isEmbedded: false,
- finalizable: true,
- completionNotification: false,
- reminderNotification: false,
- updatedBy: 306,
- dateUpdated: '2021-10-13T14:24:43.980066Z',
wfNameTemplate: null,
};
@@ -185,14 +86,27 @@ const stringifyReturn = {
kickoff: {
description: '',
fields: [],
+ fieldsets: [],
},
description: '12346789',
performersCount: 1,
tasksCount: 5,
wfNameTemplate: null,
+ loadedFieldsets: [],
+};
+
+const assertNonNull = (value: T | null, message: string): T => {
+ if (value === null) {
+ throw new Error(message);
+ }
+ return value;
};
describe('getRunnableWorkflow.', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('return correct workflow data', () => {
const runnableWorkflow = getRunnableWorkflow(templateResponseMock);
@@ -205,24 +119,16 @@ describe('getRunnableWorkflow.', () => {
kickoff: {
description: '',
fields: [
- {
- apiName: 'field-ds',
- name: 'DS Field',
- type: EExtraFieldType.Checkbox,
- order: 0,
- userId: null,
- groupId: null,
- dataset: 5,
- selections: [],
- },
+ makeExtraField({ apiName: 'field-ds', name: 'DS Field', type: EExtraFieldType.Checkbox, dataset: 5 }),
],
+ fieldsets: [],
},
};
const datasetsMap = { 5: ['A', 'B'] };
- const result = getRunnableWorkflow(template, datasetsMap);
+ const result = assertNonNull(getRunnableWorkflow(template, datasetsMap), 'expected non-null result');
- expect(result!.kickoff.fields[0].selections).toEqual(['A', 'B']);
+ expect(result.kickoff.fields[0].selections).toEqual(['A', 'B']);
});
it('normalizes object selections into string[] when field has no dataset', () => {
@@ -231,22 +137,20 @@ describe('getRunnableWorkflow.', () => {
kickoff: {
description: '',
fields: [
- {
+ makeExtraField({
apiName: 'field-obj',
name: 'Obj Field',
type: EExtraFieldType.Checkbox,
- order: 0,
- userId: null,
- groupId: null,
selections: [{ value: 'A', apiName: 'sel-1' }, { value: 'B', apiName: 'sel-2' }],
- },
+ }),
],
+ fieldsets: [],
},
};
- const result = getRunnableWorkflow(template);
+ const result = assertNonNull(getRunnableWorkflow(template), 'expected non-null result');
- expect(result!.kickoff.fields[0].selections).toEqual(['A', 'B']);
+ expect(result.kickoff.fields[0].selections).toEqual(['A', 'B']);
});
it('passes string selections as-is when field has no dataset', () => {
@@ -255,22 +159,15 @@ describe('getRunnableWorkflow.', () => {
kickoff: {
description: '',
fields: [
- {
- apiName: 'field-str',
- name: 'Str Field',
- type: EExtraFieldType.Checkbox,
- order: 0,
- userId: null,
- groupId: null,
- selections: ['A', 'B'],
- },
+ makeExtraField({ apiName: 'field-str', name: 'Str Field', type: EExtraFieldType.Checkbox, selections: ['A', 'B'] }),
],
+ fieldsets: [],
},
};
- const result = getRunnableWorkflow(template);
+ const result = assertNonNull(getRunnableWorkflow(template), 'expected non-null result');
- expect(result!.kickoff.fields[0].selections).toEqual(['A', 'B']);
+ expect(result.kickoff.fields[0].selections).toEqual(['A', 'B']);
});
it('falls back to empty array when datasetsMap does not contain the dataset id', () => {
@@ -279,34 +176,91 @@ describe('getRunnableWorkflow.', () => {
kickoff: {
description: '',
fields: [
- {
- apiName: 'field-miss',
- name: 'Missing DS',
- type: EExtraFieldType.Checkbox,
- order: 0,
- userId: null,
- groupId: null,
- dataset: 99,
- selections: [],
- },
+ makeExtraField({ apiName: 'field-miss', name: 'Missing DS', type: EExtraFieldType.Checkbox, dataset: 99 }),
],
+ fieldsets: [],
},
};
const datasetsMap = { 5: ['X'] };
- const result = getRunnableWorkflow(template, datasetsMap);
+ const result = assertNonNull(getRunnableWorkflow(template, datasetsMap), 'expected non-null result');
- expect(result!.kickoff.fields[0].selections).toEqual([]);
+ expect(result.kickoff.fields[0].selections).toEqual([]);
});
- it('returns null when isActive is false', () => {
- const template = {
- ...templateResponseMock,
- isActive: false,
+ it('returns null when isActive is false or template id is missing', () => {
+ expect(getRunnableWorkflow({ ...templateResponseMock, isActive: false })).toBeNull();
+
+ type TRunnableInput = Parameters[0];
+ const { id, ...templateWithoutId } = templateResponseMock;
+ expect(
+ getRunnableWorkflow(templateWithoutId as unknown as TRunnableInput),
+ ).toBeNull();
+ });
+
+
+ it('loadDatasetsMap returns {} and does not call getDataset when there are no dataset ids', async () => {
+ const kickoff: IKickoffClient = { description: '', fields: [], fieldsets: [] };
+
+ const result = await loadDatasetsMap(kickoff, []);
+
+ expect(result).toEqual({});
+ expect(getDataset).not.toHaveBeenCalled();
+ });
+
+ it('loadDatasetsMap dedups dataset id shared by a kickoff field and a fieldset field', async () => {
+ const kickoff: IKickoffClient = {
+ description: '',
+ fields: [makeExtraField({ apiName: 'k-f', name: 'K', type: EExtraFieldType.Checkbox, dataset: 7 })],
+ fieldsets: [],
};
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldsetRuntime({
+ name: 'FS',
+ fields: [makeExtraField({ apiName: 'fs-f', name: 'F', type: EExtraFieldType.Checkbox, dataset: 7 })],
+ }),
+ ];
+ (getDataset as jest.Mock).mockResolvedValue({ items: [{ value: 'A' }, { value: 'B' }] });
- const result = getRunnableWorkflow(template);
+ const result = await loadDatasetsMap(kickoff, fieldsets);
+
+ expect(getDataset).toHaveBeenCalledTimes(1);
+ expect(getDataset).toHaveBeenCalledWith({ id: 7 });
+ expect(result).toEqual({ 7: ['A', 'B'] });
+ });
+
+ it('getRunnableWorkflow applies datasetsMap and normalizes selections for fieldset fields', () => {
+ const datasetsMap = { 5: ['X', 'Y'] };
+ const fieldWithDataset = makeExtraField({
+ apiName: 'fs-f-ds',
+ name: 'DS',
+ type: EExtraFieldType.Checkbox,
+ dataset: 5,
+ });
+ const fieldWithObjectSelections = makeExtraField({
+ apiName: 'fs-f-obj',
+ name: 'Obj',
+ type: EExtraFieldType.Checkbox,
+ order: 1,
+ selections: [
+ { value: 'P', apiName: 's-1' },
+ { value: 'Q', apiName: 's-2' },
+ ],
+ });
+ const loadedFieldsets: IFieldsetRuntime[] = [
+ makeFieldsetRuntime({ name: 'FS', fields: [fieldWithDataset, fieldWithObjectSelections] }),
+ ];
- expect(result).toBeNull();
+ const result = assertNonNull(
+ getRunnableWorkflow(templateResponseMock, datasetsMap, loadedFieldsets),
+ 'expected non-null result',
+ );
+ const { loadedFieldsets: loaded } = result;
+ if (loaded === undefined) {
+ throw new Error('expected loadedFieldsets to be defined');
+ }
+ expect(loaded).toHaveLength(1);
+ expect(loaded[0].fields[0].selections).toEqual(['X', 'Y']);
+ expect(loaded[0].fields[1].selections).toEqual(['P', 'Q']);
});
});
diff --git a/frontend/src/public/components/TemplateEdit/utils/__tests__/normalizeSelections.test.ts b/frontend/src/public/components/TemplateEdit/utils/__tests__/normalizeSelections.test.ts
index eaee50ca9..394f5f812 100644
--- a/frontend/src/public/components/TemplateEdit/utils/__tests__/normalizeSelections.test.ts
+++ b/frontend/src/public/components/TemplateEdit/utils/__tests__/normalizeSelections.test.ts
@@ -1,4 +1,3 @@
-//
import { normalizeSelections } from '../normalizeSelections';
import { IExtraFieldSelection } from '../../../../types/template';
diff --git a/frontend/src/public/components/TemplateEdit/utils/getClonedTask.ts b/frontend/src/public/components/TemplateEdit/utils/getClonedTask.ts
index 06f62f5ee..e344a6b37 100644
--- a/frontend/src/public/components/TemplateEdit/utils/getClonedTask.ts
+++ b/frontend/src/public/components/TemplateEdit/utils/getClonedTask.ts
@@ -2,11 +2,12 @@ import {
IDueDate,
IExtraField,
IExtraFieldSelection,
- ITemplateTask,
+ ITemplateTaskClient,
ITemplateTaskPerformer,
TOutputChecklist,
TOutputChecklistItem,
} from '../../../types/template';
+import { IFieldsetBindingClient } from '../../../types/fieldset';
import {
createTaskApiName,
createFieldApiName,
@@ -19,11 +20,12 @@ import {
createChecklistSelectionApiName,
createDueDateApiName,
createPerformerApiName,
+ createFieldsetBindingApiName,
} from '../../../utils/createId';
import { omit } from '../../../utils/helpers';
import { ICondition, TConditionRule } from '../TaskForm/Conditions';
-export function getClonedTask(task: ITemplateTask) {
+export function getClonedTask(task: ITemplateTaskClient) {
const mapChecklist: { [old: string]: string } = {};
const mapSelectionsChecklist: { [old: string]: string } = {};
@@ -130,12 +132,20 @@ export function getClonedTask(task: ITemplateTask) {
const createdTaskApiName = createTaskApiName();
const clonedRawDueDate = cloneRawDueDate(task.rawDueDate, createdTaskApiName);
- const clonedTask: ITemplateTask = {
+ const cloneFieldsets = (fieldsets: IFieldsetBindingClient[]): IFieldsetBindingClient[] => {
+ return fieldsets.map((fs) => ({
+ ...fs,
+ apiNameBinding: createFieldsetBindingApiName(),
+ }));
+ };
+
+ const clonedTask: ITemplateTaskClient = {
...omit(task, [
'id',
'apiName',
'uuid',
'fields',
+ 'fieldsets',
'conditions',
'delay',
'name',
@@ -149,6 +159,7 @@ export function getClonedTask(task: ITemplateTask) {
apiName: createdTaskApiName,
uuid: createUUID(),
fields: cloneFields(task.fields),
+ fieldsets: cloneFieldsets(task.fieldsets),
conditions: cloneConditions(task.conditions),
delay: null,
rawDueDate: clonedRawDueDate,
diff --git a/frontend/src/public/components/TemplateEdit/utils/getRunnableWorkflow.ts b/frontend/src/public/components/TemplateEdit/utils/getRunnableWorkflow.ts
index 42d07867f..549bc1bbc 100644
--- a/frontend/src/public/components/TemplateEdit/utils/getRunnableWorkflow.ts
+++ b/frontend/src/public/components/TemplateEdit/utils/getRunnableWorkflow.ts
@@ -1,29 +1,35 @@
/* eslint-disable */
/* prettier-ignore */
-import { ITemplate, ITemplateTask, IKickoff } from '../../../types/template';
+import { ITemplateClient, ITemplateTaskClient, IKickoffClient, IExtraField } from '../../../types/template';
+import { IFieldsetRuntime } from '../../../types/fieldset';
import { setPerformersCounts } from '../../../utils/template';
import { IRunWorkflow } from '../../WorkflowEditPopup/types';
import { normalizeSelections } from './normalizeSelections';
-type TTemplateToRunWorkflow = Pick<
- ITemplate,
+export type TTemplateToRunWorkflow = Pick<
+ ITemplateClient,
'id' | 'name' | 'kickoff' | 'description' | 'isActive' | 'wfNameTemplate'
> & {
- tasks: Pick[];
+ tasks: Pick[];
};
import { getDataset } from '../../../api/datasets/getDataset';
-function getKickoffDatasetIds(kickoff: IKickoff): number[] {
+function getKickoffDatasetIds(kickoff: IKickoffClient, fieldsets: IFieldsetRuntime[] = []): number[] {
const ids = new Set();
for (const field of kickoff.fields) {
if (field.dataset) ids.add(field.dataset);
}
+ for (const fs of fieldsets) {
+ for (const field of fs.fields) {
+ if (field.dataset) ids.add(field.dataset);
+ }
+ }
return [...ids];
}
-export async function loadDatasetsMap(kickoff: IKickoff): Promise> {
- const datasetIds = getKickoffDatasetIds(kickoff);
+export async function loadDatasetsMap(kickoff: IKickoffClient, fieldsets: IFieldsetRuntime[] = []): Promise> {
+ const datasetIds = getKickoffDatasetIds(kickoff, fieldsets);
if (datasetIds.length === 0) {
return {};
}
@@ -41,21 +47,26 @@ export async function loadDatasetsMap(kickoff: IKickoff): Promise): IKickoff {
+function applyDatasetsToFields(fields: IExtraField[], datasetsMap: Record): IExtraField[] {
+ return fields.map((field) => ({
+ ...field,
+ selections: field.dataset
+ ? datasetsMap[field.dataset] || []
+ : normalizeSelections(field.selections),
+ }));
+}
+
+function convertSelectionsToValues(kickoff: IKickoffClient, datasetsMap: Record): IKickoffClient {
return {
...kickoff,
- fields: kickoff.fields.map((field) => ({
- ...field,
- selections: field.dataset
- ? datasetsMap[field.dataset] || []
- : normalizeSelections(field.selections),
- })),
+ fields: applyDatasetsToFields(kickoff.fields, datasetsMap),
};
}
export const getRunnableWorkflow = (
template: TTemplateToRunWorkflow,
datasetsMap: Record = {},
+ loadedFieldsets: IFieldsetRuntime[] = [],
): IRunWorkflow | null => {
const { id, name, kickoff, description, isActive, tasks, wfNameTemplate } = template;
if (!isActive || !id) {
@@ -71,5 +82,9 @@ export const getRunnableWorkflow = (
performersCount,
tasksCount: tasks.length,
wfNameTemplate,
+ loadedFieldsets: loadedFieldsets.map((fs) => ({
+ ...fs,
+ fields: applyDatasetsToFields(fs.fields, datasetsMap),
+ })),
};
};
diff --git a/frontend/src/public/components/TemplateEdit/utils/isValidTaskForm.ts b/frontend/src/public/components/TemplateEdit/utils/isValidTaskForm.ts
index 573fd9b5d..41488fc7b 100644
--- a/frontend/src/public/components/TemplateEdit/utils/isValidTaskForm.ts
+++ b/frontend/src/public/components/TemplateEdit/utils/isValidTaskForm.ts
@@ -1,8 +1,8 @@
/* eslint-disable */
/* prettier-ignore */
-import { ITemplateTask } from '../../../types/template';
+import { ITemplateTaskClient } from '../../../types/template';
import { areExtraFieldsValid } from './areExtraFieldsValid';
-export function isValidTaskForm(task: ITemplateTask) {
+export function isValidTaskForm(task: ITemplateTaskClient) {
return areExtraFieldsValid(task.fields);
}
diff --git a/frontend/src/public/components/TemplateEdit/utils/validateTemplate.ts b/frontend/src/public/components/TemplateEdit/utils/validateTemplate.ts
index 50548861a..51f6a37b0 100644
--- a/frontend/src/public/components/TemplateEdit/utils/validateTemplate.ts
+++ b/frontend/src/public/components/TemplateEdit/utils/validateTemplate.ts
@@ -2,7 +2,7 @@
/* prettier-ignore */
import { IntlShape } from 'react-intl';
-import { ITemplate } from '../../../types/template';
+import { ITemplateClient } from '../../../types/template';
import { isArrayWithItems } from '../../../utils/helpers';
import { validateWorkflowName } from '../../../utils/validators';
import { UnassignedTasksWarning, PremiumFeaturesWarning, IInfoWarningProps } from '../InfoWarningsModal/warnings';
@@ -15,7 +15,7 @@ type TWarningRule = {
getMessage(): T;
};
-export function validateTemplate(template: ITemplate, isSubscribed: boolean, intl: IntlShape) {
+export function validateTemplate(template: ITemplateClient, isSubscribed: boolean, intl: IntlShape) {
const { owners, name, tasks, kickoff } = template;
const { formatMessage } = intl;
diff --git a/frontend/src/public/components/TopNav/__tests__/TopNav.test.tsx b/frontend/src/public/components/TopNav/__tests__/TopNav.test.tsx
index 1f5529810..7e695f586 100644
--- a/frontend/src/public/components/TopNav/__tests__/TopNav.test.tsx
+++ b/frontend/src/public/components/TopNav/__tests__/TopNav.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render } from '@testing-library/react';
diff --git a/frontend/src/public/components/TuneViewModal/TuneViewModal.css b/frontend/src/public/components/TuneViewModal/TuneViewModal.css
index 8a2f30da9..9f86af738 100644
--- a/frontend/src/public/components/TuneViewModal/TuneViewModal.css
+++ b/frontend/src/public/components/TuneViewModal/TuneViewModal.css
@@ -99,6 +99,20 @@
}
}
+.tune-view-modal__fieldset-group {
+ margin-top: 1.6rem;
+ margin-bottom: 1.6rem;
+ padding-left: 1.2rem;
+ border-left: 3px solid var(--pneumatic-color-black72);
+}
+
+.tune-view-modal__fieldset-title {
+ margin-top: 0.8rem;
+ margin-bottom: 0.8rem;
+ font-weight: bold;
+ color: var(--pneumatic-color-black48);
+}
+
.tune-view-modal__tooltip {
padding: 0.4rem 0.8rem;
}
diff --git a/frontend/src/public/components/TuneViewModal/TuneViewModal.tsx b/frontend/src/public/components/TuneViewModal/TuneViewModal.tsx
index f4e8e713e..142f6c27f 100644
--- a/frontend/src/public/components/TuneViewModal/TuneViewModal.tsx
+++ b/frontend/src/public/components/TuneViewModal/TuneViewModal.tsx
@@ -1,4 +1,5 @@
-import React, { useState, useEffect } from 'react';
+import * as React from 'react';
+import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useSelector, useDispatch } from 'react-redux';
@@ -43,14 +44,20 @@ export function TuneViewModal() {
const tasksToOpen = new Set();
templateTasks?.forEach((task) => {
- const hasSelectedFields = task.fields?.some((field) => savedFieldsSet.has(field.apiName));
+ const hasSelectedFields = task.mergedOutputs?.some((output) => {
+ if (output.kind === 'fieldset') {
+ return output.data.fields.some((field) => savedFieldsSet.has(field.apiName));
+ }
+
+ return savedFieldsSet.has(output.field.apiName);
+ });
if (hasSelectedFields) {
tasksToOpen.add(task.apiName);
}
});
setOpenedTasks(tasksToOpen);
}
- }, [isOpen, templateId, templateTasks]);
+ }, [isOpen, templateId, templateTasks, savedFields]);
const shouldRender = useDelayUnmount(isOpen, 150);
if (!shouldRender) {
@@ -73,6 +80,8 @@ export function TuneViewModal() {
footer: styles['tune-view-modal__footer'],
footerButton: styles['footer-button'],
tooltip: styles['tune-view-modal__tooltip'],
+ fieldsetGroup: styles['tune-view-modal__fieldset-group'],
+ fieldsetTitle: styles['tune-view-modal__fieldset-title'],
};
const MESSAGES = {
title: 'workflow.tune-view-modal-title',
@@ -109,14 +118,16 @@ export function TuneViewModal() {
const handleApplyChanges = (type: 'personal' | 'account') => {
const orderedFields: TOrderedFields[] = [];
let orderIndex = 0;
- templateTasks.forEach(({ fields }) => {
- fields.forEach(({ apiName }) => {
- if (selectedFields.has(apiName)) {
- orderedFields.push({
- order: (orderIndex += 1),
- width: 1,
- apiName,
+ templateTasks.forEach(({ mergedOutputs }) => {
+ mergedOutputs.forEach((output) => {
+ if (output.kind === 'fieldset') {
+ output.data.fields.forEach(({ apiName }) => {
+ if (selectedFields.has(apiName)) {
+ orderedFields.push({ order: (orderIndex += 1), width: 1, apiName });
+ }
});
+ } else if (selectedFields.has(output.field.apiName)) {
+ orderedFields.push({ order: (orderIndex += 1), width: 1, apiName: output.field.apiName });
}
});
});
@@ -126,6 +137,35 @@ export function TuneViewModal() {
handleClose();
};
+ const renderFieldCheckbox = (field: IExtraField | TSystemField) => {
+ const { apiName: fieldApiName, name: fieldName } = field;
+ const hasNotTooltip = isSystemField(field) ? field.hasNotTooltip : null;
+ const isDisabled = isSystemField(field) ? field.isDisabled : null;
+
+ return (
+
+
+
+ );
+ };
+
return (
@@ -133,7 +173,7 @@ export function TuneViewModal() {
- {templateTasks?.map(({ apiName: taskApiname, name: taskName, needSteName, fields }: TTransformedTask) => (
+ {templateTasks?.map(({ apiName: taskApiname, name: taskName, needSteName, mergedOutputs }: TTransformedTask) => (
{openedTasks.has(taskApiname) && (
-
0 ? STYLES.fieldsContainer : ''}>
- {fields.map((field: IExtraField | TSystemField) => {
- const { apiName: fieldApiName, name: fieldName } = field;
- const hasNotTooltip = isSystemField(field) ? field.hasNotTooltip : null;
- const isDisabled = isSystemField(field) ? field.isDisabled : null;
-
- return (
-
-
-
- );
+
0 ? STYLES.fieldsContainer : ''}>
+ {mergedOutputs.map((output) => {
+ if (output.kind === 'fieldset') {
+ return (
+
+
{output.data.name}
+ {output.data.fields.map(renderFieldCheckbox)}
+
+ );
+ }
+
+ return renderFieldCheckbox(output.field);
})}
)}
diff --git a/frontend/src/public/components/TuneViewModal/__tests__/TuneViewModal.test.tsx b/frontend/src/public/components/TuneViewModal/__tests__/TuneViewModal.test.tsx
new file mode 100644
index 000000000..52d5d040f
--- /dev/null
+++ b/frontend/src/public/components/TuneViewModal/__tests__/TuneViewModal.test.tsx
@@ -0,0 +1,223 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { IntlProvider } from 'react-intl';
+import { enMessages } from '../../../lang/locales/en_US';
+
+import { TTransformedTask, TRuntimeMergedOutputPart, IExtraField } from '../../../types/template';
+import { makeExtraField } from '../../../__stubs__/fields.factory';
+import { EFieldLabelPosition } from '../../../types/fieldset';
+
+const mockDispatch = jest.fn();
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn((selector: (state: unknown) => unknown) => selector({})),
+ useDispatch: () => mockDispatch,
+}));
+
+const mockIsOpen = jest.fn().mockReturnValue(true);
+const mockTemplatesIdsFilter = jest.fn().mockReturnValue([100]);
+const mockSavedFields = jest.fn().mockReturnValue([] as string[]);
+
+jest.mock('../../../redux/selectors/workflows', () => ({
+ getIsTuneViewModalOpen: () => mockIsOpen(),
+ getWorkflowTemplatesIdsFilter: () => mockTemplatesIdsFilter(),
+ getSavedFields: () => mockSavedFields(),
+}));
+
+const mockTemplateTasks = jest.fn().mockReturnValue([] as TTransformedTask[]);
+const mockTemplateVariables = jest.fn().mockReturnValue([]);
+
+jest.mock('../../../redux/selectors/templates', () => ({
+ getTemplatesTasks: () => () => mockTemplateTasks(),
+ getTemplatesVariables: () => () => mockTemplateVariables(),
+}));
+
+jest.mock('../../../hooks/useDelayUnmount', () => ({
+ useDelayUnmount: () => true,
+}));
+
+jest.mock('../../../redux/workflows/slice', () => ({
+ closeTuneViewModal: jest.fn(() => ({ type: 'closeTuneViewModal' })),
+ setFilterSelectedFields: jest.fn((payload: string[]) => ({ type: 'setFilterSelectedFields', payload })),
+ saveWorkflowsPreset: jest.fn((payload: Record
) => ({ type: 'saveWorkflowsPreset', payload })),
+}));
+
+jest.mock('../../UI', () => ({
+ Button: ({ label, onClick }: { label: string; onClick: () => void }) =>
+ React.createElement('button', { onClick, 'data-testid': `btn-${label}` }, label),
+ Checkbox: ({ title, checked, onChange, checkboxId }: {
+ title: string; checked: boolean; onChange: () => void; checkboxId: string;
+ titlePosition?: string; labelClassName?: string; disabled?: boolean;
+ }) =>
+ React.createElement('input', {
+ type: 'checkbox',
+ checked,
+ onChange,
+ 'data-testid': `cb-${checkboxId}`,
+ 'aria-label': title,
+ }),
+ SideModal: Object.assign(
+ ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'side-modal' }, children),
+ {
+ Header: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'side-modal-header' }, children),
+ Body: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'side-modal-body' }, children),
+ Footer: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'side-modal-footer' }, children),
+ },
+ ),
+ Tooltip: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('span', null, children),
+}));
+
+jest.mock('../../icons', () => ({
+ ShortArrowIcon: () => null,
+}));
+
+jest.mock('../../StepName', () => ({
+ StepName: () => null,
+}));
+
+jest.mock('../../TemplateEdit/TooltipRichContent', () => ({
+ TooltipRichContent: () => null,
+}));
+
+import { TuneViewModal } from '../TuneViewModal';
+
+const makeField = (overrides: Partial = {}) => makeExtraField({
+ name: 'Field 1',
+ ...overrides,
+});
+
+const makeFieldsetOutput = (
+ apiNameBinding: string,
+ name: string,
+ fields: IExtraField[],
+): TRuntimeMergedOutputPart => ({
+ kind: 'fieldset' as const,
+ data: { apiNameBinding, name, description: '', fields, order: 0, labelPosition: EFieldLabelPosition.Top },
+});
+
+const makeFieldOutput = (field: IExtraField): TRuntimeMergedOutputPart => ({
+ kind: 'field' as const,
+ field,
+});
+
+const makeTask = (apiName: string, name: string, mergedOutputs: TRuntimeMergedOutputPart[]): TTransformedTask => ({
+ apiName,
+ name,
+ mergedOutputs,
+});
+
+const renderWithIntl = (ui: React.ReactElement) =>
+ render(
+ React.createElement(IntlProvider, { locale: 'en', messages: enMessages }, ui),
+ );
+
+describe('TuneViewModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsOpen.mockReturnValue(true);
+ mockTemplatesIdsFilter.mockReturnValue([100]);
+ mockSavedFields.mockReturnValue([]);
+ mockTemplateTasks.mockReturnValue([]);
+ mockTemplateVariables.mockReturnValue([]);
+ });
+
+ describe('Fieldsets', () => {
+ it('renders fieldset group with title and field checkboxes', () => {
+ const fsField1 = makeField({ apiName: 'fs-f1', name: 'FS Field 1' });
+ const fsField2 = makeField({ apiName: 'fs-f2', name: 'FS Field 2' });
+ const regularField = makeField({ apiName: 'reg-1', name: 'Regular' });
+
+ const task = makeTask('task-1', 'Task 1', [
+ makeFieldOutput(regularField),
+ makeFieldsetOutput('fs-1', 'My Fieldset', [fsField1, fsField2]),
+ ]);
+
+ mockTemplateTasks.mockReturnValue([task]);
+ mockSavedFields.mockReturnValue(['reg-1']);
+
+ renderWithIntl(React.createElement(TuneViewModal));
+
+ expect(screen.getByText('My Fieldset')).toBeInTheDocument();
+ expect(screen.getByTestId('cb-fs-f1')).toBeInTheDocument();
+ expect(screen.getByTestId('cb-fs-f2')).toBeInTheDocument();
+ expect(screen.getByTestId('cb-reg-1')).toBeInTheDocument();
+ });
+
+ it('toggles fieldset field checked state', () => {
+ const fsField = makeField({ apiName: 'fs-toggle', name: 'Toggle Me' });
+ const task = makeTask('task-1', 'Task 1', [
+ makeFieldsetOutput('fs-1', 'FS', [fsField]),
+ ]);
+
+ mockTemplateTasks.mockReturnValue([task]);
+ mockSavedFields.mockReturnValue(['fs-toggle']);
+
+ renderWithIntl(React.createElement(TuneViewModal));
+
+ const checkbox = screen.getByTestId('cb-fs-toggle') as HTMLInputElement;
+ expect(checkbox.checked).toBe(true);
+
+ userEvent.click(checkbox);
+
+ expect(screen.getByTestId('cb-fs-toggle')).toBeInTheDocument();
+ });
+
+ it('auto-expands task when a fieldset field is in savedFields', () => {
+ const fsField = makeField({ apiName: 'fs-saved', name: 'Saved FS Field' });
+ const task = makeTask('task-auto', 'Auto Task', [
+ makeFieldsetOutput('fs-auto', 'Auto FS', [fsField]),
+ ]);
+
+ mockTemplateTasks.mockReturnValue([task]);
+ mockSavedFields.mockReturnValue(['fs-saved']);
+
+ renderWithIntl(React.createElement(TuneViewModal));
+
+ expect(screen.getByText('Auto FS')).toBeInTheDocument();
+ expect(screen.getByTestId('cb-fs-saved')).toBeInTheDocument();
+ });
+
+ it('Apply changes includes fieldset fields in orderedFields with sequential order', () => {
+ const { saveWorkflowsPreset } = require('../../../redux/workflows/slice');
+
+ const regularField = makeField({ apiName: 'reg-1', name: 'Reg' });
+ const fsField1 = makeField({ apiName: 'fs-f1', name: 'FS F1' });
+ const fsField2 = makeField({ apiName: 'fs-f2', name: 'FS F2' });
+
+ const task = makeTask('task-1', 'Task 1', [
+ makeFieldOutput(regularField),
+ makeFieldsetOutput('fs-1', 'FS', [fsField1, fsField2]),
+ ]);
+
+ mockTemplateTasks.mockReturnValue([task]);
+ mockSavedFields.mockReturnValue(['reg-1', 'fs-f1', 'fs-f2']);
+
+ renderWithIntl(React.createElement(TuneViewModal));
+
+ const applyBtn = screen.getAllByRole('button').find(
+ (btn) => btn.textContent?.toLowerCase().includes('apply'),
+ );
+ if (!applyBtn) throw new Error('Apply button not found');
+ userEvent.click(applyBtn);
+
+ expect(saveWorkflowsPreset).toHaveBeenCalledTimes(1);
+ expect(saveWorkflowsPreset).toHaveBeenCalledWith(
+ expect.objectContaining({
+ templateId: 100,
+ type: 'personal',
+ orderedFields: expect.arrayContaining([
+ expect.objectContaining({ apiName: 'reg-1', order: 1 }),
+ expect.objectContaining({ apiName: 'fs-f1', order: 2 }),
+ expect.objectContaining({ apiName: 'fs-f2', order: 3 }),
+ ]),
+ }),
+ );
+ });
+ });
+});
diff --git a/frontend/src/public/components/UI/Dropdown/Dropdown.css b/frontend/src/public/components/UI/Dropdown/Dropdown.css
index 6829d31d4..24a74f091 100644
--- a/frontend/src/public/components/UI/Dropdown/Dropdown.css
+++ b/frontend/src/public/components/UI/Dropdown/Dropdown.css
@@ -18,10 +18,6 @@
.dropdown-menu {
z-index: 1000;
- /* stylelint-disable-next-line declaration-no-important */
- top: initial !important;
- /* stylelint-disable-next-line declaration-no-important */
- left: initial !important;
padding: 5px 0;
display: none;
width: 192px;
@@ -31,8 +27,6 @@
border: none;
border-radius: 8px;
box-shadow: 0 8px 16px rgb(98 98 95 / 8%);
- /* stylelint-disable-next-line declaration-no-important */
- transform: initial !important;
&.is-user-selection {
/* stylelint-disable-next-line declaration-no-important */
@@ -41,6 +35,16 @@
}
}
+/* Resets Bootstrap placement; omit when DropdownMenu uses positionFixed so Popper can set transform/top/left. */
+.dropdown-menu_bootstrap-placement {
+ /* stylelint-disable-next-line declaration-no-important */
+ top: initial !important;
+ /* stylelint-disable-next-line declaration-no-important */
+ left: initial !important;
+ /* stylelint-disable-next-line declaration-no-important */
+ transform: initial !important;
+}
+
.dropdown-menu_wide {
width: 304px;
}
diff --git a/frontend/src/public/components/UI/Dropdown/Dropdown.tsx b/frontend/src/public/components/UI/Dropdown/Dropdown.tsx
index 673c67f7e..fbd106e3c 100644
--- a/frontend/src/public/components/UI/Dropdown/Dropdown.tsx
+++ b/frontend/src/public/components/UI/Dropdown/Dropdown.tsx
@@ -35,6 +35,10 @@ export interface IDropdownProps {
className?: string;
toggleProps?: { [key in string]: string };
menuClassName?: string;
+ /** Passes reactstrap DropdownMenu `positionFixed` so Popper uses fixed positioning (escapes overflow scroll). */
+ menuPositionFixed?: boolean;
+ /** Passes reactstrap DropdownMenu `container` (portal target), e.g. `document.body`. */
+ menuContainer?: string | HTMLElement;
renderToggle(isOpen: boolean): React.ReactNode;
renderMenuContent?(renderedOptions: React.ReactNode): React.ReactNode;
CustomDropdownMenu?(props: IDropdownMenuProps): JSX.Element;
@@ -59,6 +63,8 @@ export function Dropdown({
className,
toggleProps,
menuClassName,
+ menuPositionFixed = false,
+ menuContainer,
renderToggle,
renderMenuContent,
CustomDropdownMenu,
@@ -77,6 +83,10 @@ export function Dropdown({
};
const renderOptions = (items: TDropdownOption[] | TDropdownOption, level = 0): React.ReactNode => {
+ const overlayAtRoot = level === 0;
+ const positionFixed = Boolean(menuPositionFixed && overlayAtRoot);
+ const container = overlayAtRoot ? menuContainer : undefined;
+
if (!Array.isArray(items)) {
const Menu = CustomDropdownMenu || DefaultDropdownMenu;
@@ -88,6 +98,8 @@ export function Dropdown({
direction={direction}
className={menuClassName}
renderMenuContent={renderMenuContent}
+ positionFixed={positionFixed}
+ container={container}
/>
);
}
@@ -141,6 +153,7 @@ export function Dropdown({
renderedOptions={customSubOption}
isWide
level={level + 1}
+ positionFixed={false}
/>
)}
@@ -184,6 +197,8 @@ export function Dropdown({
level={level}
direction={direction}
className={menuClassName}
+ positionFixed={positionFixed}
+ container={container}
{...(level === 0 && { renderMenuContent })}
/>
);
@@ -226,6 +241,8 @@ export interface IDropdownMenuProps {
direction?: 'right' | 'left';
className?: string;
renderMenuContent?(renderedOptions: React.ReactNode): React.ReactNode;
+ positionFixed?: boolean;
+ container?: string | HTMLElement;
}
export function DefaultDropdownMenu({
@@ -235,18 +252,26 @@ export function DefaultDropdownMenu({
direction = 'right',
className,
renderMenuContent,
+ positionFixed = false,
+ container,
}: IDropdownMenuProps) {
const content = renderMenuContent?.(renderedOptions) || renderedOptions;
return (
{content}
diff --git a/frontend/src/public/components/UI/ReturnLink/ReturnLink.tsx b/frontend/src/public/components/UI/ReturnLink/ReturnLink.tsx
index 69478d37e..c800367b2 100644
--- a/frontend/src/public/components/UI/ReturnLink/ReturnLink.tsx
+++ b/frontend/src/public/components/UI/ReturnLink/ReturnLink.tsx
@@ -1,7 +1,7 @@
import * as React from 'react';
import classnames from 'classnames';
+import * as H from 'history';
-import { ERoutes } from '../../../constants/routes';
import { ReturnIcon } from '../../icons';
import { NavLink } from '../../NavLink';
@@ -9,7 +9,7 @@ import styles from './ReturnLink.css';
interface IReturnLinkProps {
label: string;
- route: ERoutes;
+ route: H.Pathname;
className?: string;
}
diff --git a/frontend/src/public/components/UI/Tabs/Tabs.css b/frontend/src/public/components/UI/Tabs/Tabs.css
index d0e4bd708..76d2e9722 100644
--- a/frontend/src/public/components/UI/Tabs/Tabs.css
+++ b/frontend/src/public/components/UI/Tabs/Tabs.css
@@ -18,6 +18,7 @@
padding: 8px 16px;
flex-grow: 1;
text-align: center;
+ border-radius: 1.6rem;
transition: color 0.1s ease-in-out;
&:not(.tab_active):hover {
@@ -28,5 +29,12 @@
.tab_active {
color: white;
background: var(--pneumatic-color-link-hover);
- border-radius: 16px;
+}
+
+.separator-right {
+ border-right: 0.1rem dashed var(--pneumatic-color-link-hover);
+}
+
+.separator-left {
+ border-left: 0.1rem dashed var(--pneumatic-color-link-hover);
}
diff --git a/frontend/src/public/components/UI/Tabs/Tabs.tsx b/frontend/src/public/components/UI/Tabs/Tabs.tsx
index ad860ea2b..5ea41cd05 100644
--- a/frontend/src/public/components/UI/Tabs/Tabs.tsx
+++ b/frontend/src/public/components/UI/Tabs/Tabs.tsx
@@ -1,4 +1,5 @@
-import React, { ReactNode } from 'react';
+import * as React from 'react';
+import { ReactNode } from 'react';
import classnames from 'classnames';
import styles from './Tabs.css';
@@ -32,23 +33,34 @@ export function Tabs({
}
};
+ const activeTabIndex = values.findIndex(checkIsActiveValue);
+
return (
- {values.map((value) => (
-
- ))}
+ {values.map((value, index) => {
+ let middleTabBorderClass = '';
+ if (values.length === 3 && activeTabIndex !== 1 && index === 1) {
+ if (activeTabIndex === 0) middleTabBorderClass = styles['separator-right'];
+ if (activeTabIndex === 2) middleTabBorderClass = styles['separator-left'];
+ }
+
+ return (
+
+ );
+ })}
);
}
diff --git a/frontend/src/public/components/UI/Tabs/__tests__/Tabs.test.tsx b/frontend/src/public/components/UI/Tabs/__tests__/Tabs.test.tsx
new file mode 100644
index 000000000..27b20613d
--- /dev/null
+++ b/frontend/src/public/components/UI/Tabs/__tests__/Tabs.test.tsx
@@ -0,0 +1,96 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { Tabs, ITabsProps } from '../Tabs';
+
+type TTabOption = { id: number; label: string };
+
+const THREE_TABS: TTabOption[] = [
+ { id: 1, label: 'First' },
+ { id: 2, label: 'Second' },
+ { id: 3, label: 'Third' },
+];
+
+const TWO_TABS: TTabOption[] = [
+ { id: 1, label: 'Alpha' },
+ { id: 2, label: 'Beta' },
+];
+
+const makeProps = (overrides: Partial> = {}): ITabsProps => ({
+ activeValueId: 1,
+ values: THREE_TABS,
+ onChange: jest.fn(),
+ ...overrides,
+});
+
+const getTabButtons = () => screen.getAllByRole('button');
+
+describe('Tabs — middle tab separator logic', () => {
+ beforeEach(() => { jest.clearAllMocks(); });
+
+ it('middle tab gets separator-right when first tab is active', () => {
+ render(React.createElement(Tabs, makeProps({ activeValueId: 1 })));
+
+ const buttons = getTabButtons();
+ expect(buttons[1].className).toContain('separator-right');
+ expect(buttons[1].className).not.toContain('separator-left');
+ });
+
+ it('middle tab gets separator-left when third tab is active', () => {
+ render(React.createElement(Tabs, makeProps({ activeValueId: 3 })));
+
+ const buttons = getTabButtons();
+ expect(buttons[1].className).toContain('separator-left');
+ expect(buttons[1].className).not.toContain('separator-right');
+ });
+
+ it('middle tab gets no separator when it is active itself', () => {
+ render(React.createElement(Tabs, makeProps({ activeValueId: 2 })));
+
+ const buttons = getTabButtons();
+ expect(buttons[1].className).not.toContain('separator-left');
+ expect(buttons[1].className).not.toContain('separator-right');
+ });
+
+ it('no separator classes when there are only 2 tabs', () => {
+ render(React.createElement(Tabs, makeProps({ values: TWO_TABS, activeValueId: 1 })));
+
+ const buttons = getTabButtons();
+ buttons.forEach((button) => {
+ expect(button.className).not.toContain('separator');
+ });
+ });
+});
+
+describe('Tabs — active tab and click behavior', () => {
+ beforeEach(() => { jest.clearAllMocks(); });
+
+ it('applies tab_active class only to the active tab', () => {
+ render(React.createElement(Tabs, makeProps({ activeValueId: 2 })));
+
+ const buttons = getTabButtons();
+ expect(buttons[0].className).not.toContain('tab_active');
+ expect(buttons[1].className).toContain('tab_active');
+ expect(buttons[2].className).not.toContain('tab_active');
+ });
+
+ it('calls onChange with the clicked tab id', () => {
+ const onChange = jest.fn();
+ render(React.createElement(Tabs, makeProps({ activeValueId: 1, onChange })));
+
+ userEvent.click(screen.getByRole('button', { name: 'Third' }));
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith(3);
+ });
+
+ it('does not call onChange when clicking the already active tab', () => {
+ const onChange = jest.fn();
+ render(React.createElement(Tabs, makeProps({ activeValueId: 1, onChange })));
+
+ userEvent.click(screen.getByRole('button', { name: 'First' }));
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/public/components/VacationSettings/__tests__/VacationSettings.test.tsx b/frontend/src/public/components/VacationSettings/__tests__/VacationSettings.test.tsx
index 8841f4fa5..26e2d0ada 100644
--- a/frontend/src/public/components/VacationSettings/__tests__/VacationSettings.test.tsx
+++ b/frontend/src/public/components/VacationSettings/__tests__/VacationSettings.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
diff --git a/frontend/src/public/components/WorkflowEditPopup/WorkflowEditPopup.tsx b/frontend/src/public/components/WorkflowEditPopup/WorkflowEditPopup.tsx
index 78895a744..3205498b2 100644
--- a/frontend/src/public/components/WorkflowEditPopup/WorkflowEditPopup.tsx
+++ b/frontend/src/public/components/WorkflowEditPopup/WorkflowEditPopup.tsx
@@ -10,12 +10,13 @@ import { ERoutes } from '../../constants/routes';
import { history } from '../../utils/history';
import { IntlMessages } from '../IntlMessages';
import { EInputNameBackgroundColor } from '../../types/workflow';
-import { IExtraField, EExtraFieldMode } from '../../types/template';
+import { IExtraField } from '../../types/template';
+import { IFieldsetRuntime } from '../../types/fieldset';
import { getPluralNoun, isArrayWithItems } from '../../utils/helpers';
import { getEditedFields } from '../TemplateEdit/ExtraFields/utils/getEditedFields';
+import { MergedOutputList } from '../MergedOutputList';
import { getInitialKickoff } from './utils/getInitialKickoff';
-import { ExtraFieldIntl } from '../TemplateEdit/ExtraFields';
import { PlayLogoIcon } from '../icons';
import { validateWorkflowName } from '../../utils/validators';
import { checkExtraFieldsAreValid } from './utils/areKickoffFieldsValid';
@@ -71,6 +72,7 @@ function WorkflowEditPopupComponent({
workflow.wfNameTemplate || `${reactElementToText()} — ${workflow.name}`,
);
const [kickoffState, setKickoffState] = useState(getInitialKickoff(workflow.kickoff));
+ const [fieldsetStates, setFieldsetStates] = useState(workflow.loadedFieldsets || []);
const [isUrgent, setIsUrgent] = useState(false);
@@ -82,8 +84,20 @@ function WorkflowEditPopupComponent({
});
};
+ const handleEditFieldsetField = (apiName: string) => (changedProps: Partial) => {
+ setFieldsetStates((prevFieldsets) =>
+ prevFieldsets.map((fs) => ({
+ ...fs,
+ fields: getEditedFields(fs.fields, apiName, changedProps),
+ })),
+ );
+ };
+
const isWorkflowsStartButtonDisabled =
- isLoading || Boolean(validateWorkflowName(workflowName)) || !checkExtraFieldsAreValid(kickoffState?.fields);
+ isLoading ||
+ Boolean(validateWorkflowName(workflowName)) ||
+ !checkExtraFieldsAreValid(kickoffState?.fields) ||
+ fieldsetStates.some(fieldset => !checkExtraFieldsAreValid(fieldset.fields));
const handleToggleIsUrgent = () => setIsUrgent(!isUrgent);
@@ -193,9 +207,14 @@ function WorkflowEditPopupComponent({
const handleRunWorkflow = (event: FormEvent) => {
event.preventDefault();
+ const allFieldsetFields = fieldsetStates.flatMap((fs) => fs.fields);
+ const mergedKickoff = {
+ ...kickoffState,
+ fields: [...kickoffState.fields, ...allFieldsetFields],
+ };
onRunWorkflow({
...workflow,
- kickoff: kickoffState,
+ kickoff: mergedKickoff,
name: workflowName,
isUrgent,
dueDate: undefined,
@@ -222,6 +241,7 @@ function WorkflowEditPopupComponent({
title={formatMessage({ id: 'templates.start-name' })}
listVariables={variables}
templateVariables={variables}
+ showInsertButton={false}
value={workflowName || ''}
onChange={(value) => {
changeWorkflowName(value);
@@ -232,7 +252,7 @@ function WorkflowEditPopupComponent({
toolipText={formatMessage({ id: 'kickoff.workflow-name-tooltip' })}
foregroundColor="beige"
/>
- {kickoffState && isArrayWithItems(visibleKickoffFields) && (
+ {kickoffState && (isArrayWithItems(visibleKickoffFields) || isArrayWithItems(fieldsetStates)) && (
{formatMessage({ id: 'template.kick-off-form-title' })}
@@ -244,20 +264,15 @@ function WorkflowEditPopupComponent({
)}
- {visibleKickoffFields.map((field) => (
-
- ))}
+
)}
diff --git a/frontend/src/public/components/WorkflowEditPopup/__tests__/WorkflowEditPopup.test.tsx b/frontend/src/public/components/WorkflowEditPopup/__tests__/WorkflowEditPopup.test.tsx
index c730dc09c..535024c2a 100644
--- a/frontend/src/public/components/WorkflowEditPopup/__tests__/WorkflowEditPopup.test.tsx
+++ b/frontend/src/public/components/WorkflowEditPopup/__tests__/WorkflowEditPopup.test.tsx
@@ -1,10 +1,18 @@
import * as React from 'react';
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { IntlProvider } from 'react-intl';
import { enMessages } from '../../../lang/locales/en_US';
import { WorkflowEditPopup } from '../WorkflowEditPopup';
-import { EExtraFieldType } from '../../../types/template';
+import { IExtraField } from '../../../types/template';
+import { IFieldsetRuntime } from '../../../types/fieldset';
+import { makeExtraField } from '../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../__stubs__/fieldsets.factory';
+import { MergedOutputList } from '../../MergedOutputList';
+import { RichText } from '../../RichText';
+import { InputWithVariables } from '../../TemplateEdit/InputWithVariables';
+import { intlMock } from '../../../__stubs__/intlMock';
jest.mock('react-dom', () => {
const actual = jest.requireActual('react-dom');
@@ -14,12 +22,12 @@ jest.mock('react-dom', () => {
};
});
-jest.mock('../../TemplateEdit/ExtraFields', () => ({
- ExtraFieldIntl: jest.fn(() => ),
+jest.mock('../../MergedOutputList', () => ({
+ MergedOutputList: jest.fn(() => ),
}));
jest.mock('../../TemplateEdit/InputWithVariables', () => ({
- InputWithVariables: () => ,
+ InputWithVariables: jest.fn(() => React.createElement('div')),
}));
jest.mock('../../TemplateEdit/TaskForm/utils/getTaskVariables', () => ({
@@ -35,13 +43,34 @@ jest.mock('../../../utils/history', () => ({
history: { location: { pathname: '/' }, push: jest.fn() },
}));
-const makeField = (overrides = {}) => ({
+jest.mock('../../UI/Buttons/Button', () => ({
+ Button: (props: { label: string; disabled?: boolean; type?: string; onClick?: () => void; isLoading?: boolean }) =>
+ React.createElement('button', {
+ type: props.type || 'button',
+ disabled: props.disabled,
+ }, props.label),
+}));
+
+jest.mock('../../UI', () => ({
+ SectionTitle: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', null, children),
+}));
+
+jest.mock('../../RichText', () => ({
+ RichText: jest.fn(({ text }: { text: string }) => React.createElement('span', null, text)),
+}));
+
+jest.mock('../../icons', () => ({
+ PlayLogoIcon: () => null,
+}));
+
+const makeField = (overrides: Partial = {}) => makeExtraField({
apiName: `f-${Math.random()}`,
- name: 'Field',
- type: EExtraFieldType.String,
- order: 0,
- userId: null,
- groupId: null,
+ ...overrides,
+});
+
+const makeFieldset = (overrides: Partial & { fields: IExtraField[] }) => makeFieldsetRuntime({
+ name: 'Fieldset',
...overrides,
});
@@ -55,6 +84,7 @@ const baseWorkflow = {
kickoff: {
description: '',
fields: [],
+ fieldsets: [],
},
};
@@ -68,6 +98,9 @@ const baseProps = {
onRunWorkflow: jest.fn(),
};
+const formatMsg = (id: string) => intlMock.formatMessage({ id });
+const START_LABEL = formatMsg('templates.start-submit');
+
const renderWithIntl = (ui: React.ReactElement) =>
render(
@@ -80,28 +113,256 @@ describe('WorkflowEditPopup', () => {
jest.clearAllMocks();
});
- describe('Filtering fields by isHidden', () => {
- it('renders only visible fields from a mixed list', () => {
+ it('renders MergedOutputList and passes fields and fieldsets', () => {
+ const loadedFieldsets = [
+ makeFieldset({ fields: [], order: 2 }),
+ ];
+
+ const workflow = {
+ ...baseWorkflow,
+ kickoff: {
+ description: '',
+ fields: [makeField({ apiName: 'f1', order: 1 })],
+ fieldsets: [],
+ },
+ loadedFieldsets,
+ };
+
+ renderWithIntl();
+
+ expect(screen.queryByTestId('merged-output-list')).toBeInTheDocument();
+
+ expect(MergedOutputList).toHaveBeenCalledTimes(1);
+ expect(MergedOutputList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fields: expect.arrayContaining([expect.objectContaining({ apiName: 'f1' })]),
+ fieldsets: loadedFieldsets,
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('passes empty fieldsets when workflow has no loadedFieldsets', () => {
+ const workflow = {
+ ...baseWorkflow,
+ kickoff: {
+ description: '',
+ fields: [makeField({ apiName: 'f1' })],
+ fieldsets: [],
+ },
+ };
+
+ renderWithIntl();
+
+ expect(screen.queryByTestId('merged-output-list')).toBeInTheDocument();
+
+ expect(MergedOutputList).toHaveBeenCalledTimes(1);
+ expect(MergedOutputList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fieldsets: [],
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('filters out isHidden fields before passing to MergedOutputList', () => {
+ const workflow = {
+ ...baseWorkflow,
+ kickoff: {
+ description: '',
+ fields: [
+ makeField({ apiName: 'hidden', isHidden: true }),
+ makeField({ apiName: 'visible-1', isHidden: false }),
+ makeField({ apiName: 'visible-2' }),
+ ],
+ fieldsets: [],
+ },
+ };
+
+ renderWithIntl();
+
+ const callArgs = (MergedOutputList as jest.Mock).mock.calls[0][0];
+ expect(callArgs.fields).toHaveLength(2);
+ expect(callArgs.fields.map((f: IExtraField) => f.apiName)).toEqual(
+ expect.arrayContaining(['visible-1', 'visible-2']),
+ );
+ });
+
+ it('disables Start button when fieldset contains an empty required field', () => {
+ const workflow = {
+ ...baseWorkflow,
+ kickoff: {
+ description: '',
+ fields: [makeField({ apiName: 'f1', value: 'filled' })],
+ fieldsets: [],
+ },
+ loadedFieldsets: [
+ makeFieldset({
+ fields: [
+ makeField({ apiName: 'fs-field-1', isRequired: true, value: '', order: 1 }),
+ ],
+ order: 1,
+ }),
+ ],
+ };
+
+ renderWithIntl();
+
+ const startButton = screen.getByRole('button', { name: START_LABEL });
+ expect(startButton).toBeDisabled();
+ });
+
+ it('enables Start button when all required fields including fieldsets are filled', () => {
+ const workflow = {
+ ...baseWorkflow,
+ kickoff: {
+ description: '',
+ fields: [makeField({ apiName: 'f1', value: 'filled' })],
+ fieldsets: [],
+ },
+ loadedFieldsets: [
+ makeFieldset({
+ fields: [
+ makeField({ apiName: 'fs-field-1', isRequired: true, value: 'also filled', order: 1 }),
+ ],
+ order: 1,
+ }),
+ ],
+ };
+
+ renderWithIntl();
+
+ const startButton = screen.getByRole('button', { name: START_LABEL });
+ expect(startButton).not.toBeDisabled();
+ });
+
+ it('passes showInsertButton=false to InputWithVariables', () => {
+ renderWithIntl();
+
+ expect(InputWithVariables as jest.Mock).toHaveBeenCalledTimes(1);
+ expect(InputWithVariables as jest.Mock).toHaveBeenCalledWith(
+ expect.objectContaining({ showInsertButton: false }),
+ {},
+ );
+ });
+
+ describe('Fieldsets: merge and validation', () => {
+ it('onRunWorkflow receives kickoff.fields = kickoff fields + all fieldset fields on submit', () => {
+ const kickoffField = makeField({ apiName: 'k1', value: 'kickoff-val' });
+ const fsField1 = makeField({ apiName: 'fs1', value: 'fs-val-1' });
+ const fsField2 = makeField({ apiName: 'fs2', value: 'fs-val-2' });
+
const workflow = {
...baseWorkflow,
kickoff: {
description: '',
- fields: [
- makeField({ apiName: 'f1', isHidden: true }),
- makeField({ apiName: 'f2', isHidden: false }),
- makeField({ apiName: 'f3' }),
- ],
+ fields: [kickoffField],
+ fieldsets: [],
},
+ loadedFieldsets: [
+ makeFieldset({ fields: [fsField1], order: 1 }),
+ makeFieldset({ apiNameBinding: 'fs-2', fields: [fsField2], order: 2 }),
+ ],
+ };
+
+ renderWithIntl();
+
+ userEvent.click(screen.getByRole('button', { name: START_LABEL }));
+
+ expect(baseProps.onRunWorkflow).toHaveBeenCalledTimes(1);
+ expect(baseProps.onRunWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kickoff: expect.objectContaining({
+ fields: expect.arrayContaining([
+ expect.objectContaining({ apiName: 'k1' }),
+ expect.objectContaining({ apiName: 'fs1' }),
+ expect.objectContaining({ apiName: 'fs2' }),
+ ]),
+ }),
+ }),
+ );
+ });
+
+ it('editing a fieldset field via onEditFieldsetField is reflected in onRunWorkflow on submit', () => {
+ const fsField = makeField({ apiName: 'fs-edit-1', value: 'old-value' });
+
+ const workflow = {
+ ...baseWorkflow,
+ kickoff: { description: '', fields: [], fieldsets: [] },
+ loadedFieldsets: [
+ makeFieldset({ fields: [fsField], order: 1 }),
+ ],
+ };
+
+ renderWithIntl();
+
+ const mergedMock = MergedOutputList as jest.Mock;
+ const lastCallProps = mergedMock.mock.calls[mergedMock.mock.calls.length - 1][0];
+
+ act(() => {
+ lastCallProps.onEditFieldsetField('fs-edit-1')({ value: 'new-value' });
+ });
+
+ userEvent.click(screen.getByRole('button', { name: START_LABEL }));
+
+ expect(baseProps.onRunWorkflow).toHaveBeenCalledTimes(1);
+ expect(baseProps.onRunWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kickoff: expect.objectContaining({
+ fields: expect.arrayContaining([
+ expect.objectContaining({ apiName: 'fs-edit-1', value: 'new-value' }),
+ ]),
+ }),
+ }),
+ );
+ });
+
+ it('Start is disabled when one of multiple fieldsets has an empty required field', () => {
+ const workflow = {
+ ...baseWorkflow,
+ kickoff: { description: '', fields: [], fieldsets: [] },
+ loadedFieldsets: [
+ makeFieldset({
+ apiNameBinding: 'fs-ok',
+ fields: [makeField({ apiName: 'ok-field', isRequired: true, value: 'filled' })],
+ order: 1,
+ }),
+ makeFieldset({
+ apiNameBinding: 'fs-bad',
+ fields: [makeField({ apiName: 'bad-field', isRequired: true, value: '' })],
+ order: 2,
+ }),
+ ],
};
renderWithIntl();
- expect(screen.getAllByTestId('extra-field')).toHaveLength(2);
+ expect(screen.getByRole('button', { name: START_LABEL })).toBeDisabled();
+ });
+
+ it('renders MergedOutputList when kickoff fields are empty but fieldsets exist', () => {
+ const workflow = {
+ ...baseWorkflow,
+ kickoff: { description: '', fields: [], fieldsets: [] },
+ loadedFieldsets: [
+ makeFieldset({ fields: [makeField({ apiName: 'fs-only-1' })], order: 1 }),
+ ],
+ };
+
+ renderWithIntl();
+
+ expect(screen.queryByTestId('merged-output-list')).toBeInTheDocument();
+
+ const mergedMock = MergedOutputList as jest.Mock;
+ expect(mergedMock).toHaveBeenCalledTimes(1);
+ const lastCallProps = mergedMock.mock.calls[mergedMock.mock.calls.length - 1][0];
+ expect(lastCallProps.fields).toHaveLength(0);
+ expect(lastCallProps.fieldsets).toHaveLength(1);
});
});
describe('Template description', () => {
- it('renders markdown in template description', async () => {
+ it('renders template description via RichText', () => {
const workflow = {
...baseWorkflow,
description: '**Bold description**',
@@ -109,9 +370,11 @@ describe('WorkflowEditPopup', () => {
renderWithIntl();
- await waitFor(() => {
- expect(screen.getByText('Bold description')).toBeInTheDocument();
- });
+ expect(RichText as jest.Mock).toHaveBeenCalledTimes(1);
+ expect(RichText as jest.Mock).toHaveBeenCalledWith(
+ expect.objectContaining({ text: '**Bold description**' }),
+ {},
+ );
});
});
});
diff --git a/frontend/src/public/components/WorkflowEditPopup/types.ts b/frontend/src/public/components/WorkflowEditPopup/types.ts
index 94cf4812e..486bfb0fb 100644
--- a/frontend/src/public/components/WorkflowEditPopup/types.ts
+++ b/frontend/src/public/components/WorkflowEditPopup/types.ts
@@ -1,10 +1,12 @@
-import { ITemplate } from '../../types/template';
+import { ITemplateClient } from '../../types/template';
+import { IFieldsetRuntime } from '../../types/fieldset';
-export interface IRunWorkflow extends Pick {
+export interface IRunWorkflow extends Pick {
id: number;
tasksCount: number;
performersCount: number;
isUrgent?: boolean;
ancestorTaskId?: number;
dueDate?: string;
+ loadedFieldsets?: IFieldsetRuntime[];
}
diff --git a/frontend/src/public/components/WorkflowEditPopup/utils/getInitialKickoff.ts b/frontend/src/public/components/WorkflowEditPopup/utils/getInitialKickoff.ts
index 0419323ba..712956ae3 100644
--- a/frontend/src/public/components/WorkflowEditPopup/utils/getInitialKickoff.ts
+++ b/frontend/src/public/components/WorkflowEditPopup/utils/getInitialKickoff.ts
@@ -1,8 +1,8 @@
/* eslint-disable */
/* prettier-ignore */
-import { IKickoff } from '../../../types/template';
+import { IKickoffClient } from '../../../types/template';
import { ExtraFieldsHelper } from '../../TemplateEdit/ExtraFields/utils/ExtraFieldsHelper';
-export function getInitialKickoff(kickoff: IKickoff): IKickoff {
+export function getInitialKickoff(kickoff: IKickoffClient): IKickoffClient {
return { ...kickoff, fields: new ExtraFieldsHelper(kickoff.fields).getFieldsWithValues() };
}
diff --git a/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/WorkflowLogTaskComplete.tsx b/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/WorkflowLogTaskComplete.tsx
index e69b192c7..1366a829a 100644
--- a/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/WorkflowLogTaskComplete.tsx
+++ b/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/WorkflowLogTaskComplete.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import * as React from 'react';
import { useIntl } from 'react-intl';
import { Avatar } from '../../../../UI/Avatar';
@@ -28,7 +28,7 @@ export function WorkflowLogTaskComplete({
const { formatMessage } = useIntl();
const renderOutputValues = () => {
- const hasOutputValue = isArrayWithItems(currentTask?.output.filter(Boolean));
+ const hasOutputValue = isArrayWithItems(currentTask?.output.filter(Boolean)) || isArrayWithItems(currentTask?.fieldsets);
if (!hasOutputValue) {
return null;
@@ -39,6 +39,7 @@ export function WorkflowLogTaskComplete({
containerClassName={styles['outputs-container']}
viewMode={EKickoffOutputsViewModes.Short}
outputs={currentTask?.output.filter(Boolean)}
+ fieldsets={currentTask?.fieldsets || []}
isOnlyAttachmentsShown={isOnlyAttachmentsShown}
/>
);
diff --git a/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/__tests__/WorkflowLogTaskComplete.test.tsx b/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/__tests__/WorkflowLogTaskComplete.test.tsx
new file mode 100644
index 000000000..01076e70b
--- /dev/null
+++ b/frontend/src/public/components/Workflows/WorkflowLog/WorkflowLogEvents/WorkflowLogTaskComplete/__tests__/WorkflowLogTaskComplete.test.tsx
@@ -0,0 +1,147 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import { enMessages } from '../../../../../../lang/locales/en_US';
+
+import { WorkflowLogTaskComplete } from '../WorkflowLogTaskComplete';
+import { KickoffOutputs } from '../../../../../KickoffOutputs';
+import { makeExtraField } from '../../../../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../../../../__stubs__/fieldsets.factory';
+import { IExtraField } from '../../../../../../types/template';
+import { IFieldsetRuntime } from '../../../../../../types/fieldset';
+import { IWorkflowLogTask } from '../../../../../../types/workflow';
+
+jest.mock('../../../../../KickoffOutputs', () => ({
+ KickoffOutputs: jest.fn(() => React.createElement('div', { 'data-testid': 'kickoff-outputs' })),
+ EKickoffOutputsViewModes: { Short: 'Short', Detailed: 'Detailed' },
+}));
+
+jest.mock('../../../../../UserData', () => ({
+ UserData: jest.fn(({ children }: { children: (user: Record | null) => React.ReactNode }) =>
+ children({ id: '1', firstName: 'Test', lastName: 'User', email: 'test@test.com' }),
+ ),
+}));
+
+jest.mock('../../../../../UI/Avatar', () => ({
+ Avatar: () => null,
+}));
+
+jest.mock('../../../../../icons', () => ({
+ DoneInfoIcon: () => null,
+}));
+
+jest.mock('../../../../../UI/DateFormat', () => ({
+ DateFormat: () => 'mocked-date',
+}));
+
+jest.mock('../../../../../../utils/users', () => ({
+ getUserFullName: jest.fn(() => 'Test User'),
+}));
+
+jest.mock('../../../../../../utils/helpers', () => {
+ const actual = jest.requireActual('../../../../../../utils/helpers');
+ return { isArrayWithItems: actual.isArrayWithItems };
+});
+
+const makeField = (overrides: Partial = {}) => makeExtraField({
+ value: 'val',
+ ...overrides,
+});
+
+const makeFieldset = (overrides: Partial = {}) => makeFieldsetRuntime({
+ name: 'Fieldset',
+ fields: [makeField()],
+ ...overrides,
+});
+
+const makeTask = (overrides: Partial = {}): IWorkflowLogTask => ({
+ id: 1,
+ name: 'Task 1',
+ description: '',
+ output: [],
+ fieldsets: [],
+ performers: [],
+ dueDate: null,
+ number: 1,
+ delay: null,
+ ...overrides,
+});
+
+const renderWithIntl = (ui: React.ReactElement) =>
+ render(
+ React.createElement(IntlProvider, { locale: 'en', messages: enMessages }, ui),
+ );
+
+describe('WorkflowLogTaskComplete', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Fieldsets: render condition and props', () => {
+ it('renders KickoffOutputs when output is empty but fieldsets exist', () => {
+ const task = makeTask({
+ output: [],
+ fieldsets: [makeFieldset()],
+ });
+
+ renderWithIntl(
+ React.createElement(WorkflowLogTaskComplete, {
+ userId: 1,
+ created: '2024-01-01',
+ currentTask: task,
+ }),
+ );
+
+ expect(screen.getByTestId('kickoff-outputs')).toBeInTheDocument();
+ const koMock = KickoffOutputs as unknown as jest.Mock;
+ expect(koMock).toHaveBeenCalledTimes(1);
+ const lastCallProps = koMock.mock.calls[koMock.mock.calls.length - 1][0];
+ expect(lastCallProps.fieldsets).toHaveLength(1);
+ expect(lastCallProps.fieldsets[0].apiNameBinding).toBe('fs-1');
+ });
+
+ it('does not render KickoffOutputs when both output and fieldsets are empty', () => {
+ const task = makeTask({
+ output: [],
+ fieldsets: [],
+ });
+
+ renderWithIntl(
+ React.createElement(WorkflowLogTaskComplete, {
+ userId: 1,
+ created: '2024-01-01',
+ currentTask: task,
+ }),
+ );
+
+ expect(screen.queryByTestId('kickoff-outputs')).not.toBeInTheDocument();
+ });
+
+ it('passes both outputs and fieldsets from currentTask to KickoffOutputs', () => {
+ const outputField = makeField({ apiName: 'out-1', value: 'output-val' });
+ const task = makeTask({
+ output: [outputField],
+ fieldsets: [makeFieldset({ apiNameBinding: 'fs-data' })],
+ });
+
+ renderWithIntl(
+ React.createElement(WorkflowLogTaskComplete, {
+ userId: 1,
+ created: '2024-01-01',
+ currentTask: task,
+ }),
+ );
+
+ const koMock = KickoffOutputs as unknown as jest.Mock;
+ expect(koMock).toHaveBeenCalledTimes(1);
+ const lastCallProps = koMock.mock.calls[koMock.mock.calls.length - 1][0];
+ expect(lastCallProps.outputs).toEqual(expect.arrayContaining([
+ expect.objectContaining({ apiName: 'out-1' }),
+ ]));
+ expect(lastCallProps.fieldsets).toEqual(expect.arrayContaining([
+ expect.objectContaining({ apiNameBinding: 'fs-data' }),
+ ]));
+ expect(lastCallProps.viewMode).toBe('Short');
+ });
+ });
+});
diff --git a/frontend/src/public/components/Workflows/WorkflowLog/__tests__/WorkflowLog.test.tsx b/frontend/src/public/components/Workflows/WorkflowLog/__tests__/WorkflowLog.test.tsx
index 1155f1e95..39c1a2188 100644
--- a/frontend/src/public/components/Workflows/WorkflowLog/__tests__/WorkflowLog.test.tsx
+++ b/frontend/src/public/components/Workflows/WorkflowLog/__tests__/WorkflowLog.test.tsx
@@ -1,4 +1,3 @@
-//
import * as React from 'react';
import { render, screen } from '@testing-library/react';
diff --git a/frontend/src/public/components/Workflows/WorkflowModal/WorkflowModal.tsx b/frontend/src/public/components/Workflows/WorkflowModal/WorkflowModal.tsx
index f28e2dbbf..85db81c5b 100644
--- a/frontend/src/public/components/Workflows/WorkflowModal/WorkflowModal.tsx
+++ b/frontend/src/public/components/Workflows/WorkflowModal/WorkflowModal.tsx
@@ -12,6 +12,7 @@ import {
IWorkflowLogItem,
} from '../../../types/workflow';
import { IExtraField } from '../../../types/template';
+import { IFieldsetRuntime } from '../../../types/fieldset';
import { Avatar } from '../../UI/Avatar';
import { EditIcon, ModalCloseIcon } from '../../icons';
import { EditKickoffContainer } from '../../KickoffEdit';
@@ -78,16 +79,23 @@ export interface IWorkflowModalStoreProps {
export type IWorkflowModalProps = IWorkflowModalOwnProps & IWorkflowModalStoreProps;
+interface IWorkflowModalState {
+ fieldsetFields: IFieldsetRuntime[];
+}
+
const syncWorkflowModalPrintMode = (isOpen: boolean) => {
document.body.classList.toggle('workflow-modal-print-mode', isOpen);
};
-export class WorkflowModal extends React.Component {
+export class WorkflowModal extends React.Component {
private processProgress: number | undefined;
public constructor(props: IWorkflowModalProps) {
super(props);
this.processProgress = this.calculateWorkflowProgress();
+ this.state = {
+ fieldsetFields: WorkflowModal.getFieldsetsFromProps(props),
+ };
}
@@ -121,11 +129,16 @@ export class WorkflowModal extends React.Component {
if (prevKickoffId !== kickoff.id) {
const editProcess = { name, kickoff: getEditKickoff(kickoff) };
setWorkflowEdit(editProcess);
+ this.setState({ fieldsetFields: WorkflowModal.getFieldsetsFromProps(this.props) });
}
this.processProgress = this.calculateWorkflowProgress();
}
+ private static getFieldsetsFromProps(props: IWorkflowModalProps): IFieldsetRuntime[] {
+ return (props.workflow?.kickoff?.fieldsets || []).map((fs) => ({ ...fs, fields: [...fs.fields] }));
+ }
+
public componentWillUnmount() {
syncWorkflowModalPrintMode(false);
}
@@ -280,6 +293,7 @@ export class WorkflowModal extends React.Component {
return null;
}
+ const { fieldsetFields } = this.state;
const initialEditKickoff = getEditKickoff(initialKickoff);
if (!isKickoffEditing) {
@@ -289,6 +303,7 @@ export class WorkflowModal extends React.Component {
containerClassName="mt-3"
description={initialKickoff?.description}
outputs={initialKickoff?.output}
+ fieldsets={initialKickoff?.fieldsets || []}
onEdit={canEdit ? () => setIsEditKickoff(true) : undefined}
/>
);
@@ -301,8 +316,28 @@ export class WorkflowModal extends React.Component {
setWorkflowEdit({ ...workflowEditData, kickoff: newKickoff });
};
+ const handleEditFieldsetField = (apiName: string) => (changedProps: Partial) => {
+ this.setState((prevState) => ({
+ fieldsetFields: prevState.fieldsetFields.map((fs) => ({
+ ...fs,
+ fields: getEditedFields(fs.fields, apiName, changedProps),
+ })),
+ }));
+ };
+
+ const handleSave = () => {
+ // Merge fieldset fields into kickoff.fields so they are included in the PATCH body
+ const allFieldsetFields = fieldsetFields.flatMap((fs) => fs.fields);
+ const mergedKickoff = {
+ ...editKickoff,
+ fields: [...editKickoff.fields, ...allFieldsetFields],
+ };
+ this.handleEditProcess({ kickoff: mergedKickoff });
+ };
+
const handleCancel = () => {
setWorkflowEdit({ ...workflowEditData, kickoff: initialEditKickoff });
+ this.setState({ fieldsetFields: WorkflowModal.getFieldsetsFromProps(this.props) });
setIsEditKickoff(false);
};
@@ -311,8 +346,10 @@ export class WorkflowModal extends React.Component {
this.handleEditProcess({ kickoff: editKickoff })}
+ onEditFieldsetField={handleEditFieldsetField}
+ onSave={handleSave}
onCancel={handleCancel}
/>
diff --git a/frontend/src/public/components/Workflows/WorkflowModal/__tests__/WorkflowModal.test.tsx b/frontend/src/public/components/Workflows/WorkflowModal/__tests__/WorkflowModal.test.tsx
new file mode 100644
index 000000000..76b3e8418
--- /dev/null
+++ b/frontend/src/public/components/Workflows/WorkflowModal/__tests__/WorkflowModal.test.tsx
@@ -0,0 +1,450 @@
+import * as React from 'react';
+import { render, act } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import { enMessages } from '../../../../lang/locales/en_US';
+
+import { WorkflowModal } from '../WorkflowModal';
+import { KickoffOutputs } from '../../../KickoffOutputs';
+import { EditKickoffContainer } from '../../../KickoffEdit';
+import { makeExtraField } from '../../../../__stubs__/fields.factory';
+import { makeFieldsetRuntime } from '../../../../__stubs__/fieldsets.factory';
+import {
+ IExtraField,
+} from '../../../../types/template';
+import { IFieldsetRuntime } from '../../../../types/fieldset';
+import {
+ EWorkflowStatus,
+ EWorkflowsLogSorting,
+ IWorkflowDetailsClient,
+ IWorkflowDetailsKickoff,
+ IWorkflowEdit,
+} from '../../../../types/workflow';
+
+jest.mock('react-dom', () => {
+ const actual = jest.requireActual('react-dom');
+ return {
+ ...actual,
+ default: { ...actual.default, createPortal: (el: React.ReactNode) => el },
+ };
+});
+
+jest.mock('reactstrap', () => ({
+ Modal: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) =>
+ isOpen ? React.createElement('div', { 'data-testid': 'modal' }, children) : null,
+ ModalBody: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', null, children),
+}));
+
+jest.mock('react-outside-click-handler', () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', null, children),
+}));
+
+jest.mock('react-textarea-autosize', () => ({
+ __esModule: true,
+ default: () => React.createElement('textarea'),
+}));
+
+jest.mock('../../../KickoffOutputs', () => ({
+ KickoffOutputs: jest.fn(() => React.createElement('div', { 'data-testid': 'kickoff-outputs' })),
+ EKickoffOutputsViewModes: { Detailed: 'Detailed', Short: 'Short' },
+}));
+
+jest.mock('../../../KickoffEdit', () => ({
+ EditKickoffContainer: jest.fn(() => React.createElement('div', { 'data-testid': 'edit-kickoff' })),
+}));
+
+jest.mock('../../WorkflowLog', () => ({
+ WorkflowLog: jest.fn(() => null),
+}));
+
+jest.mock('../WorkflowModalHeaderProgressBar', () => ({
+ WorkflowModalHeaderProgressBar: jest.fn(() => null),
+}));
+
+jest.mock('../../../UserData', () => ({
+ UserData: jest.fn(() => null),
+}));
+
+jest.mock('../../../UI/Avatar', () => ({
+ Avatar: jest.fn(() => null),
+}));
+
+jest.mock('../../../UI/Loader', () => ({
+ Loader: () => null,
+}));
+
+jest.mock('../../../UI/DateFormat', () => ({
+ DateFormat: () => 'mocked-date',
+}));
+
+jest.mock('../../../UI/TemplateName', () => ({
+ TemplateName: () => null,
+}));
+
+jest.mock('../../../IntlMessages', () => ({
+ IntlMessages: ({ id }: { id: string }) => React.createElement('span', null, id),
+}));
+
+jest.mock('../../../icons', () => ({
+ EditIcon: () => null,
+ ModalCloseIcon: () => React.createElement('button', { 'data-testid': 'close-icon' }),
+}));
+
+jest.mock('../../../../utils/workflows', () => ({
+ getEditKickoff: jest.fn((kickoff: IWorkflowDetailsKickoff) => ({
+ description: kickoff.description || '',
+ fields: kickoff.output ? [...kickoff.output] : [],
+ fieldsets: [],
+ })),
+}));
+
+jest.mock('../../../../utils/helpers', () => ({
+ getPercent: jest.fn(() => 50),
+}));
+
+jest.mock('../../../../utils/users', () => ({
+ getUserFullName: jest.fn(() => 'Test User'),
+}));
+
+jest.mock('../../../../utils/validators', () => ({
+ validateWorkflowName: jest.fn(() => null),
+}));
+
+jest.mock('../../../TemplateEdit/ExtraFields/utils/getEditedFields', () => ({
+ getEditedFields: jest.fn((fields: IExtraField[], apiName: string, changedProps: Partial
) =>
+ fields.map((f) => (f.apiName === apiName ? { ...f, ...changedProps } : f)),
+ ),
+}));
+
+jest.mock('../../utils/getWorkflowProgressColor', () => ({
+ getWorkflowProgressColor: jest.fn(() => 'green'),
+}));
+
+const makeField = (overrides: Partial = {}) => makeExtraField({
+ apiName: `f-${Math.random().toString(36).slice(2, 6)}`,
+ ...overrides,
+});
+
+const makeFieldset = (overrides: Partial & { fields: IExtraField[] }) => makeFieldsetRuntime({
+ name: 'Fieldset',
+ ...overrides,
+});
+
+const makeWorkflow = (overrides: Partial = {}): IWorkflowDetailsClient =>
+ ({
+ id: 1,
+ name: 'Test Workflow',
+ owners: [1],
+ status: EWorkflowStatus.Running,
+ dateCreated: '2024-01-01',
+ dateCompleted: null,
+ dueDate: null,
+ isLegacyTemplate: false,
+ legacyTemplateName: '',
+ isExternal: false,
+ isUrgent: false,
+ workflowStarter: 1,
+ template: { id: 1, name: 'Template', isActive: true, wfNameTemplate: null },
+ description: '',
+ kickoff: {
+ id: 1,
+ description: '',
+ output: [],
+ },
+ finalizable: true,
+ tasks: [],
+ completedTasks: [],
+ areMultipleTasks: false,
+ multipleTasksNamesByApiNames: {},
+ oneActiveTaskName: null,
+ selectedUsers: [],
+ tasksCountWithoutSkipped: 0,
+ minDelay: null,
+ areOverdueTasks: false,
+ oldestDeadline: null,
+ ...overrides,
+ } as IWorkflowDetailsClient);
+
+const makeViewWorkflowEdit = (kickoffOutput: IExtraField[] = []): IWorkflowEdit => ({
+ workflow: {
+ name: 'Test Workflow',
+ kickoff: {
+ description: '',
+ fields: kickoffOutput,
+ fieldsets: [],
+ },
+ },
+ isWorkflowNameEditing: false,
+ isWorkflowNameSaving: false,
+ isKickoffEditing: false,
+ isKickoffSaving: false,
+});
+
+const makeEditWorkflowEdit = (kickoffFields: IExtraField[] = []): IWorkflowEdit => ({
+ workflow: {
+ name: 'Test Workflow',
+ kickoff: {
+ description: '',
+ fields: kickoffFields,
+ fieldsets: [],
+ },
+ },
+ isWorkflowNameEditing: false,
+ isWorkflowNameSaving: false,
+ isKickoffEditing: true,
+ isKickoffSaving: false,
+});
+
+const baseProps = {
+ workflowId: 1,
+ isAccountOwner: false,
+ sorting: EWorkflowsLogSorting.New,
+ isCommentsShown: false,
+ isOnlyAttachmentsShown: false,
+ isSkippedTasksShown: false,
+ isOpen: true,
+ timezone: 'UTC',
+ dateFmt: 'MM/DD/YYYY',
+ items: [],
+ isLoading: false,
+ isLogLoading: false,
+ canEdit: true,
+ isRunWorkflowOpen: false,
+ isFullscreenImageOpen: false,
+ sendWorkflowLogComments: jest.fn(),
+ setIsEditWorkflowName: jest.fn(),
+ setIsEditKickoff: jest.fn(),
+ changeWorkflowLogViewSettings: jest.fn(),
+ toggleSkippedTasksVisibility: jest.fn(),
+ editWorkflow: jest.fn(),
+ setWorkflowEdit: jest.fn(),
+ toggleModal: jest.fn(),
+};
+
+const renderWithIntl = (ui: React.ReactElement) =>
+ render(
+ React.createElement(IntlProvider, { locale: 'en', messages: enMessages }, ui),
+ );
+
+describe('WorkflowModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Fieldsets: view, edit, merge, cancel', () => {
+ it('view mode: KickoffOutputs receives fieldsets from workflow.kickoff.fieldsets', () => {
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldset({ fields: [makeField({ apiName: 'fs-f1', value: 'v1' })], order: 1 }),
+ ];
+
+ const workflow = makeWorkflow({
+ kickoff: { id: 1, description: '', output: [], fieldsets },
+ });
+
+ renderWithIntl(
+ React.createElement(WorkflowModal, {
+ ...baseProps,
+ workflow,
+ workflowEdit: makeViewWorkflowEdit(),
+ }),
+ );
+
+ const koMock = KickoffOutputs as jest.Mock;
+ expect(koMock).toHaveBeenCalledTimes(1);
+ const lastCallProps = koMock.mock.calls[koMock.mock.calls.length - 1][0];
+ expect(lastCallProps.fieldsets).toHaveLength(1);
+ expect(lastCallProps.fieldsets).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ apiNameBinding: 'fs-1' }),
+ ]),
+ );
+ });
+
+ it('view mode without fieldsets: KickoffOutputs receives fieldsets=[]', () => {
+ const workflow = makeWorkflow({
+ kickoff: { id: 1, description: '', output: [] },
+ });
+
+ renderWithIntl(
+ React.createElement(WorkflowModal, {
+ ...baseProps,
+ workflow,
+ workflowEdit: makeViewWorkflowEdit(),
+ }),
+ );
+
+ const koMock = KickoffOutputs as jest.Mock;
+ expect(koMock).toHaveBeenCalledTimes(1);
+ const lastCallProps = koMock.mock.calls[koMock.mock.calls.length - 1][0];
+ expect(lastCallProps.fieldsets).toEqual([]);
+ });
+
+ it('edit mode: EditKickoffContainer receives fieldsets from local state', () => {
+ const fsField = makeField({ apiName: 'fs-e1', value: 'edit-val' });
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldset({ fields: [fsField], order: 1 }),
+ ];
+
+ const workflow = makeWorkflow({
+ kickoff: { id: 1, description: '', output: [], fieldsets },
+ });
+
+ renderWithIntl(
+ React.createElement(WorkflowModal, {
+ ...baseProps,
+ workflow,
+ workflowEdit: makeEditWorkflowEdit(),
+ }),
+ );
+
+ const ekMock = EditKickoffContainer as unknown as jest.Mock;
+ expect(ekMock).toHaveBeenCalledTimes(1);
+ const lastCallProps = ekMock.mock.calls[ekMock.mock.calls.length - 1][0];
+ expect(lastCallProps.fieldsets).toHaveLength(1);
+ expect(lastCallProps.fieldsets[0].fields).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ apiName: 'fs-e1', value: 'edit-val' }),
+ ]),
+ );
+ });
+
+ it('reinitializes fieldsetFields from new kickoff when workflow changes', () => {
+ const fieldsets1: IFieldsetRuntime[] = [
+ makeFieldset({ fields: [makeField({ apiName: 'old-field' })], order: 1 }),
+ ];
+ const fieldsets2: IFieldsetRuntime[] = [
+ makeFieldset({
+ apiNameBinding: 'fs-new',
+ fields: [makeField({ apiName: 'new-field' })],
+ order: 1,
+ }),
+ ];
+
+ const workflow1 = makeWorkflow({
+ kickoff: { id: 1, description: '', output: [], fieldsets: fieldsets1 },
+ });
+ const workflow2 = makeWorkflow({
+ kickoff: { id: 2, description: '', output: [], fieldsets: fieldsets2 },
+ });
+
+ const editState = makeEditWorkflowEdit();
+
+ const { rerender } = renderWithIntl(
+ React.createElement(WorkflowModal, {
+ ...baseProps,
+ workflow: workflow1,
+ workflowEdit: editState,
+ }),
+ );
+
+ rerender(
+ React.createElement(
+ IntlProvider,
+ { locale: 'en', messages: enMessages },
+ React.createElement(WorkflowModal, {
+ ...baseProps,
+ workflow: workflow2,
+ workflowEdit: editState,
+ }),
+ ),
+ );
+
+ const ekMock = EditKickoffContainer as unknown as jest.Mock;
+ const lastCallProps = ekMock.mock.calls[ekMock.mock.calls.length - 1][0];
+ expect(lastCallProps.fieldsets).toHaveLength(1);
+ expect(lastCallProps.fieldsets[0].apiNameBinding).toBe('fs-new');
+ expect(lastCallProps.fieldsets[0].fields).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ apiName: 'new-field' }),
+ ]),
+ );
+ });
+
+ it('save merges fieldset fields into kickoff.fields and calls editWorkflow', () => {
+ const kickoffField = makeField({ apiName: 'k1', value: 'kick-val' });
+ const fsField = makeField({ apiName: 'fs-s1', value: 'fs-val' });
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldset({ fields: [fsField], order: 1 }),
+ ];
+
+ const workflow = makeWorkflow({
+ kickoff: { id: 1, description: '', output: [kickoffField], fieldsets },
+ });
+
+ const editState = makeEditWorkflowEdit([kickoffField]);
+
+ renderWithIntl(
+ React.createElement(WorkflowModal, {
+ ...baseProps,
+ workflow,
+ workflowEdit: editState,
+ }),
+ );
+
+ const ekMock = EditKickoffContainer as unknown as jest.Mock;
+ const lastCallProps = ekMock.mock.calls[ekMock.mock.calls.length - 1][0];
+
+ act(() => {
+ lastCallProps.onSave();
+ });
+
+ expect(baseProps.editWorkflow).toHaveBeenCalledTimes(1);
+ expect(baseProps.editWorkflow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kickoff: expect.objectContaining({
+ fields: expect.arrayContaining([
+ expect.objectContaining({ apiName: 'k1' }),
+ expect.objectContaining({ apiName: 'fs-s1' }),
+ ]),
+ }),
+ }),
+ );
+ });
+
+ it('cancel calls setIsEditKickoff(false) and resets setWorkflowEdit', () => {
+ const fsField = makeField({ apiName: 'fs-c1', value: 'original' });
+ const fieldsets: IFieldsetRuntime[] = [
+ makeFieldset({ fields: [fsField], order: 1 }),
+ ];
+
+ const workflow = makeWorkflow({
+ kickoff: { id: 1, description: '', output: [], fieldsets },
+ });
+
+ const editState = makeEditWorkflowEdit();
+
+ renderWithIntl(
+ React.createElement(WorkflowModal, {
+ ...baseProps,
+ workflow,
+ workflowEdit: editState,
+ }),
+ );
+
+ baseProps.setWorkflowEdit.mockClear();
+ baseProps.setIsEditKickoff.mockClear();
+
+ const ekMock = EditKickoffContainer as unknown as jest.Mock;
+ const lastCallProps = ekMock.mock.calls[ekMock.mock.calls.length - 1][0];
+
+ act(() => {
+ lastCallProps.onCancel();
+ });
+
+ expect(baseProps.setIsEditKickoff).toHaveBeenCalledTimes(1);
+ expect(baseProps.setIsEditKickoff).toHaveBeenCalledWith(false);
+
+ expect(baseProps.setWorkflowEdit).toHaveBeenCalledTimes(1);
+ expect(baseProps.setWorkflowEdit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kickoff: expect.objectContaining({
+ description: '',
+ fields: [],
+ }),
+ }),
+ );
+ });
+ });
+});
diff --git a/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowCard/utils/__tests__/getClonedKickoff.test.ts b/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowCard/utils/__tests__/getClonedKickoff.test.ts
index 638abf832..31e3277de 100644
--- a/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowCard/utils/__tests__/getClonedKickoff.test.ts
+++ b/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowCard/utils/__tests__/getClonedKickoff.test.ts
@@ -1,5 +1,7 @@
import { IWorkflowDetailsKickoff } from '../../../../../../types/workflow';
-import { EExtraFieldType, IKickoff } from '../../../../../../types/template';
+import { EExtraFieldType, IKickoffClient } from '../../../../../../types/template';
+import { makeFieldsetRuntime } from '../../../../../../__stubs__/fieldsets.factory';
+import { makeExtraField } from '../../../../../../__stubs__/fields.factory';
import { getClonedKickoff } from '../getClonedKickoff';
const mockWorkflowDetailKickoff: IWorkflowDetailsKickoff = {
@@ -116,7 +118,7 @@ const mockWorkflowDetailKickoff: IWorkflowDetailsKickoff = {
],
};
-const templateKickoffMock: IKickoff = {
+const templateKickoffMock: IKickoffClient = {
description:
' youtube: \nhttps://www.youtube.com/watch?v=JZRm7NKTPhk\n loom:\nhttps://www.loom.com/share/29f210bc12484eaa81ca462381fb4415?t=0\n 404 loom:\n\nhttps://www.loom.com/share/9853f0790ad2408094a3717bfcf4a0c0\nYoutube 404 :\n\nhttps://www.youtube.com/watch?v=D6hIeqZt22g',
fields: [
@@ -207,6 +209,7 @@ const templateKickoffMock: IKickoff = {
groupId: null,
},
],
+ fieldsets: [],
};
const expectedKickoff = {
@@ -304,6 +307,7 @@ const expectedKickoff = {
groupId: null,
},
],
+ fieldsets: [],
};
describe('getClonedKickoff.', () => {
@@ -312,4 +316,121 @@ describe('getClonedKickoff.', () => {
expect(clonedKickoff).toStrictEqual(expectedKickoff);
});
+
+ describe('Fieldsets', () => {
+ it('does not carry over fieldsets from workflowKickoff into the result (fieldsets=[])', async () => {
+ const workflowWithFieldsets: IWorkflowDetailsKickoff = {
+ id: 2,
+ description: '',
+ output: [],
+ fieldsets: [
+ makeFieldsetRuntime({
+ apiNameBinding: 'fs-1',
+ name: 'Fieldset 1',
+ fields: [makeExtraField({ apiName: 'fs-field-1', name: 'FS Field', value: 'data' })],
+ }),
+ ],
+ };
+
+ const emptyTemplateKickoff: IKickoffClient = {
+ description: '',
+ fields: [],
+ fieldsets: [],
+ };
+
+ const result = await getClonedKickoff(workflowWithFieldsets, emptyTemplateKickoff);
+
+ expect(result.fieldsets).toEqual([]);
+ expect(result.fields).toEqual([]);
+ });
+
+ it('drops workflow fields that are absent in the template', async () => {
+ const workflowKickoff: IWorkflowDetailsKickoff = {
+ id: 3,
+ description: '',
+ output: [
+ {
+ apiName: 'exists-in-both',
+ name: 'Both',
+ type: EExtraFieldType.String,
+ value: 'keep',
+ order: 0,
+ userId: null,
+ groupId: null,
+ },
+ {
+ apiName: 'only-in-workflow',
+ name: 'Orphan',
+ type: EExtraFieldType.String,
+ value: 'drop',
+ order: 1,
+ userId: null,
+ groupId: null,
+ },
+ ],
+ };
+
+ const templateKickoff: IKickoffClient = {
+ description: '',
+ fields: [
+ {
+ apiName: 'exists-in-both',
+ name: 'Both',
+ type: EExtraFieldType.String,
+ order: 0,
+ userId: null,
+ groupId: null,
+ },
+ ],
+ fieldsets: [],
+ };
+
+ const result = await getClonedKickoff(workflowKickoff, templateKickoff);
+
+ expect(result.fields).toHaveLength(1);
+ expect(result.fields[0].apiName).toBe('exists-in-both');
+ expect(result.fields[0].value).toBe('keep');
+ });
+
+ it('filters checkbox value by template selections', async () => {
+ const workflowKickoff: IWorkflowDetailsKickoff = {
+ id: 4,
+ description: '',
+ output: [
+ {
+ apiName: 'cb-1',
+ name: 'CB',
+ type: EExtraFieldType.Checkbox,
+ value: 'a, b, c',
+ selections: [],
+ order: 0,
+ userId: null,
+ groupId: null,
+ },
+ ],
+ };
+
+ const templateKickoff: IKickoffClient = {
+ description: '',
+ fields: [
+ {
+ apiName: 'cb-1',
+ name: 'CB',
+ type: EExtraFieldType.Checkbox,
+ selections: ['a', 'c', 'd'],
+ order: 0,
+ userId: null,
+ groupId: null,
+ },
+ ],
+ fieldsets: [],
+ };
+
+ const result = await getClonedKickoff(workflowKickoff, templateKickoff);
+
+ expect(result.fields).toHaveLength(1);
+ expect(result.fields[0].value).toEqual(['a', 'c']);
+ expect(result.fields[0].selections).toEqual(['a', 'c', 'd']);
+ });
+ });
});
diff --git a/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowCard/utils/getClonedKickoff.ts b/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowCard/utils/getClonedKickoff.ts
index e129f8435..e49c84b8a 100644
--- a/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowCard/utils/getClonedKickoff.ts
+++ b/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowCard/utils/getClonedKickoff.ts
@@ -1,7 +1,7 @@
/* eslint-disable */
/* prettier-ignore */
import { copyAttachment } from '../../../../../api/copyAttachment';
-import { EExtraFieldType, IExtraField, IKickoff, TExtraFieldValue } from '../../../../../types/template';
+import { EExtraFieldType, IExtraField, IKickoffClient, TExtraFieldValue } from '../../../../../types/template';
import { IWorkflowDetailsKickoff } from '../../../../../types/workflow';
import { TUploadedFile } from '../../../../../utils/uploadFiles';
import { getEditKickoff } from '../../../../../utils/workflows';
@@ -9,8 +9,8 @@ import { normalizeSelections } from '../../../../TemplateEdit/utils/normalizeSel
export async function getClonedKickoff(
workflowKickoff: IWorkflowDetailsKickoff,
- templateKickoff: IKickoff,
-): Promise {
+ templateKickoff: IKickoffClient,
+): Promise {
const kickoff = getEditKickoff(workflowKickoff);
const normalizedKickoffFieldsPromises = kickoff.fields.map((field) => {
const templateField = templateKickoff.fields.find((templateField) => templateField.apiName === field.apiName);
diff --git a/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowsGridPage.tsx b/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowsGridPage.tsx
index 985a42f82..e4ae7b586 100644
--- a/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowsGridPage.tsx
+++ b/frontend/src/public/components/Workflows/WorkflowsGridPage/WorkflowsGridPage.tsx
@@ -1,4 +1,5 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import * as React from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
import { useIntl } from 'react-intl';
import classnames from 'classnames';
@@ -10,6 +11,7 @@ import { EPageTitle } from '../../../constants/defaultValues';
import { SearchLargeIcon, StartRoundIcon } from '../../icons';
import { getTemplate } from '../../../api/getTemplate';
import { getRunnableWorkflow, loadDatasetsMap } from '../../TemplateEdit/utils/getRunnableWorkflow';
+import { mapTemplateFieldsetsToRuntime } from '../../../utils/mapTemplateFieldsetsToRuntime';
import { logger } from '../../../utils/logger';
import { NotificationManager } from '../../UI/Notifications';
import { getErrorMessage } from '../../../utils/getErrorMessage';
@@ -91,9 +93,10 @@ export const WorkflowsGridPage = function Workflows({
return;
}
- const datasetsMap = await loadDatasetsMap(template.kickoff);
+ const { normalizedTemplate, loadedFieldsets } = mapTemplateFieldsetsToRuntime(template);
+ const datasetsMap = await loadDatasetsMap(normalizedTemplate.kickoff, loadedFieldsets);
- const runnableWorkflow = getRunnableWorkflow(template, datasetsMap);
+ const runnableWorkflow = getRunnableWorkflow(normalizedTemplate, datasetsMap, loadedFieldsets);
if (!runnableWorkflow) {
openSelectTemplateModal();
diff --git a/frontend/src/public/components/Workflows/WorkflowsGridPage/__tests__/WorkflowsGridPage.test.tsx b/frontend/src/public/components/Workflows/WorkflowsGridPage/__tests__/WorkflowsGridPage.test.tsx
new file mode 100644
index 000000000..01c482e70
--- /dev/null
+++ b/frontend/src/public/components/Workflows/WorkflowsGridPage/__tests__/WorkflowsGridPage.test.tsx
@@ -0,0 +1,232 @@
+import * as React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { IntlProvider } from 'react-intl';
+import { enMessages } from '../../../../lang/locales/en_US';
+
+import { WorkflowsGridPage } from '../WorkflowsGridPage';
+import { getTemplate } from '../../../../api/getTemplate';
+import {
+ getRunnableWorkflow,
+ loadDatasetsMap,
+} from '../../../TemplateEdit/utils/getRunnableWorkflow';
+import { mapTemplateFieldsetsToRuntime } from '../../../../utils/mapTemplateFieldsetsToRuntime';
+import { EWorkflowsLoadingStatus, EWorkflowsView } from '../../../../types/workflow';
+import { ITemplateTitle } from '../../../../types/template';
+import { intlMock } from '../../../../__stubs__/intlMock';
+import { makeFieldsetRuntime } from '../../../../__stubs__/fieldsets.factory';
+
+const renderWithIntl = (ui: React.ReactElement) =>
+ render(
+ React.createElement(IntlProvider, { locale: 'en', messages: enMessages }, ui),
+ );
+
+jest.mock('../../../../api/getTemplate', () => ({
+ getTemplate: jest.fn(),
+}));
+
+jest.mock('../../../TemplateEdit/utils/getRunnableWorkflow', () => ({
+ loadDatasetsMap: jest.fn(),
+ getRunnableWorkflow: jest.fn(),
+}));
+
+jest.mock('../../../../utils/mapTemplateFieldsetsToRuntime', () => ({
+ mapTemplateFieldsetsToRuntime: jest.fn(),
+}));
+
+jest.mock('../../../../utils/logger', () => ({
+ logger: { error: jest.fn() },
+}));
+
+jest.mock('../../../../utils/getErrorMessage', () => ({
+ getErrorMessage: jest.fn(() => 'error'),
+}));
+
+jest.mock('../../../UI/Notifications', () => ({
+ NotificationManager: { notifyApiError: jest.fn() },
+}));
+
+jest.mock('../../../UI/Typeography/Header', () => ({
+ Header: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('span', null, children),
+}));
+
+jest.mock('../../../icons', () => ({
+ SearchLargeIcon: () => null,
+ StartRoundIcon: () => null,
+}));
+
+jest.mock('../../../UI', () => ({
+ InputField: jest.fn(() => React.createElement('input')),
+ Loader: () => null,
+}));
+
+jest.mock('../../../PageTitle/PageTitle', () => ({
+ PageTitle: () => null,
+}));
+
+jest.mock('../WorkflowCard', () => ({
+ WorkflowCardContainer: jest.fn(() => null),
+}));
+
+jest.mock('../WorkflowCardLoader', () => ({
+ WorkflowCardLoader: () => null,
+}));
+
+jest.mock('react-infinite-scroll-component', () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', null, children),
+}));
+
+jest.mock('../../../../utils/helpers', () => {
+ const actual = jest.requireActual('../../../../utils/helpers');
+ return { isArrayWithItems: actual.isArrayWithItems };
+});
+
+const formatMsg = (id: string) => intlMock.formatMessage({ id });
+const RUN_WORKFLOW_TEXT = formatMsg('workflows.run-workflow');
+
+const makeTemplateTitle = (overrides: Partial = {}): ITemplateTitle => ({
+ id: 1,
+ name: 'Template A',
+ count: 0,
+ ...overrides,
+});
+
+const baseProps = {
+ workflowsLoadingStatus: EWorkflowsLoadingStatus.Loaded,
+ workflowsList: { count: 0, offset: 0, items: [] },
+ templatesFilter: [] as ITemplateTitle[],
+ tasksApiNamesFilter: [],
+ searchText: '',
+ view: EWorkflowsView.Grid,
+ onSearch: jest.fn(),
+ setTasksFilter: jest.fn(),
+ openRunWorkflowModal: jest.fn(),
+ loadWorkflowsList: jest.fn(),
+ openWorkflowLogPopup: jest.fn(),
+ loadTemplatesTitles: jest.fn(),
+ resetWorkflows: jest.fn(),
+ openSelectTemplateModal: jest.fn(),
+ removeWorkflowFromList: jest.fn(),
+};
+
+const fakeTemplate = {
+ id: 1,
+ name: 'Template A',
+ kickoff: { description: '', fields: [], fieldsets: [] },
+ description: '',
+ isActive: true,
+ tasks: [],
+ wfNameTemplate: null,
+};
+
+const fakeRunnable = { id: 1, name: 'Template A', kickoff: { description: '', fields: [] } };
+const fakeFieldsets = [makeFieldsetRuntime({ apiNameBinding: 'fs-1', name: 'FS' })];
+const fakeDatasetsMap = {};
+
+describe('WorkflowsGridPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const clickRunButton = () => {
+ const buttons = screen.getAllByRole('button');
+ const runBtn = buttons.find((btn) => btn.textContent?.includes(RUN_WORKFLOW_TEXT)
+ || btn.textContent?.includes('Template'));
+ if (!runBtn) throw new Error('Run Workflow button not found');
+ userEvent.click(runBtn);
+ };
+
+ describe('Fieldsets: loading orchestration on Run Workflow', () => {
+ it('calls the full fieldsets loading chain when a single template is in the filter', async () => {
+ (getTemplate as jest.Mock).mockResolvedValue(fakeTemplate);
+ (mapTemplateFieldsetsToRuntime as jest.Mock).mockReturnValue({
+ normalizedTemplate: fakeTemplate,
+ loadedFieldsets: fakeFieldsets,
+ });
+ (loadDatasetsMap as jest.Mock).mockResolvedValue(fakeDatasetsMap);
+ (getRunnableWorkflow as jest.Mock).mockReturnValue(fakeRunnable);
+
+ renderWithIntl(
+ React.createElement(WorkflowsGridPage, {
+ ...baseProps,
+ templatesFilter: [makeTemplateTitle({ id: 42 })],
+ }),
+ );
+
+ clickRunButton();
+
+ await waitFor(() => {
+ expect(baseProps.openRunWorkflowModal).toHaveBeenCalledTimes(1);
+ });
+
+ expect(getTemplate).toHaveBeenCalledTimes(1);
+ expect(getTemplate).toHaveBeenCalledWith(42);
+
+ expect(mapTemplateFieldsetsToRuntime).toHaveBeenCalledTimes(1);
+ expect(mapTemplateFieldsetsToRuntime).toHaveBeenCalledWith(fakeTemplate);
+
+ expect(loadDatasetsMap).toHaveBeenCalledTimes(1);
+ expect(loadDatasetsMap).toHaveBeenCalledWith(fakeTemplate.kickoff, fakeFieldsets);
+
+ expect(getRunnableWorkflow).toHaveBeenCalledTimes(1);
+ expect(getRunnableWorkflow).toHaveBeenCalledWith(fakeTemplate, fakeDatasetsMap, fakeFieldsets);
+
+ expect(baseProps.openRunWorkflowModal).toHaveBeenCalledWith(fakeRunnable);
+ });
+
+ it('opens SelectTemplateModal without loading fieldsets when filter is empty', () => {
+ renderWithIntl(
+ React.createElement(WorkflowsGridPage, {
+ ...baseProps,
+ templatesFilter: [],
+ }),
+ );
+
+ clickRunButton();
+
+ expect(baseProps.openSelectTemplateModal).toHaveBeenCalledTimes(1);
+ expect(mapTemplateFieldsetsToRuntime).not.toHaveBeenCalled();
+ expect(getTemplate).not.toHaveBeenCalled();
+ });
+
+ it('opens SelectTemplateModal with ids without loading fieldsets when multiple templates are in the filter', () => {
+ renderWithIntl(
+ React.createElement(WorkflowsGridPage, {
+ ...baseProps,
+ templatesFilter: [makeTemplateTitle({ id: 1 }), makeTemplateTitle({ id: 2 })],
+ }),
+ );
+
+ clickRunButton();
+
+ expect(baseProps.openSelectTemplateModal).toHaveBeenCalledTimes(1);
+ expect(baseProps.openSelectTemplateModal).toHaveBeenCalledWith(
+ expect.objectContaining({ templatesIdsFilter: [1, 2] }),
+ );
+ expect(mapTemplateFieldsetsToRuntime).not.toHaveBeenCalled();
+ });
+
+ it('falls back to SelectTemplateModal when getTemplate returns null', async () => {
+ (getTemplate as jest.Mock).mockResolvedValue(null);
+
+ renderWithIntl(
+ React.createElement(WorkflowsGridPage, {
+ ...baseProps,
+ templatesFilter: [makeTemplateTitle({ id: 1 })],
+ }),
+ );
+
+ clickRunButton();
+
+ await waitFor(() => {
+ expect(baseProps.openSelectTemplateModal).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mapTemplateFieldsetsToRuntime).not.toHaveBeenCalled();
+ expect(baseProps.openRunWorkflowModal).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/public/components/Workflows/WorkflowsTablePage/WorkflowsTable/constants.ts b/frontend/src/public/components/Workflows/WorkflowsTablePage/WorkflowsTable/constants.ts
index ad0fdc5f1..70fd750fc 100644
--- a/frontend/src/public/components/Workflows/WorkflowsTablePage/WorkflowsTable/constants.ts
+++ b/frontend/src/public/components/Workflows/WorkflowsTablePage/WorkflowsTable/constants.ts
@@ -1,4 +1,5 @@
import { TSystemField } from './types';
+import { TRuntimeMergedOutputPart } from '../../../../types/template';
export const SYSTEM_FIELD_NAMES = [
'system-column-workflow',
@@ -24,4 +25,6 @@ export const SYSTEM_FIELDS: TSystemField[] = [
{ apiName: 'system-column-performer', name: 'performer', isDisabled: false, hasNotTooltip: true },
];
+export const SYSTEM_MERGED_OUTPUTS: TRuntimeMergedOutputPart[] = SYSTEM_FIELDS.map((field) => ({ kind: 'system', field }));
+
export const SKELETON_ROWS = Array.from({ length: 5 }, (_, index) => `skeleton-row-${index}`);
\ No newline at end of file
diff --git a/frontend/src/public/components/icons/FieldsetIcon.tsx b/frontend/src/public/components/icons/FieldsetIcon.tsx
new file mode 100644
index 000000000..5bf76ff67
--- /dev/null
+++ b/frontend/src/public/components/icons/FieldsetIcon.tsx
@@ -0,0 +1,11 @@
+/* eslint-disable */
+/* prettier-ignore */
+import * as React from 'react';
+
+export function FieldsetIcon({ fill = 'currentColor', ...rest }: React.SVGAttributes) {
+ return (
+
+ );
+}
diff --git a/frontend/src/public/constants/defaultValues.ts b/frontend/src/public/constants/defaultValues.ts
index 87a6094bb..b03378e1d 100644
--- a/frontend/src/public/constants/defaultValues.ts
+++ b/frontend/src/public/constants/defaultValues.ts
@@ -118,6 +118,7 @@ export const enum EPageTitle {
Integrations = 'integrations.title',
Team = 'team.title',
Datasets = 'datasets.title',
+ Fieldsets = 'fieldsets.title',
}
export const enum ELearnMoreLinks {
@@ -129,6 +130,7 @@ export const enum ELearnMoreLinks {
Integrations = 'https://support.pneumatic.app/articles/6014550-integrations',
HowToCreateTemplate = 'https://support.pneumatic.app/articles/5534875-how-to-create-your-first-workflow-template',
Datasets = '',
+ Fieldsets = '',
GuestUsers = 'https://support.pneumatic.app/articles/6145048-free-external-users',
Checklists = 'https://support.pneumatic.app/articles/6145048-free-external-users',
Tenants = 'https://www.pneumatic.app/partners/',
@@ -182,3 +184,4 @@ export const enum EResponseStatuses {
export const LIMIT_LOAD_TEMPLATES = 30;
export const LIMIT_LOAD_SYSTEMS_TEMPLATES = 16;
export const LIMIT_LOAD_DATASETS = 30;
+export const LIMIT_LOAD_FIELDSETS = 30;
diff --git a/frontend/src/public/constants/routes.ts b/frontend/src/public/constants/routes.ts
index 97debcfdc..548ef2ea3 100644
--- a/frontend/src/public/constants/routes.ts
+++ b/frontend/src/public/constants/routes.ts
@@ -51,6 +51,8 @@ export enum ERoutes {
Tenants = '/tenants/',
Datasets = '/datasets/',
DatasetDetail = '/datasets/:id/',
+ Fieldsets = '/fieldsets/',
+ FieldsetDetail = '/fieldsets/:id/',
}
export enum EPublicFormRoutes {
diff --git a/frontend/src/public/constants/sortings.ts b/frontend/src/public/constants/sortings.ts
index 3749f1673..8cc1bae4f 100644
--- a/frontend/src/public/constants/sortings.ts
+++ b/frontend/src/public/constants/sortings.ts
@@ -6,6 +6,7 @@ import {
import { ETaskListCompleteSorting, ETaskListSorting } from '../types/tasks';
import { EGroupsListSorting, EUserListSorting } from '../types/user';
import { EDatasetsSorting } from '../types/dataset';
+import { EFieldsetsSorting } from '../types/fieldset';
export const processesTasksSortingValues = Object.values(ETaskListSorting);
export const processesTasksCompleteSortingValues = Object.values(ETaskListCompleteSorting);
@@ -15,6 +16,7 @@ export const workflowLogAttachmentsModes = Object.values(EWorkflowLogAttachments
export const userListSortingValues = Object.values(EUserListSorting);
export const groupListSortingValues = Object.values(EGroupsListSorting);
export const datasetsSortingValues = Object.values(EDatasetsSorting);
+export const fieldsetsSortingValues = Object.values(EFieldsetsSorting);
export const datasetsOrderingMap: Record = {
[EDatasetsSorting.NameAsc]: 'name',
@@ -22,3 +24,11 @@ export const datasetsOrderingMap: Record = {
[EDatasetsSorting.DateAsc]: 'date',
[EDatasetsSorting.DateDesc]: '-date',
};
+
+export const fieldsetsOrderingMap: Record = {
+ [EFieldsetsSorting.NameAsc]: 'name',
+ [EFieldsetsSorting.NameDesc]: '-name',
+ [EFieldsetsSorting.DateAsc]: 'date',
+ [EFieldsetsSorting.DateDesc]: '-date',
+};
+
diff --git a/frontend/src/public/constants/titles.ts b/frontend/src/public/constants/titles.ts
index 1f37b6661..cc0e478b0 100644
--- a/frontend/src/public/constants/titles.ts
+++ b/frontend/src/public/constants/titles.ts
@@ -28,4 +28,5 @@ export const TITLES = {
'WorkflowHighlights': `Workflow Highlights ${PNEUMATIC_SUFFIX}`,
'Integrations': `Integrations ${PNEUMATIC_SUFFIX}`,
'Datasets': `Datasets ${PNEUMATIC_SUFFIX}`,
+ 'Fieldsets': `Fieldsets ${PNEUMATIC_SUFFIX}`,
};
diff --git a/frontend/src/public/lang/locales/en_US.ts b/frontend/src/public/lang/locales/en_US.ts
index 3bbfe7bd4..5e595befc 100644
--- a/frontend/src/public/lang/locales/en_US.ts
+++ b/frontend/src/public/lang/locales/en_US.ts
@@ -60,6 +60,7 @@ export const enMessages = {
'notifications.overdue': 'The task {task} is past the deadline, please look into it and take appropriate action.',
'notifications.snoozed': 'Workflow snoozed until {date}',
'notifications.resumed': 'Workflow resumed',
+ 'notifications.workflow-completed': 'Workflow completed',
'duration-month': 'months',
'duration-month-placeholder': '0',
@@ -633,6 +634,7 @@ export const enMessages = {
'template.more': 'More options',
'template.more-show-workflows': 'Show workflows',
'template.more-show-activity': 'Show activity',
+ 'template.more-show-fieldsets': 'Fieldsets',
'template.more-clone-template': 'Clone template',
'template.more-integrate-template': 'Integrate',
'template.more-delete-template': 'Delete template',
@@ -1463,4 +1465,67 @@ export const enMessages = {
'validation.dataset-name-to-long': 'The dataset name is longer than 200 characters',
'validation.dataset-row-empty': 'Please enter a value',
'validation.dataset-row-exists': 'Item with this value already exists',
+ 'templates.tab-fieldsets': 'Fieldsets',
+ 'fieldsets.title': 'Fieldsets',
+ 'fieldsets.title.tooltip': 'Reusable sets of fields with validation rules',
+ 'fieldsets.back-to-template': 'Back to template',
+ 'fieldsets.breadcrumb.template': 'Template',
+ 'fieldsets.breadcrumb.fieldsets': 'Fieldsets',
+ 'fieldsets.new-fieldset.title': 'New Fieldset',
+ 'fieldsets.new-fieldset.caption': 'Create a new fieldset',
+ 'fieldsets.new-fieldset.description': 'Once a new fieldset has been created, you can add fields and rules to it.',
+ 'fieldsets.edit-fieldset.title': 'Edit Fieldset Name',
+ 'fieldsets.edit-fieldset.description': 'Change the name of the fieldset.',
+ 'fieldsets.modal-button-create': 'Create',
+ 'fieldsets.modal-button-confirm': 'Confirm',
+ 'fieldsets.modal-button-cancel': 'CANCEL',
+ 'fieldsets.edit': 'Edit',
+ 'fieldsets.delete': 'Delete',
+ 'fieldsets.all-fieldsets': 'ALL FIELDSETS',
+ 'fieldsets.stats.fields': '{count} fields',
+ 'fieldsets.stats.rules': '{count} rules',
+ 'fieldsets.delete.title': 'Confirm Action',
+ 'fieldsets.delete.message': 'Are you sure you want to delete the {name} fieldset?',
+ 'fieldsets.fields-section': 'Fields',
+ 'fieldsets.rules-section': 'Rules',
+ 'fieldsets.no-fields': 'No fields yet',
+ 'fieldsets.no-rules': 'No rules yet',
+ 'fieldsets.save-fields': 'Save',
+ 'fieldsets.unsaved-changes': 'You have unsaved changes',
+ 'fieldsets.add-field': 'Add field',
+ 'fieldsets.add-rule': 'Add Rule',
+ 'fieldsets.rule-type': 'Type',
+ 'fieldsets.rule-value': 'Value',
+ 'fieldsets.rule-value-placeholder': 'Enter value',
+ 'fieldsets.rule-type-sum_equal': 'Sum Equal',
+ 'fieldsets.rule-delete': 'Delete',
+ 'fieldsets.rule-fields': 'Fields',
+ 'fieldsets.rule-fields-placeholder': 'Select fields...',
+ 'fieldsets.rule-fields-all': 'All fields',
+ 'fieldsets.save-rules': 'Save',
+ 'fieldsets.unsaved-rule-changes': 'You have unsaved rule changes',
+ 'fieldsets.settings-section': 'Settings',
+ 'fieldsets.settings.description': 'Description',
+ 'fieldsets.settings.description-placeholder': 'Enter fieldset description...',
+ 'fieldsets.settings.label-position': 'Label Position',
+ 'fieldsets.settings.label-position.top': 'Top',
+ 'fieldsets.settings.label-position.left': 'Left',
+ 'fieldsets.settings.binding': 'Source',
+ 'fieldsets.settings.binding.kickoff-option': 'Kickoff',
+ 'fieldsets.settings.binding.task-option-fallback': 'Task {number}',
+ 'fieldsets.settings.order': 'Order',
+ 'fieldsets.settings.order-placeholder': 'Enter fieldset order',
+ 'fieldsets.settings.layout': 'Layout',
+ 'fieldsets.settings.layout.horizontal': 'Horizontal',
+ 'fieldsets.settings.layout.vertical': 'Vertical',
+ 'fieldsets.save-settings': 'Save',
+ 'fieldsets.unsaved-settings-changes': 'You have unsaved settings changes',
+ 'validation.fieldset-name-empty': 'Please enter a fieldset name',
+ 'validation.fieldset-name-to-long': 'The fieldset name is longer than 200 characters',
+ 'template.kick-off-fieldsets': 'Fieldsets',
+ 'tasks.task-fieldsets': 'Fieldsets',
+ 'template.fieldset-picker.placeholder': 'Select fieldsets...',
+ 'template.fieldset-picker.empty': 'No fieldsets available. Create one first.',
+ 'fieldset.group-title': 'Fieldset: {name}',
+ 'fieldset.validation-error.sum-max': 'The sum of numeric fields in "{name}" must not exceed {value}.',
};
diff --git a/frontend/src/public/lang/locales/ru_RU.ts b/frontend/src/public/lang/locales/ru_RU.ts
index f2191558a..f3c89db66 100644
--- a/frontend/src/public/lang/locales/ru_RU.ts
+++ b/frontend/src/public/lang/locales/ru_RU.ts
@@ -57,6 +57,7 @@ export const ruMessages = {
'notifications.overdue': 'Задача {task} просрочена.',
'notifications.snoozed': 'Процесс отложен до {date}',
'notifications.resumed': 'Процесс возобновлен',
+ 'notifications.workflow-completed': 'Процесс завершён',
'duration-month': 'месяцы',
'duration-month-placeholder': '0',
'duration-day': 'дни',
@@ -552,6 +553,7 @@ export const ruMessages = {
'template.more': 'Еще опции',
'template.more-show-workflows': 'Процессы',
'template.more-show-activity': 'События',
+ 'template.more-show-fieldsets': 'Наборы полей',
'template.more-clone-template': 'Копировать',
'template.more-integrate-template': 'Интегрировать',
'template.more-delete-template': 'Удалить шаблон',
@@ -1349,6 +1351,66 @@ export const ruMessages = {
'validation.dataset-name-to-long': 'Название набора данных длиннее 200 символов',
'validation.dataset-row-empty': 'Пожалуйста, введите значение',
'validation.dataset-row-exists': 'Элемент с таким значением уже существует',
+ 'templates.tab-fieldsets': 'Наборы полей',
+ 'fieldsets.title': 'Наборы полей',
+ 'fieldsets.title.tooltip': 'Переиспользуемые наборы полей с правилами валидации',
+ 'fieldsets.back-to-template': 'К шаблону',
+ 'fieldsets.breadcrumb.template': 'Шаблон',
+ 'fieldsets.breadcrumb.fieldsets': 'Наборы полей',
+ 'fieldsets.new-fieldset.title': 'Новый набор полей',
+ 'fieldsets.new-fieldset.caption': 'Создать новый набор полей',
+ 'fieldsets.new-fieldset.description': 'После создания набора полей вы сможете добавить в него поля и правила.',
+ 'fieldsets.edit-fieldset.title': 'Редактировать название набора полей',
+ 'fieldsets.edit-fieldset.description': 'Изменить название набора полей.',
+ 'fieldsets.modal-button-create': 'Создать',
+ 'fieldsets.modal-button-confirm': 'Подтвердить',
+ 'fieldsets.modal-button-cancel': 'ОТМЕНА',
+ 'fieldsets.edit': 'Редактировать',
+ 'fieldsets.delete': 'Удалить',
+ 'fieldsets.all-fieldsets': 'ВСЕ НАБОРЫ ПОЛЕЙ',
+ 'fieldsets.stats.fields': '{count} полей',
+ 'fieldsets.stats.rules': '{count} правил',
+ 'fieldsets.delete.title': 'Подтверждение',
+ 'fieldsets.delete.message': 'Вы уверены, что хотите удалить набор полей {name}?',
+ 'fieldsets.fields-section': 'Поля',
+ 'fieldsets.rules-section': 'Правила',
+ 'fieldsets.no-fields': 'Пока нет полей',
+ 'fieldsets.no-rules': 'Пока нет правил',
+ 'fieldsets.save-fields': 'Сохранить',
+ 'fieldsets.unsaved-changes': 'Есть несохранённые изменения',
+ 'fieldsets.add-field': 'Добавить поле',
+ 'fieldsets.add-rule': 'Добавить правило',
+ 'fieldsets.rule-type': 'Тип',
+ 'fieldsets.rule-value': 'Значение',
+ 'fieldsets.rule-value-placeholder': 'Введите значение',
+ 'fieldsets.rule-type-sum_equal': 'Сумма равна',
+ 'fieldsets.rule-delete': 'Удалить',
+ 'fieldsets.save-rules': 'Сохранить',
+ 'fieldsets.unsaved-rule-changes': 'Есть несохранённые изменения правил',
+ 'fieldsets.settings-section': 'Настройки',
+ 'fieldsets.settings.description': 'Описание',
+ 'fieldsets.settings.description-placeholder': 'Введите описание набора полей...',
+ 'fieldsets.settings.label-position': 'Позиция метки',
+ 'fieldsets.settings.label-position.top': 'Сверху',
+ 'fieldsets.settings.label-position.left': 'Слева',
+ 'fieldsets.settings.binding': 'Источник',
+ 'fieldsets.settings.binding.kickoff-option': 'Kickoff',
+ 'fieldsets.settings.binding.task-option-fallback': 'Задача {number}',
+ 'fieldsets.settings.order': 'Порядок',
+ 'fieldsets.settings.order-placeholder': 'Введите порядок набора полей',
+ 'fieldsets.settings.layout': 'Расположение',
+ 'fieldsets.settings.layout.horizontal': 'Горизонтальное',
+ 'fieldsets.settings.layout.vertical': 'Вертикальное',
+ 'fieldsets.save-settings': 'Сохранить',
+ 'fieldsets.unsaved-settings-changes': 'Есть несохранённые изменения настроек',
+ 'validation.fieldset-name-empty': 'Введите название набора полей',
+ 'validation.fieldset-name-to-long': 'Название набора полей не должно превышать 200 символов',
+ 'template.kick-off-fieldsets': 'Наборы полей',
+ 'tasks.task-fieldsets': 'Наборы полей',
+ 'template.fieldset-picker.placeholder': 'Выберите наборы полей...',
+ 'template.fieldset-picker.empty': 'Нет доступных наборов полей. Сначала создайте один.',
+ 'fieldset.group-title': 'Набор полей: {name}',
+ 'fieldset.validation-error.sum-max': 'Сумма числовых полей в «{name}» не должна превышать {value}.',
'user-info.vacation.title': 'Статус',
'user-info.vacation.status': 'Опции',
'user-info.vacation.active': 'Вы в отпуске/отсутствуете',
diff --git a/frontend/src/public/layout/TemplateLayout/TemplateLayout.tsx b/frontend/src/public/layout/TemplateLayout/TemplateLayout.tsx
index af64c27bf..46ec5f39c 100644
--- a/frontend/src/public/layout/TemplateLayout/TemplateLayout.tsx
+++ b/frontend/src/public/layout/TemplateLayout/TemplateLayout.tsx
@@ -8,6 +8,7 @@ import { ERoutes } from '../../constants/routes';
interface ITemplateLayoutProps {
children: React.ReactNode;
}
+
export function TemplateLayout({ children }: ITemplateLayoutProps) {
const { formatMessage } = useIntl();
@@ -31,3 +32,4 @@ export function TemplateLayout({ children }: ITemplateLayoutProps) {
>
);
}
+
diff --git a/frontend/src/public/layout/TemplateLayout/__tests__/TemplateLayout.test.tsx b/frontend/src/public/layout/TemplateLayout/__tests__/TemplateLayout.test.tsx
new file mode 100644
index 000000000..20897c2c4
--- /dev/null
+++ b/frontend/src/public/layout/TemplateLayout/__tests__/TemplateLayout.test.tsx
@@ -0,0 +1,49 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { TemplateLayout } from '../TemplateLayout';
+import { ReturnLink } from '../../../components/UI';
+import { ERoutes } from '../../../constants/routes';
+import { intlMock } from '../../../__stubs__/intlMock';
+
+type TTopNavMockProps = { leftContent: React.ReactNode };
+
+jest.mock('../../../components/TopNav', () => ({
+ TopNavContainer: jest.fn(({ leftContent }: TTopNavMockProps) =>
+ require('react').createElement('div', { 'data-testid': 'topnav' }, leftContent),
+ ),
+}));
+
+jest.mock('../../../components/UI', () => ({
+ ReturnLink: jest.fn(() => null),
+}));
+
+describe('TemplateLayout', () => {
+ const formatMsg = (id: string) => intlMock.formatMessage({ id });
+ const BACK_TO_TEMPLATES = formatMsg('menu.templates');
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders single ReturnLink to templates list', () => {
+ render(React.createElement(TemplateLayout, null, 'child'));
+
+ expect(ReturnLink as jest.Mock).toHaveBeenCalledTimes(1);
+ expect(ReturnLink as jest.Mock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ label: BACK_TO_TEMPLATES,
+ route: ERoutes.Templates,
+ }),
+ {},
+ );
+ });
+
+ it('renders children content', () => {
+ render(
+ React.createElement(TemplateLayout, null, 'test-child-content'),
+ );
+
+ expect(screen.getByText('test-child-content')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/public/layout/TemplatesLayout/TemplatesLayout.css b/frontend/src/public/layout/TemplatesLayout/TemplatesLayout.css
index f75a297c1..ba62457bd 100644
--- a/frontend/src/public/layout/TemplatesLayout/TemplatesLayout.css
+++ b/frontend/src/public/layout/TemplatesLayout/TemplatesLayout.css
@@ -31,6 +31,6 @@
}
}
-.dataset__sorting-toggle-text {
+.sorting-toggle-text {
margin-right: 4px !important;
}
diff --git a/frontend/src/public/layout/TemplatesLayout/TemplatesLayout.tsx b/frontend/src/public/layout/TemplatesLayout/TemplatesLayout.tsx
index 3a6161773..f34a19450 100644
--- a/frontend/src/public/layout/TemplatesLayout/TemplatesLayout.tsx
+++ b/frontend/src/public/layout/TemplatesLayout/TemplatesLayout.tsx
@@ -6,10 +6,13 @@ import { TemplatesSortingContainer } from './TemplatesSortingContainer';
import { TopNavContainer } from '../../components/TopNav';
import { SelectMenu, Tabs } from '../../components/UI';
import { ERoutes } from '../../constants/routes';
-import { datasetsSortingValues } from '../../constants/sortings';
+import { datasetsSortingValues, fieldsetsSortingValues } from '../../constants/sortings';
import { setDatasetsListSorting } from '../../redux/datasets/slice';
+import { setFieldsetsListSorting } from '../../redux/fieldsets/slice';
import { getDatasetsSorting } from '../../redux/selectors/datasets';
+import { getFieldsetsSorting } from '../../redux/selectors/fieldsets';
import { EDatasetsSorting } from '../../types/dataset';
+import { EFieldsetsSorting } from '../../types/fieldset';
import { ETemplatesTab, ITemplatesLayoutProps } from '../../types/template';
import { history } from '../../utils/history';
@@ -21,25 +24,28 @@ export function TemplatesLayout({ children }: ITemplatesLayoutProps) {
const { formatMessage } = useIntl();
const dispatch = useDispatch();
- const [pathname, setPathname] = React.useState(history.location.pathname);
-
- React.useEffect(() => {
- return history.listen((location) => {
- setPathname(location.pathname);
- });
- }, []);
-
- const datasetsSorting = useSelector(getDatasetsSorting);
+ const datasetsSortingType = useSelector(getDatasetsSorting);
+ const fieldsetsSortingType = useSelector(getFieldsetsSorting);
const handleDatasetsSortingChange = (value: EDatasetsSorting) => {
dispatch(setDatasetsListSorting(value));
};
- const isDatasetDetail = /^\/datasets\/\d+\/?$/.test(pathname);
+ const handleFieldsetsSortingChange = (value: EFieldsetsSorting) => {
+ dispatch(setFieldsetsListSorting(value));
+ };
+
+ const isDatasetDetail = /^\/datasets\/\d+\/?$/.test(history.location.pathname);
+ const isFieldsetDetail = /^\/fieldsets\/\d+\/?$/.test(history.location.pathname);
+
+ const getActiveTab = () => {
+ const path = history.location.pathname;
+ if (path.startsWith(ERoutes.Datasets)) return ETemplatesTab.Datasets;
+ if (path.startsWith(ERoutes.Fieldsets)) return ETemplatesTab.Fieldsets;
+ return ETemplatesTab.Templates;
+ };
- const activeTab = pathname.startsWith(ERoutes.Datasets)
- ? ETemplatesTab.Datasets
- : ETemplatesTab.Templates;
+ const activeTab = getActiveTab();
const tabValues = [
{
@@ -50,11 +56,17 @@ export function TemplatesLayout({ children }: ITemplatesLayoutProps) {
id: ETemplatesTab.Datasets,
label: formatMessage({ id: 'templates.tab-datasets' }),
},
+ {
+ id: ETemplatesTab.Fieldsets,
+ label: formatMessage({ id: 'templates.tab-fieldsets' }),
+ },
];
const handleTabChange = (tabId: ETemplatesTab) => {
if (tabId === ETemplatesTab.Datasets) {
history.push(ERoutes.Datasets);
+ } else if (tabId === ETemplatesTab.Fieldsets) {
+ history.push(ERoutes.Fieldsets);
} else {
history.push(ERoutes.Templates);
}
@@ -73,6 +85,18 @@ export function TemplatesLayout({ children }: ITemplatesLayoutProps) {
);
}
+ if (isFieldsetDetail) {
+ return (
+
+
+
+ );
+ }
+
return (
)}
+ {activeTab === ETemplatesTab.Fieldsets && (
+
+ )}
);
};
diff --git a/frontend/src/public/layout/TemplatesLayout/__tests__/TemplatesLayout.test.tsx b/frontend/src/public/layout/TemplatesLayout/__tests__/TemplatesLayout.test.tsx
new file mode 100644
index 000000000..06d589691
--- /dev/null
+++ b/frontend/src/public/layout/TemplatesLayout/__tests__/TemplatesLayout.test.tsx
@@ -0,0 +1,110 @@
+import * as React from 'react';
+import { render } from '@testing-library/react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { TemplatesLayout } from '../TemplatesLayout';
+import { SelectMenu } from '../../../components/UI';
+import { setFieldsetsListSorting } from '../../../redux/fieldsets/slice';
+import { EFieldsetsSorting } from '../../../types/fieldset';
+import { fieldsetsSortingValues } from '../../../constants/sortings';
+import { history } from '../../../utils/history';
+import { ERoutes } from '../../../constants/routes';
+
+jest.mock('../../../components/TopNav', () => ({
+ TopNavContainer: jest.fn(({ leftContent }: { leftContent: React.ReactNode }) =>
+ require('react').createElement('div', { 'data-testid': 'topnav' }, leftContent),
+ ),
+}));
+
+jest.mock('../../../components/UI', () => ({
+ SelectMenu: jest.fn(() => null),
+ Tabs: jest.fn(() => null),
+}));
+
+jest.mock('../../../components/UI/ReturnLink', () => ({
+ ReturnLink: jest.fn(() => null),
+}));
+
+jest.mock('../TemplatesSortingContainer', () => ({
+ TemplatesSortingContainer: jest.fn(() => null),
+}));
+
+jest.mock('../../../utils/history', () => ({
+ history: {
+ location: { pathname: '/' },
+ push: jest.fn(),
+ },
+}));
+
+jest.mock('../../../redux/fieldsets/slice', () => ({
+ setFieldsetsListSorting: jest.fn((payload: string) => ({
+ type: 'fieldsets/setFieldsetsListSorting',
+ payload,
+ })),
+}));
+
+jest.mock('../../../redux/datasets/slice', () => ({
+ setDatasetsListSorting: jest.fn((payload: string) => ({
+ type: 'datasets/setDatasetsListSorting',
+ payload,
+ })),
+}));
+
+jest.mock('../../../redux/selectors/fieldsets', () => ({
+ getFieldsetsSorting: jest.fn(),
+}));
+
+jest.mock('../../../redux/selectors/datasets', () => ({
+ getDatasetsSorting: jest.fn(),
+}));
+
+describe('TemplatesLayout — fieldsets sorting', () => {
+ const mockDispatch = jest.fn();
+
+ const setPathname = (url: string) => {
+ (history.location as { pathname: string }).pathname = url;
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useDispatch as jest.Mock).mockReturnValue(mockDispatch);
+ (useSelector as jest.Mock).mockReturnValue(EFieldsetsSorting.DateDesc);
+ });
+
+ it('renders sorting SelectMenu with fieldsets values on fieldsets tab', () => {
+ setPathname(ERoutes.Fieldsets);
+
+ render(React.createElement(TemplatesLayout, null, 'child'));
+
+ const selectMenuMock = SelectMenu as jest.Mock;
+ const fieldsetsCalls = selectMenuMock.mock.calls.filter(
+ (call: [Record]) => call[0].values === fieldsetsSortingValues,
+ );
+ expect(fieldsetsCalls.length).toBe(1);
+ expect(fieldsetsCalls[0][0]).toEqual(
+ expect.objectContaining({
+ activeValue: EFieldsetsSorting.DateDesc,
+ values: fieldsetsSortingValues,
+ closeOnSelect: true,
+ }),
+ );
+ });
+
+ it('dispatches setFieldsetsListSorting when fieldsets sorting onChange is called', () => {
+ setPathname(ERoutes.Fieldsets);
+
+ render(React.createElement(TemplatesLayout, null, 'child'));
+
+ const selectMenuMock = SelectMenu as jest.Mock;
+ const fieldsetsCall = selectMenuMock.mock.calls.find(
+ (call: [Record]) => call[0].values === fieldsetsSortingValues,
+ );
+ const onChangeProp = fieldsetsCall[0].onChange as (value: EFieldsetsSorting) => void;
+
+ onChangeProp(EFieldsetsSorting.NameAsc);
+
+ expect(setFieldsetsListSorting).toHaveBeenCalledTimes(1);
+ expect(setFieldsetsListSorting).toHaveBeenCalledWith(EFieldsetsSorting.NameAsc);
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/src/public/redux/auth/saga.ts b/frontend/src/public/redux/auth/saga.ts
index c001cb207..f60ee4f69 100644
--- a/frontend/src/public/redux/auth/saga.ts
+++ b/frontend/src/public/redux/auth/saga.ts
@@ -91,7 +91,7 @@ import { getBrowserConfig } from '../../utils/getConfig';
import { getCustomerPortalLink } from '../../api/getCustomerPortalLink';
import { setSentryUser } from '../../utils/sentryUser';
import { createTemplateFromName } from './utils/createTemplateFromName';
-import { ITemplate } from '../../types/template';
+import { ITemplateResponse } from '../../types/template';
import { removeLocalStorage } from '../../utils/localStorage';
import { isEnvBilling } from '../../constants/enviroment';
import { activateVacation, deactivateVacation } from '../../api/vacation';
@@ -567,7 +567,7 @@ export function* onAfterPaymentDetailsProvidedSaga() {
}
try {
- const templateId: Pick = yield createTemplateFromName();
+ const templateId: Pick = yield createTemplateFromName();
if (templateId) {
history.push(ERoutes.TemplatesEdit.replace(':id', String(templateId)));
diff --git a/frontend/src/public/redux/auth/utils/createTemplateFromName.ts b/frontend/src/public/redux/auth/utils/createTemplateFromName.ts
index 03602ce26..9ce0104df 100644
--- a/frontend/src/public/redux/auth/utils/createTemplateFromName.ts
+++ b/frontend/src/public/redux/auth/utils/createTemplateFromName.ts
@@ -1,14 +1,14 @@
import { call } from 'redux-saga/effects';
import { getTemplateName, resetTemplateName } from '../../../views/user/Register/utils/templateName';
import { createTemplateByName } from '../../../api/createTemplateByName';
-import { ITemplate } from '../../../types/template';
+import { ITemplateResponse } from '../../../types/template';
export function* createTemplateFromName() {
try {
const templateName = getTemplateName();
if (templateName) {
- const template: ITemplate = yield call(createTemplateByName, { name: templateName });
+ const template: ITemplateResponse = yield call(createTemplateByName, { name: templateName });
resetTemplateName();
return template?.id;
}
diff --git a/frontend/src/public/redux/dashboard/__tests__/saga.test.ts b/frontend/src/public/redux/dashboard/__tests__/saga.test.ts
index c379a95a8..d306be63d 100644
--- a/frontend/src/public/redux/dashboard/__tests__/saga.test.ts
+++ b/frontend/src/public/redux/dashboard/__tests__/saga.test.ts
@@ -1,8 +1,9 @@
import { expectSaga } from 'redux-saga-test-plan';
import * as matchers from 'redux-saga-test-plan/matchers';
+import { call } from 'redux-saga/effects';
import { loadBreakdownTasks } from '../actions';
-import { fetchBreakdownTasks } from '../saga';
+import { fetchBreakdownTasks, openRunWorflowByTemplateDataSaga } from '../saga';
import { getDashboardStore } from '../../selectors/dashboard';
import { getCanAccessWorkflows } from '../../selectors/user';
import { EDashboardModes, IDashboardStore, IDashboardTask } from '../../../types/redux';
@@ -10,6 +11,9 @@ import { EDashboardTimeRange } from '../../../types/dashboard';
import { reducer } from '../reducer';
import { getDashboardWorkflowsTasks } from '../../../api/dashboard/getDashboardWorkflowsTasks';
import * as templatesSaga from '../../templates/saga';
+import { openRunWorkflowModalSideMenu } from '../actions';
+import { loadDatasetsMap } from '../../../components/TemplateEdit/utils/getRunnableWorkflow';
+import { openRunWorkflowModal } from '../../runWorkflowModal/actions';
describe('fetchBreakdownTasks', () => {
it('load breakdown tasks', () => {
@@ -54,9 +58,12 @@ describe('fetchBreakdownTasks', () => {
];
jest.spyOn(templatesSaga, 'handleLoadTemplateVariables').mockImplementation(function* () {});
- // tslint:disable-next-line: no-any
+ function* wrapper() {
+ yield call(fetchBreakdownTasks, fetchBreakdownTasksAction);
+ }
+
return (
- expectSaga(fetchBreakdownTasks as any, fetchBreakdownTasksAction)
+ expectSaga(wrapper)
.provide([
[matchers.select.selector(getCanAccessWorkflows), true],
[matchers.select.selector(getDashboardStore), mockDashboardStore],
@@ -64,7 +71,6 @@ describe('fetchBreakdownTasks', () => {
])
.withReducer(reducer, mockDashboardStore)
- // assert dispatch the following action
.put({
type: 'PATCH_DASHBOARD_BREAKDOWN_ITEM',
payload: { templateId: 1, changedFields: { tasks: mockTasks } },
@@ -73,3 +79,96 @@ describe('fetchBreakdownTasks', () => {
);
});
});
+
+describe('openRunWorflowByTemplateDataSaga — fieldset selections enrichment', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('enriches fieldset field selections from datasetsMap', async () => {
+ const DATASET_ID = 5;
+ const DATASET_OPTIONS = ['opt-a', 'opt-b'];
+
+ const templateData = {
+ id: 10,
+ name: 'Template From Side Menu',
+ kickoff: {
+ fields: [],
+ fieldsets: [
+ {
+ apiName: 'fs-1',
+ name: 'Fieldset 1',
+ order: 0,
+ fields: [
+ {
+ apiName: 'field-ds',
+ name: 'Field With Dataset',
+ type: 'string',
+ isRequired: false,
+ isHidden: false,
+ order: 0,
+ selections: [],
+ dataset: DATASET_ID,
+ },
+ {
+ apiName: 'field-no-ds',
+ name: 'Field Without Dataset',
+ type: 'string',
+ isRequired: false,
+ isHidden: false,
+ order: 1,
+ selections: ['original'],
+ dataset: null,
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ const action = openRunWorkflowModalSideMenu({
+ templateData,
+ ancestorTaskId: 99,
+ });
+
+ const openModalActionType = openRunWorkflowModal({} as never).type;
+
+ function* wrapper() {
+ yield call(openRunWorflowByTemplateDataSaga, action);
+ }
+
+ const { effects } = await expectSaga(wrapper)
+ .provide([
+ [matchers.call.fn(loadDatasetsMap), { [DATASET_ID]: DATASET_OPTIONS }],
+ ])
+ .run();
+
+ const putEffects = effects.put || [];
+ const openModalPut = putEffects.find(
+ (effect) => effect.payload.action.type === openModalActionType,
+ );
+
+ if (!openModalPut) {
+ throw new Error('Expected openRunWorkflowModal PUT not found');
+ }
+
+ const { loadedFieldsets } = openModalPut.payload.action.payload;
+
+ expect(loadedFieldsets).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ fields: expect.arrayContaining([
+ expect.objectContaining({
+ apiName: 'field-ds',
+ selections: DATASET_OPTIONS,
+ }),
+ expect.objectContaining({
+ apiName: 'field-no-ds',
+ selections: ['original'],
+ }),
+ ]),
+ }),
+ ]),
+ );
+ });
+});
diff --git a/frontend/src/public/redux/dashboard/saga.ts b/frontend/src/public/redux/dashboard/saga.ts
index d734bb789..11e20c3a1 100644
--- a/frontend/src/public/redux/dashboard/saga.ts
+++ b/frontend/src/public/redux/dashboard/saga.ts
@@ -43,7 +43,11 @@ import { getTemplate } from '../../api/getTemplate';
import { openRunWorkflowModal } from '../runWorkflowModal/actions';
import { getRunnableWorkflow, loadDatasetsMap } from '../../components/TemplateEdit/utils/getRunnableWorkflow';
-import { ITemplateResponse } from '../../types/template';
+import { ITemplateResponse, IExtraField, IKickoffClient } from '../../types/template';
+import { IFieldsetRuntime } from '../../types/fieldset';
+import { mapTemplateFieldsetsToRuntime } from '../../utils/mapTemplateFieldsetsToRuntime';
+import { mapFieldsetBindingsToClient } from '../../utils/mapFieldsetsAPIToClient';
+import { mapFieldsetBindingClientToRuntime, mapFieldsToExtraFields } from '../../utils/mapFieldsetBindingClientToRuntime';
import { getGettingStartedChecklist } from '../../api/getGettingStartedChecklist';
import { IGettingStartedChecklist } from '../../types/dashboard';
import { loadTemplateIntegrationsStats } from '../actions';
@@ -141,9 +145,12 @@ export function* openRunWorflowSaga({ payload: { templateId, ancestorTaskId } }:
yield put(setGeneralLoaderVisibility(true));
const resData: ITemplateResponse = yield getTemplate(templateId);
- const datasetsMap: Record = yield call(loadDatasetsMap, resData.kickoff);
+ const { normalizedTemplate, loadedFieldsets } = mapTemplateFieldsetsToRuntime(resData);
+ const datasetsMap: Record = yield call(
+ loadDatasetsMap, normalizedTemplate.kickoff, loadedFieldsets,
+ );
- const templateData = getRunnableWorkflow(resData, datasetsMap);
+ const templateData = getRunnableWorkflow(normalizedTemplate, datasetsMap, loadedFieldsets);
if (templateData) {
yield put(openRunWorkflowModal({ ...templateData, ancestorTaskId }));
@@ -162,7 +169,43 @@ export function* openRunWorflowByTemplateDataSaga({
try {
yield put(setGeneralLoaderVisibility(true));
- yield put(openRunWorkflowModal({ ...templateData, ancestorTaskId }));
+ const kickoffFieldsets = templateData.kickoff?.fieldsets || [];
+ const clientFieldsets = mapFieldsetBindingsToClient(kickoffFieldsets);
+ const loadedFieldsets = clientFieldsets.map(mapFieldsetBindingClientToRuntime);
+
+ const kickoffFields = mapFieldsToExtraFields(templateData.kickoff?.fields || []);
+
+ const kickoff: IKickoffClient = {
+ description: '',
+ fields: kickoffFields,
+ fieldsets: [],
+ };
+
+ // Load datasets for both kickoff fields and fieldset fields
+ const datasetsMap: Record = yield call(loadDatasetsMap, kickoff, loadedFieldsets);
+
+ yield put(openRunWorkflowModal({
+ ...templateData,
+ kickoff: {
+ ...kickoff,
+ fields: kickoffFields.map((field) => ({
+ ...field,
+ selections: field.dataset
+ ? datasetsMap[field.dataset] || []
+ : field.selections,
+ })),
+ },
+ loadedFieldsets: loadedFieldsets.map((fs: IFieldsetRuntime) => ({
+ ...fs,
+ fields: fs.fields.map((field: IExtraField) => ({
+ ...field,
+ selections: field.dataset
+ ? datasetsMap[field.dataset] || []
+ : field.selections,
+ })),
+ })),
+ ancestorTaskId,
+ }));
} catch (error) {
logger.info('fetch template error : ', error);
NotificationManager.notifyApiError(error, { message: getErrorMessage(error) });
diff --git a/frontend/src/public/redux/datasets/__tests__/utils.test.ts b/frontend/src/public/redux/datasets/__tests__/utils.test.ts
index 3a030ce34..89b6230b3 100644
--- a/frontend/src/public/redux/datasets/__tests__/utils.test.ts
+++ b/frontend/src/public/redux/datasets/__tests__/utils.test.ts
@@ -1,4 +1,3 @@
-//
import { generateCloneName, getCloneBaseName } from '../utils';
describe('generateCloneName', () => {
diff --git a/frontend/src/public/redux/fieldsets/__tests__/saga.test.ts b/frontend/src/public/redux/fieldsets/__tests__/saga.test.ts
new file mode 100644
index 000000000..cc1681729
--- /dev/null
+++ b/frontend/src/public/redux/fieldsets/__tests__/saga.test.ts
@@ -0,0 +1,402 @@
+
+import { runSaga } from 'redux-saga';
+import { call } from 'redux-saga/effects';
+
+import { loadFieldsetsSaga, loadCurrentFieldsetSaga, deleteFieldsetSaga, updateFieldsetSaga } from '../saga';
+import { getFieldsets } from '../../../api/fieldsets/getFieldsets';
+import { getFieldset } from '../../../api/fieldsets/getFieldset';
+import { deleteFieldset } from '../../../api/fieldsets/deleteFieldset';
+import { updateFieldset } from '../../../api/fieldsets/updateFieldset';
+import {
+ loadFieldsets, loadFieldsetsSuccess, loadFieldsetsFailed,
+ loadCurrentFieldset, loadCurrentFieldsetSuccess,
+ removeFieldsetFromList,
+ setCurrentFieldset,
+ updateFieldsetAction,
+} from '../slice';
+import { initialState } from '../slice';
+import { history } from '../../../utils/history';
+import { ERoutes } from '../../../constants/routes';
+import { EFieldsetsSorting } from '../../../types/fieldset';
+import { makeFieldsetCatalogItem } from '../../../__stubs__/fieldsets.factory';
+import { isRequestCanceled } from '../../../utils/isRequestCanceled';
+
+jest.mock('../../../utils/getConfig', () => ({
+ getBrowserConfigEnv: jest.fn().mockReturnValue({
+ api: { urls: { fieldsets: '/fieldsets', fieldset: '/fieldsets/:id' } },
+ }),
+}));
+
+jest.mock('../../../api/fieldsets/getFieldsets', () => ({ getFieldsets: jest.fn() }));
+jest.mock('../../../api/fieldsets/getFieldset', () => ({ getFieldset: jest.fn() }));
+jest.mock('../../../api/fieldsets/createFieldset', () => ({ createFieldset: jest.fn() }));
+jest.mock('../../../api/fieldsets/updateFieldset', () => ({ updateFieldset: jest.fn() }));
+jest.mock('../../../api/fieldsets/deleteFieldset', () => ({ deleteFieldset: jest.fn() }));
+
+jest.mock('../../../utils/history', () => ({
+ history: { replace: jest.fn(), push: jest.fn() },
+}));
+
+jest.mock('../../../components/UI/Notifications', () => ({
+ NotificationManager: { warning: jest.fn() },
+}));
+
+jest.mock('../../../utils/logger', () => ({
+ logger: { error: jest.fn() },
+}));
+
+jest.mock('../../../utils/isRequestCanceled', () => ({
+ isRequestCanceled: jest.fn(() => false),
+}));
+
+interface IDispatchedAction {
+ type: string;
+ payload?: unknown;
+}
+
+describe('loadFieldsetsSaga', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const makeMockState = () => ({
+ fieldsets: {
+ fieldsetsList: { items: [], count: 0, offset: 0 },
+ fieldsetsListSorting: '-date',
+ isCatalogLoaded: false,
+ },
+ });
+
+ const runLoadFieldsets = async (
+ offset: number = 0,
+ ) => {
+ const dispatched: IDispatchedAction[] = [];
+ const mockState = makeMockState();
+ const action = loadFieldsets({ offset });
+
+ function* wrapper() {
+ yield call(loadFieldsetsSaga, action);
+ }
+
+ const saga = runSaga(
+ {
+ dispatch: (dispatchedAction: IDispatchedAction) => {
+ dispatched.push(dispatchedAction);
+ },
+ getState: () => mockState,
+ },
+ wrapper,
+ );
+
+ await saga.toPromise();
+
+ return dispatched;
+ };
+
+ describe('redirect on non-existent template', () => {
+ it('redirects to templates list on 404 API response', async () => {
+ const error404 = { status: 404, message: 'Not found' };
+ (getFieldsets as jest.Mock).mockRejectedValue(error404);
+
+ const dispatched = await runLoadFieldsets();
+
+ expect(getFieldsets).toHaveBeenCalledTimes(1);
+
+ const failedAction = dispatched.find(
+ (a) => a.type === loadFieldsetsFailed.type,
+ );
+ expect(failedAction).toBeDefined();
+
+ expect(history.replace).toHaveBeenCalledTimes(1);
+ expect(history.replace).toHaveBeenCalledWith(ERoutes.Templates);
+ });
+
+ it('does not redirect on non-404 errors', async () => {
+ const error500 = { status: 500, message: 'Internal server error' };
+ (getFieldsets as jest.Mock).mockRejectedValue(error500);
+
+ const dispatched = await runLoadFieldsets();
+
+ const failedAction = dispatched.find(
+ (a) => a.type === loadFieldsetsFailed.type,
+ );
+ expect(failedAction).toBeDefined();
+
+ expect(history.replace).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('loadCurrentFieldsetSaga', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const FIELDSET_ID = 5;
+
+ const makeFieldset = (overrides = {}) =>
+ makeFieldsetCatalogItem({
+ id: FIELDSET_ID,
+ name: 'Test Fieldset',
+ ...overrides,
+ });
+
+ const runLoadCurrentFieldset = async (fieldsetId: number) => {
+ const dispatched: IDispatchedAction[] = [];
+ const action = loadCurrentFieldset({ id: fieldsetId });
+
+ function* wrapper() {
+ yield call(loadCurrentFieldsetSaga, action);
+ }
+
+ const saga = runSaga(
+ {
+ dispatch: (dispatchedAction: IDispatchedAction) => {
+ dispatched.push(dispatchedAction);
+ },
+ getState: () => ({ fieldsets: { ...initialState } }),
+ },
+ wrapper,
+ );
+
+ await saga.toPromise();
+
+ return dispatched;
+ };
+
+ it('dispatches loadCurrentFieldsetSuccess on API success', async () => {
+ const fieldset = makeFieldset();
+ (getFieldset as jest.Mock).mockResolvedValue(fieldset);
+
+ const dispatched = await runLoadCurrentFieldset(FIELDSET_ID);
+
+ expect(getFieldset).toHaveBeenCalledTimes(1);
+ expect(history.replace).not.toHaveBeenCalled();
+
+ const successAction = dispatched.find(
+ (a) => a.type === loadCurrentFieldsetSuccess.type,
+ );
+ expect(successAction).toBeDefined();
+ });
+
+ it('redirects to fieldsets list on API error', async () => {
+ (getFieldset as jest.Mock).mockRejectedValue(new Error('Not found'));
+
+ const dispatched = await runLoadCurrentFieldset(FIELDSET_ID);
+
+ expect(history.push).toHaveBeenCalledTimes(1);
+ expect(history.push).toHaveBeenCalledWith(ERoutes.Fieldsets);
+
+ const successAction = dispatched.find(
+ (a) => a.type === loadCurrentFieldsetSuccess.type,
+ );
+ expect(successAction).toBeUndefined();
+ });
+});
+
+describe('loadFieldsetsSaga — additional cases', () => {
+ beforeEach(() => { jest.clearAllMocks(); });
+
+ const runSagaHelper = async (
+ saga: (...args: unknown[]) => Generator,
+ action: { type: string; payload?: unknown },
+ stateOverrides: Record = {},
+ ) => {
+ const dispatched: { type: string; payload?: unknown }[] = [];
+ const fieldsets = { ...initialState };
+ Object.keys(stateOverrides).forEach((key) => {
+ (fieldsets as Record)[key] = stateOverrides[key];
+ });
+ const state = { fieldsets };
+
+ function* wrapper() {
+ yield call(saga, action);
+ }
+
+ await runSaga(
+ {
+ dispatch: (a: { type: string; payload?: unknown }) => dispatched.push(a),
+ getState: () => state,
+ },
+ wrapper,
+ ).toPromise();
+
+ return dispatched;
+ };
+
+ it('offset=0: passes offset*30, sorting from store, dispatches success', async () => {
+ const apiResponse = { count: 2, results: [{ id: 1 }, { id: 2 }] };
+ (getFieldsets as jest.Mock).mockResolvedValue(apiResponse);
+
+ const dispatched = await runSagaHelper(
+ loadFieldsetsSaga,
+ loadFieldsets({ offset: 0 }),
+ { fieldsetsListSorting: EFieldsetsSorting.NameAsc },
+ );
+
+ expect(getFieldsets).toHaveBeenCalledTimes(1);
+ expect(getFieldsets).toHaveBeenCalledWith(
+ expect.objectContaining({
+ offset: 0,
+ limit: 30,
+ ordering: EFieldsetsSorting.NameAsc,
+ }),
+ );
+
+ expect(dispatched).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: loadFieldsetsSuccess.type,
+ payload: { count: 2, offset: 0, items: [{ id: 1 }, { id: 2 }] },
+ }),
+ ]),
+ );
+ });
+
+ it('isRequestCanceled — does not dispatch failed', async () => {
+ (getFieldsets as jest.Mock).mockRejectedValue(new Error('canceled'));
+ (isRequestCanceled as jest.Mock).mockReturnValueOnce(true);
+
+ const dispatched = await runSagaHelper(
+ loadFieldsetsSaga,
+ loadFieldsets({ offset: 0 }),
+ );
+
+ expect(dispatched).toEqual([]);
+ });
+});
+
+describe('deleteFieldsetSaga', () => {
+ beforeEach(() => { jest.clearAllMocks(); });
+
+ it('happy: removes from list, calls onSuccess', async () => {
+ (deleteFieldset as jest.Mock).mockResolvedValue(undefined);
+ const onSuccess = jest.fn();
+
+ const dispatched: { type: string; payload?: unknown }[] = [];
+ const state = { fieldsets: { ...initialState } };
+
+ await runSaga(
+ {
+ dispatch: (a: { type: string; payload?: unknown }) => dispatched.push(a),
+ getState: () => state,
+ },
+ function* wrapper() {
+ yield call(deleteFieldsetSaga, {
+ type: 'fieldsets/deleteFieldsetAction',
+ payload: { id: 5, onSuccess },
+ });
+ },
+ ).toPromise();
+
+ expect(deleteFieldset).toHaveBeenCalledTimes(1);
+ expect(deleteFieldset).toHaveBeenCalledWith({ id: 5 });
+ expect(dispatched).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ type: removeFieldsetFromList.type, payload: 5 }),
+ ]),
+ );
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ });
+
+ it('error: onSuccess is NOT called', async () => {
+ (deleteFieldset as jest.Mock).mockRejectedValue(new Error('fail'));
+ const onSuccess = jest.fn();
+
+ const dispatched: { type: string; payload?: unknown }[] = [];
+ const state = { fieldsets: { ...initialState } };
+
+ await runSaga(
+ {
+ dispatch: (a: { type: string; payload?: unknown }) => dispatched.push(a),
+ getState: () => state,
+ },
+ function* wrapper() {
+ yield call(deleteFieldsetSaga, {
+ type: 'fieldsets/deleteFieldsetAction',
+ payload: { id: 5, onSuccess },
+ });
+ },
+ ).toPromise();
+
+ expect(onSuccess).not.toHaveBeenCalled();
+ expect(dispatched).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ type: loadFieldsetsFailed.type }),
+ ]),
+ );
+ });
+});
+
+describe('updateFieldsetSaga', () => {
+ beforeEach(() => { jest.clearAllMocks(); });
+
+ const FIELDSET_ID = 10;
+
+ const runUpdateFieldset = async (payload: { id: number; name?: string; description?: string }) => {
+ const dispatched: IDispatchedAction[] = [];
+
+ await runSaga(
+ {
+ dispatch: (a: IDispatchedAction) => dispatched.push(a),
+ getState: () => ({ fieldsets: { ...initialState } }),
+ },
+ function* wrapper() {
+ yield call(updateFieldsetSaga, {
+ type: updateFieldsetAction.type,
+ payload,
+ });
+ },
+ ).toPromise();
+
+ return dispatched;
+ };
+
+ it('dispatches setCurrentFieldset on API success', async () => {
+ const updatedFieldset = makeFieldsetCatalogItem({ id: FIELDSET_ID, name: 'Updated' });
+ (updateFieldset as jest.Mock).mockResolvedValue(updatedFieldset);
+
+ const dispatched = await runUpdateFieldset({ id: FIELDSET_ID, name: 'Updated' });
+
+ expect(updateFieldset).toHaveBeenCalledTimes(1);
+ expect(dispatched).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ type: setCurrentFieldset.type }),
+ ]),
+ );
+ expect(dispatched).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ type: loadCurrentFieldset.type }),
+ ]),
+ );
+ });
+
+ it('dispatches loadCurrentFieldset on API error to rollback UI state', async () => {
+ (updateFieldset as jest.Mock).mockRejectedValue(new Error('Cannot modify bound fieldset'));
+
+ const dispatched = await runUpdateFieldset({ id: FIELDSET_ID, description: 'new desc' });
+
+ expect(dispatched).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: loadCurrentFieldset.type,
+ payload: { id: FIELDSET_ID },
+ }),
+ ]),
+ );
+ expect(dispatched).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ type: setCurrentFieldset.type }),
+ ]),
+ );
+ });
+
+ it('does nothing on canceled request', async () => {
+ (updateFieldset as jest.Mock).mockRejectedValue(new Error('canceled'));
+ (isRequestCanceled as jest.Mock).mockReturnValueOnce(true);
+
+ const dispatched = await runUpdateFieldset({ id: FIELDSET_ID, name: 'x' });
+
+ expect(dispatched).toEqual([]);
+ });
+});
diff --git a/frontend/src/public/redux/fieldsets/__tests__/slice.test.ts b/frontend/src/public/redux/fieldsets/__tests__/slice.test.ts
new file mode 100644
index 000000000..dd691430f
--- /dev/null
+++ b/frontend/src/public/redux/fieldsets/__tests__/slice.test.ts
@@ -0,0 +1,148 @@
+
+import fieldsetsReducer, {
+ initialState,
+ loadFieldsets,
+ setCurrentFieldset,
+ updateFieldsetAction,
+ removeFieldsetFromList,
+ loadFieldsetsCatalogFailed,
+ loadFieldsetsCatalogSuccess,
+} from '../slice';
+import { IFieldsetCatalogItem } from '../../../types/fieldset';
+import { makeFieldsetCatalogItem } from '../../../__stubs__/fieldsets.factory';
+import { IFieldsetsStore } from '../../../types/redux';
+
+const makeStateWithList = (items: IFieldsetCatalogItem[], overrides: Partial = {}): IFieldsetsStore => ({
+ ...initialState,
+ fieldsetsList: { count: items.length, offset: 0, items },
+ ...overrides,
+});
+
+describe('fieldsets slice', () => {
+ describe('loadFieldsets', () => {
+ it('clears the list when offset is 0 (template switch)', () => {
+ const stateWithData: IFieldsetsStore = {
+ ...initialState,
+ fieldsetsList: {
+ count: 2,
+ offset: 0,
+ items: [
+ makeFieldsetCatalogItem({ name: 'Fieldset from template A' }),
+ makeFieldsetCatalogItem({ id: 2, name: 'Another fieldset from template A' }),
+ ],
+ },
+ };
+
+ const result = fieldsetsReducer(stateWithData, loadFieldsets({ offset: 0 }));
+
+ expect(result.fieldsetsList.items).toEqual([]);
+ expect(result.fieldsetsList.count).toBe(0);
+ expect(result.fieldsetsList.offset).toBe(0);
+ expect(result.isLoading).toBe(true);
+ });
+
+ it('keeps existing items when offset > 0 (pagination)', () => {
+ const existingItems = [
+ makeFieldsetCatalogItem({ name: 'Fieldset 1' }),
+ makeFieldsetCatalogItem({ id: 2, name: 'Fieldset 2' }),
+ ];
+ const stateWithData: IFieldsetsStore = {
+ ...initialState,
+ fieldsetsList: {
+ count: 5,
+ offset: 0,
+ items: existingItems,
+ },
+ };
+
+ const result = fieldsetsReducer(stateWithData, loadFieldsets({ offset: 1 }));
+
+ expect(result.fieldsetsList.items).toHaveLength(2);
+ expect(result.fieldsetsList.items).toEqual(existingItems);
+ expect(result.isLoading).toBe(true);
+ });
+ });
+
+ describe('setCurrentFieldset', () => {
+ it('writes fieldset to currentFieldset and updates name/description in list', () => {
+ const item = makeFieldsetCatalogItem({ id: 5, name: 'Old Name' });
+ const state = makeStateWithList([item]);
+ const updated = makeFieldsetCatalogItem({ id: 5, name: 'New Name', description: 'New Desc' });
+
+ const result = fieldsetsReducer(state, setCurrentFieldset(updated));
+
+ expect(result.currentFieldset).toEqual(updated);
+ expect(result.fieldsetsList.items[0].name).toBe('New Name');
+ expect(result.fieldsetsList.items[0].description).toBe('New Desc');
+ });
+
+ it('does not crash or add phantom item when id is not in the list', () => {
+ const state = makeStateWithList([makeFieldsetCatalogItem({ name: 'Existing' })]);
+ const fieldset = makeFieldsetCatalogItem({ id: 999 });
+
+ const result = fieldsetsReducer(state, setCurrentFieldset(fieldset));
+
+ expect(result.currentFieldset).toEqual(fieldset);
+ expect(result.fieldsetsList.items).toHaveLength(1);
+ expect(result.fieldsetsList.items[0].id).toBe(1);
+ });
+ });
+
+ describe('updateFieldsetAction', () => {
+ it('resets isCatalogLoaded and does NOT mutate currentFieldset', () => {
+ const state: IFieldsetsStore = {
+ ...initialState,
+ isCatalogLoaded: true,
+ currentFieldset: makeFieldsetCatalogItem({ id: 5, name: 'Original' }),
+ };
+
+ const result = fieldsetsReducer(state, updateFieldsetAction({ id: 5, name: 'Updated' }));
+
+ expect(result.isCatalogLoaded).toBe(false);
+ if (result.currentFieldset === null) throw new Error('expected currentFieldset');
+ expect(result.currentFieldset.name).toBe('Original');
+ });
+ });
+
+ describe('removeFieldsetFromList', () => {
+ it('removes item by id and decrements count', () => {
+ const items = [makeFieldsetCatalogItem({ name: 'A' }), makeFieldsetCatalogItem({ id: 2, name: 'B' })];
+ const state = makeStateWithList(items);
+
+ const result = fieldsetsReducer(state, removeFieldsetFromList(1));
+
+ expect(result.fieldsetsList.items).toHaveLength(1);
+ expect(result.fieldsetsList.items[0].id).toBe(2);
+ expect(result.fieldsetsList.count).toBe(1);
+ expect(result.isLoading).toBe(false);
+ });
+ });
+
+ describe('loadFieldsetsCatalogFailed', () => {
+ it('resets isCatalogLoading and sets isCatalogLoaded to false', () => {
+ const state: IFieldsetsStore = {
+ ...initialState,
+ isCatalogLoading: true,
+ isCatalogLoaded: true,
+ };
+
+ const result = fieldsetsReducer(state, loadFieldsetsCatalogFailed());
+
+ expect(result.isCatalogLoading).toBe(false);
+ expect(result.isCatalogLoaded).toBe(false);
+ });
+ });
+
+ describe('loadFieldsetsCatalogSuccess', () => {
+ it('writes catalogAllFieldsets and resets isCatalogLoading', () => {
+ const state: IFieldsetsStore = { ...initialState, isCatalogLoading: true };
+ const items = [makeFieldsetCatalogItem({ name: 'A' }), makeFieldsetCatalogItem({ id: 2, name: 'B' })];
+
+ const result = fieldsetsReducer(state, loadFieldsetsCatalogSuccess(items));
+
+ expect(result.catalogAllFieldsets).toEqual(items);
+ expect(result.isCatalogLoading).toBe(false);
+ expect(result.isCatalogLoaded).toBe(true);
+ });
+ });
+});
diff --git a/frontend/src/public/redux/fieldsets/saga.ts b/frontend/src/public/redux/fieldsets/saga.ts
new file mode 100644
index 000000000..f92b7b799
--- /dev/null
+++ b/frontend/src/public/redux/fieldsets/saga.ts
@@ -0,0 +1,193 @@
+import { all, call, fork, put, takeEvery, takeLatest, select } from 'redux-saga/effects';
+import { PayloadAction } from '@reduxjs/toolkit';
+
+import uniqBy from 'lodash.uniqby';
+import { NotificationManager } from '../../components/UI/Notifications';
+import { getErrorMessage } from '../../utils/getErrorMessage';
+import { logger } from '../../utils/logger';
+import { history } from '../../utils/history';
+import { ERoutes } from '../../constants/routes';
+import { isRequestCanceled } from '../../utils/isRequestCanceled';
+
+import {
+ IFieldsetCatalogItem, IGetFieldsetsResponse,
+ ICreateFieldsetParams, IUpdateFieldsetParams,
+} from '../../types/fieldset';
+import { TDeleteFieldsetPayload } from './types';
+import { LIMIT_LOAD_FIELDSETS } from '../../constants/defaultValues';
+import { getFieldsetsStore } from '../selectors/fieldsets';
+import { getFieldsets } from '../../api/fieldsets/getFieldsets';
+import { getFieldset } from '../../api/fieldsets/getFieldset';
+import { getAllFieldsetsCatalog } from '../../api/fieldsets/getAllFieldsetsCatalog';
+import { createFieldset } from '../../api/fieldsets/createFieldset';
+import { updateFieldset } from '../../api/fieldsets/updateFieldset';
+import { deleteFieldset } from '../../api/fieldsets/deleteFieldset';
+
+import {
+ loadFieldsets,
+ loadFieldsetsSuccess,
+ loadFieldsetsFailed,
+ loadCurrentFieldset,
+ loadCurrentFieldsetSuccess,
+ loadCurrentFieldsetFailed,
+ setCurrentFieldset,
+ createFieldsetAction,
+ updateFieldsetAction,
+ deleteFieldsetAction,
+ removeFieldsetFromList,
+ loadFieldsetsCatalog,
+ loadFieldsetsCatalogSuccess,
+ loadFieldsetsCatalogFailed,
+} from './slice';
+
+function getFieldsetsRoute(): string {
+ return ERoutes.Fieldsets;
+}
+
+function getFieldsetDetailRoute(fieldsetId: number): string {
+ return ERoutes.FieldsetDetail
+ .replace(':id', String(fieldsetId));
+}
+
+export function* loadFieldsetsSaga({ payload }: ReturnType) {
+ const abortController = new AbortController();
+ const { offset } = payload;
+
+ try {
+ const fieldsetsStore: ReturnType = yield select(getFieldsetsStore);
+ const { fieldsetsList, fieldsetsListSorting } = fieldsetsStore;
+
+ const data: IGetFieldsetsResponse = yield call(getFieldsets, {
+ offset: offset * LIMIT_LOAD_FIELDSETS,
+ limit: LIMIT_LOAD_FIELDSETS,
+ ordering: fieldsetsListSorting,
+ signal: abortController.signal,
+ });
+
+ const results = data.results || [];
+ const count = data.count || 0;
+
+ const items = offset > 0 ? uniqBy([...fieldsetsList.items, ...results], 'id') : results;
+
+ yield put(loadFieldsetsSuccess({ count, offset, items }));
+ } catch (error) {
+ if (isRequestCanceled(error)) return;
+ yield put(loadFieldsetsFailed());
+ NotificationManager.warning({ message: getErrorMessage(error) });
+ logger.error('failed to load fieldsets', error);
+
+ if (error?.status === 404) {
+ history.replace(ERoutes.Templates);
+ }
+ } finally {
+ abortController.abort();
+ }
+}
+
+export function* loadCurrentFieldsetSaga({ payload: { id } }: PayloadAction<{ id: number }>) {
+ const abortController = new AbortController();
+
+ try {
+ const currentFieldset: IFieldsetCatalogItem = yield call(getFieldset, { id, signal: abortController.signal });
+ yield put(loadCurrentFieldsetSuccess(currentFieldset));
+ } catch (error) {
+ if (isRequestCanceled(error)) return;
+ yield put(loadCurrentFieldsetFailed());
+ history.push(getFieldsetsRoute());
+ NotificationManager.warning({ message: getErrorMessage(error) });
+ logger.error('failed to load current fieldset', error);
+ } finally {
+ abortController.abort();
+ }
+}
+
+function* createFieldsetSaga({ payload }: PayloadAction) {
+ try {
+ const createdFieldset: IFieldsetCatalogItem = yield call(createFieldset, payload);
+ yield put(loadCurrentFieldsetSuccess(createdFieldset));
+ history.push(getFieldsetDetailRoute(createdFieldset.id));
+ } catch (error) {
+ NotificationManager.warning({ message: getErrorMessage(error) });
+ logger.error('failed to create fieldset', error);
+ }
+}
+
+export function* updateFieldsetSaga({ payload }: PayloadAction) {
+ const abortController = new AbortController();
+
+ try {
+ const updatedFieldset: IFieldsetCatalogItem = yield call(updateFieldset, { ...payload, signal: abortController.signal });
+ yield put(setCurrentFieldset(updatedFieldset));
+ } catch (error) {
+ if (isRequestCanceled(error)) return;
+ NotificationManager.warning({ message: getErrorMessage(error) });
+ logger.error('failed to update fieldset', error);
+ yield put(loadCurrentFieldset({ id: payload.id }));
+ } finally {
+ abortController.abort();
+ }
+}
+
+export function* deleteFieldsetSaga({ payload: { id, onSuccess } }: PayloadAction) {
+ try {
+ yield call(deleteFieldset, { id });
+ yield put(removeFieldsetFromList(id));
+ onSuccess?.();
+ } catch (error) {
+ yield put(loadFieldsetsFailed());
+ NotificationManager.warning({ message: getErrorMessage(error) });
+ logger.error('failed to delete fieldset', error);
+ }
+}
+
+function* watchLoadFieldsets() {
+ yield takeLatest(loadFieldsets.type, loadFieldsetsSaga);
+}
+
+function* watchLoadCurrentFieldset() {
+ yield takeLatest(loadCurrentFieldset.type, loadCurrentFieldsetSaga);
+}
+
+function* watchCreateFieldset() {
+ yield takeEvery(createFieldsetAction.type, createFieldsetSaga);
+}
+
+function* watchUpdateFieldset() {
+ yield takeLatest(updateFieldsetAction.type, updateFieldsetSaga);
+}
+
+function* watchDeleteFieldset() {
+ yield takeEvery(deleteFieldsetAction.type, deleteFieldsetSaga);
+}
+
+function* loadFieldsetsCatalogSaga() {
+ const abortController = new AbortController();
+
+ try {
+ const data: IFieldsetCatalogItem[] = yield call(getAllFieldsetsCatalog, abortController.signal);
+
+ yield put(loadFieldsetsCatalogSuccess(data));
+ } catch (error) {
+ if (isRequestCanceled(error)) return;
+ yield put(loadFieldsetsCatalogFailed());
+ NotificationManager.warning({ message: getErrorMessage(error) });
+ logger.error('failed to load fieldsets catalog', error);
+ } finally {
+ abortController.abort();
+ }
+}
+
+function* watchLoadFieldsetsCatalog() {
+ yield takeLatest(loadFieldsetsCatalog.type, loadFieldsetsCatalogSaga);
+}
+
+export function* rootSaga() {
+ yield all([
+ fork(watchLoadFieldsets),
+ fork(watchLoadCurrentFieldset),
+ fork(watchCreateFieldset),
+ fork(watchUpdateFieldset),
+ fork(watchDeleteFieldset),
+ fork(watchLoadFieldsetsCatalog),
+ ]);
+}
diff --git a/frontend/src/public/redux/fieldsets/slice.ts b/frontend/src/public/redux/fieldsets/slice.ts
new file mode 100644
index 000000000..10b8c756d
--- /dev/null
+++ b/frontend/src/public/redux/fieldsets/slice.ts
@@ -0,0 +1,170 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+import { IFieldsetsStore, IFieldsetsList } from '../../types/redux';
+import {
+ IFieldsetCatalogItem, ICreateFieldsetParams,
+ IUpdateFieldsetParams,
+ EFieldsetsSorting,
+} from '../../types/fieldset';
+import { TDeleteFieldsetPayload } from './types';
+
+export const initialState: IFieldsetsStore = {
+ fieldsetsList: {
+ count: 0,
+ offset: 0,
+ items: [],
+ },
+ isLoading: false,
+ searchQuery: '',
+ fieldsetsListSorting: EFieldsetsSorting.DateDesc,
+ isCreateModalOpen: false,
+ isEditModalOpen: false,
+
+ currentFieldset: null,
+ isCurrentFieldsetLoading: false,
+
+ catalogAllFieldsets: [],
+ isCatalogLoading: false,
+ isCatalogLoaded: false,
+};
+
+const fieldsetsSlice = createSlice({
+ name: 'fieldsets',
+ initialState,
+ reducers: {
+ loadFieldsets: (state, action: PayloadAction<{ offset: number }>) => {
+ state.isLoading = true;
+ if (action.payload.offset === 0) {
+ state.fieldsetsList = { count: 0, offset: 0, items: [] };
+ }
+ },
+
+ loadFieldsetsSuccess: (state, action: PayloadAction) => {
+ state.fieldsetsList = action.payload;
+ state.isLoading = false;
+ },
+
+ loadFieldsetsFailed: (state) => {
+ state.isLoading = false;
+ },
+
+ setSearchQuery: (state, action: PayloadAction) => {
+ state.searchQuery = action.payload;
+ },
+
+ setFieldsetsListSorting: (state, action: PayloadAction) => {
+ state.fieldsetsListSorting = action.payload;
+ },
+
+ openCreateModal: (state) => {
+ state.isCreateModalOpen = true;
+ },
+
+ closeCreateModal: (state) => {
+ state.isCreateModalOpen = false;
+ },
+
+ openEditModal: (state) => {
+ state.isEditModalOpen = true;
+ },
+
+ closeEditModal: (state) => {
+ state.isEditModalOpen = false;
+ },
+
+ loadCurrentFieldset: (state, _action: PayloadAction<{ id: number }>) => {
+ state.currentFieldset = null;
+ state.isCurrentFieldsetLoading = true;
+ },
+
+ loadCurrentFieldsetSuccess: (state, action: PayloadAction) => {
+ state.currentFieldset = action.payload;
+ state.isCurrentFieldsetLoading = false;
+ },
+
+ loadCurrentFieldsetFailed: (state) => {
+ state.isCurrentFieldsetLoading = false;
+ },
+
+ resetCurrentFieldset: (state) => {
+ state.currentFieldset = null;
+ state.isCurrentFieldsetLoading = false;
+ },
+
+ setCurrentFieldset: (state, action: PayloadAction) => {
+ state.currentFieldset = action.payload;
+ state.isCurrentFieldsetLoading = false;
+
+ const listIndex = state.fieldsetsList.items.findIndex(
+ (item) => item.id === action.payload.id
+ );
+ if (listIndex !== -1) {
+ state.fieldsetsList.items[listIndex].name = action.payload.name;
+ state.fieldsetsList.items[listIndex].description = action.payload.description;
+ }
+ },
+
+ createFieldsetAction: (state, _action: PayloadAction) => {
+ state.isCatalogLoaded = false;
+ },
+
+ updateFieldsetAction: (state, _action: PayloadAction) => {
+ state.isCatalogLoaded = false;
+ },
+
+ deleteFieldsetAction: (state, _action: PayloadAction) => {
+ state.isLoading = true;
+ state.isCatalogLoaded = false;
+ },
+
+ removeFieldsetFromList: (state, action: PayloadAction) => {
+ state.fieldsetsList.items = state.fieldsetsList.items.filter((item) => item.id !== action.payload);
+ state.fieldsetsList.count -= 1;
+ state.isLoading = false;
+ },
+ loadFieldsetsCatalog: (state) => {
+ state.isCatalogLoading = true;
+ },
+
+ loadFieldsetsCatalogSuccess: (state, action: PayloadAction) => {
+ state.catalogAllFieldsets = action.payload;
+ state.isCatalogLoading = false;
+ state.isCatalogLoaded = true;
+ },
+
+ loadFieldsetsCatalogFailed: (state) => {
+ state.isCatalogLoading = false;
+ state.isCatalogLoaded = false;
+ },
+ },
+});
+
+export const {
+ loadFieldsets,
+ loadFieldsetsSuccess,
+ loadFieldsetsFailed,
+ setSearchQuery,
+ setFieldsetsListSorting,
+ openCreateModal,
+ closeCreateModal,
+ openEditModal,
+ closeEditModal,
+
+ loadCurrentFieldset,
+ loadCurrentFieldsetSuccess,
+ loadCurrentFieldsetFailed,
+ resetCurrentFieldset,
+ setCurrentFieldset,
+
+ createFieldsetAction,
+ updateFieldsetAction,
+ deleteFieldsetAction,
+ removeFieldsetFromList,
+
+ loadFieldsetsCatalog,
+ loadFieldsetsCatalogSuccess,
+ loadFieldsetsCatalogFailed,
+} = fieldsetsSlice.actions;
+
+export default fieldsetsSlice.reducer;
diff --git a/frontend/src/public/redux/fieldsets/types.ts b/frontend/src/public/redux/fieldsets/types.ts
new file mode 100644
index 000000000..1f7fa073c
--- /dev/null
+++ b/frontend/src/public/redux/fieldsets/types.ts
@@ -0,0 +1,4 @@
+export type TDeleteFieldsetPayload = {
+ id: number;
+ onSuccess?: () => void;
+};
diff --git a/frontend/src/public/redux/reducers.ts b/frontend/src/public/redux/reducers.ts
index 5d1c6fe71..dcd7a436d 100644
--- a/frontend/src/public/redux/reducers.ts
+++ b/frontend/src/public/redux/reducers.ts
@@ -29,6 +29,7 @@ import workflows from './workflows/slice';
import accounts from './accounts/slice';
import tasks from './tasks/slice';
import datasets from './datasets/slice';
+import fieldsets from './fieldsets/slice';
export const reducers = combineReducers({
@@ -55,6 +56,7 @@ export const reducers = combineReducers({
webhooks,
tenants,
datasets,
+ fieldsets,
pages,
});
diff --git a/frontend/src/public/redux/runWorkflowModal/__tests__/reducer.test.ts b/frontend/src/public/redux/runWorkflowModal/__tests__/reducer.test.ts
index a65d2c783..f62e37a22 100644
--- a/frontend/src/public/redux/runWorkflowModal/__tests__/reducer.test.ts
+++ b/frontend/src/public/redux/runWorkflowModal/__tests__/reducer.test.ts
@@ -1,15 +1,12 @@
/* eslint-disable */
/* prettier-ignore */
import { reducer, INIT_STATE } from '../reducer';
-import { TRunWorkflowModalActions, openRunWorkflowModal } from '../actions';
-import { IKickoff, EExtraFieldType } from '../../../types/template';
+import { openRunWorkflowModal } from '../actions';
+import { IKickoffClient, EExtraFieldType } from '../../../types/template';
+import { makeFieldsetBindingClient, makeFieldsetRuntime } from '../../../__stubs__/fieldsets.factory';
+import { makeExtraField } from '../../../__stubs__/fields.factory';
describe('runWorkflowModal reducer', () => {
- it('return default state', () => {
- const result = reducer(undefined, 'NOT_ACTION' as unknown as TRunWorkflowModalActions);
-
- expect(result).toEqual(INIT_STATE);
- });
it('openRunWorkflowModal open modal window', () => {
const runnableWorkflow = {
id: 4654,
@@ -25,7 +22,7 @@ describe('runWorkflowModal reducer', () => {
wfNameTemplate: '',
};
- const kickoff: IKickoff = {
+ const kickoff: IKickoffClient = {
description:
'youtube: \nhttps://www.youtube.com/watch?v=JZRm7NKTPhk\n loom:\nhttps://www.loom.com/share/29f210bc12484eaa81ca462381fb4415?t=0\n 404 loom:\n\nhttps://www.loom.com/share/9853f0790ad2408094a3717bfcf4a0c0\nYoutube 404 :\n\nhttps://www.youtube.com/watch?v=D6hIeqZt22g',
fields: [
@@ -136,6 +133,7 @@ describe('runWorkflowModal reducer', () => {
groupId: null,
},
],
+ fieldsets: [],
};
const action = openRunWorkflowModal({
@@ -149,3 +147,49 @@ describe('runWorkflowModal reducer', () => {
expect(result.isOpen).toEqual(true);
});
});
+
+describe('OpenModal — fieldsets acceptance', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('preserves kickoff.fieldsets and loadedFieldsets without data loss', () => {
+ const mockFieldsetData = makeFieldsetRuntime({
+ apiNameBinding: 'fs-contacts',
+ name: 'Contacts',
+ description: 'Contact information',
+ fields: [makeExtraField({ apiName: 'contact-name', name: 'Contact Name', isRequired: true, value: '' })],
+ });
+
+ const action = openRunWorkflowModal({
+ id: 10,
+ name: 'Template With Fieldsets',
+ wfNameTemplate: null,
+ description: '',
+ tasksCount: 2,
+ performersCount: 1,
+ kickoff: {
+ description: '',
+ fields: [],
+ fieldsets: [makeFieldsetBindingClient({ apiNameBinding: 'fs-contacts' })],
+ },
+ loadedFieldsets: [mockFieldsetData],
+ });
+
+ const result = reducer(INIT_STATE, action);
+
+ expect(result.isOpen).toBe(true);
+
+ if (result.workflow === null) {
+ throw new Error('Expected workflow to be non-null');
+ }
+
+ expect(result.workflow.kickoff.fieldsets).toHaveLength(1);
+ expect(result.workflow.kickoff.fieldsets).toMatchObject([
+ { apiNameBinding: 'fs-contacts' },
+ ]);
+
+ expect(result.workflow.loadedFieldsets).toHaveLength(1);
+ expect(result.workflow.loadedFieldsets).toEqual([mockFieldsetData]);
+ });
+});
diff --git a/frontend/src/public/redux/sagas.ts b/frontend/src/public/redux/sagas.ts
index 064b29a84..49ddea4db 100644
--- a/frontend/src/public/redux/sagas.ts
+++ b/frontend/src/public/redux/sagas.ts
@@ -25,6 +25,7 @@ import { rootSaga as tenantsSagas } from './tenants/saga';
import { rootSaga as pagesSagas } from './pages/saga';
import { rootSaga as groupsSagas } from './groups/saga';
import { rootSaga as datasetsSagas } from './datasets/saga';
+import { rootSaga as fieldsetsSagas } from './fieldsets/saga';
export function* rootSaga() {
while (true) {
@@ -51,6 +52,7 @@ export function* rootSaga() {
fork(tenantsSagas),
fork(pagesSagas),
fork(datasetsSagas),
+ fork(fieldsetsSagas),
]);
yield take(EAuthActions.RedirectToLogin);
diff --git a/frontend/src/public/redux/selectors/fieldsets.ts b/frontend/src/public/redux/selectors/fieldsets.ts
new file mode 100644
index 000000000..1afb346ea
--- /dev/null
+++ b/frontend/src/public/redux/selectors/fieldsets.ts
@@ -0,0 +1,27 @@
+import { EFieldsetsSorting, IFieldsetCatalogItem } from '../../types/fieldset';
+import { IApplicationState, IFieldsetsStore, IFieldsetsList } from '../../types/redux';
+
+export const getFieldsetsStore = (state: IApplicationState): IFieldsetsStore => state.fieldsets;
+
+export const getFieldsetsList = (state: IApplicationState): IFieldsetCatalogItem[] => state.fieldsets.fieldsetsList.items;
+
+export const getFieldsetsListSelection = (state: IApplicationState): IFieldsetsList => state.fieldsets.fieldsetsList;
+
+export const getFieldsetsIsLoading = (state: IApplicationState): boolean => state.fieldsets.isLoading;
+
+export const getFieldsetsSorting = (state: IApplicationState): EFieldsetsSorting => state.fieldsets.fieldsetsListSorting;
+
+export const isCreateModalOpen = (state: IApplicationState): boolean => state.fieldsets.isCreateModalOpen;
+
+export const isEditModalOpen = (state: IApplicationState): boolean => state.fieldsets.isEditModalOpen;
+
+export const getCurrentFieldset = (state: IApplicationState): IFieldsetCatalogItem | null => state.fieldsets.currentFieldset;
+
+export const isCurrentFieldsetLoading = (state: IApplicationState): boolean => state.fieldsets.isCurrentFieldsetLoading;
+
+export const getFieldsetsCatalogItems = (state: IApplicationState): IFieldsetCatalogItem[] => state.fieldsets.catalogAllFieldsets;
+
+export const getFieldsetsCatalogIsLoading = (state: IApplicationState): boolean => state.fieldsets.isCatalogLoading;
+
+export const getIsCatalogLoaded = (state: IApplicationState): boolean => state.fieldsets.isCatalogLoaded;
+
diff --git a/frontend/src/public/redux/selectors/template.ts b/frontend/src/public/redux/selectors/template.ts
index f4c588b1c..37a46a635 100644
--- a/frontend/src/public/redux/selectors/template.ts
+++ b/frontend/src/public/redux/selectors/template.ts
@@ -1,17 +1,17 @@
import { IApplicationState } from '../../types/redux';
-import { ITemplate, IExtraField, ITemplateTask, IKickoff } from '../../types/template';
+import { ITemplateClient, ITemplateTaskClient, IKickoffClient, IExtraField } from '../../types/template';
-export const getTemplateData = (state: IApplicationState): ITemplate => {
+export const getTemplateData = (state: IApplicationState): ITemplateClient => {
return state.template.data;
};
export const getTemplateStatus = (state: IApplicationState) => state.template.status;
-export const getKickoff = (state: IApplicationState): IKickoff =>
+export const getKickoff = (state: IApplicationState): IKickoffClient =>
state.template.data.kickoff;
export const getKickoffFields = (state: IApplicationState): IExtraField[] =>
state.template.data.kickoff.fields;
-export const getTemplateTasks = (state: IApplicationState): ITemplateTask[] =>
+export const getTemplateTasks = (state: IApplicationState): ITemplateTaskClient[] =>
state.template.data.tasks;
diff --git a/frontend/src/public/redux/task/__tests__/saga.test.ts b/frontend/src/public/redux/task/__tests__/saga.test.ts
new file mode 100644
index 000000000..551446aa0
--- /dev/null
+++ b/frontend/src/public/redux/task/__tests__/saga.test.ts
@@ -0,0 +1,105 @@
+import { setTaskCompleted } from '../saga';
+import { ETaskActions, TSetTaskCompleted } from '../actions';
+import { ETaskCardViewMode } from '../../../components/TaskCard';
+import { completeTask } from '../../../api/completeTask';
+import { fieldsetsStorage } from '../../../components/TaskCard/utils/storageOutputs';
+
+jest.mock('../../../api/completeTask', () => ({
+ completeTask: jest.fn(),
+}));
+
+jest.mock('../../../components/TaskCard/utils/storageOutputs', () => ({
+ outputStorage: { remove: jest.fn() },
+ fieldsetsStorage: { remove: jest.fn() },
+}));
+
+jest.mock('../../../api/deleteRemovedFilesFromFields', () => ({
+ deleteRemovedFilesFromFields: jest.fn(() => Promise.resolve()),
+}));
+
+jest.mock('../../../utils/mappers', () => ({
+ mapOutputToCompleteTask: jest.fn((x) => x),
+ getNormalizeOutputUsersToEmails: jest.fn((x) => x),
+}));
+
+jest.mock('../../../utils/logger', () => ({
+ logger: { info: jest.fn(), error: jest.fn() },
+}));
+
+jest.mock('../../../components/UI/Notifications', () => ({
+ NotificationManager: { success: jest.fn(), warning: jest.fn(), notifyApiError: jest.fn() },
+}));
+
+jest.mock('../../../utils/getErrorMessage', () => ({
+ getErrorMessage: jest.fn(() => 'error'),
+}));
+
+jest.mock('../../../components/TaskCard', () => ({
+ ETaskCardViewMode: {
+ Single: 'single',
+ List: 'list',
+ Guest: 'guest',
+ },
+}));
+
+jest.mock('../../../utils/history', () => ({
+ history: { push: jest.fn(), replace: jest.fn() },
+}));
+
+describe('setTaskCompleted — fieldsets draft cleanup', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const TASK_ID = 42;
+
+ const makeAction = (): TSetTaskCompleted => ({
+ type: ETaskActions.SetTaskCompleted,
+ payload: {
+ taskId: TASK_ID,
+ output: [],
+ viewMode: ETaskCardViewMode.Single,
+ },
+ });
+
+ const step = (gen: Generator, value?: unknown) =>
+ (gen.next as (v?: unknown) => IteratorResult)(value);
+
+ const stepThrow = (gen: Generator, error: Error) =>
+ (gen.throw as (e: Error) => IteratorResult)(error);
+
+ const advanceToCompleteTask = (action: TSetTaskCompleted) => {
+ (completeTask as jest.Mock).mockReturnValue(Promise.resolve());
+
+ const saga = setTaskCompleted(action);
+
+ step(saga);
+ step(saga, { authUser: { id: 1 } });
+
+ step(saga);
+ step(saga, { id: TASK_ID, areChecklistsHandling: false });
+
+ step(saga);
+
+ step(saga, []);
+
+ return saga;
+ };
+
+ it('clears fieldsets draft on successful complete', () => {
+ const saga = advanceToCompleteTask(makeAction());
+
+ step(saga);
+
+ expect(fieldsetsStorage.remove).toHaveBeenCalledTimes(1);
+ expect(fieldsetsStorage.remove).toHaveBeenCalledWith(TASK_ID);
+ });
+
+ it('does not clear fieldsets draft on complete error', () => {
+ const saga = advanceToCompleteTask(makeAction());
+
+ stepThrow(saga, new Error('API error'));
+
+ expect(fieldsetsStorage.remove).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/public/redux/task/saga.ts b/frontend/src/public/redux/task/saga.ts
index e33a78755..5567ac438 100644
--- a/frontend/src/public/redux/task/saga.ts
+++ b/frontend/src/public/redux/task/saga.ts
@@ -51,7 +51,7 @@ import { patchTaskInList, shiftTaskList, ETaskListActions } from '../tasks/slice
import { getErrorMessage } from '../../utils/getErrorMessage';
import { getAuthUser, getUsers, getUserTimezone } from '../selectors/user';
-import { removeOutputFromLocalStorage } from '../../components/TaskCard/utils/storageOutputs';
+import { outputStorage, fieldsetsStorage } from '../../components/TaskCard/utils/storageOutputs';
import { ETaskCardViewMode } from '../../components/TaskCard';
import { deleteRemovedFilesFromFields } from '../../api/deleteRemovedFilesFromFields';
import { TChannelAction } from '../tasks/saga';
@@ -311,7 +311,8 @@ export function* setTaskCompleted({ payload: { taskId, output, viewMode } }: TSe
yield completeTask(taskId, normalizedOutputs);
NotificationManager.success({ title: 'tasks.task-success-complete' });
- removeOutputFromLocalStorage(taskId);
+ outputStorage.remove(taskId);
+ fieldsetsStorage.remove(taskId);
yield put(setCurrentTaskStatus(ETaskStatus.Completed));
if (viewMode === ETaskCardViewMode.List) {
diff --git a/frontend/src/public/redux/template/__tests__/saga.test.ts b/frontend/src/public/redux/template/__tests__/saga.test.ts
new file mode 100644
index 000000000..30fdeba07
--- /dev/null
+++ b/frontend/src/public/redux/template/__tests__/saga.test.ts
@@ -0,0 +1,469 @@
+import { runSaga, stdChannel } from 'redux-saga';
+import { call } from 'redux-saga/effects';
+import { fetchTemplate, watchPatchTemplate, watchSaveTemplate } from '../saga';
+import { getTemplate } from '../../../api/getTemplate';
+import { updateTemplate } from '../../../api/updateTemplate';
+import { ETemplateActions, TLoadTemplate } from '../actions';
+import { loadFieldsetsCatalog, loadFieldsetsCatalogSuccess, loadFieldsetsCatalogFailed } from '../../fieldsets/slice';
+import { ETemplateStatus } from '../../../types/redux';
+import { ESubscriptionPlan } from '../../../types/account';
+import { cleanTemplateReferences, mapTemplateRequest } from '../../../utils/template';
+import { checkSomeRouteIsActive } from '../../../utils/history';
+import { ITemplate } from '../../../types/template';
+
+jest.mock('../../../utils/getConfig', () => ({
+ getBrowserConfigEnv: jest.fn().mockReturnValue({
+ api: {
+ urls: {
+ templates: '/templates',
+ },
+ },
+ }),
+}));
+
+jest.mock('../../../api/getTemplate', () => ({
+ getTemplate: jest.fn(),
+}));
+
+jest.mock('../../../utils/history', () => ({
+ history: { replace: jest.fn() },
+ checkSomeRouteIsActive: jest.fn(),
+}));
+
+jest.mock('../../../components/UI/Notifications', () => ({
+ NotificationManager: { warning: jest.fn() },
+}));
+
+jest.mock('../../../utils/logger', () => ({
+ logger: { info: jest.fn(), error: jest.fn() },
+}));
+
+jest.mock('../../../utils/template', () => ({
+ cleanTemplateReferences: jest.fn((template: ITemplate) => template),
+ getNormalizedTemplate: jest.fn((template: ITemplate) => template),
+ mapTemplateRequest: jest.fn((template: ITemplate) => template),
+}));
+
+jest.mock('../../../api/updateTemplate', () => ({
+ updateTemplate: jest.fn(),
+}));
+
+jest.mock('../../../api/createTemplate', () => ({
+ createTemplate: jest.fn(),
+}));
+
+jest.mock('../../../utils/getErrorMessage', () => ({
+ getErrorMessage: jest.fn(() => 'error'),
+ isPaidFeatureError: jest.fn(() => false),
+}));
+
+jest.mock('../../../utils/templates/insertId', () => ({
+ insertId: jest.fn((lastState: ITemplate, saved: ITemplate) => ({ ...lastState, id: saved.id })),
+}));
+
+describe('fetchTemplate saga', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const makeTemplate = (overrides = {}) => ({
+ id: 42,
+ name: 'Test Template',
+ description: '',
+ isActive: false,
+ finalizable: false,
+ dateUpdated: null,
+ updatedBy: null,
+ tasks: [],
+ kickoff: { fields: [], fieldsets: [], description: '' },
+ isPublic: false,
+ publicUrl: null,
+ publicSuccessUrl: null,
+ isEmbedded: false,
+ embedUrl: null,
+ wfNameTemplate: null,
+ tasksCount: 0,
+ performersCount: 0,
+ owners: [],
+ viewers: [],
+ starters: [],
+ completionNotification: false,
+ reminderNotification: false,
+ ...overrides,
+ });
+
+ interface IMockFieldsetsOverrides {
+ fieldsets?: { isCatalogLoaded?: boolean };
+ }
+
+ const makeMockState = (overrides: IMockFieldsetsOverrides = {}) => ({
+ authUser: {
+ account: {
+ isSubscribed: false,
+ billingPlan: ESubscriptionPlan.Free,
+ },
+ },
+ accounts: {
+ users: [],
+ },
+ fieldsets: {
+ isCatalogLoaded: false,
+ ...overrides.fieldsets,
+ },
+ });
+
+ interface IDispatchedAction {
+ type: string;
+ payload?: unknown;
+ }
+
+ const runFetchTemplate = async (
+ templateId: number,
+ mockState: ReturnType,
+ catalogResponse: 'success' | 'failed' = 'success',
+ ) => {
+ const channel = stdChannel();
+ const dispatched: IDispatchedAction[] = [];
+ const action: TLoadTemplate = { payload: templateId, type: ETemplateActions.Load };
+
+ function* wrapper() {
+ yield call(fetchTemplate, action);
+ }
+
+ const saga = runSaga(
+ {
+ channel,
+ dispatch: (dispathedAction: IDispatchedAction) => {
+ dispatched.push(dispathedAction);
+
+ if (dispathedAction.type === loadFieldsetsCatalog.type) {
+ setTimeout(() => {
+ if (catalogResponse === 'success') {
+ channel.put(loadFieldsetsCatalogSuccess([]));
+ } else {
+ channel.put(loadFieldsetsCatalogFailed());
+ }
+ }, 0);
+ }
+ },
+ getState: () => mockState,
+ },
+ wrapper,
+ );
+
+ await saga.toPromise();
+
+ return dispatched;
+ };
+
+ describe('Catalog loading and status synchronization', () => {
+ it('dispatches loadFieldsetsCatalog BEFORE setTemplateStatus(Saved)', async () => {
+ const template = makeTemplate({ id: 42 });
+ (getTemplate as jest.Mock).mockResolvedValue(template);
+
+ const dispatched = await runFetchTemplate(42, makeMockState());
+
+ const catalogIndex = dispatched.findIndex(
+ (a) => a.type === loadFieldsetsCatalog.type,
+ );
+ const savedIndex = dispatched.findIndex(
+ (a) => a.type === ETemplateActions.SetTemplateStatus && a.payload === ETemplateStatus.Saved,
+ );
+
+ expect(catalogIndex).toBeGreaterThan(-1);
+ expect(savedIndex).toBeGreaterThan(-1);
+ expect(catalogIndex).toBeLessThan(savedIndex);
+ });
+
+ it('does not dispatch any setTemplateStatus between catalog load and Saved', async () => {
+ const template = makeTemplate({ id: 42 });
+ (getTemplate as jest.Mock).mockResolvedValue(template);
+
+ const dispatched = await runFetchTemplate(42, makeMockState());
+
+ const catalogIndex = dispatched.findIndex(
+ (a) => a.type === loadFieldsetsCatalog.type,
+ );
+ const savedIndex = dispatched.findIndex(
+ (a) => a.type === ETemplateActions.SetTemplateStatus && a.payload === ETemplateStatus.Saved,
+ );
+
+ const statusActionsBetween = dispatched
+ .slice(catalogIndex + 1, savedIndex)
+ .filter((a) => a.type === ETemplateActions.SetTemplateStatus);
+
+ expect(statusActionsBetween).toHaveLength(0);
+ });
+
+ it('skips catalog loading when already loaded for the same template', async () => {
+ const template = makeTemplate({ id: 42 });
+ (getTemplate as jest.Mock).mockResolvedValue(template);
+
+ const mockState = makeMockState({
+ fieldsets: { isCatalogLoaded: true },
+ });
+
+ const dispatched = await runFetchTemplate(42, mockState);
+
+ const catalogAction = dispatched.find(
+ (a) => a.type === loadFieldsetsCatalog.type,
+ );
+ expect(catalogAction).toBeUndefined();
+
+ const savedAction = dispatched.find(
+ (a) => a.type === ETemplateActions.SetTemplateStatus && a.payload === ETemplateStatus.Saved,
+ );
+ expect(savedAction).toBeDefined();
+ });
+
+ it('dispatches setTemplateStatus(Saved) even when catalog loading fails', async () => {
+ const template = makeTemplate({ id: 42 });
+ (getTemplate as jest.Mock).mockResolvedValue(template);
+
+ const dispatched = await runFetchTemplate(42, makeMockState(), 'failed');
+
+ const savedAction = dispatched.find(
+ (a) => a.type === ETemplateActions.SetTemplateStatus && a.payload === ETemplateStatus.Saved,
+ );
+ expect(savedAction).toBeDefined();
+ });
+
+ it('blocks on take — Saved does not appear until catalog responds', async () => {
+ const template = makeTemplate({ id: 42 });
+ (getTemplate as jest.Mock).mockResolvedValue(template);
+
+ const channel = stdChannel();
+ const dispatched: IDispatchedAction[] = [];
+ const mockState = makeMockState();
+ const action: TLoadTemplate = { payload: 42, type: ETemplateActions.Load };
+
+ function* wrapper() {
+ yield call(fetchTemplate, action);
+ }
+
+ const saga = runSaga(
+ {
+ channel,
+ dispatch: (dispathedAction: IDispatchedAction) => {
+ dispatched.push(dispathedAction);
+ },
+ getState: () => mockState,
+ },
+ wrapper,
+ );
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ const savedBefore = dispatched.find(
+ (a) => a.type === ETemplateActions.SetTemplateStatus && a.payload === ETemplateStatus.Saved,
+ );
+ expect(savedBefore).toBeUndefined();
+
+ channel.put(loadFieldsetsCatalogSuccess([]));
+ await saga.toPromise();
+
+ const savedAfter = dispatched.find(
+ (a) => a.type === ETemplateActions.SetTemplateStatus && a.payload === ETemplateStatus.Saved,
+ );
+ expect(savedAfter).toBeDefined();
+ });
+ });
+});
+
+describe('patchTemplateSaga — fieldsets reference cleanup', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const makeTemplate = (overrides = {}) => ({
+ id: 42,
+ name: 'Test Template',
+ description: '',
+ isActive: false,
+ finalizable: false,
+ dateUpdated: null,
+ updatedBy: null,
+ tasks: [],
+ kickoff: { fields: [], fieldsets: [], description: '' },
+ isPublic: false,
+ publicUrl: null,
+ publicSuccessUrl: null,
+ isEmbedded: false,
+ embedUrl: null,
+ wfNameTemplate: null,
+ tasksCount: 0,
+ performersCount: 0,
+ owners: [],
+ viewers: [],
+ starters: [],
+ completionNotification: false,
+ reminderNotification: false,
+ ...overrides,
+ });
+
+ interface IDispatchedAction {
+ type: string;
+ payload?: unknown;
+ }
+
+ const makePatchMockState = (templateOverrides = {}) => ({
+ authUser: {
+ account: { isSubscribed: false, billingPlan: ESubscriptionPlan.Free },
+ },
+ accounts: { users: [] },
+ template: {
+ data: makeTemplate(templateOverrides),
+ },
+ fieldsets: {
+ isCatalogLoaded: true,
+ catalogAllFieldsets: [],
+ },
+ });
+
+ const runPatchTemplate = async (
+ changedFields: Partial,
+ stateOverrides = {},
+ ) => {
+ const channel = stdChannel();
+ const dispatched: IDispatchedAction[] = [];
+ const mockState = makePatchMockState(stateOverrides);
+
+ const saga = runSaga(
+ {
+ channel,
+ dispatch: (action: IDispatchedAction) => {
+ dispatched.push(action);
+ },
+ getState: () => mockState,
+ },
+ watchPatchTemplate,
+ );
+
+ channel.put({
+ type: ETemplateActions.PatchTemplate,
+ payload: { changedFields },
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ saga.cancel();
+
+ return dispatched;
+ };
+
+ it('calls cleanTemplateReferences when tasks change', async () => {
+ const newTasks = [{ uuid: 't1', name: 'Task 1', apiName: 'task-1' }];
+
+ await runPatchTemplate({ tasks: newTasks } as Partial);
+
+ expect(cleanTemplateReferences).toHaveBeenCalledTimes(1);
+ expect(cleanTemplateReferences).toHaveBeenCalledWith(
+ expect.objectContaining({ tasks: newTasks }),
+ );
+ });
+
+ it('calls cleanTemplateReferences when kickoff changes', async () => {
+ const newKickoff = { fields: [], fieldsets: [], description: 'new kickoff desc' };
+
+ await runPatchTemplate({ kickoff: newKickoff } as Partial);
+
+ expect(cleanTemplateReferences).toHaveBeenCalledTimes(1);
+ expect(cleanTemplateReferences).toHaveBeenCalledWith(
+ expect.objectContaining({ kickoff: newKickoff }),
+ );
+ });
+
+ it('does not call cleanTemplateReferences when only name changes', async () => {
+ await runPatchTemplate({ name: 'New Name' });
+
+ expect(cleanTemplateReferences).not.toHaveBeenCalled();
+ });
+});
+
+describe('fetchSaveTemplate — fieldsets in mapTemplateRequest', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const makeTemplate = (overrides = {}) => ({
+ id: 99,
+ name: 'Test Template',
+ description: '',
+ isActive: false,
+ finalizable: false,
+ dateUpdated: null,
+ updatedBy: null,
+ tasks: [],
+ kickoff: { fields: [], fieldsets: [], description: '' },
+ isPublic: false,
+ publicUrl: null,
+ publicSuccessUrl: null,
+ isEmbedded: false,
+ embedUrl: null,
+ wfNameTemplate: null,
+ tasksCount: 0,
+ performersCount: 0,
+ owners: [],
+ viewers: [],
+ starters: [],
+ completionNotification: false,
+ reminderNotification: false,
+ ...overrides,
+ });
+
+ interface IDispatchedAction {
+ type: string;
+ payload?: unknown;
+ }
+
+ const makeSaveMockState = () => ({
+ authUser: {
+ account: { isSubscribed: false, billingPlan: ESubscriptionPlan.Free },
+ },
+ accounts: { users: [] },
+ template: {
+ data: makeTemplate({ id: 99 }),
+ },
+ fieldsets: {
+ isCatalogLoaded: true,
+ catalogAllFieldsets: [],
+ },
+ });
+
+ it('passes template data to mapTemplateRequest', async () => {
+ const mockState = makeSaveMockState();
+ (checkSomeRouteIsActive as jest.Mock).mockReturnValue(true);
+ (mapTemplateRequest as jest.Mock).mockReturnValue({ id: 99, name: 'Test' });
+ (updateTemplate as jest.Mock).mockResolvedValue(makeTemplate({ id: 99 }));
+
+ const channel = stdChannel();
+ const dispatched: IDispatchedAction[] = [];
+
+ function* wrapper() {
+ yield call(watchSaveTemplate);
+ }
+
+ const saga = runSaga(
+ {
+ channel,
+ dispatch: (action: IDispatchedAction) => {
+ dispatched.push(action);
+ },
+ getState: () => mockState,
+ },
+ wrapper,
+ );
+
+ channel.put({
+ type: ETemplateActions.Save,
+ payload: {},
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ saga.cancel();
+
+ expect(mapTemplateRequest).toHaveBeenCalledTimes(1);
+ expect(mapTemplateRequest).toHaveBeenCalledWith(
+ mockState.template.data,
+ );
+ });
+});
diff --git a/frontend/src/public/redux/template/actions.ts b/frontend/src/public/redux/template/actions.ts
index 4c537e5ea..852b99652 100644
--- a/frontend/src/public/redux/template/actions.ts
+++ b/frontend/src/public/redux/template/actions.ts
@@ -2,7 +2,7 @@
/* prettier-ignore */
import { ETemplateStatus, ITypedReduxAction } from '../../types/redux';
import { actionGenerator } from '../../utils/redux';
-import { ITemplate, ITemplateTask, TAITemplateGenerationStatus } from '../../types/template';
+import { ITemplateClient, ITemplateTaskClient, TAITemplateGenerationStatus } from '../../types/template';
export const enum ETemplateActions {
Load = 'LOAD_TEMPLATE',
@@ -33,10 +33,10 @@ export const loadTemplate: (payload: number) => TLoadTemplate = actionGenerator<
ETemplateActions.Load,
);
-export type TSetTemplate = ITypedReduxAction;
-export const setTemplate: (payload: ITemplate) => TSetTemplate = actionGenerator<
+export type TSetTemplate = ITypedReduxAction;
+export const setTemplate: (payload: ITemplateClient) => TSetTemplate = actionGenerator<
ETemplateActions.SetTemplate,
- ITemplate
+ ITemplateClient
>(ETemplateActions.SetTemplate);
export type TLoadTemplateFromSystem = ITypedReduxAction;
@@ -94,7 +94,7 @@ export const setTemplateStatus: (payload: ETemplateStatus) => TSetTemplateStatus
>(ETemplateActions.SetTemplateStatus);
export type TPatchTemplatePayload = {
- changedFields: Partial;
+ changedFields: Partial;
onSuccess?(): void;
onFailed?(): void;
};
@@ -106,7 +106,7 @@ export const patchTemplate: (payload: TPatchTemplatePayload) => TPatchTemplate =
export type TPatchTaskPayload = {
taskUUID: string;
- changedFields: Partial;
+ changedFields: Partial;
};
export type TPatchTask = ITypedReduxAction;
export const patchTask: (payload: TPatchTaskPayload) => TPatchTask = actionGenerator<
@@ -140,7 +140,7 @@ export const setAITemplateGenerationStatus: (payload: TAITemplateGenerationStatu
TAITemplateGenerationStatus
>(ETemplateActions.SetAITemplateGenerationStatus);
-export type TSetAITemplateDataPayload = { template: ITemplate | null };
+export type TSetAITemplateDataPayload = { template: ITemplateClient | null };
export type TSetAITemplateData = ITypedReduxAction<
ETemplateActions.SetAITemplateData,
TSetAITemplateDataPayload
diff --git a/frontend/src/public/redux/template/reducer.ts b/frontend/src/public/redux/template/reducer.ts
index 625ca7980..df452590c 100644
--- a/frontend/src/public/redux/template/reducer.ts
+++ b/frontend/src/public/redux/template/reducer.ts
@@ -1,6 +1,6 @@
import produce from 'immer';
-import { ITemplate, ITemplateTask } from '../../types/template';
+import { ITemplateClient, ITemplateTaskClient } from '../../types/template';
import { ETemplateStatus, ITemplateStore } from '../../types/redux';
import { getEmptyKickoff } from '../../utils/template';
@@ -14,7 +14,7 @@ export const initialTemplate: ITemplateStore = {
finalizable: false,
dateUpdated: null,
updatedBy: null,
- tasks: [] as ITemplateTask[],
+ tasks: [] as ITemplateTaskClient[],
kickoff: getEmptyKickoff(),
isPublic: false,
publicUrl: null,
@@ -29,7 +29,7 @@ export const initialTemplate: ITemplateStore = {
starters: [],
completionNotification: false,
reminderNotification: false,
- } as ITemplate,
+ } as ITemplateClient,
status: ETemplateStatus.Saved,
AITemplate: {
isModalOpened: false,
diff --git a/frontend/src/public/redux/template/saga.ts b/frontend/src/public/redux/template/saga.ts
index 582cac366..1795a0f2a 100644
--- a/frontend/src/public/redux/template/saga.ts
+++ b/frontend/src/public/redux/template/saga.ts
@@ -50,7 +50,7 @@ import { getTemplateData } from '../selectors/template';
import { getTemplate } from '../../api/getTemplate';
import { getSystemTemplate } from '../../api/getSystemTemplate';
import { checkSomeRouteIsActive, history } from '../../utils/history';
-import { ITemplate, ITemplateRequest, ITemplateResponse } from '../../types/template';
+import { ITemplateClient, ITemplateRequest, ITemplateResponse } from '../../types/template';
import { logger } from '../../utils/logger';
import { NotificationManager } from '../../components/UI/Notifications';
import { updateTemplate } from '../../api/updateTemplate';
@@ -60,6 +60,9 @@ import { insertId } from '../../utils/templates/insertId';
import { ETemplateStatus } from '../../types/redux';
import { TUserListItem } from '../../types/user';
import { loadTemplateIntegrationsStats, loadTemplates } from '../actions';
+import { loadFieldsetsCatalog } from '../fieldsets/slice';
+import { loadFieldsetsCatalogSuccess, loadFieldsetsCatalogFailed } from '../fieldsets/slice';
+import { getIsCatalogLoaded, getFieldsetsCatalogIsLoading } from '../selectors/fieldsets';
import { copyTemplate } from '../../api/copyTemplate';
import { deleteTemplate } from '../../api/deleteTemplate';
import { setGeneralLoaderVisibility } from '../general/actions';
@@ -75,7 +78,7 @@ function* setTemplateByTemplateResponse(template: ITemplateResponse) {
yield put(setTemplate(normalizedTemplate));
}
-function* fetchTemplate({ payload: id }: TLoadTemplate) {
+export function* fetchTemplate({ payload: id }: TLoadTemplate) {
if (!Number.isInteger(id)) {
history.replace(ERoutes.Templates);
NotificationManager.warning({ message: 'template.not-found' });
@@ -86,8 +89,19 @@ function* fetchTemplate({ payload: id }: TLoadTemplate) {
try {
const template: ITemplateResponse = yield getTemplate(id);
yield setTemplateByTemplateResponse(template);
- yield put(setTemplateStatus(ETemplateStatus.Saved));
+ const isCatalogLoaded: ReturnType = yield select(getIsCatalogLoaded);
+ const isCatalogLoading: ReturnType = yield select(getFieldsetsCatalogIsLoading);
+
+ if (!isCatalogLoaded && !isCatalogLoading) {
+ yield put(loadFieldsetsCatalog());
+ }
+
+ if (!isCatalogLoaded) {
+ yield take([loadFieldsetsCatalogSuccess.type, loadFieldsetsCatalogFailed.type]);
+ }
+
+ yield put(setTemplateStatus(ETemplateStatus.Saved));
yield put(loadTemplateIntegrationsStats({ templates: [template.id] }));
} catch (error) {
logger.info('failed lo load template: ', error);
@@ -102,9 +116,9 @@ function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed } }:
yield put(setTemplateStatus(ETemplateStatus.Saving));
- const nonDeactivativeFields: (keyof ITemplate)[] = ['isActive', 'isPublic', 'publicUrl'];
+ const nonDeactivativeFields: (keyof ITemplateClient)[] = ['isActive', 'isPublic', 'publicUrl'];
let shouldDeactivateTemplate = Object.keys(changedFields).some(
- (key) => !nonDeactivativeFields.includes(key as keyof ITemplate),
+ (key) => !nonDeactivativeFields.includes(key as keyof ITemplateClient),
);
if (Object.keys(changedFields).length === 1 && changedFields.hasOwnProperty('kickoff')) {
@@ -112,7 +126,7 @@ function* patchTemplateSaga({ payload: { changedFields, onSuccess, onFailed } }:
changedFields.kickoff?.description === template.kickoff.description ? shouldDeactivateTemplate : false;
}
- const mergedTemplate: ITemplate = {
+ const mergedTemplate: ITemplateClient = {
...template,
...changedFields,
...(shouldDeactivateTemplate && { isActive: false }),
@@ -203,7 +217,7 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void) {
const editingTemplate: ReturnType = yield select(getTemplateData);
const templateRequest = mapTemplateRequest(editingTemplate);
- const savedTemplate: ITemplate | null = yield createOrUpdateTemplate(templateRequest, isSubscribed, users);
+ const savedTemplate: ITemplateClient | null = yield createOrUpdateTemplate(templateRequest, isSubscribed, users);
const lastTemplateState: ReturnType = yield select(getTemplateData);
if (!savedTemplate) {
@@ -216,7 +230,7 @@ function* fetchSaveTemplate(onSuccess?: () => void, onFailed?: () => void) {
const isTemplateCreated = !templateRequest.id;
- const newTemplateState: ITemplate = {
+ const newTemplateState: ITemplateClient = {
...insertId(lastTemplateState, savedTemplate),
updatedBy: savedTemplate.updatedBy,
dateUpdated: savedTemplate.dateUpdated,
diff --git a/frontend/src/public/redux/templates/__tests__/reducer.test.ts b/frontend/src/public/redux/templates/__tests__/reducer.test.ts
index d353aaf6c..480e5670c 100644
--- a/frontend/src/public/redux/templates/__tests__/reducer.test.ts
+++ b/frontend/src/public/redux/templates/__tests__/reducer.test.ts
@@ -1,5 +1,15 @@
import { INIT_STATE, reducer } from '../../templates/reducer';
-import { changeTemplatesList, loadTemplates, loadTemplatesFailed, TTemplatesActions } from '../../actions';
+import {
+ changeTemplatesList,
+ loadTemplates,
+ loadTemplatesFailed,
+ loadTemplateVariablesSuccess,
+ saveTemplateTasks,
+ TTemplatesActions,
+} from '../../actions';
+import { TTransformedTask } from '../../../types/template';
+import { TTaskVariable } from '../../../components/TemplateEdit/types';
+import { EExtraFieldType } from '../../../types/template';
describe('reducer', () => {
it('returns the initial state if no action was passed', () => {
@@ -33,7 +43,7 @@ describe('reducer', () => {
expect(result).toHaveProperty('isListLoading', false);
expect(result).toHaveProperty('templatesList', payload);
});
- it('adds a “loading” status to the store when fetching the template', () => {
+ it('adds a "loading" status to the store when fetching the template', () => {
const state = { ...INIT_STATE, isListLoading: false };
const action = loadTemplates(6);
@@ -41,7 +51,7 @@ describe('reducer', () => {
expect(result).toHaveProperty('isListLoading', true);
});
- it('removes the “loading” status from the store upon an error while fetching the template', () => {
+ it('removes the "loading" status from the store upon an error while fetching the template', () => {
const state = { ...INIT_STATE, isListLoading: true };
const action = loadTemplatesFailed();
@@ -50,3 +60,97 @@ describe('reducer', () => {
expect(result).toHaveProperty('isListLoading', false);
});
});
+
+describe('LoadTemplateVariablesSuccess — template variables merge', () => {
+ const makeVariable = (apiName: string, title = `Var ${apiName}`): TTaskVariable => ({
+ apiName,
+ title,
+ type: EExtraFieldType.String,
+ });
+
+ const TEMPLATE_ID = 42;
+
+ it('writes variables to an empty map on first call', () => {
+ const variables = [makeVariable('var-1'), makeVariable('var-2')];
+ const state = { ...INIT_STATE, templatesVariablesMap: {} };
+ const action = loadTemplateVariablesSuccess({ templateId: TEMPLATE_ID, variables });
+
+ const result = reducer(state, action);
+
+ expect(result.templatesVariablesMap[TEMPLATE_ID]).toHaveLength(2);
+ expect(result.templatesVariablesMap[TEMPLATE_ID]).toEqual(variables);
+ });
+
+ it('replaces variables with matching apiName and preserves unique old ones', () => {
+ const oldVariables = [makeVariable('var-1', 'Old V1'), makeVariable('var-2', 'Old V2')];
+ const newVariables = [makeVariable('var-1', 'New V1'), makeVariable('var-3', 'New V3')];
+
+ const state = {
+ ...INIT_STATE,
+ templatesVariablesMap: { [TEMPLATE_ID]: oldVariables },
+ };
+ const action = loadTemplateVariablesSuccess({ templateId: TEMPLATE_ID, variables: newVariables });
+
+ const result = reducer(state, action);
+
+ expect(result.templatesVariablesMap[TEMPLATE_ID]).toHaveLength(3);
+ expect(result.templatesVariablesMap[TEMPLATE_ID].find((v) => v.apiName === 'var-1')?.title)
+ .toBe('New V1');
+ expect(result.templatesVariablesMap[TEMPLATE_ID].find((v) => v.apiName === 'var-2')?.title)
+ .toBe('Old V2');
+ expect(result.templatesVariablesMap[TEMPLATE_ID].find((v) => v.apiName === 'var-3')?.title)
+ .toBe('New V3');
+ });
+
+ it('does not clear existing variables on pre-flush with empty array', () => {
+ const existingVariables = [makeVariable('var-1'), makeVariable('var-2')];
+ const state = {
+ ...INIT_STATE,
+ templatesVariablesMap: { [TEMPLATE_ID]: existingVariables },
+ };
+ const action = loadTemplateVariablesSuccess({ templateId: TEMPLATE_ID, variables: [] });
+
+ const result = reducer(state, action);
+
+ expect(result.templatesVariablesMap[TEMPLATE_ID]).toEqual(existingVariables);
+ });
+});
+
+describe('SaveTemplateTasks — transformedTasks with fieldsets', () => {
+ const TEMPLATE_ID = 42;
+
+ const makeTransformedTask = (apiName: string, name: string): TTransformedTask => ({
+ apiName,
+ name,
+ mergedOutputs: [],
+ });
+
+ it('writes transformedTasks to templatesTasksMap', () => {
+ const tasks = [makeTransformedTask('-2', 'System'), makeTransformedTask('task-1', 'Task One')];
+ const state = { ...INIT_STATE, templatesTasksMap: {} };
+ const action = saveTemplateTasks({ templateId: TEMPLATE_ID, transformedTasks: tasks });
+
+ const result = reducer(state, action);
+
+ expect(result.templatesTasksMap[TEMPLATE_ID]).toEqual(tasks);
+ });
+
+ it('overwrites transformedTasks on subsequent calls', () => {
+ const oldTasks = [makeTransformedTask('-2', 'System'), makeTransformedTask('task-1', 'Old Task')];
+ const newTasks = [
+ makeTransformedTask('-2', 'System'),
+ makeTransformedTask('task-1', 'Updated Task'),
+ makeTransformedTask('task-2', 'New Task'),
+ ];
+ const state = {
+ ...INIT_STATE,
+ templatesTasksMap: { [TEMPLATE_ID]: oldTasks },
+ };
+ const action = saveTemplateTasks({ templateId: TEMPLATE_ID, transformedTasks: newTasks });
+
+ const result = reducer(state, action);
+
+ expect(result.templatesTasksMap[TEMPLATE_ID]).toEqual(newTasks);
+ expect(result.templatesTasksMap[TEMPLATE_ID]).toHaveLength(3);
+ });
+});
diff --git a/frontend/src/public/redux/templates/__tests__/saga.test.ts b/frontend/src/public/redux/templates/__tests__/saga.test.ts
index 654232852..2f3b9a3d4 100644
--- a/frontend/src/public/redux/templates/__tests__/saga.test.ts
+++ b/frontend/src/public/redux/templates/__tests__/saga.test.ts
@@ -1,3 +1,15 @@
+import { runSaga, stdChannel } from 'redux-saga';
+import { call } from 'redux-saga/effects';
+
+import * as getTemplatesApi from '../../../api/getTemplates';
+import { IGetTemplatesResponsePaginated } from '../../../api/getTemplates';
+import { ETemplatesSorting } from '../../../types/workflow';
+import { LIMIT_LOAD_TEMPLATES } from '../../../constants/defaultValues';
+import { handleLoadTemplateVariables } from '../saga';
+import { getTemplateFields } from '../../../api/getTemplateFields';
+import { buildRuntimeMergedOutputParts } from '../../../components/TemplateEdit/TaskOutputFlow/mergeTaskOutputFlow';
+
+
jest.mock('../../../utils/getConfig', () => ({
getBrowserConfigEnv: jest.fn().mockReturnValue({
api: {
@@ -9,9 +21,37 @@ jest.mock('../../../utils/getConfig', () => ({
}),
}));
-import * as getTemplatesApi from '../../../api/getTemplates';
-import { ETemplatesSorting } from '../../../types/workflow';
-import { LIMIT_LOAD_TEMPLATES } from '../../../constants/defaultValues';
+jest.mock('../../../api/getTemplateFields', () => ({
+ getTemplateFields: jest.fn(),
+}));
+
+jest.mock('../../../components/TemplateEdit/TaskOutputFlow/mergeTaskOutputFlow', () => ({
+ buildRuntimeMergedOutputParts: jest.fn(() => []),
+}));
+
+jest.mock('../../../components/TemplateEdit/TaskForm/utils/getTaskVariables', () => ({
+ getVariables: jest.fn(() => []),
+}));
+
+jest.mock('../../../components/Workflows/WorkflowsTablePage/WorkflowsTable/constants', () => ({
+ SYSTEM_MERGED_OUTPUTS: [],
+}));
+
+jest.mock('../../../utils/isRequestCanceled', () => ({
+ isRequestCanceled: jest.fn(() => false),
+}));
+
+jest.mock('../../../utils/logger', () => ({
+ logger: { info: jest.fn() },
+}));
+
+jest.mock('../../../components/UI/Notifications', () => ({
+ NotificationManager: { notifyApiError: jest.fn() },
+}));
+
+jest.mock('../../../utils/getErrorMessage', () => ({
+ getErrorMessage: jest.fn(() => 'error'),
+}));
describe('templates saga', () => {
beforeEach(() => {
@@ -24,11 +64,13 @@ describe('templates saga', () => {
.spyOn(getTemplatesApi, 'getTemplatesByOwners')
.mockResolvedValue({
count: 2,
+ next: '',
+ previous: '',
results: [
{ id: 1, name: 'Template 1' },
{ id: 2, name: 'Template 2' },
],
- } as any);
+ } as IGetTemplatesResponsePaginated);
await getTemplatesApi.getTemplatesByOwners({
offset: 0,
@@ -47,7 +89,7 @@ describe('templates saga', () => {
const getTemplatesMock = jest.spyOn(getTemplatesApi, 'getTemplates');
const getTemplatesByOwnersMock = jest
.spyOn(getTemplatesApi, 'getTemplatesByOwners')
- .mockResolvedValue({ count: 0, results: [] } as any);
+ .mockResolvedValue({ count: 0, next: '', previous: '', results: [] });
await getTemplatesApi.getTemplatesByOwners({
offset: 0,
@@ -62,7 +104,7 @@ describe('templates saga', () => {
it('handles pagination offset correctly', async () => {
const getTemplatesByOwnersMock = jest
.spyOn(getTemplatesApi, 'getTemplatesByOwners')
- .mockResolvedValue({ count: 50, results: [] } as any);
+ .mockResolvedValue({ count: 50, next: '', previous: '', results: [] });
await getTemplatesApi.getTemplatesByOwners({
offset: 30,
@@ -80,7 +122,7 @@ describe('templates saga', () => {
it('passes sorting parameter correctly', async () => {
const getTemplatesByOwnersMock = jest
.spyOn(getTemplatesApi, 'getTemplatesByOwners')
- .mockResolvedValue({ count: 0, results: [] } as any);
+ .mockResolvedValue({ count: 0, next: '', previous: '', results: [] });
await getTemplatesApi.getTemplatesByOwners({
offset: 0,
@@ -100,7 +142,7 @@ describe('templates saga', () => {
it('uses getTemplates (not getTemplatesByOwners) to check ownership', async () => {
const getTemplatesMock = jest
.spyOn(getTemplatesApi, 'getTemplates')
- .mockResolvedValue({ count: 1, results: [{ id: 1 }] } as any);
+ .mockResolvedValue({ count: 1, next: '', previous: '', results: [{ id: 1 }] } as IGetTemplatesResponsePaginated);
await getTemplatesApi.getTemplates({
limit: 1,
@@ -114,3 +156,86 @@ describe('templates saga', () => {
});
});
});
+
+describe('handleLoadTemplateVariables — empty fieldsets filtering', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ interface IDispatchedAction {
+ type: string;
+ payload?: unknown;
+ }
+
+ const makeFieldset = (apiName: string, fieldsCount: number) => ({
+ name: `Fieldset ${apiName}`,
+ description: '',
+ apiName,
+ order: 0,
+ fields: Array.from({ length: fieldsCount }, (_, i) => ({
+ apiName: `${apiName}-field-${i}`,
+ name: `Field ${i}`,
+ type: 'string',
+ order: i,
+ isRequired: false,
+ })),
+ });
+
+ it('filters out fieldsets without fields when building transformedTasks', async () => {
+ const fullFieldset = makeFieldset('fs-full', 2);
+ const emptyFieldset = makeFieldset('fs-empty', 0);
+ const taskFieldset1 = makeFieldset('fs-task-1', 1);
+ const taskFieldset2 = makeFieldset('fs-task-2', 3);
+ const taskEmptyFieldset = makeFieldset('fs-task-empty', 0);
+
+ const apiResponse = {
+ kickoff: {
+ fields: [{ apiName: 'kf-1', name: 'KF1', type: 'string', order: 0, isRequired: false }],
+ fieldsets: [fullFieldset, emptyFieldset],
+ },
+ tasks: [{
+ id: 1,
+ name: 'Task One',
+ apiName: 'task-1',
+ fields: [{ apiName: 'tf-1', name: 'TF1', type: 'string', order: 0, isRequired: false }],
+ fieldsets: [taskFieldset1, taskFieldset2, taskEmptyFieldset],
+ }],
+ };
+
+ (getTemplateFields as jest.Mock).mockResolvedValue(apiResponse);
+
+ const channel = stdChannel();
+ const dispatched: IDispatchedAction[] = [];
+
+ function* wrapper() {
+ yield call(handleLoadTemplateVariables, 42);
+ }
+
+ const saga = runSaga(
+ {
+ channel,
+ dispatch: (action: IDispatchedAction) => {
+ dispatched.push(action);
+ },
+ getState: () => ({}),
+ },
+ wrapper,
+ );
+
+ await saga.toPromise();
+
+ expect(buildRuntimeMergedOutputParts).toHaveBeenCalledTimes(2);
+
+ expect(buildRuntimeMergedOutputParts).toHaveBeenNthCalledWith(
+ 1,
+ apiResponse.kickoff.fields,
+ [fullFieldset],
+ );
+
+ expect(buildRuntimeMergedOutputParts).toHaveBeenNthCalledWith(
+ 2,
+ apiResponse.tasks[0].fields,
+ [taskFieldset1, taskFieldset2],
+ );
+ });
+});
diff --git a/frontend/src/public/redux/templates/saga.ts b/frontend/src/public/redux/templates/saga.ts
index dac92c038..214f8415b 100644
--- a/frontend/src/public/redux/templates/saga.ts
+++ b/frontend/src/public/redux/templates/saga.ts
@@ -22,7 +22,8 @@ import {
} from '../actions';
import { getTemplatesSystem } from '../../api/getSystemTemplates';
import { getTemplatesIntegrationsStats } from '../../api/getTemplatesIntegrationsStats';
-import { TTemplateIntegrationStatsApi, TTransformedTask } from '../../types/template';
+import { IExtraField, TTemplateFieldFieldset, TTemplateIntegrationStatsApi, TTransformedTask } from '../../types/template';
+import { buildRuntimeMergedOutputParts } from '../../components/TemplateEdit/TaskOutputFlow/mergeTaskOutputFlow';
import { logger } from '../../utils/logger';
import { getErrorMessage } from '../../utils/getErrorMessage';
import { getTemplatesStore, getTemplatesSystemList } from '../selectors/templates';
@@ -35,7 +36,7 @@ import { isArrayWithItems } from '../../utils/helpers';
import { getTemplatesSystemCategories } from '../../api/getSystemTemplatesCategories';
import { ITemplatesSystemCategories } from '../../types/redux';
import { LIMIT_LOAD_SYSTEMS_TEMPLATES, LIMIT_LOAD_TEMPLATES, varibleIdRegex } from '../../constants/defaultValues';
-import { SYSTEM_FIELDS } from '../../components/Workflows/WorkflowsTablePage/WorkflowsTable/constants';
+import { SYSTEM_MERGED_OUTPUTS } from '../../components/Workflows/WorkflowsTablePage/WorkflowsTable/constants';
import { NotificationManager } from '../../components/UI/Notifications';
import { isRequestCanceled } from '../../utils/isRequestCanceled';
@@ -140,20 +141,27 @@ export function* handleLoadTemplateVariables(templateId: number) {
const variables = getVariables({ kickoff, tasks });
yield put(loadTemplateVariablesSuccess({ templateId, variables }));
+
+ const hasContent = (item: { fields: IExtraField[]; fieldsets: TTemplateFieldFieldset[] }) =>
+ item.fields.length > 0 || item.fieldsets.length > 0;
+
const transformedTasks: TTransformedTask[] = [
- ...[{ apiName: '-2', name: 'System', needSteName: null, fields: SYSTEM_FIELDS }],
- ...(kickoff.fields.length > 0 ? [{ apiName: '-1', name: 'Kick-off', fields: kickoff.fields }] : []),
+ ...[{ apiName: '-2', name: 'System', needSteName: null, mergedOutputs: SYSTEM_MERGED_OUTPUTS }],
+ ...(hasContent(kickoff)
+ ? [{
+ apiName: '-1',
+ name: 'Kick-off',
+ mergedOutputs: buildRuntimeMergedOutputParts(kickoff.fields, kickoff.fieldsets.filter((fieldset) => fieldset.fields.length > 0)),
+ }]
+ : []),
...tasks
- .filter((task) => task.fields.length > 0)
- .map((task) => {
- if (varibleIdRegex.test(task.name)) {
- return {
- ...task,
- needSteName: true,
- };
- }
- return task;
- }),
+ .filter(hasContent)
+ .map((task) => ({
+ apiName: task.apiName,
+ name: task.name,
+ ...(varibleIdRegex.test(task.name) && { needSteName: true }),
+ mergedOutputs: buildRuntimeMergedOutputParts(task.fields, task.fieldsets.filter((fieldset) => fieldset.fields.length > 0)),
+ })),
];
yield put(saveTemplateTasks({ templateId, transformedTasks }));
} catch (error) {
diff --git a/frontend/src/public/redux/workflows/__tests__/saga.test.ts b/frontend/src/public/redux/workflows/__tests__/saga.test.ts
index 5d8cdf98b..00637e73b 100644
--- a/frontend/src/public/redux/workflows/__tests__/saga.test.ts
+++ b/frontend/src/public/redux/workflows/__tests__/saga.test.ts
@@ -6,11 +6,74 @@ import {
watchReturnWorkflowToTask,
returnWorkflowToTaskSaga,
setWorkflowFinishedSaga,
+ fetchFilterSteps,
} from '../saga';
import * as deleteApi from '../../../api/deleteWorkflow';
import * as finishWorkflowApi from '../../../api/finishWorkflow';
import { NotificationManager } from '../../../components/UI/Notifications';
import { cloneWorkflowAction, deleteWorkflowAction, returnWorkflowToTaskAction, setWorkflowFinished } from '../slice';
+import { runSaga } from 'redux-saga';
+import { call } from 'redux-saga/effects';
+import { getWorkflow } from '../../../api/getWorkflow';
+import { getTemplate } from '../../../api/getTemplate';
+import {
+ loadDatasetsMap,
+ getRunnableWorkflow,
+} from '../../../components/TemplateEdit/utils/getRunnableWorkflow';
+import { mapTemplateFieldsetsToRuntime } from '../../../utils/mapTemplateFieldsetsToRuntime';
+import { getClonedKickoff } from '../../../components/Workflows/WorkflowsGridPage/WorkflowCard/utils/getClonedKickoff';
+import { getTemplateSteps } from '../../../api/getTemplateSteps';
+import { handleLoadTemplateVariables } from '../../templates/saga';
+import { IKickoff } from '../../../types/template';
+import { loadFilterSteps } from '../slice';
+import { makeFieldsetRuntime } from '../../../__stubs__/fieldsets.factory';
+import { makeTemplateResponse } from '../../../__stubs__/templates.factory';
+
+jest.mock('../../../api/getWorkflow', () => ({
+ getWorkflow: jest.fn(),
+}));
+
+jest.mock('../../../api/getTemplate', () => ({
+ getTemplate: jest.fn(),
+}));
+
+jest.mock('../../../components/TemplateEdit/utils/getRunnableWorkflow', () => ({
+ loadDatasetsMap: jest.fn(),
+ getRunnableWorkflow: jest.fn(),
+}));
+
+jest.mock('../../../utils/mapTemplateFieldsetsToRuntime', () => ({
+ mapTemplateFieldsetsToRuntime: jest.fn(),
+}));
+
+jest.mock('../../../components/Workflows/WorkflowsGridPage/WorkflowCard/utils/getClonedKickoff', () => ({
+ getClonedKickoff: jest.fn(),
+}));
+
+jest.mock('../../../api/getTemplateSteps', () => ({
+ getTemplateSteps: jest.fn(),
+}));
+
+jest.mock('../../templates/saga', () => ({
+ handleLoadTemplateVariables: jest.fn(function* () {}),
+}));
+
+jest.mock('../../../utils/dateTime', () => ({
+ formatDateToISOInWorkflow: jest.fn((x) => x),
+ toTspDate: jest.fn(),
+}));
+
+jest.mock('../../../utils/logger', () => ({
+ logger: { info: jest.fn(), error: jest.fn() },
+}));
+
+jest.mock('../../../utils/getErrorMessage', () => ({
+ getErrorMessage: jest.fn(() => 'error'),
+}));
+
+jest.mock('../../../utils/isRequestCanceled', () => ({
+ isRequestCanceled: jest.fn(() => false),
+}));
describe('workflows saga', () => {
it('deleteWorkflowSaga work', () => {
@@ -47,10 +110,127 @@ describe('workflows saga', () => {
[watchReturnWorkflowToTask, returnWorkflowToTaskAction.type, returnWorkflowToTaskSaga],
])(
'for the function %s, calls takeEvery with parameters %s and %s',
- (testingFn: any, action: any, expectedFn: any) => {
- const result = testingFn();
+ (testingFn, _action, _expectedFn) => {
+ const result = (testingFn as () => Generator)();
result.next();
},
);
});
});
+
+describe('cloneWorkflowSaga — fieldsets loading on clone', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ interface IDispatchedAction {
+ type: string;
+ payload?: unknown;
+ }
+
+ const mockKickoff: IKickoff = { fields: [], fieldsets: [], description: '' };
+
+ const mockTemplate = makeTemplateResponse({
+ id: 10,
+ name: 'Test Template',
+ kickoff: mockKickoff,
+ });
+
+ const mockWorkflow = {
+ id: 1,
+ name: 'Test WF',
+ kickoff: mockKickoff,
+ tasks: [],
+ status: 'running',
+ };
+
+ const mockLoadedFieldsets = [
+ makeFieldsetRuntime({ apiNameBinding: 'fs-1', name: 'Fieldset 1' }),
+ ];
+
+ const mockDatasetsMap: Record = {};
+
+ it('loads fieldsets, enriches datasets and passes to getRunnableWorkflow', async () => {
+ (getWorkflow as jest.Mock).mockResolvedValue(mockWorkflow);
+ (getTemplate as jest.Mock).mockResolvedValue(mockTemplate);
+ (mapTemplateFieldsetsToRuntime as jest.Mock).mockReturnValue({
+ normalizedTemplate: mockTemplate,
+ loadedFieldsets: mockLoadedFieldsets,
+ });
+ (loadDatasetsMap as jest.Mock).mockResolvedValue(mockDatasetsMap);
+ (getRunnableWorkflow as jest.Mock).mockReturnValue({
+ templateId: 10,
+ kickoff: mockKickoff,
+ });
+ (getClonedKickoff as jest.Mock).mockReturnValue(mockKickoff);
+
+ const dispatched: IDispatchedAction[] = [];
+
+ const action = cloneWorkflowAction({
+ workflowId: 1,
+ workflowName: 'Test WF',
+ templateId: 10,
+ });
+
+ function* wrapper() {
+ yield call(cloneWorkflowSaga, action);
+ }
+
+ await runSaga(
+ {
+ dispatch: (a: IDispatchedAction) => { dispatched.push(a); },
+ getState: () => ({}),
+ },
+ wrapper,
+ ).toPromise();
+
+ expect(mapTemplateFieldsetsToRuntime).toHaveBeenCalledTimes(1);
+ expect(mapTemplateFieldsetsToRuntime).toHaveBeenCalledWith(mockTemplate);
+
+ expect(loadDatasetsMap).toHaveBeenCalledTimes(1);
+ expect(loadDatasetsMap).toHaveBeenCalledWith(mockTemplate.kickoff, mockLoadedFieldsets);
+
+ expect(getRunnableWorkflow).toHaveBeenCalledTimes(1);
+ expect(getRunnableWorkflow).toHaveBeenCalledWith(mockTemplate, mockDatasetsMap, mockLoadedFieldsets);
+ });
+});
+
+describe('fetchFilterSteps — fieldsets variables loading for filters', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ interface IDispatchedAction {
+ type: string;
+ payload?: unknown;
+ }
+
+ it('calls handleLoadTemplateVariables with templateId', async () => {
+ const TEMPLATE_ID = 42;
+
+ (getTemplateSteps as jest.Mock).mockResolvedValue([
+ { apiName: 'step-1', name: 'Step 1' },
+ ]);
+
+ const dispatched: IDispatchedAction[] = [];
+
+ const action = loadFilterSteps({
+ templateId: TEMPLATE_ID,
+ });
+
+ function* wrapper() {
+ yield call(fetchFilterSteps, action);
+ }
+
+ await runSaga(
+ {
+ dispatch: (a: IDispatchedAction) => { dispatched.push(a); },
+ getState: () => ({}),
+ },
+ wrapper,
+ ).toPromise();
+
+ expect(handleLoadTemplateVariables).toHaveBeenCalledTimes(1);
+ expect(handleLoadTemplateVariables).toHaveBeenCalledWith(TEMPLATE_ID);
+ });
+});
diff --git a/frontend/src/public/redux/workflows/saga.ts b/frontend/src/public/redux/workflows/saga.ts
index d234cf70d..9a308a5e2 100644
--- a/frontend/src/public/redux/workflows/saga.ts
+++ b/frontend/src/public/redux/workflows/saga.ts
@@ -115,7 +115,7 @@ import { sendWorkflowComment } from '../../api/sendWorkflowComment';
import { finishWorkflow } from '../../api/finishWorkflow';
import { editWorkflow, IEditWorkflowResponse } from '../../api/editWorkflow';
import { getTemplatesTitles, TGetTemplatesTitlesResponse } from '../../api/getTemplatesTitles';
-import { IKickoff, ITemplateResponse, TTemplatePreset } from '../../types/template';
+import { IKickoffClient, ITemplateResponse, TTemplatePreset } from '../../types/template';
import { getWorkflowLogStore } from '../selectors/workflowLog';
import { deleteRemovedFilesFromFields } from '../../api/deleteRemovedFilesFromFields';
import { TChannelAction } from '../tasks/saga';
@@ -127,6 +127,7 @@ import { handleLoadTemplateVariables } from '../templates/saga';
import { deleteWorkflow } from '../../api/deleteWorkflow';
import { getTemplate } from '../../api/getTemplate';
import { getRunnableWorkflow, loadDatasetsMap } from '../../components/TemplateEdit/utils/getRunnableWorkflow';
+import { mapTemplateFieldsetsToRuntime } from '../../utils/mapTemplateFieldsetsToRuntime';
import { getClonedKickoff } from '../../components/Workflows/WorkflowsGridPage/WorkflowCard/utils/getClonedKickoff';
import { getWorkflowsCurrentPerformerCounters } from '../../api/getWorkflowsCurrentPerformerCounters';
import { getWorkflowsStartersCounters } from '../../api/getWorkflowsStartersCounters';
@@ -629,14 +630,19 @@ export function* cloneWorkflowSaga({
throw new Error('failed to prepare runnable workflow object');
}
- const datasetsMap: Record = yield call(loadDatasetsMap, template.kickoff);
+ const { normalizedTemplate, loadedFieldsets } = mapTemplateFieldsetsToRuntime(template);
+ const datasetsMap: Record = yield call(
+ loadDatasetsMap, normalizedTemplate.kickoff, loadedFieldsets,
+ );
- const runnableWorkflow = getRunnableWorkflow(template, datasetsMap);
+ const runnableWorkflow = getRunnableWorkflow(normalizedTemplate, datasetsMap, loadedFieldsets);
if (!runnableWorkflow) {
return;
}
- const kickoff: IKickoff = yield getClonedKickoff(formattedworkflowDetails.kickoff, template.kickoff);
+ const kickoff: IKickoffClient = yield getClonedKickoff(
+ formattedworkflowDetails.kickoff, normalizedTemplate.kickoff,
+ );
yield put(
openRunWorkflowModal({
diff --git a/frontend/src/public/redux/workflows/slice.ts b/frontend/src/public/redux/workflows/slice.ts
index 007305cc3..0b39f2a8f 100644
--- a/frontend/src/public/redux/workflows/slice.ts
+++ b/frontend/src/public/redux/workflows/slice.ts
@@ -13,7 +13,7 @@ import { IDeleteComment } from '../../api/workflows/deleteComment';
import { IEditComment } from '../../api/workflows/editComment';
import { IStoreWorkflows, IWorkflowLog, IWorkflowsList } from '../../types/redux';
-import { IKickoff, ITemplateTitleBaseWithCount, TTemplatePreset } from '../../types/template';
+import { IKickoffClient, ITemplateTitleBaseWithCount, TTemplatePreset } from '../../types/template';
import { ITemplateStep } from '../../types/tasks';
import {
EWorkflowsLoadingStatus,
@@ -64,7 +64,7 @@ export const initialWorkflowsFilters: IWorkflowsSettings['values'] = {
const initialWorkflowEdit = {
workflow: {
name: '',
- kickoff: { description: '', fields: [] } as IKickoff,
+ kickoff: { description: '', fields: [], fieldsets: [] } as IKickoffClient,
},
isWorkflowNameEditing: false,
isKickoffEditing: false,
diff --git a/frontend/src/public/redux/workflows/types.ts b/frontend/src/public/redux/workflows/types.ts
index 86490e515..8b3e75c66 100644
--- a/frontend/src/public/redux/workflows/types.ts
+++ b/frontend/src/public/redux/workflows/types.ts
@@ -1,7 +1,7 @@
import { TUploadedFile } from '../../utils/uploadFiles';
import { ITemplateStep } from '../../types/tasks';
-import { IKickoff, TOrderedFields } from '../../types/template';
+import { IKickoffClient, TOrderedFields } from '../../types/template';
import { EWorkflowsLogSorting, IWorkflowClient } from '../../types/workflow';
export type TOpenWorkflowLogPopupPayload = {
@@ -28,7 +28,7 @@ export type TRemoveWorkflowFromListPayload = {
export type TEditWorkflowPayload = {
typeChange?: string;
name?: string;
- kickoff?: IKickoff | null;
+ kickoff?: IKickoffClient | null;
isUrgent?: boolean;
dueDate?: string | null;
workflowId: number;
diff --git a/frontend/src/public/types/fieldset.ts b/frontend/src/public/types/fieldset.ts
new file mode 100644
index 000000000..a7043bae7
--- /dev/null
+++ b/frontend/src/public/types/fieldset.ts
@@ -0,0 +1,128 @@
+import type { IExtraField } from './template';
+
+export interface IFieldsetTemplateRule {
+ apiName: string;
+ type: string;
+ value: string | null;
+ fields: string[];
+}
+
+export interface IFieldsetField {
+ type: string;
+ name: string;
+ description?: string;
+ isRequired?: boolean;
+ isHidden?: boolean;
+ selections?: { apiName: string; value: string }[];
+ order: number;
+ apiName: string;
+ default?: string;
+ dataset?: number | null;
+}
+
+export enum EFieldLabelPosition {
+ Top = 'top',
+ Left = 'left',
+}
+export type TFieldSetLayout = 'horizontal' | 'vertical';
+
+export interface IFieldsetCatalogItem {
+ id: number;
+ apiName: string;
+ name: string;
+ description: string;
+ labelPosition: EFieldLabelPosition;
+ layout: TFieldSetLayout;
+ order: number;
+ title: string;
+ rules: IFieldsetTemplateRule[];
+ fields: IFieldsetField[];
+}
+
+export interface IFieldsetBinding extends Omit {
+ sharedFieldsetId: number;
+}
+
+export interface IFieldsetBindingClient extends Omit {
+ apiNameBinding: string;
+}
+
+export interface IFieldsetRuntime extends Omit {
+ fields: IExtraField[];
+}
+
+export interface IFieldsetTaskAPI {
+ id: number;
+ apiName: string;
+ name: string;
+ description: string;
+ order: number;
+ labelPosition: EFieldLabelPosition;
+ layout: TFieldSetLayout;
+ title: string;
+ fields: IExtraField[];
+}
+
+export interface IFieldsetBindingMeta {
+ sharedFieldsetId: number;
+ order: number;
+ apiName?: string;
+ title?: string;
+ description?: string;
+}
+
+export interface IGetFieldsetsResponse {
+ count: number;
+ next: string | null;
+ previous: string | null;
+ results: IFieldsetCatalogItem[];
+}
+
+export interface IGetFieldsetsParams {
+ ordering?: string;
+ limit?: number;
+ offset?: number;
+ signal?: AbortSignal;
+}
+
+export interface IGetFieldsetParams {
+ id: number;
+ signal?: AbortSignal;
+}
+
+export interface ICreateFieldsetParams {
+ name: string;
+ apiName?: string;
+ title?: string;
+ description?: string;
+ order?: number;
+ labelPosition?: EFieldLabelPosition;
+ layout?: TFieldSetLayout;
+ rules?: Omit[];
+ fields?: Omit[];
+}
+
+export interface IUpdateFieldsetParams {
+ id: number;
+ name?: string;
+ apiName?: string;
+ description?: string;
+ order?: number;
+ title?: string;
+ labelPosition?: EFieldLabelPosition;
+ layout?: TFieldSetLayout;
+ rules?: IFieldsetTemplateRule[];
+ fields?: IFieldsetField[];
+ signal?: AbortSignal;
+}
+
+export interface IDeleteFieldsetParams {
+ id: number;
+}
+
+export enum EFieldsetsSorting {
+ DateDesc = 'date-desc',
+ DateAsc = 'date-asc',
+ NameAsc = 'name-asc',
+ NameDesc = 'name-desc',
+}
diff --git a/frontend/src/public/types/highlights.ts b/frontend/src/public/types/highlights.ts
index 408577f36..e43b44ba3 100644
--- a/frontend/src/public/types/highlights.ts
+++ b/frontend/src/public/types/highlights.ts
@@ -1,6 +1,7 @@
import { EWorkflowLogEvent, EWorkflowStatus, IWorkflowDelay, IWorkflowDetailsKickoff } from './workflow';
import { TUserId } from './user';
import { IExtraField, ITemplateTitle } from './template';
+import { IFieldsetRuntime } from './fieldset';
export interface IHighlightsItem {
id: number;
@@ -12,6 +13,7 @@ export interface IHighlightsItem {
process: number;
delay: IWorkflowDelay | null;
output: IExtraField[];
+ fieldsets: IFieldsetRuntime[];
dueDate: string | null;
} | null;
text: string;
diff --git a/frontend/src/public/types/notifications.ts b/frontend/src/public/types/notifications.ts
index 021995b97..c140a7b54 100644
--- a/frontend/src/public/types/notifications.ts
+++ b/frontend/src/public/types/notifications.ts
@@ -134,6 +134,13 @@ export type TNotificationsListItemOptional =
name: string;
};
}
+ | {
+ type: 'complete_workflow';
+ workflow: {
+ id: number;
+ name: string;
+ };
+ }
| {
type: 'due_date_changed';
author: number;
diff --git a/frontend/src/public/types/redux.ts b/frontend/src/public/types/redux.ts
index 81d7300f7..a0f583e75 100644
--- a/frontend/src/public/types/redux.ts
+++ b/frontend/src/public/types/redux.ts
@@ -19,7 +19,7 @@ import { IHighlightsItem, EHighlightsDateFilter } from './highlights';
import { EDashboardTimeRange, IGettingStartedChecklist } from './dashboard';
import { EAuthUserFailType, ETaskStatus, ETemplatesSystemStatus } from '../redux/actions';
import {
- ITemplate,
+ ITemplateClient,
ITemplateListItem,
ISystemTemplate,
TTemplateIntegrationStats,
@@ -35,6 +35,7 @@ import { IMenuItem } from './menu';
import { EWebhooksTypeEvent, IWebhook } from './webhooks';
import { ETenantsSorting, ITenant } from './tenants';
import { IDataset, IDatasetListItem, EDatasetsSorting, TDatasetItemsSortOrder } from './dataset';
+import { IFieldsetCatalogItem, EFieldsetsSorting } from './fieldset';
import { IPagesStore } from '../redux/pages/types';
import { TeamPages, IGroup, UserInvite } from '../redux/team/types';
@@ -63,7 +64,8 @@ export interface IApplicationState {
team: ITeamStore;
groups: IGroupsStore;
tenants: ITenantsStore;
- datasets: IDatasetsStore;
+ datasets: IDatasetsStore;
+ fieldsets: IFieldsetsStore;
}
export enum ELoggedState {
@@ -302,12 +304,12 @@ export enum ETemplateStatus {
}
export interface ITemplateStore {
- data: ITemplate;
+ data: ITemplateClient;
status: ETemplateStatus;
AITemplate: {
isModalOpened: boolean;
generationStatus: TAITemplateGenerationStatus;
- generatedData: ITemplate | null;
+ generatedData: ITemplateClient | null;
};
}
@@ -391,6 +393,29 @@ export type IDatasetsStore = {
datasetsMap: Record;
};
+export interface IFieldsetsList {
+ count: number;
+ offset: number;
+ items: IFieldsetCatalogItem[];
+}
+
+export type IFieldsetsStore = {
+ fieldsetsList: IFieldsetsList;
+ isLoading: boolean;
+ searchQuery: string;
+ fieldsetsListSorting: EFieldsetsSorting;
+
+ isCreateModalOpen: boolean;
+ isEditModalOpen: boolean;
+
+ currentFieldset: IFieldsetCatalogItem | null;
+ isCurrentFieldsetLoading: boolean;
+
+ catalogAllFieldsets: IFieldsetCatalogItem[];
+ isCatalogLoading: boolean;
+ isCatalogLoaded: boolean;
+};
+
export interface IAction {
type: Type;
}
diff --git a/frontend/src/public/types/tasks.ts b/frontend/src/public/types/tasks.ts
index 5a7d8d397..d6385a6b0 100644
--- a/frontend/src/public/types/tasks.ts
+++ b/frontend/src/public/types/tasks.ts
@@ -1,5 +1,6 @@
import { IWorkflow, IWorkflowClient, WorkflowWithTsp } from './workflow';
import { IExtraField, ITemplateTitleBaseWithCount, RawPerformer } from './template';
+import { IFieldsetRuntime, IFieldsetTaskAPI } from './fieldset';
export type TTaskWorkflow = Pick & {
templateName: string;
@@ -21,6 +22,7 @@ export type TaskWithTsp = Omit & TaskWithTspFiel
export type TFormatTaskDates = {
output?: IExtraField[];
+ fieldsets?: IFieldsetRuntime[];
subWorkflows?: WorkflowWithTsp[] | null;
dueDateTsp?: number | null;
dateStartedTsp?: number;
@@ -45,6 +47,7 @@ export interface ITask {
id: number;
name: string;
output: IExtraField[];
+ fieldsets?: IFieldsetRuntime[];
description: string | null;
workflow: TTaskWorkflow;
performers: RawPerformer[];
@@ -89,7 +92,7 @@ export type TTaskChecklistsItem = {
};
export interface ITaskAPI
- extends Omit {
+ extends Omit {
checklists: {
id: number;
apiName: string;
@@ -99,6 +102,7 @@ export interface ITaskAPI
isSelected: boolean;
}[];
}[];
+ fieldsets: IFieldsetTaskAPI[];
dueDateTsp: number | null;
dateStartedTsp: number;
dateCompletedTsp: number | null;
diff --git a/frontend/src/public/types/template.ts b/frontend/src/public/types/template.ts
index 64946397e..d3abbcf7f 100644
--- a/frontend/src/public/types/template.ts
+++ b/frontend/src/public/types/template.ts
@@ -7,6 +7,9 @@ import {
} from '../components/TemplateEdit/TaskForm/Conditions';
import { TUploadedFile } from '../utils/uploadFiles';
import { TSystemField } from '../components/Workflows/WorkflowsTablePage/WorkflowsTable/types';
+import { IFieldsetBinding, IFieldsetBindingClient, IFieldsetBindingMeta, IFieldsetRuntime } from './fieldset';
+
+export type { IFieldsetBindingClient } from './fieldset';
export interface ITemplate {
id?: number;
@@ -31,6 +34,11 @@ export interface ITemplate {
performersCount: number;
}
+export interface ITemplateClient extends Omit {
+ tasks: ITemplateTaskClient[];
+ kickoff: IKickoffClient;
+}
+
export enum ETemplateOwnerRole {
Owner = 'owner',
Viewer = 'viewer',
@@ -47,10 +55,22 @@ export interface ITemplateOwner {
export type ITemplateViewer = ITemplateOwner;
export type ITemplateStarter = ITemplateOwner;
-export type TTransformedTask =
- | { apiName: string; name: string; needSteName: null; fields: TSystemField[] }
- | (Pick & { needSteName?: boolean })
- | (Pick & { apiName: string; name: string; needSteName: null });
+export type TRuntimeMergedOutputPart =
+ | { kind: 'field'; field: IExtraField }
+ | { kind: 'fieldset'; data: IFieldsetRuntime | TTemplateFieldFieldset }
+ | { kind: 'system'; field: TSystemField };
+
+export type TTransformedTask = {
+ apiName: string;
+ name: string;
+ needSteName?: boolean | null;
+ mergedOutputs: TRuntimeMergedOutputPart[];
+};
+
+
+export type TTemplateFieldFieldset = Pick<
+ IFieldsetRuntime, 'name' | 'description' | 'apiNameBinding' | 'fields' | 'order' | 'labelPosition'
+>;
export interface ITemplateTask {
id?: number;
@@ -64,6 +84,7 @@ export interface ITemplateTask {
skipForStarter: boolean;
rawPerformers: ITemplateTaskPerformer[];
fields: IExtraField[];
+ fieldsets: IFieldsetBinding[];
uuid: string;
conditions: ICondition[];
checklists: TOutputChecklist[];
@@ -71,6 +92,10 @@ export interface ITemplateTask {
ancestors: string[];
}
+export interface ITemplateTaskClient extends Omit {
+ fieldsets: IFieldsetBindingClient[];
+}
+
export type TDueDateRuleTarget = 'field' | 'workflow started' | 'task started' | 'task completed';
export type TDueDateRulePreposition = 'before' | 'after';
type IDueDateRuleAPI =
@@ -125,27 +150,35 @@ export enum ETaskPerformerType {
Manager = 'manager',
}
-export interface ITemplateResponse extends Omit {
+export interface ITemplateResponse extends Omit {
id: number;
tasks: ITemplateTaskResponse[];
+ kickoff: Omit & { fieldsets: IFieldsetBinding[] };
}
export interface ITemplateTaskResponse
- extends Omit {
+ extends Omit {
id: number;
conditions: IConditionResponse[];
rawDueDate: IDueDateAPI | null;
dueIn?: string | null; // deprecated
apiName?: string;
+ fieldsets: IFieldsetBinding[];
}
-export interface ITemplateRequest extends Omit {
+export interface ITemplateRequest extends Omit {
tasks: ITemplateTaskRequest[];
+ kickoff: ITemplateKickoffRequest;
}
-export interface ITemplateTaskRequest extends Omit {
+export interface ITemplateTaskRequest extends Omit {
conditions: IConditionResponse[];
rawDueDate: IDueDateAPI | null;
+ fieldsets: IFieldsetBindingMeta[];
+}
+
+export interface ITemplateKickoffRequest extends Omit {
+ fieldsets: IFieldsetBindingMeta[];
}
export interface IConditionResponse {
@@ -177,9 +210,51 @@ export type TConditionRulePredicateResponse = {
operator: EConditionOperators;
} & TConditionPredicateValue;
+
+/** Fieldset template object from list API response (camelCased by commonRequest) */
+export interface IFieldsetTemplateData {
+ id: number;
+ name: string;
+ description: string;
+ rules: { id: number; type: string; value: string }[];
+ fields: {
+ type: string;
+ name: string;
+ description?: string;
+ isRequired?: boolean;
+ isHidden?: boolean;
+ selections?: string[];
+ dataset?: number | null;
+ order: number;
+ apiName: string;
+ default?: string;
+ }[];
+}
+
export interface IKickoff {
description: string;
fields: IExtraField[];
+ fieldsets: IFieldsetBinding[];
+}
+
+export interface IKickoffClient extends Omit {
+ fieldsets: IFieldsetBindingClient[];
+}
+
+/** Kickoff shape from template list APIs (GET /templates/, GET /templates/titles-by-owners) */
+export interface IKickoffList {
+ fields: {
+ name: string;
+ type: string;
+ isRequired?: boolean;
+ isHidden?: boolean;
+ description?: string;
+ apiName: string;
+ selections?: string[];
+ dataset?: number | null;
+ order: number;
+ }[];
+ fieldsets: IFieldsetTemplateData[];
}
export interface ITemplateListItem {
@@ -192,7 +267,7 @@ export interface ITemplateListItem {
tasksCount: number;
performersCount: number;
owners: number[];
- kickoff: IKickoff | null;
+ kickoff: IKickoffList | null;
isEditable: boolean;
}
@@ -324,6 +399,7 @@ export type TAddTemplatePreset = Omit {
+ extends Pick {
number: number;
delay: IWorkflowDelay | null;
}
diff --git a/frontend/src/public/utils/__tests__/getSortedAndFilteredDatasetItems.test.ts b/frontend/src/public/utils/__tests__/getSortedAndFilteredDatasetItems.test.ts
index bf5e74282..436b01584 100644
--- a/frontend/src/public/utils/__tests__/getSortedAndFilteredDatasetItems.test.ts
+++ b/frontend/src/public/utils/__tests__/getSortedAndFilteredDatasetItems.test.ts
@@ -1,4 +1,3 @@
-//
import { getSortedAndFilteredDatasetItems } from '../dataset';
import { EDatasetsSorting, IDatasetItem } from '../../types/dataset';
diff --git a/frontend/src/public/utils/__tests__/mapFieldsetBindingClientToRuntime.test.ts b/frontend/src/public/utils/__tests__/mapFieldsetBindingClientToRuntime.test.ts
new file mode 100644
index 000000000..20e5979e1
--- /dev/null
+++ b/frontend/src/public/utils/__tests__/mapFieldsetBindingClientToRuntime.test.ts
@@ -0,0 +1,148 @@
+import { mapFieldsToExtraFields, mapFieldsetBindingClientToRuntime } from '../mapFieldsetBindingClientToRuntime';
+import { makeFieldsetField, makeFieldsetBindingClient } from '../../__stubs__/fieldsets.factory';
+import { EFieldLabelPosition } from '../../types/fieldset';
+import { EExtraFieldType } from '../../types/template';
+
+describe('mapFieldsToExtraFields', () => {
+ it('returns an empty array when given an empty array', () => {
+ expect(mapFieldsToExtraFields([])).toEqual([]);
+ });
+
+ it('maps all IFieldsetField properties to IExtraField', () => {
+ const field = makeFieldsetField({
+ apiName: 'contact-email',
+ name: 'Email',
+ description: 'User email',
+ type: 'string',
+ isRequired: true,
+ isHidden: false,
+ order: 3,
+ default: 'test@example.com',
+ selections: [{ apiName: 'sel-1', value: 'Option A' }],
+ dataset: 42,
+ });
+
+ const [result] = mapFieldsToExtraFields([field]);
+
+ expect(result).toEqual({
+ apiName: 'contact-email',
+ name: 'Email',
+ description: 'User email',
+ type: 'string',
+ isRequired: true,
+ isHidden: false,
+ order: 3,
+ value: 'test@example.com',
+ selections: [{ apiName: 'sel-1', value: 'Option A' }],
+ dataset: 42,
+ userId: null,
+ groupId: null,
+ });
+ });
+
+ it('applies default values for missing or falsy properties', () => {
+ const field = makeFieldsetField({
+ apiName: '',
+ name: '',
+ description: undefined,
+ type: '',
+ isRequired: undefined,
+ isHidden: undefined,
+ order: undefined,
+ default: undefined,
+ selections: undefined,
+ dataset: undefined,
+ });
+
+ const [result] = mapFieldsToExtraFields([field]);
+
+ expect(result.apiName).toBe('');
+ expect(result.name).toBe('');
+ expect(result.description).toBe('');
+ expect(result.type).toBe(EExtraFieldType.String);
+ expect(result.isRequired).toBe(false);
+ expect(result.isHidden).toBe(false);
+ expect(result.value).toBe('');
+ expect(result.selections).toEqual([]);
+ expect(result.dataset).toBeNull();
+ expect(result.userId).toBeNull();
+ expect(result.groupId).toBeNull();
+ });
+
+ it('uses index as order when order is undefined', () => {
+ const fields = [
+ makeFieldsetField({ apiName: 'a', order: undefined }),
+ makeFieldsetField({ apiName: 'b', order: undefined }),
+ ];
+
+ const results = mapFieldsToExtraFields(fields);
+
+ expect(results[0].order).toBe(0);
+ expect(results[1].order).toBe(1);
+ });
+
+ it('preserves explicit order=0 without replacing with index', () => {
+ const field = makeFieldsetField({ order: 0 });
+ const [result] = mapFieldsToExtraFields([field]);
+ expect(result.order).toBe(0);
+ });
+});
+
+describe('mapFieldsetBindingClientToRuntime', () => {
+ it('maps a binding client to runtime with converted fields', () => {
+ const binding = makeFieldsetBindingClient({
+ apiNameBinding: 'fs-contacts',
+ name: 'Contacts',
+ description: 'Contact info',
+ order: 2,
+ labelPosition: EFieldLabelPosition.Top,
+ layout: 'horizontal',
+ title: 'Contact Details',
+ fields: [makeFieldsetField({ apiName: 'email', name: 'Email', type: 'string' })],
+ });
+
+ const result = mapFieldsetBindingClientToRuntime(binding);
+
+ expect(result.apiNameBinding).toBe('fs-contacts');
+ expect(result.name).toBe('Contacts');
+ expect(result.description).toBe('Contact info');
+ expect(result.order).toBe(2);
+ expect(result.labelPosition).toBe(EFieldLabelPosition.Top);
+ expect(result.layout).toBe('horizontal');
+ expect(result.title).toBe('Contact Details');
+ expect(result.fields).toHaveLength(1);
+ expect(result.fields[0].apiName).toBe('email');
+ expect(result.fields[0].type).toBe(EExtraFieldType.String);
+ });
+
+ it('normalizes labelPosition=Left to EFieldLabelPosition.Left', () => {
+ const binding = makeFieldsetBindingClient({ labelPosition: EFieldLabelPosition.Left });
+ const result = mapFieldsetBindingClientToRuntime(binding);
+ expect(result.labelPosition).toBe(EFieldLabelPosition.Left);
+ });
+
+ it('normalizes any non-Left labelPosition to EFieldLabelPosition.Top', () => {
+ const binding = makeFieldsetBindingClient({ labelPosition: EFieldLabelPosition.Top });
+ const result = mapFieldsetBindingClientToRuntime(binding);
+ expect(result.labelPosition).toBe(EFieldLabelPosition.Top);
+ });
+
+ it('defaults order to 0 when undefined', () => {
+ const binding = makeFieldsetBindingClient({ order: undefined });
+ const result = mapFieldsetBindingClientToRuntime(binding);
+ expect(result.order).toBe(0);
+ });
+
+ it('defaults name and description to empty string when falsy', () => {
+ const binding = makeFieldsetBindingClient({ name: '', description: '' });
+ const result = mapFieldsetBindingClientToRuntime(binding);
+ expect(result.name).toBe('');
+ expect(result.description).toBe('');
+ });
+
+ it('handles empty fields array', () => {
+ const binding = makeFieldsetBindingClient({ fields: [] });
+ const result = mapFieldsetBindingClientToRuntime(binding);
+ expect(result.fields).toEqual([]);
+ });
+});
diff --git a/frontend/src/public/utils/__tests__/mapFieldsetsAPIToClient.test.ts b/frontend/src/public/utils/__tests__/mapFieldsetsAPIToClient.test.ts
new file mode 100644
index 000000000..f858bd7c6
--- /dev/null
+++ b/frontend/src/public/utils/__tests__/mapFieldsetsAPIToClient.test.ts
@@ -0,0 +1,116 @@
+import { mapFieldsetBindingsToClient, mapFieldsetTaskAPIToRuntime } from '../mapFieldsetsAPIToClient';
+import { EFieldLabelPosition } from '../../types/fieldset';
+import { makeFieldsetField, makeFieldsetBinding, makeFieldsetTaskAPI } from '../../__stubs__/fieldsets.factory';
+import { makeExtraField } from '../../__stubs__/fields.factory';
+
+describe('mapFieldsetBindingsToClient', () => {
+ it('returns an empty array for empty input', () => {
+ expect(mapFieldsetBindingsToClient([])).toEqual([]);
+ });
+
+ it('renames apiName to apiNameBinding and drops apiName', () => {
+ const result = mapFieldsetBindingsToClient([makeFieldsetBinding({ apiName: 'catalog-abc' })]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].apiNameBinding).toBe('catalog-abc');
+ expect(result[0]).not.toHaveProperty('apiName');
+ });
+
+ it('preserves all other properties', () => {
+ const fields = [makeFieldsetField({ apiName: 'f-1', name: 'Field 1' })];
+ const binding = makeFieldsetBinding({
+ apiName: 'my-fs',
+ sharedFieldsetId: 42,
+ name: 'My Fieldset',
+ description: 'desc',
+ labelPosition: EFieldLabelPosition.Left,
+ layout: 'horizontal',
+ order: 5,
+ title: 'Title',
+ rules: [{ apiName: 'r-1', type: 'sum_equal', value: '100', fields: [] }],
+ fields,
+ });
+
+ const [result] = mapFieldsetBindingsToClient([binding]);
+
+ expect(result.apiNameBinding).toBe('my-fs');
+ expect(result.sharedFieldsetId).toBe(42);
+ expect(result.name).toBe('My Fieldset');
+ expect(result.description).toBe('desc');
+ expect(result.labelPosition).toBe(EFieldLabelPosition.Left);
+ expect(result.layout).toBe('horizontal');
+ expect(result.order).toBe(5);
+ expect(result.title).toBe('Title');
+ expect(result.rules).toHaveLength(1);
+ expect(result.fields).toHaveLength(1);
+ });
+
+ it('maps multiple bindings', () => {
+ const bindings = [
+ makeFieldsetBinding({ apiName: 'fs-a' }),
+ makeFieldsetBinding({ apiName: 'fs-b' }),
+ ];
+
+ const results = mapFieldsetBindingsToClient(bindings);
+
+ expect(results).toHaveLength(2);
+ expect(results[0].apiNameBinding).toBe('fs-a');
+ expect(results[1].apiNameBinding).toBe('fs-b');
+ });
+});
+
+describe('mapFieldsetTaskAPIToRuntime', () => {
+ it('returns an empty array for empty input', () => {
+ expect(mapFieldsetTaskAPIToRuntime([])).toEqual([]);
+ });
+
+ it('renames apiName to apiNameBinding and drops both id and apiName', () => {
+ const result = mapFieldsetTaskAPIToRuntime([
+ makeFieldsetTaskAPI({ id: 999, apiName: 'task-xyz' }),
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].apiNameBinding).toBe('task-xyz');
+ expect(result[0]).not.toHaveProperty('id');
+ expect(result[0]).not.toHaveProperty('apiName');
+ });
+
+ it('preserves all other properties', () => {
+ const fields = [makeExtraField({ apiName: 'ef-1', name: 'Extra' })];
+ const taskFs = makeFieldsetTaskAPI({
+ id: 50,
+ apiName: 'bound-fs',
+ name: 'Task FS',
+ description: 'task desc',
+ order: 3,
+ labelPosition: EFieldLabelPosition.Left,
+ layout: 'horizontal',
+ title: 'Task Title',
+ fields,
+ });
+
+ const [result] = mapFieldsetTaskAPIToRuntime([taskFs]);
+
+ expect(result.apiNameBinding).toBe('bound-fs');
+ expect(result.name).toBe('Task FS');
+ expect(result.description).toBe('task desc');
+ expect(result.order).toBe(3);
+ expect(result.labelPosition).toBe(EFieldLabelPosition.Left);
+ expect(result.layout).toBe('horizontal');
+ expect(result.title).toBe('Task Title');
+ expect(result.fields).toHaveLength(1);
+ });
+
+ it('maps multiple task fieldsets', () => {
+ const fieldsets = [
+ makeFieldsetTaskAPI({ id: 1, apiName: 'fs-a' }),
+ makeFieldsetTaskAPI({ id: 2, apiName: 'fs-b' }),
+ ];
+
+ const results = mapFieldsetTaskAPIToRuntime(fieldsets);
+
+ expect(results).toHaveLength(2);
+ expect(results[0].apiNameBinding).toBe('fs-a');
+ expect(results[1].apiNameBinding).toBe('fs-b');
+ });
+});
diff --git a/frontend/src/public/utils/__tests__/mapOutputToCompleteTask.test.ts b/frontend/src/public/utils/__tests__/mapOutputToCompleteTask.test.ts
index ee9b0e931..34a05a711 100644
--- a/frontend/src/public/utils/__tests__/mapOutputToCompleteTask.test.ts
+++ b/frontend/src/public/utils/__tests__/mapOutputToCompleteTask.test.ts
@@ -1,5 +1,5 @@
-///
import { mapOutputToCompleteTask } from '../mappers';
+import { makeExtraField } from '../../__stubs__/fields.factory';
import { EExtraFieldType, IExtraField } from '../../types/template';
jest.mock('../dateTime', () => ({
@@ -11,13 +11,8 @@ jest.mock('../dateTime', () => ({
formatDateToISOInTask: jest.fn(),
}));
-const makeField = (overrides: Partial): IExtraField => ({
- apiName: 'field-1',
+const makeField = (overrides: Partial) => makeExtraField({
name: 'Test',
- type: EExtraFieldType.String,
- order: 0,
- userId: null,
- groupId: null,
...overrides,
});
diff --git a/frontend/src/public/utils/__tests__/mapTemplateFieldsetsToRuntime.test.ts b/frontend/src/public/utils/__tests__/mapTemplateFieldsetsToRuntime.test.ts
new file mode 100644
index 000000000..180aa9049
--- /dev/null
+++ b/frontend/src/public/utils/__tests__/mapTemplateFieldsetsToRuntime.test.ts
@@ -0,0 +1,84 @@
+import { mapTemplateFieldsetsToRuntime } from '../mapTemplateFieldsetsToRuntime';
+import { makeFieldsetBinding } from '../../__stubs__/fieldsets.factory';
+import { makeTemplateResponse } from '../../__stubs__/templates.factory';
+
+describe('mapTemplateFieldsetsToRuntime', () => {
+ it('returns empty fieldsets when kickoff has no fieldsets', () => {
+ const template = makeTemplateResponse();
+ const { normalizedTemplate, loadedFieldsets } = mapTemplateFieldsetsToRuntime(template);
+
+ expect(loadedFieldsets).toEqual([]);
+ expect(normalizedTemplate.kickoff.fieldsets).toEqual([]);
+ });
+
+ it('returns normalizedTemplate with apiNameBinding in kickoff fieldsets', () => {
+ const template = makeTemplateResponse({
+ kickoff: {
+ description: '',
+ fields: [],
+ fieldsets: [makeFieldsetBinding({ apiName: 'bind-abc', sharedFieldsetId: 42, name: 'Contacts' })],
+ },
+ });
+
+ const { normalizedTemplate } = mapTemplateFieldsetsToRuntime(template);
+
+ expect(normalizedTemplate.kickoff.fieldsets).toHaveLength(1);
+ expect(normalizedTemplate.kickoff.fieldsets[0].apiNameBinding).toBe('bind-abc');
+ expect(normalizedTemplate.kickoff.fieldsets[0]).not.toHaveProperty('apiName');
+ expect(normalizedTemplate.kickoff.fieldsets[0].sharedFieldsetId).toBe(42);
+ });
+
+ it('returns loadedFieldsets as IFieldsetRuntime with apiNameBinding', () => {
+ const template = makeTemplateResponse({
+ kickoff: {
+ description: '',
+ fields: [],
+ fieldsets: [makeFieldsetBinding({ apiName: 'bind-xyz', name: 'Addresses' })],
+ },
+ });
+
+ const { loadedFieldsets } = mapTemplateFieldsetsToRuntime(template);
+
+ expect(loadedFieldsets).toHaveLength(1);
+ expect(loadedFieldsets[0].apiNameBinding).toBe('bind-xyz');
+ expect(loadedFieldsets[0].name).toBe('Addresses');
+ expect(loadedFieldsets[0]).not.toHaveProperty('apiName');
+ expect(loadedFieldsets[0]).not.toHaveProperty('sharedFieldsetId');
+ });
+
+ it('preserves other template properties unchanged', () => {
+ const template = makeTemplateResponse({
+ kickoff: { description: '', fields: [], fieldsets: [makeFieldsetBinding()] },
+ });
+ const { normalizedTemplate } = mapTemplateFieldsetsToRuntime(template);
+
+ expect(normalizedTemplate.id).toBe(1);
+ expect(normalizedTemplate.name).toBe('Template');
+ expect(normalizedTemplate.kickoff.description).toBe('');
+ expect(normalizedTemplate.kickoff.fields).toEqual([]);
+ expect(normalizedTemplate.tasks).toEqual([]);
+ });
+
+ it('handles multiple fieldsets correctly', () => {
+ const template = makeTemplateResponse({
+ kickoff: {
+ description: '',
+ fields: [],
+ fieldsets: [
+ makeFieldsetBinding({ apiName: 'fs-a', name: 'A', order: 0 }),
+ makeFieldsetBinding({ apiName: 'fs-b', name: 'B', order: 1 }),
+ ],
+ },
+ });
+
+ const { normalizedTemplate, loadedFieldsets } = mapTemplateFieldsetsToRuntime(template);
+
+ expect(normalizedTemplate.kickoff.fieldsets).toHaveLength(2);
+ expect(normalizedTemplate.kickoff.fieldsets[0].apiNameBinding).toBe('fs-a');
+ expect(normalizedTemplate.kickoff.fieldsets[1].apiNameBinding).toBe('fs-b');
+
+ expect(loadedFieldsets).toHaveLength(2);
+ expect(loadedFieldsets[0].apiNameBinding).toBe('fs-a');
+ expect(loadedFieldsets[1].apiNameBinding).toBe('fs-b');
+ });
+});
diff --git a/frontend/src/public/utils/__tests__/tasks.test.ts b/frontend/src/public/utils/__tests__/tasks.test.ts
new file mode 100644
index 000000000..d8d3c0976
--- /dev/null
+++ b/frontend/src/public/utils/__tests__/tasks.test.ts
@@ -0,0 +1,59 @@
+import { getNormalizedTask } from '../tasks';
+import { EFieldLabelPosition } from '../../types/fieldset';
+import { makeExtraField } from '../../__stubs__/fields.factory';
+import { makeFieldsetTaskAPI } from '../../__stubs__/fieldsets.factory';
+import { makeTaskAPI } from '../../__stubs__/tasks.factory';
+
+describe('getNormalizedTask', () => {
+ describe('fieldset normalization', () => {
+ it('maps IFieldsetTaskAPI[] to IFieldsetRuntime[] via mapFieldsetTaskAPIToRuntime', () => {
+ const task = makeTaskAPI({
+ fieldsets: [
+ makeFieldsetTaskAPI({ id: 50, apiName: 'fs-binding-abc', name: 'Budget' }),
+ ],
+ });
+
+ const result = getNormalizedTask(task);
+
+ expect(result.fieldsets).toHaveLength(1);
+ expect(result.fieldsets[0].apiNameBinding).toBe('fs-binding-abc');
+ expect(result.fieldsets[0].name).toBe('Budget');
+ expect(result.fieldsets[0]).not.toHaveProperty('id');
+ expect(result.fieldsets[0]).not.toHaveProperty('apiName');
+ });
+
+ it('maps multiple fieldsets preserving order and properties', () => {
+ const task = makeTaskAPI({
+ fieldsets: [
+ makeFieldsetTaskAPI({ id: 1, apiName: 'fs-a', name: 'A', order: 0 }),
+ makeFieldsetTaskAPI({
+ id: 2,
+ apiName: 'fs-b',
+ name: 'B',
+ order: 5,
+ labelPosition: EFieldLabelPosition.Left,
+ layout: 'horizontal',
+ fields: [makeExtraField({ apiName: 'ef-1', name: 'Amount' })],
+ }),
+ ],
+ });
+
+ const result = getNormalizedTask(task);
+
+ expect(result.fieldsets).toHaveLength(2);
+ expect(result.fieldsets[0].apiNameBinding).toBe('fs-a');
+ expect(result.fieldsets[1].apiNameBinding).toBe('fs-b');
+ expect(result.fieldsets[1].labelPosition).toBe(EFieldLabelPosition.Left);
+ expect(result.fieldsets[1].layout).toBe('horizontal');
+ expect(result.fieldsets[1].fields).toHaveLength(1);
+ });
+
+ it('returns empty fieldsets array when task has no fieldsets', () => {
+ const task = makeTaskAPI({ fieldsets: [] });
+
+ const result = getNormalizedTask(task);
+
+ expect(result.fieldsets).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/public/utils/__tests__/template.test.ts b/frontend/src/public/utils/__tests__/template.test.ts
index 18b000293..ba46cd449 100644
--- a/frontend/src/public/utils/__tests__/template.test.ts
+++ b/frontend/src/public/utils/__tests__/template.test.ts
@@ -3,14 +3,16 @@ import {
ETemplateOwnerRole,
ETaskPerformerType,
ITemplateResponse,
- ITemplate,
+ ITemplateClient,
EExtraFieldType,
- IExtraField,
} from '../../types/template';
import { ESubscriptionPlan } from '../../types/account';
+import { EFieldLabelPosition, IFieldsetBinding } from '../../types/fieldset';
import { TUserListItem, EUserStatus } from '../../types/user';
-import { getNormalizedTemplate, mapTemplateRequest, getEmptyKickoff, cleanTemplateReferences } from '../template';
+import { getNormalizedTemplate, mapTemplateRequest, getEmptyKickoff, cleanTemplateReferences, collectFieldApiNames } from '../template';
import { EConditionAction, EConditionOperators, EConditionLogicOperations, TConditionRule } from '../../components/TemplateEdit/TaskForm/Conditions/types';
+import { makeExtraField } from '../../__stubs__/fields.factory';
+import { makeFieldsetBinding, makeFieldsetBindingClient, makeFieldsetField } from '../../__stubs__/fieldsets.factory';
const createMockUser = (overrides: Partial = {}): TUserListItem => ({
id: 1,
@@ -47,6 +49,7 @@ const createMockTemplateResponse = (
kickoff: {
description: '',
fields: [],
+ fieldsets: [],
},
tasks: [
{
@@ -71,6 +74,7 @@ const createMockTemplateResponse = (
checklists: [],
revertTask: null,
ancestors: [],
+ fieldsets: [],
},
],
isPublic: false,
@@ -84,7 +88,7 @@ const createMockTemplateResponse = (
...overrides,
});
-const createMockTemplate = (overrides: Partial = {}): ITemplate => ({
+const createMockTemplate = (overrides: Partial = {}): ITemplateClient => ({
id: 1,
name: 'Test Template',
description: 'Test description',
@@ -133,6 +137,7 @@ const createMockTemplate = (overrides: Partial = {}): ITemplate => ({
checklists: [],
revertTask: null,
ancestors: [],
+ fieldsets: [],
},
],
isPublic: false,
@@ -156,6 +161,7 @@ describe('template utilities', () => {
expect(kickoff).toEqual({
description: '',
fields: [],
+ fieldsets: [],
});
});
});
@@ -308,6 +314,86 @@ describe('template utilities', () => {
expect(result.performersCount).toBe(1);
});
+
+ it('maps kickoff fieldsets via mapFieldsetBindingsToClient (apiName → apiNameBinding)', () => {
+ const kickoffFieldsets: IFieldsetBinding[] = [
+ makeFieldsetBinding({
+ apiName: 'kickoff-fs-1',
+ sharedFieldsetId: 10,
+ name: 'Budget Fieldset',
+ description: 'desc',
+ labelPosition: EFieldLabelPosition.Left,
+ layout: 'horizontal',
+ title: 'Budget',
+ fields: [makeFieldsetField({ apiName: 'amount', name: 'Amount' })],
+ }),
+ ];
+ const templateResponse = createMockTemplateResponse({
+ kickoff: {
+ description: 'kickoff desc',
+ fields: [],
+ fieldsets: kickoffFieldsets,
+ },
+ });
+
+ const result = getNormalizedTemplate(
+ templateResponse,
+ true,
+ mockUsers,
+ ESubscriptionPlan.Premium,
+ );
+
+ expect(result.kickoff.fieldsets).toHaveLength(1);
+ expect(result.kickoff.fieldsets[0].apiNameBinding).toBe('kickoff-fs-1');
+ expect(result.kickoff.fieldsets[0]).not.toHaveProperty('apiName');
+ expect(result.kickoff.fieldsets[0].name).toBe('Budget Fieldset');
+ expect(result.kickoff.fieldsets[0].fields).toHaveLength(1);
+ });
+
+ it('maps task fieldsets via mapFieldsetBindingsToClient', () => {
+ const taskFieldsets: IFieldsetBinding[] = [
+ makeFieldsetBinding({
+ apiName: 'task-fs-1',
+ sharedFieldsetId: 20,
+ name: 'Review Fieldset',
+ labelPosition: EFieldLabelPosition.Top,
+ layout: 'vertical',
+ }),
+ ];
+ const templateResponse = createMockTemplateResponse({
+ tasks: [
+ {
+ ...createMockTemplateResponse().tasks[0],
+ fieldsets: taskFieldsets,
+ },
+ ],
+ });
+
+ const result = getNormalizedTemplate(
+ templateResponse,
+ true,
+ mockUsers,
+ ESubscriptionPlan.Premium,
+ );
+
+ expect(result.tasks[0].fieldsets).toHaveLength(1);
+ expect(result.tasks[0].fieldsets[0].apiNameBinding).toBe('task-fs-1');
+ expect(result.tasks[0].fieldsets[0]).not.toHaveProperty('apiName');
+ });
+
+ it('handles null kickoff by returning empty fieldsets', () => {
+ const templateResponse = createMockTemplateResponse();
+ Object.assign(templateResponse, { kickoff: null });
+
+ const result = getNormalizedTemplate(
+ templateResponse,
+ true,
+ mockUsers,
+ ESubscriptionPlan.Premium,
+ );
+
+ expect(result.kickoff.fieldsets).toEqual([]);
+ });
});
describe('mapTemplateRequest', () => {
@@ -370,13 +456,78 @@ describe('template utilities', () => {
expect(result.publicSuccessUrl).toBeNull();
});
+ it('converts task fieldset apiNameBinding back to apiName for API', () => {
+ const template = createMockTemplate({
+ tasks: [
+ {
+ ...createMockTemplate().tasks[0],
+ fieldsets: [
+ makeFieldsetBindingClient({
+ apiNameBinding: 'fs-bind-abc',
+ sharedFieldsetId: 42,
+ order: 3,
+ title: 'Budget',
+ description: 'desc',
+ }),
+ ],
+ },
+ ],
+ });
+
+ const result = mapTemplateRequest(template);
+
+ expect(result.tasks[0].fieldsets).toHaveLength(1);
+ expect(result.tasks[0].fieldsets[0].apiName).toBe('fs-bind-abc');
+ expect(result.tasks[0].fieldsets[0].sharedFieldsetId).toBe(42);
+ expect(result.tasks[0].fieldsets[0].order).toBe(3);
+ expect(result.tasks[0].fieldsets[0]).not.toHaveProperty('apiNameBinding');
+ });
+
+ it('converts kickoff fieldset apiNameBinding back to apiName for API', () => {
+ const template = createMockTemplate({
+ kickoff: {
+ ...getEmptyKickoff(),
+ fieldsets: [
+ makeFieldsetBindingClient({
+ apiNameBinding: 'kickoff-bind-xyz',
+ sharedFieldsetId: 99,
+ order: 0,
+ }),
+ makeFieldsetBindingClient({
+ apiNameBinding: 'kickoff-bind-abc',
+ sharedFieldsetId: 50,
+ order: 1,
+ }),
+ ],
+ },
+ });
+
+ const result = mapTemplateRequest(template);
+
+ expect(result.kickoff.fieldsets).toHaveLength(2);
+ expect(result.kickoff.fieldsets[0].apiName).toBe('kickoff-bind-xyz');
+ expect(result.kickoff.fieldsets[0].sharedFieldsetId).toBe(99);
+ expect(result.kickoff.fieldsets[1].apiName).toBe('kickoff-bind-abc');
+ expect(result.kickoff.fieldsets[1].sharedFieldsetId).toBe(50);
+ });
+
+ it('handles empty fieldsets arrays in mapTemplateRequest', () => {
+ const template = createMockTemplate();
+
+ const result = mapTemplateRequest(template);
+
+ expect(result.tasks[0].fieldsets).toEqual([]);
+ expect(result.kickoff.fieldsets).toEqual([]);
+ });
+ });
+
describe('cleanTemplateReferences', () => {
it('removes invalid field references from template and task text fields but preserves system vars', () => {
const template = createMockTemplate({
wfNameTemplate: 'Name: {{valid-field}} and {{invalid-field}} and {{template-name}}',
kickoff: {
...getEmptyKickoff(),
- fields: [{ apiName: 'valid-field', type: EExtraFieldType.Text, name: 'Valid Field', order: 1 } as unknown as IExtraField],
+ fields: [makeExtraField({ apiName: 'valid-field', type: EExtraFieldType.Text, name: 'Valid Field', order: 1 })],
},
tasks: [
{
@@ -384,7 +535,7 @@ describe('template utilities', () => {
name: 'Task {{invalid-field}} and {{valid-field}}',
description: 'Desc {{workflow-starter}} and {{invalid-field}}',
number: 1,
- fields: [{ apiName: 'valid-task-field', type: EExtraFieldType.Text, name: 'Valid Task Field', order: 1 } as unknown as IExtraField],
+ fields: [makeExtraField({ apiName: 'valid-task-field', type: EExtraFieldType.Text, name: 'Valid Task Field', order: 1 })],
},
{
...createMockTemplate().tasks[0],
@@ -406,6 +557,27 @@ describe('template utilities', () => {
expect(cleaned.tasks[1].name).toBe('Task 2 {{valid-field}} and {{valid-task-field}} and ');
});
+ it('preserves kickoff fieldset field references in wfNameTemplate', () => {
+ const template = createMockTemplate({
+ wfNameTemplate: 'Name: {{direct-field}} and {{fieldset-field-1}} and {{fieldset-field-2}} and {{invalid-field}} and {{date}}',
+ kickoff: {
+ ...getEmptyKickoff(),
+ fields: [makeExtraField({ apiName: 'direct-field', type: EExtraFieldType.Text, name: 'Direct', order: 1 })],
+ fieldsets: [makeFieldsetBindingClient({
+ apiNameBinding: 'my-fieldset',
+ fields: [
+ makeFieldsetField({ apiName: 'fieldset-field-1', type: 'text', name: 'Fieldset Field 1', order: 1 }),
+ makeFieldsetField({ apiName: 'fieldset-field-2', type: 'text', name: 'Fieldset Field 2', order: 2 }),
+ ],
+ })],
+ },
+ });
+
+ const cleaned = cleanTemplateReferences(template);
+
+ expect(cleaned.wfNameTemplate).toBe('Name: {{direct-field}} and {{fieldset-field-1}} and {{fieldset-field-2}} and and {{date}}');
+ });
+
it('removes invalid field references from conditions', () => {
const template = createMockTemplate({
tasks: [
@@ -426,7 +598,7 @@ describe('template utilities', () => {
],
kickoff: {
...getEmptyKickoff(),
- fields: [{ apiName: 'valid-field', type: EExtraFieldType.Text, name: 'Valid', order: 1 } as unknown as IExtraField],
+ fields: [makeExtraField({ apiName: 'valid-field', type: EExtraFieldType.Text, name: 'Valid', order: 1 })],
},
});
@@ -454,7 +626,7 @@ describe('template utilities', () => {
const template = createMockTemplate({
kickoff: {
...getEmptyKickoff(),
- fields: [{ apiName: 'valid-user-field', type: EExtraFieldType.User, name: 'Valid User', order: 1 } as unknown as IExtraField],
+ fields: [makeExtraField({ apiName: 'valid-user-field', type: EExtraFieldType.User, name: 'Valid User', order: 1 })],
},
tasks: [
{
@@ -476,7 +648,7 @@ describe('template utilities', () => {
const template = createMockTemplate({
kickoff: {
...getEmptyKickoff(),
- fields: [{ apiName: 'valid-date-field', type: EExtraFieldType.Date, name: 'Valid Date', order: 1 } as unknown as IExtraField],
+ fields: [makeExtraField({ apiName: 'valid-date-field', type: EExtraFieldType.Date, name: 'Valid Date', order: 1 })],
},
tasks: [
{
@@ -525,7 +697,6 @@ describe('template utilities', () => {
},
],
});
-
const cleaned = cleanTemplateReferences(template);
const rules = cleaned.tasks[0].conditions[0].rules;
@@ -598,5 +769,43 @@ describe('template utilities', () => {
expect(cleaned.tasks[0].rawPerformers).toHaveLength(0);
});
});
-});
+
+ describe('collectFieldApiNames', () => {
+ it('does not crash and leaves Set empty for empty fields and fieldsets', () => {
+ const validApiNames = new Set();
+
+ collectFieldApiNames([], [], validApiNames);
+
+ expect(validApiNames.size).toBe(0);
+ });
+
+ it('collects apiNames from direct fields and embedded fieldset fields', () => {
+ const fields = [
+ makeExtraField({ apiName: 'direct-field', type: EExtraFieldType.Text, name: 'Direct' }),
+ ];
+ const fieldsets = [makeFieldsetBindingClient({
+ apiNameBinding: 'my-fs',
+ fields: [
+ makeFieldsetField({ apiName: 'fs-field-1', name: 'F1' }),
+ makeFieldsetField({ apiName: 'fs-field-2', type: 'number', name: 'F2', order: 1 }),
+ ],
+ })];
+ const validApiNames = new Set();
+
+ collectFieldApiNames(fields, fieldsets, validApiNames);
+
+ expect(validApiNames.has('direct-field')).toBe(true);
+ expect(validApiNames.has('fs-field-1')).toBe(true);
+ expect(validApiNames.has('fs-field-2')).toBe(true);
+ expect(validApiNames.size).toBe(3);
+ });
+
+ it('handles fieldset with no fields without crashing', () => {
+ const fieldsets = [makeFieldsetBindingClient({ apiNameBinding: 'empty-fs' })];
+ const validApiNames = new Set();
+ collectFieldApiNames([], fieldsets, validApiNames);
+
+ expect(validApiNames.size).toBe(0);
+ });
+ });
});
diff --git a/frontend/src/public/utils/__tests__/validators.dataset.test.ts b/frontend/src/public/utils/__tests__/validators.dataset.test.ts
index c09a8289b..04f81603d 100644
--- a/frontend/src/public/utils/__tests__/validators.dataset.test.ts
+++ b/frontend/src/public/utils/__tests__/validators.dataset.test.ts
@@ -1,4 +1,3 @@
-//
import { validateDatasetName, validateDatasetRow } from '../validators';
describe('validateDatasetName', () => {
diff --git a/frontend/src/public/utils/__tests__/validators.fieldset.test.ts b/frontend/src/public/utils/__tests__/validators.fieldset.test.ts
new file mode 100644
index 000000000..b784c3701
--- /dev/null
+++ b/frontend/src/public/utils/__tests__/validators.fieldset.test.ts
@@ -0,0 +1,17 @@
+
+import { validateFieldsetName } from '../validators';
+
+describe('validateFieldsetName', () => {
+ it('returns error for empty name', () => {
+ expect(validateFieldsetName('')).toBe('validation.fieldset-name-empty');
+ });
+
+ it('returns error for name longer than 200 characters', () => {
+ const longName = 'a'.repeat(201);
+ expect(validateFieldsetName(longName)).toBe('validation.fieldset-name-to-long');
+ });
+
+ it('passes for name exactly 200 characters', () => {
+ expect(validateFieldsetName('a'.repeat(200))).toBe('');
+ });
+});
diff --git a/frontend/src/public/utils/createId.ts b/frontend/src/public/utils/createId.ts
index cb524379c..3e7e35993 100644
--- a/frontend/src/public/utils/createId.ts
+++ b/frontend/src/public/utils/createId.ts
@@ -27,3 +27,4 @@ export const createDueDateApiName = () => createUniqueId('due-date-xxxyxx');
export const createOwnerApiName = () => createUniqueId('owner-xxxyxx');
export const createViewerApiName = () => createUniqueId('viewer-xxxyxx');
export const createStarterApiName = () => createUniqueId('starter-xxxyxx');
+export const createFieldsetBindingApiName = () => createUniqueId('fieldset-bind-xxxyxx');
diff --git a/frontend/src/public/utils/getErrorMessage.ts b/frontend/src/public/utils/getErrorMessage.ts
index dba2453e1..ab26b02c2 100644
--- a/frontend/src/public/utils/getErrorMessage.ts
+++ b/frontend/src/public/utils/getErrorMessage.ts
@@ -24,12 +24,16 @@ const errorMapper: { [key: string]: string } = {
'Failed to fetch': 'error.fetch-failed',
};
-export const getErrorMessage = (error?: ICustomError) => {
- if (!error) {
+export const getErrorMessage = (error?: ICustomError | unknown): string => {
+ if (error === undefined || error === null) {
+ return UNKNOWN_ERROR;
+ }
+
+ if (typeof error !== 'object') {
return UNKNOWN_ERROR;
}
- const { code, message, details, detail } = error;
+ const { code, message, details, detail } = error as ICustomError;
const dictionaryErrorMessage = errorMapper[code] ?? errorMapper[message];
diff --git a/frontend/src/public/utils/mapFieldsetBindingClientToRuntime.ts b/frontend/src/public/utils/mapFieldsetBindingClientToRuntime.ts
new file mode 100644
index 000000000..34c1a91fc
--- /dev/null
+++ b/frontend/src/public/utils/mapFieldsetBindingClientToRuntime.ts
@@ -0,0 +1,51 @@
+import { IFieldsetBindingClient, IFieldsetRuntime, EFieldLabelPosition, IFieldsetField } from '../types/fieldset';
+import { IExtraField, EExtraFieldType } from '../types/template';
+
+export function mapFieldsToExtraFields(fields: IFieldsetField[]): IExtraField[] {
+ return (fields || []).map(
+ ({
+ apiName, name, description, type, isRequired, isHidden,
+ order, default: defaultValue, selections, dataset,
+ }, index) => ({
+ apiName: apiName || '',
+ name: name || '',
+ description: description || '',
+ type: type as EExtraFieldType || EExtraFieldType.String,
+ isRequired: isRequired ?? false,
+ isHidden: isHidden ?? false,
+ order: order ?? index,
+ value: defaultValue || '',
+ selections: selections || [],
+ dataset: dataset || null,
+ userId: null,
+ groupId: null,
+ }),
+ );
+}
+
+
+export function mapFieldsetBindingClientToRuntime({
+ apiNameBinding,
+ name,
+ description,
+ fields: rawFields,
+ order,
+ labelPosition,
+ layout,
+ title,
+}: IFieldsetBindingClient): IFieldsetRuntime {
+ const fields = mapFieldsToExtraFields(rawFields || []);
+
+ return {
+ apiNameBinding,
+ name: name || '',
+ description: description || '',
+ fields,
+ order: order ?? 0,
+ labelPosition: labelPosition === EFieldLabelPosition.Left
+ ? EFieldLabelPosition.Left
+ : EFieldLabelPosition.Top,
+ layout,
+ title,
+ };
+}
diff --git a/frontend/src/public/utils/mapFieldsetsAPIToClient.ts b/frontend/src/public/utils/mapFieldsetsAPIToClient.ts
new file mode 100644
index 000000000..8549738d1
--- /dev/null
+++ b/frontend/src/public/utils/mapFieldsetsAPIToClient.ts
@@ -0,0 +1,15 @@
+import { IFieldsetBinding, IFieldsetBindingClient, IFieldsetTaskAPI, IFieldsetRuntime } from '../types/fieldset';
+
+export function mapFieldsetBindingsToClient(fieldsets: IFieldsetBinding[]): IFieldsetBindingClient[] {
+ return fieldsets.map(({ apiName, ...rest }) => ({
+ ...rest,
+ apiNameBinding: apiName,
+ }));
+}
+
+export function mapFieldsetTaskAPIToRuntime(fieldsets: IFieldsetTaskAPI[]): IFieldsetRuntime[] {
+ return fieldsets.map(({ id, apiName, ...rest }) => ({
+ ...rest,
+ apiNameBinding: apiName,
+ }));
+}
diff --git a/frontend/src/public/utils/mapTemplateFieldsetsToRuntime.ts b/frontend/src/public/utils/mapTemplateFieldsetsToRuntime.ts
new file mode 100644
index 000000000..27db03c65
--- /dev/null
+++ b/frontend/src/public/utils/mapTemplateFieldsetsToRuntime.ts
@@ -0,0 +1,20 @@
+import { ITemplateResponse } from '../types/template';
+import { IFieldsetRuntime } from '../types/fieldset';
+import { mapFieldsetBindingsToClient } from './mapFieldsetsAPIToClient';
+import { mapFieldsetBindingClientToRuntime } from './mapFieldsetBindingClientToRuntime';
+
+export function mapTemplateFieldsetsToRuntime(template: ITemplateResponse) {
+ const clientFieldsets = mapFieldsetBindingsToClient(
+ template.kickoff.fieldsets || [],
+ );
+ const normalizedKickoff = {
+ ...template.kickoff,
+ fieldsets: clientFieldsets,
+ };
+ const normalizedTemplate = { ...template, kickoff: normalizedKickoff };
+ const loadedFieldsets: IFieldsetRuntime[] = clientFieldsets.map(
+ mapFieldsetBindingClientToRuntime,
+ );
+
+ return { normalizedTemplate, loadedFieldsets };
+}
diff --git a/frontend/src/public/utils/mappers.ts b/frontend/src/public/utils/mappers.ts
index 47478be8e..c36d01f68 100644
--- a/frontend/src/public/utils/mappers.ts
+++ b/frontend/src/public/utils/mappers.ts
@@ -1,4 +1,4 @@
-import { IExtraField, IKickoff } from '../types/template';
+import { IExtraField, IKickoffClient } from '../types/template';
import { isArrayWithItems } from './helpers';
import { IRunWorkflow } from '../components/WorkflowEditPopup/types';
import { ExtraFieldsHelper } from '../components/TemplateEdit/ExtraFields/utils/ExtraFieldsHelper';
@@ -170,7 +170,7 @@ export const mapEndOfDayTsp = (fields: IExtraField[]): IExtraField[] => {
}));
};
-export const getNormalizedKickoff = (kickoff: IKickoff): { [key: string]: string } => {
+export const getNormalizedKickoff = (kickoff: Pick): { [key: string]: string } => {
const mappedKickoffFields = new ExtraFieldsHelper(kickoff.fields).normalizeFieldsValues();
const mappedKickoff = isArrayWithItems(mappedKickoffFields) ? Object.assign({}, ...mappedKickoffFields) : null;
return mappedKickoff;
@@ -217,12 +217,24 @@ export const mapBackendWorkflowToRedux = {
- if (!workflow.kickoff?.output?.length) return workflow;
+ if (!workflow.kickoff) return workflow;
+
+ const hasOutput = workflow.kickoff.output?.length;
+ const hasFieldsets = workflow.kickoff.fieldsets?.length;
+
+ if (!hasOutput && !hasFieldsets) return workflow;
+
return {
...workflow,
kickoff: {
...workflow.kickoff,
- output: mapTspToString(workflow.kickoff.output, timezone),
+ ...(hasOutput && { output: mapTspToString(workflow.kickoff.output, timezone) }),
+ ...(hasFieldsets && {
+ fieldsets: workflow.kickoff.fieldsets!.map((fieldset) => ({
+ ...fieldset,
+ fields: mapTspToString(fieldset.fields, timezone),
+ })),
+ }),
},
};
};
@@ -253,6 +265,12 @@ export const formatTaskDatesForRedux = (
...(task.dateStartedTsp && { dateStarted: toISOStringFromTsp(task.dateStartedTsp) }),
...(task.dateCompletedTsp && { dateCompleted: toISOStringFromTsp(task.dateCompletedTsp) }),
...(task.output && { output: mapTspToString(task.output, timezone) }),
+ ...(task.fieldsets?.length && {
+ fieldsets: task.fieldsets.map((fieldset) => ({
+ ...fieldset,
+ fields: mapTspToString(fieldset.fields, timezone),
+ })),
+ }),
...(task.subWorkflows && {
subWorkflows: mapWorkflowsAddComputedPropsToRedux(mapWorkflowsToISOStringToRedux(task.subWorkflows)),
}),
diff --git a/frontend/src/public/utils/tasks.ts b/frontend/src/public/utils/tasks.ts
index 24bdccf69..423dc4cce 100644
--- a/frontend/src/public/utils/tasks.ts
+++ b/frontend/src/public/utils/tasks.ts
@@ -6,10 +6,16 @@ import {
TTaskChecklistsItem,
TTaskChecklist,
} from '../types/tasks';
+import { IFieldsetRuntime } from '../types/fieldset';
+import { mapFieldsetTaskAPIToRuntime } from './mapFieldsetsAPIToClient';
export const getNormalizedTask = (
task: ITaskAPI,
-): Omit & { checklists: TTaskChecklists; areChecklistsHandling: boolean } => {
+): Omit & {
+ checklists: TTaskChecklists;
+ fieldsets: IFieldsetRuntime[];
+ areChecklistsHandling: boolean;
+} => {
const normalizedChecklists: TTaskChecklists = {};
task.checklists.forEach((checklist) => {
@@ -30,7 +36,9 @@ export const getNormalizedTask = (
};
});
- return { ...task, checklists: normalizedChecklists, areChecklistsHandling: false };
+ const fieldsets = mapFieldsetTaskAPIToRuntime(task.fieldsets);
+
+ return { ...task, checklists: normalizedChecklists, fieldsets, areChecklistsHandling: false };
};
export const getTaskChecklist = (task: ITask, listApiName: string): TTaskChecklist | null => {
return task.checklists[listApiName] || null;
diff --git a/frontend/src/public/utils/template.ts b/frontend/src/public/utils/template.ts
index a7f286490..800f9825f 100644
--- a/frontend/src/public/utils/template.ts
+++ b/frontend/src/public/utils/template.ts
@@ -2,9 +2,10 @@
/* prettier-ignore */
import {
ITemplate,
+ ITemplateClient,
+ ITemplateTaskClient,
+ IKickoffClient,
ITemplateRequest,
- ITemplateTask,
- IKickoff,
ITemplateTaskRequest,
ITemplateResponse,
ITemplateTaskResponse,
@@ -12,11 +13,13 @@ import {
ETemplateOwnerType,
ETemplateOwnerRole,
ITemplateOwner,
+ IExtraField,
} from '../types/template';
+import { IFieldsetBindingClient, IFieldsetBindingMeta } from '../types/fieldset';
+import { mapFieldsetBindingsToClient } from './mapFieldsetsAPIToClient';
import { getUrlParams } from './getUrlParams';
import { DEFAULT_TEMPLATE_NAME } from '../components/TemplateEdit/constants';
-import { getNormalizeFieldsOrders } from './workflows';
import { createOwnerApiName, createTaskApiName, createUUID } from './createId';
import { isArrayWithItems, omit } from './helpers';
import { getEmptyConditions } from '../components/TemplateEdit/TaskForm/Conditions/utils/getEmptyConditions';
@@ -43,10 +46,11 @@ export function getTemplateIdFromUrl(url: string) {
return Number(template) ? template : null;
}
-export function getEmptyKickoff(): IKickoff {
+export function getEmptyKickoff(): IKickoffClient {
return {
description: '',
fields: [],
+ fieldsets: [],
};
}
@@ -55,8 +59,14 @@ export const getNormalizedTemplate = (
isSubscribed: boolean,
users: TUserListItem[],
billingPlan: ESubscriptionPlan,
-): ITemplate => {
- const normalizedKickoff = template.kickoff || getEmptyKickoff();
+): ITemplateClient => {
+ const normalizedKickoff = {
+ ...getEmptyKickoff(),
+ ...(template.kickoff || {}),
+ fieldsets: template.kickoff
+ ? mapFieldsetBindingsToClient(template.kickoff.fieldsets)
+ : [],
+ };
const normalizedTasks = [...template.tasks]
.sort((a, b) => a.number - b.number)
.map((task, index, tasks) => getNormalizedTask(task, isSubscribed, tasks[index - 1]));
@@ -114,7 +124,7 @@ export const getNormalizedTask = (
task: ITemplateTaskResponse,
isSubscribed: boolean,
prevTask?: ITemplateTaskResponse,
-): ITemplateTask => {
+): ITemplateTaskClient => {
const conditions = isArrayWithItems(task.conditions)
? normalizeConditiosForFrontend(task.conditions)
: getEmptyConditions(isSubscribed);
@@ -124,24 +134,38 @@ export const getNormalizedTask = (
return {
...omit(task, ['dueIn']),
apiName: task.apiName || createTaskApiName(),
- fields: getNormalizeFieldsOrders(task.fields),
uuid: createUUID(),
conditions,
rawDueDate,
+ fieldsets: mapFieldsetBindingsToClient(task.fieldsets),
};
};
-export const cleanTemplateReferences = (template: ITemplate): ITemplate => {
+export const collectFieldApiNames = (
+ fields: IExtraField[],
+ fieldsets: IFieldsetBindingClient[],
+ validApiNames: Set,
+) => {
+ fields.forEach((field) => {
+ if (field.apiName) validApiNames.add(field.apiName);
+ });
+ fieldsets.forEach((fieldset) => {
+ fieldset.fields?.forEach((field) => {
+ if (field.apiName) validApiNames.add(field.apiName);
+ });
+ });
+};
+
+export const cleanTemplateReferences = (
+ template: ITemplateClient,
+): ITemplateClient => {
// System variables that the backend recognizes and skips during validation.
// Must stay in sync with backend/src/processes/enums.py :: SystemVariable
const TASK_SYSTEM_VARS = new Set(['workflow-starter']);
const WF_NAME_SYSTEM_VARS = new Set(['date', 'template-name', 'workflow-id', 'workflow-starter']);
const validApiNames = new Set();
-
- template.kickoff?.fields?.forEach((f) => {
- if (f.apiName) validApiNames.add(f.apiName);
- });
+ collectFieldApiNames(template.kickoff.fields, template.kickoff.fieldsets, validApiNames);
const removeInvalidReferences = (
text: string | null | undefined,
@@ -201,9 +225,7 @@ export const cleanTemplateReferences = (template: ITemplate): ITemplate => {
}
}
- (task.fields || []).forEach((f) => {
- if (f.apiName) validApiNames.add(f.apiName);
- });
+ collectFieldApiNames(task.fields, task.fieldsets, validApiNames);
return {
...task,
@@ -216,18 +238,38 @@ export const cleanTemplateReferences = (template: ITemplate): ITemplate => {
});
const validKickoffApiNames = new Set();
- template.kickoff?.fields?.forEach((f) => {
- if (f.apiName) validKickoffApiNames.add(f.apiName);
- });
+ collectFieldApiNames(template.kickoff.fields, template.kickoff.fieldsets, validKickoffApiNames);
return {
...template,
- wfNameTemplate: removeInvalidReferences(template.wfNameTemplate, validKickoffApiNames, WF_NAME_SYSTEM_VARS),
+ wfNameTemplate: removeInvalidReferences(
+ template.wfNameTemplate,
+ validKickoffApiNames,
+ WF_NAME_SYSTEM_VARS
+ ),
tasks: cleanedTasks,
};
};
-export const mapTemplateRequest = (template: ITemplate): ITemplateRequest => {
+function mapFieldsetForApi({
+ sharedFieldsetId,
+ order,
+ title,
+ description,
+ apiNameBinding,
+}: IFieldsetBindingClient): IFieldsetBindingMeta {
+ return {
+ sharedFieldsetId,
+ order,
+ title,
+ description,
+ apiName: apiNameBinding,
+ };
+}
+
+export const mapTemplateRequest = (
+ template: ITemplateClient,
+): ITemplateRequest => {
const cleanedTemplate = cleanTemplateReferences(template);
const { tasks } = cleanedTemplate;
@@ -241,6 +283,7 @@ export const mapTemplateRequest = (template: ITemplate): ITemplateRequest => {
delay: normalizeDuration(task.delay),
conditions: normalizeConditionForBackend(task.conditions),
rawDueDate: normalizeDueDateForBackend(task.rawDueDate),
+ fieldsets: task.fieldsets.map(mapFieldsetForApi),
};
});
@@ -248,6 +291,10 @@ export const mapTemplateRequest = (template: ITemplate): ITemplateRequest => {
...cleanedTemplate,
name: cleanedTemplate.name || DEFAULT_TEMPLATE_NAME,
tasks: normilizedTasks,
+ kickoff: {
+ ...cleanedTemplate.kickoff,
+ fieldsets: cleanedTemplate.kickoff.fieldsets.map(mapFieldsetForApi),
+ },
publicSuccessUrl: cleanedTemplate.publicSuccessUrl || null,
};
};
diff --git a/frontend/src/public/utils/templates/insertId.ts b/frontend/src/public/utils/templates/insertId.ts
index e5e69b7d5..ee8a14651 100644
--- a/frontend/src/public/utils/templates/insertId.ts
+++ b/frontend/src/public/utils/templates/insertId.ts
@@ -10,7 +10,7 @@ const isObject = (value: any) => {
};
// tslint:disable-next-line: no-any
-export const insertId = (initialObj: T, savedObj: T): T => {
+export const insertId = (initialObj: T, savedObj: { [key: string]: any }): T => {
const EQUALTY_KEYS = ['apiName', 'predicateApiName'];
const ID_KEYS = ['id', 'predicateId'];
diff --git a/frontend/src/public/utils/validators.ts b/frontend/src/public/utils/validators.ts
index 3e6c626fe..d842ec549 100644
--- a/frontend/src/public/utils/validators.ts
+++ b/frontend/src/public/utils/validators.ts
@@ -285,6 +285,18 @@ export const getSelectionDuplicateError = (value: string, allValues: string[]):
export const validateTenantName = validateFieldCreator(TENANT_NAME_RULES);
export const validateGroupName = validateFieldCreator(GROUP_NAME_RULES);
export const validateDatasetName = validateFieldCreator(DATASET_NAME_RULES);
+
+export const FIELDSET_NAME_RULES: IRule[] = [
+ {
+ message: 'validation.fieldset-name-empty',
+ isInvalid: isEmpty,
+ },
+ {
+ message: 'validation.fieldset-name-to-long',
+ isInvalid: (value) => value.length > 200,
+ },
+];
+export const validateFieldsetName = validateFieldCreator(FIELDSET_NAME_RULES);
export const validateDatasetRow = (value: string, existingItems: string[], excludeValue?: string) => {
const filtered = excludeValue
? existingItems.filter((item) => item.trim().toLowerCase() !== excludeValue.trim().toLowerCase())
diff --git a/frontend/src/public/utils/workflows.ts b/frontend/src/public/utils/workflows.ts
index 64e7cfaaf..0e91e5da4 100644
--- a/frontend/src/public/utils/workflows.ts
+++ b/frontend/src/public/utils/workflows.ts
@@ -6,8 +6,8 @@ import {
ITaskCommentAttachmentRequest,
} from '../types/workflow';
import {
- ITemplateTask,
- IKickoff,
+ ITemplateTaskClient,
+ IKickoffClient,
IExtraField,
} from '../types/template';
import { isArrayWithItems, deepCopy } from './helpers';
@@ -25,7 +25,7 @@ export const COMMENT_FIELD_PLACEHOLDER: {[key in ECommentType]: string} = {
[ECommentType.Finish]: 'State the reason for workflow completion',
};
-export const moveTask = (a: number, b: number, arr: ITemplateTask[]): ITemplateTask[] => {
+export const moveTask = (a: number, b: number, arr: ITemplateTaskClient[]): ITemplateTaskClient[] => {
if (!isArrayWithItems(arr)) {
return [];
}
@@ -64,11 +64,11 @@ export const moveWorkflowField = (a: number, b: number, arr: IExtraField[]) => {
return copy;
};
-export const getEditKickoff = (kickoff: IWorkflowDetailsKickoff): IKickoff => {
+export const getEditKickoff = (kickoff: IWorkflowDetailsKickoff): IKickoffClient => {
const kickoffFields = new ExtraFieldsHelper(kickoff.output).getFieldsWithValues();
const kickoffDescritpiton = kickoff.description || '';
- return { description: kickoffDescritpiton, fields: kickoffFields };
+ return { description: kickoffDescritpiton, fields: kickoffFields, fieldsets: [] };
};
export const getNormalizeFieldsOrders = (fields?: IExtraField[]): IExtraField[] => {
diff --git a/frontend/src/public/views/Fieldsets/Fieldsets.tsx b/frontend/src/public/views/Fieldsets/Fieldsets.tsx
new file mode 100644
index 000000000..8ee0d0506
--- /dev/null
+++ b/frontend/src/public/views/Fieldsets/Fieldsets.tsx
@@ -0,0 +1,38 @@
+import * as React from 'react';
+import { Route, Switch, Redirect } from 'react-router-dom';
+import loadable from '@loadable/component';
+
+import { ERoutes } from '../../constants/routes';
+import { Loader } from '../../components/UI';
+import { TemplatesLayout } from '../../layout';
+
+const Fieldsets = loadable(
+ () => import(/* webpackChunkName: "fieldsets", webpackPrefetch: true */ '../../components/Fieldsets'),
+ { fallback: },
+);
+
+const FieldsetDetails = loadable(
+ () => import(/* webpackChunkName: "fieldsetDetails", webpackPrefetch: true */ '../../components/Fieldsets/FieldsetDetails'),
+ { fallback: },
+);
+
+export const FieldsetsView = () => {
+ return (
+
+ }>
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/public/views/Fieldsets/__tests__/Fieldsets.test.tsx b/frontend/src/public/views/Fieldsets/__tests__/Fieldsets.test.tsx
new file mode 100644
index 000000000..f6211f139
--- /dev/null
+++ b/frontend/src/public/views/Fieldsets/__tests__/Fieldsets.test.tsx
@@ -0,0 +1,57 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+import { FieldsetsView } from '../Fieldsets';
+
+jest.mock('@loadable/component', () => {
+ const R = require('react');
+ let callIndex = 0;
+ const components = [
+ () => R.createElement('div', { 'data-testid': 'fieldsets-list' }, 'FieldsetsList'),
+ () => R.createElement('div', { 'data-testid': 'fieldset-details' }, 'FieldsetDetails'),
+ ];
+ return {
+ __esModule: true,
+ default: () => components[callIndex++] || (() => null),
+ };
+});
+
+jest.mock('../../../components/UI', () => ({
+ Loader: () => require('react').createElement('div', { 'data-testid': 'loader' }),
+}));
+
+jest.mock('../../../layout', () => ({
+ TemplatesLayout: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+describe('FieldsetsView — fieldsets routing contract', () => {
+
+ const renderWithRoute = (url: string) => {
+ return render(
+ React.createElement(
+ MemoryRouter,
+ { initialEntries: [url] },
+ React.createElement(FieldsetsView),
+ ),
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders FieldsetDetails (not Fieldsets list) for /fieldsets/2/', () => {
+ renderWithRoute('/fieldsets/2/');
+
+ expect(screen.getByTestId('fieldset-details')).toBeInTheDocument();
+ expect(screen.queryByTestId('fieldsets-list')).not.toBeInTheDocument();
+ });
+
+ it('renders Fieldsets list (not FieldsetDetails) for /fieldsets/', () => {
+ renderWithRoute('/fieldsets/');
+
+ expect(screen.getByTestId('fieldsets-list')).toBeInTheDocument();
+ expect(screen.queryByTestId('fieldset-details')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/public/views/Fieldsets/index.ts b/frontend/src/public/views/Fieldsets/index.ts
new file mode 100644
index 000000000..92aab5b6f
--- /dev/null
+++ b/frontend/src/public/views/Fieldsets/index.ts
@@ -0,0 +1 @@
+export { FieldsetsView } from './Fieldsets';
diff --git a/frontend/src/public/views/Template/Template.tsx b/frontend/src/public/views/Template/Template.tsx
index 458b1ff52..f55e06a84 100644
--- a/frontend/src/public/views/Template/Template.tsx
+++ b/frontend/src/public/views/Template/Template.tsx
@@ -27,3 +27,4 @@ export const TemplateView = () => {
);
};
+
diff --git a/frontend/src/public/views/Template/__tests__/Template.test.tsx b/frontend/src/public/views/Template/__tests__/Template.test.tsx
new file mode 100644
index 000000000..4775a60a1
--- /dev/null
+++ b/frontend/src/public/views/Template/__tests__/Template.test.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+import { TemplateView } from '../Template';
+
+jest.mock('@loadable/component', () => ({
+ __esModule: true,
+ default: () => {
+ const Lazy = () => React.createElement('div', { 'data-testid': 'template-edit' }, 'TemplateEdit');
+
+ return Lazy;
+ },
+}));
+
+jest.mock('../../../layout', () => ({
+ TemplateLayout: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'template-layout' }, children),
+}));
+
+jest.mock('../../../components/UI', () => ({
+ Loader: () => React.createElement('div', { 'data-testid': 'loader' }),
+}));
+
+describe('TemplateView — routing contract', () => {
+
+ const renderWithRoute = (url: string) => {
+ return render(
+ React.createElement(
+ MemoryRouter,
+ { initialEntries: [url] },
+ React.createElement(TemplateView),
+ ),
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders TemplateEdit (not FieldsetsView) for /templates/1/', () => {
+ renderWithRoute('/templates/1/');
+
+ expect(screen.getByTestId('template-edit')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/stylelint/config.js b/frontend/stylelint/config.js
index 2bab8748c..fcead8487 100644
--- a/frontend/stylelint/config.js
+++ b/frontend/stylelint/config.js
@@ -255,7 +255,7 @@ module.exports = {
// Property
// 'property-blacklist': array|string,
'property-case': 'lower',
- 'property-no-unknown': [true, { ignoreProperties: ['composes', 'field-sizing', 'print-color-adjust'] }],
+ 'property-no-unknown': [true, { ignoreProperties: ['composes', 'field-sizing', 'print-color-adjust', 'accent-color'] }],
'property-no-vendor-prefix': true,
// 'property-whitelist': array|string,
// Keyframe declaration