diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 99b96481ff17..4c3294407cf0 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -527,9 +527,9 @@ export const FEATURE_FLAGS = { TASK_TOOL: 'phai-task-tool', // owner: @kappa90 #team-posthog-ai TASKS: 'tasks', // owner: #team-ai-observability TASKS_STREAM_VIA_PROXY: 'tasks-stream-via-proxy', // owner: #team-ai-observability + TAXONOMIC_FILTER_EVENT_MATCH: 'taxonomic-filter-event-match', // owner: @pauldambra, asks the decision model which core events an events search with no results describes, then suggests them in the empty state TAXONOMIC_FILTER_MENU_REBUILD: 'taxonomic-filter-menu-rebuild', // owner: @adamleith, opt-in to the rebuilt TaxonomicFilter — headless filter panel + new popover menu (column / preview-pane) TAXONOMIC_FILTER_SEARCH_INTENT: 'taxonomic-filter-search-intent', // owner: @pauldambra multivariate=control,banner,promote, classifies a picker search with the decision model. Every arm promotes the predicted group on the All tab; only banner also suggests the right tab - TAXONOMIC_FILTER_EVENT_MATCH: 'taxonomic-filter-event-match', // owner: @pauldambra, asks the decision model which core events an events search with no results describes, then suggests them in the empty state TEXT_CARD_WORD_ART: 'text-card-word-art', // owner: @jonmcwest, gates the word art insert button in dashboard text cards TOOLBAR_PAID_HEATMAPS: 'toolbar-paid-heatmaps', // owner: #team-web-analytics TRACING: 'tracing', // owner: #team-apm (@jonmcwest, @frankh) @@ -598,6 +598,7 @@ export const FEATURE_FLAGS = { WORKFLOWS_INTERNAL_EVENT_FILTERS: 'workflows-internal-event-filters', // owner: @haven #team-workflows WORKFLOWS_ISP_SENDING_HEALTH: 'workflows-isp-sending-health', // owner: #team-workflows WORKFLOWS_LINEAR_VIEW: 'workflows-linear-view', // owner: #team-workflows + WORKFLOWS_LIST_V2: 'workflows-list-v2', // owner: #team-workflows WORKFLOWS_PUSH_NOTIFICATIONS: 'workflows-push-notifications', // owner: #team-workflows WORKFLOWS_TRIGGER_VOLUME_ESTIMATE: 'workflows-trigger-volume-estimate', // owner: @mayteio #team-workflows XAA_AUTHENTICATION: 'xaa-authentication', // owner: @reecejones #team-platform-features diff --git a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx index 54a4ee49c128..255459864071 100644 --- a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx +++ b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx @@ -35,6 +35,7 @@ export interface LemonButtonPropsBase | 'aria-pressed' | 'aria-expanded' | 'aria-controls' + | 'aria-selected' > { children?: React.ReactNode type?: 'primary' | 'secondary' | 'tertiary' diff --git a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx index 1bd07f23933a..eb67e42728a9 100644 --- a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx +++ b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx @@ -32,6 +32,11 @@ interface LemonInputPropsBase extends Pick< | 'spellCheck' | 'inputMode' | 'pattern' + | 'role' + | 'aria-autocomplete' + | 'aria-expanded' + | 'aria-controls' + | 'aria-activedescendant' > { inputRef?: React.Ref inputComponent?: React.JSXElementConstructor< diff --git a/frontend/src/lib/lemon-ui/LemonSnack/LemonSnack.tsx b/frontend/src/lib/lemon-ui/LemonSnack/LemonSnack.tsx index bbb0661287a3..9c7ddaa56032 100644 --- a/frontend/src/lib/lemon-ui/LemonSnack/LemonSnack.tsx +++ b/frontend/src/lib/lemon-ui/LemonSnack/LemonSnack.tsx @@ -11,6 +11,8 @@ export interface LemonSnackProps extends React.HTMLAttributes { children?: React.ReactNode onClick?: React.MouseEventHandler onClose?: React.MouseEventHandler + /** Accessible name of the close button, for example "Remove filter Status: Active". */ + closeLabel?: string title?: string wrap?: boolean className?: string @@ -19,7 +21,7 @@ export interface LemonSnackProps extends React.HTMLAttributes { export const LemonSnack: React.FunctionComponent> = forwardRef( function LemonSnack( - { type = 'regular', children, wrap, onClick, onClose, title, className, ...rest }, + { type = 'regular', children, wrap, onClick, onClose, closeLabel, title, className, ...rest }, ref ): JSX.Element { const isRegular = type === 'regular' @@ -54,6 +56,7 @@ export const LemonSnack: React.FunctionComponent} + aria-label={closeLabel} onClick={(e) => { e.stopPropagation() onClose(e) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c08452b8a81..bb9b136d317c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5360,6 +5360,9 @@ importers: '@testing-library/react': specifier: '*' version: 14.3.1(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: '*' + version: 14.6.1(@testing-library/dom@10.4.0) '@types/papaparse': specifier: '*' version: 5.3.8 diff --git a/posthog/settings/web.py b/posthog/settings/web.py index 5c6e7a900fee..408bde8e0470 100644 --- a/posthog/settings/web.py +++ b/posthog/settings/web.py @@ -882,6 +882,8 @@ def static_varies_origin(headers, path, url): "^/api/organizations/@current/plugins/?$", "^api/(environments|projects)/@current/feature_flags/my_flags/?$", "^/?api/(environments|projects)/\\d+/query/?$", + # Summary rows have no step config, and the trigger's secret inputs are masked. + "^/?api/(environments|projects)/\\d+/hog_flows/summaries/?$", # Deploy-static source catalog (no user input or secrets reflected): several # hundred KB of JSON that compresses ~7x. "^/?api/(environments|projects)/(\\d+|@current)/external_data_sources/wizard/?$", diff --git a/products/workflows/CONTRIBUTING.md b/products/workflows/CONTRIBUTING.md index 45a376779c4a..f535d6924166 100644 --- a/products/workflows/CONTRIBUTING.md +++ b/products/workflows/CONTRIBUTING.md @@ -301,6 +301,18 @@ Existing example: - `posthog_assignee` type defined in nodejs/src/cdp/templates/\_destinations/posthog_conversations/posthog-update-ticket.template.ts - Renderer in products/conversations/frontend/components/Assignee/CyclotronJobInputAssignee.tsx +## Listing workflows + +`GET /api/projects/:id/hog_flows/` returns full workflows with the step graph. +MCP requests (`x-posthog-client: mcp`) get `HogFlowSummarySerializer` instead, which has no step graph. + +`GET /api/projects/:id/hog_flows/summaries/` returns those summary fields plus `type`, for loading a whole project's list page by page. +It takes the same filters and search as the list. Keep these in mind when you change it: + +- **It sorts on `-created_at, -id`.** A save during the load changes `updated_at`, so sorting on it moves rows between pages. +- **It applies the access-level filter itself.** `_filter_queryset_by_access_level` in the routing mixin only runs for `list`. +- **Only this path is gzipped.** The full list has step config next to the `search` input that its `next` link reflects, which is the shape `ScopedGZipMiddleware` warns about. + ## Metrics and version attribution Workflow metrics live in the ClickHouse `app_metrics2` table, written by the CDP workers. diff --git a/products/workflows/backend/api/hog_flow.py b/products/workflows/backend/api/hog_flow.py index 0b6cb8011d87..8316125b33bd 100644 --- a/products/workflows/backend/api/hog_flow.py +++ b/products/workflows/backend/api/hog_flow.py @@ -86,7 +86,7 @@ from posthog.models import Team from posthog.models.filters import Filter from posthog.models.integration import Integration -from posthog.permissions import posthog_feature_flag_enabled +from posthog.permissions import is_service_auth, posthog_feature_flag_enabled from posthog.plugins.plugin_server_api import ( cancel_hog_flow_batch_job, cancel_hog_flow_invocations, @@ -3792,28 +3792,73 @@ class CommaSeparatedListFilter(BaseInFilter, CharFilter): # by surface in the UI (see WorkflowTypeTag), so returning it under `messaging` would contradict the # tag on the row. Accepting several lets a list say which surfaces it covers, which is how the # workflows page asks for everything except the ones that moved out. -WORKFLOW_TYPES: Final[tuple[str, ...]] = ("messaging", "automation", "loop", "broadcast") +class HogFlowType(models.TextChoices): + MESSAGING = "messaging" + AUTOMATION = "automation" + LOOP = "loop" + BROADCAST = "broadcast" + + +WORKFLOW_TYPES: Final[tuple[str, ...]] = tuple(HogFlowType.values) OWNED_WORKFLOW_TYPES: Final[dict[str, str]] = { - "loop": HogFlow.OriginProduct.LOOPS, - "broadcast": HogFlow.OriginProduct.BROADCASTS, + HogFlowType.LOOP: HogFlow.OriginProduct.LOOPS, + HogFlowType.BROADCAST: HogFlow.OriginProduct.BROADCASTS, } +def _has_messaging_action_q() -> Q: + messaging = Q() + for action_type in MESSAGING_ACTION_TYPES: + messaging |= Q(actions__contains=[{"type": action_type}]) + return messaging + + def workflow_type_q(requested: set[str]) -> Q: owned = Q(origin_product__in=[OWNED_WORKFLOW_TYPES[t] for t in requested if t in OWNED_WORKFLOW_TYPES]) behavioural = requested - set(OWNED_WORKFLOW_TYPES) if not behavioural: return owned - messaging = Q() - for action_type in MESSAGING_ACTION_TYPES: - messaging |= Q(actions__contains=[{"type": action_type}]) + messaging = _has_messaging_action_q() unowned = ~Q(origin_product__in=list(OWNED_WORKFLOW_TYPES.values())) if behavioural == {"messaging", "automation"}: return owned | unowned return owned | (unowned & (messaging if behavioural == {"messaging"} else ~messaging)) +def annotate_workflow_type(queryset: QuerySet) -> QuerySet: + """Adds `workflow_type`, decided by the same rules as the `type` filter in workflow_type_q.""" + return queryset.annotate( + workflow_type=models.Case( + *( + models.When(origin_product=origin_product, then=models.Value(workflow_type)) + for workflow_type, origin_product in OWNED_WORKFLOW_TYPES.items() + ), + models.When(_has_messaging_action_q(), then=models.Value(HogFlowType.MESSAGING)), + default=models.Value(HogFlowType.AUTOMATION), + output_field=models.CharField(), + ) + ) + + +class HogFlowListSummarySerializer(HogFlowSummarySerializer): + """One row of the workflows list: the summary fields and the workflow type, without the step graph.""" + + type = serializers.ChoiceField( + source="workflow_type", + choices=HogFlowType.choices, + read_only=True, + help_text=( + "`loop` and `broadcast` for workflows those surfaces own. Otherwise `messaging` when the workflow " + "has an email, SMS or push step, else `automation`. The same rules as the `type` filter." + ), + ) + + class Meta(HogFlowSummarySerializer.Meta): + fields = [*HogFlowSummarySerializer.Meta.fields, "type"] + read_only_fields = fields + + BROADCAST_TRIGGER_TYPE = "batch" BROADCAST_ALLOWED_ACTION_TYPES = frozenset({"trigger", "function_email", "exit"}) @@ -3972,50 +4017,60 @@ def mint_audience_confirm_token( WRITABLE_DRAFT_CONTENT_FIELDS = frozenset(DRAFT_CONTENT_FIELDS) - frozenset(HogFlowSerializer.Meta.read_only_fields) +LIST_QUERY_PARAMETERS: Final[list[OpenApiParameter]] = [ + OpenApiParameter( + "search", + OpenApiTypes.STR, + description="Case-insensitive search. Matches workflow name and description first; only when nothing matches those, it matches step names and the subject line, preheader and body text of email steps, in both the live workflow and its pending draft.", + ), + OpenApiParameter( + "created_by", + OpenApiTypes.UUID, + description="Filter to workflows created by the user with this uuid.", + ), + OpenApiParameter( + "type", + OpenApiTypes.STR, + description="Comma-separated workflow types. `loop` and `broadcast` return the workflows those surfaces own; `messaging` returns the remaining workflows with an email, SMS, or push action, and `automation` the rest.", + ), + OpenApiParameter( + "origin_product", + OpenApiTypes.STR, + enum=HogFlow.OriginProduct.values, + description="Filter to workflows owned by a product surface, e.g. `loops` for Desktop loops.", + ), + OpenApiParameter( + "trigger", + OpenApiTypes.STR, + description='Filter by trigger config as a JSON object. Returns workflows whose trigger contains the given object, e.g. {"type": "event"}.', + ), + OpenApiParameter( + "broadcast_eligible", + OpenApiTypes.BOOL, + description="Pass `true` to return broadcasts plus the ordinary workflows the broadcasts UI can render: a batch trigger and a single email step.", + ), +] + + +SUMMARIES_QUERY_PARAMETERS: Final[list[OpenApiParameter]] = [ + OpenApiParameter( + "search", + OpenApiTypes.STR, + description="Case-insensitive search over workflow name, description, step names and the subject line, preheader and body text of email steps, in both the live workflow and its pending draft.", + ), + *(parameter for parameter in LIST_QUERY_PARAMETERS if parameter.name != "search"), +] + + @extend_schema(extensions={"x-product": "workflows"}) -@extend_schema_view( - list=extend_schema( - parameters=[ - OpenApiParameter( - "search", - OpenApiTypes.STR, - description="Case-insensitive search. Matches workflow name and description first; only when nothing matches those, it matches step names and the subject line, preheader and body text of email steps, in both the live workflow and its pending draft.", - ), - OpenApiParameter( - "created_by", - OpenApiTypes.UUID, - description="Filter to workflows created by the user with this uuid.", - ), - OpenApiParameter( - "type", - OpenApiTypes.STR, - description="Comma-separated workflow types. `loop` and `broadcast` return the workflows those surfaces own; `messaging` returns the remaining workflows with an email, SMS, or push action, and `automation` the rest.", - ), - OpenApiParameter( - "origin_product", - OpenApiTypes.STR, - enum=HogFlow.OriginProduct.values, - description="Filter to workflows owned by a product surface, e.g. `loops` for Desktop loops.", - ), - OpenApiParameter( - "trigger", - OpenApiTypes.STR, - description='Filter by trigger config as a JSON object. Returns workflows whose trigger contains the given object, e.g. {"type": "event"}.', - ), - OpenApiParameter( - "broadcast_eligible", - OpenApiTypes.BOOL, - description="Pass `true` to return broadcasts plus the ordinary workflows the broadcasts UI can render: a batch trigger and a single email step.", - ), - ] - ) -) +@extend_schema_view(list=extend_schema(parameters=LIST_QUERY_PARAMETERS)) class HogFlowViewSet( TeamAndOrgViewSetMixin, AccessControlViewSetMixin, LogEntryMixin, AppMetricsMixin, viewsets.ModelViewSet ): scope_object = "hog_flow" scope_object_read_actions = [ "list", + "summaries", "retrieve", "logs", "metrics", @@ -4050,6 +4105,7 @@ class HogFlowViewSet( ] queryset = HogFlow.objects.all() pagination_class = HogFlowPagination + LIST_ACTIONS: Final = frozenset({"list", "summaries"}) filter_backends = [DjangoFilterBackend] filterset_class = HogFlowFilterSet log_source = "hog_flow" @@ -4110,6 +4166,8 @@ def get_serializer_class(self) -> type[BaseSerializer]: if self.request is not None and self._is_mcp_request(self.request): return HogFlowSummarySerializer return HogFlowMinimalSerializer + if self.action == "summaries": + return HogFlowListSummarySerializer if self.action in ("update", "partial_update"): return HogFlowUpdateSerializer return HogFlowSerializer @@ -4124,10 +4182,11 @@ def get_serializer_context(self) -> dict: return context def safely_get_queryset(self, queryset: QuerySet) -> QuerySet: - if self.action == "list": - # `id` breaks ties so LIMIT/OFFSET paging stays stable: rows sharing an updated_at can - # otherwise repeat on one page and never appear on another. - queryset = queryset.order_by("-updated_at", "-id") + if self.action in self.LIST_ACTIONS: + # `id` breaks ties so LIMIT/OFFSET paging stays stable. `summaries` loads every page, and a save + # during that load changes `updated_at`, so it sorts on `created_at`. + ordering = ("-created_at", "-id") if self.action == "summaries" else ("-updated_at", "-id") + queryset = queryset.order_by(*ordering).select_related("created_by") created_by = self.request.GET.get("created_by") if created_by: @@ -4184,7 +4243,7 @@ def filter_queryset(self, queryset: QuerySet) -> QuerySet: # Search runs after the filter backends so the tier decision below sees the same rows the response # will: a name match that the `status` filter then drops must not stop the step search from running. queryset = super().filter_queryset(queryset) - if self.action != "list": + if self.action not in self.LIST_ACTIONS: return queryset search = (self.request.GET.get("search") or "").strip() @@ -4201,9 +4260,13 @@ def filter_queryset(self, queryset: QuerySet) -> QuerySet: # name, so the common search stays cheap and a subject line or body text, which rarely appears in a # workflow name, is still found. by_name = Q(name__iregex=regex_pattern) | Q(description__iregex=regex_pattern) + by_content = Q(_action_content_matches(regex_pattern)) + # `summaries` callers filter rows in the browser, so a name match must not hide the content matches. + if self.action == "summaries": + return queryset.filter(by_name | by_content) if queryset.filter(by_name).exists(): return queryset.filter(by_name) - return queryset.filter(Q(_action_content_matches(regex_pattern))) + return queryset.filter(by_content) def safely_get_object(self, queryset): # TODO(team-workflows): Somehow implement version lookups @@ -4213,6 +4276,24 @@ def safely_get_object(self, queryset): def _is_mcp_request(request: Request) -> bool: return request.headers.get("x-posthog-client") == "mcp" + @extend_schema( + summary="List workflow summaries", + description=( + "Workflow rows without the step graph, for loading a whole project's list page by page. " + "Sorted newest created first. Takes the same filters as the list." + ), + parameters=SUMMARIES_QUERY_PARAMETERS, + responses={200: HogFlowListSummarySerializer(many=True)}, + ) + @action(detail=False, methods=["GET"], url_path="summaries") + def summaries(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Applies the access-level filter itself: the routing mixin only applies it to `list`.""" + queryset = self.get_queryset() + if not is_service_auth(request): + queryset = self.user_access_control.filter_queryset_by_access_level(queryset) + page = self.paginate_queryset(annotate_workflow_type(self.filter_queryset(queryset))) + return self.get_paginated_response(self.get_serializer(page, many=True).data) + @extend_schema( request=HogInvocationRerunRequestSerializer, responses={200: HogInvocationRerunResponseSerializer, 400: HogInvocationRerunResponseSerializer}, diff --git a/products/workflows/backend/api/test/test_hog_flow.py b/products/workflows/backend/api/test/test_hog_flow.py index cb430e23cc30..919b3d949410 100644 --- a/products/workflows/backend/api/test/test_hog_flow.py +++ b/products/workflows/backend/api/test/test_hog_flow.py @@ -346,6 +346,22 @@ def test_list_search_matches_name_description_and_step_content(self, _name, quer assert response.status_code == 200, response.json() assert {flow["name"] for flow in response.json()["results"]} == expected_names + def test_summaries_search_returns_name_and_step_matches_together(self): + HogFlow.objects.create(team=self.team, name="March campaign", created_by=self.user) + HogFlow.objects.create( + team=self.team, + name="Billing", + created_by=self.user, + actions=[_email_step("email_1", "Monthly invoice email", subject="Your invoice for March is ready")], + ) + + list_response = self.client.get(f"/api/projects/{self.team.id}/hog_flows?search=march") + assert {flow["name"] for flow in list_response.json()["results"]} == {"March campaign"} + + response = self.client.get(f"/api/projects/{self.team.id}/hog_flows/summaries?search=march") + assert response.status_code == 200, response.json() + assert {flow["name"] for flow in response.json()["results"]} == {"March campaign", "Billing"} + def test_list_search_step_tier_counts_every_match_across_pages(self): for name in ("Alpha", "Beta"): HogFlow.objects.create( @@ -448,6 +464,22 @@ def test_list_filter_by_workflow_type(self, _name, workflow_type, expected_names assert response.status_code == 200, response.json() assert {flow["name"] for flow in response.json()["results"]} == expected_names + summaries = self.client.get(f"/api/projects/{self.team.id}/hog_flows/summaries?type={workflow_type}") + assert summaries.status_code == 200, summaries.json() + rows = summaries.json()["results"] + assert {row["name"] for row in rows} == expected_names + assert {row["type"] for row in rows} <= set(workflow_type.split(",")) + + def test_summaries_keep_their_order_when_a_workflow_is_saved(self): + first = HogFlow.objects.create(team=self.team, name="First", created_by=self.user) + HogFlow.objects.create(team=self.team, name="Second", created_by=self.user) + first.name = "First, renamed" + first.save() + + response = self.client.get(f"/api/projects/{self.team.id}/hog_flows/summaries") + assert response.status_code == 200, response.json() + assert [row["name"] for row in response.json()["results"]] == ["Second", "First, renamed"] + @parameterized.expand( [ ("unknown_value", "campaign"), @@ -540,6 +572,11 @@ def test_mcp_list_is_metadata_only_and_hides_action_secrets(self): assert "edges" not in result assert secret not in mcp_response.content.decode() + summaries_response = self.client.get(f"/api/projects/{self.team.id}/hog_flows/summaries") + assert summaries_response.status_code == 200, summaries_response.json() + assert "actions" not in summaries_response.json()["results"][0] + assert secret not in summaries_response.content.decode() + # The web app / raw API still get the full graph they rely on (e.g. client-side duplication) — # and it does carry the secret, proving the MCP omission above is the summary serializer at # work, not validation quietly dropping the header. diff --git a/products/workflows/backend/api/test/test_hog_flow_access_control.py b/products/workflows/backend/api/test/test_hog_flow_access_control.py index c16423bd682a..045656a373f4 100644 --- a/products/workflows/backend/api/test/test_hog_flow_access_control.py +++ b/products/workflows/backend/api/test/test_hog_flow_access_control.py @@ -151,10 +151,13 @@ def test_object_level_none_blocks_and_excludes_from_list(self): self._create_access_control(self.viewer_user, access_level="viewer") self._create_access_control(self.viewer_user, resource_id=str(self.hog_flow.id), access_level="none") self.client.force_login(self.viewer_user) + visible = self._create_workflow(name="visible_workflow") self.assertEqual(self.client.get(self._detail_url()).status_code, status.HTTP_403_FORBIDDEN) - ids = [row["id"] for row in self.client.get(self._list_url()).json()["results"]] - self.assertNotIn(str(self.hog_flow.id), ids) + for url in (self._list_url(), f"{self._list_url()}/summaries"): + ids = [row["id"] for row in self.client.get(url).json()["results"]] + self.assertNotIn(str(self.hog_flow.id), ids, url) + self.assertIn(str(visible.id), ids, url) def test_create_blocked_without_resource_editor_access(self): # A project default of "none" leaves the member below editor, so create is rejected. diff --git a/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx b/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx new file mode 100644 index 000000000000..0da8a29b410b --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx @@ -0,0 +1,97 @@ +import { AccessControlAction } from 'lib/components/AccessControlAction' +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { LemonDivider } from 'lib/lemon-ui/LemonDivider' + +import { AccessControlLevel, AccessControlResourceType } from '~/types' + +import type { WorkflowRowAction } from './workflowRowActions' + +export interface WorkflowRowMenuOverlayProps { + status: string + userAccessLevel?: AccessControlLevel + pendingAction?: WorkflowRowAction + onToggleStatus: () => void + onDuplicate: () => void + onArchive: () => void + onRestore: () => void + onDelete: () => void +} + +/** The actions of one workflow row, shared by both workflows lists. */ +export function WorkflowRowMenuOverlay({ + status, + userAccessLevel, + pendingAction, + onToggleStatus, + onDuplicate, + onArchive, + onRestore, + onDelete, +}: WorkflowRowMenuOverlayProps): JSX.Element { + const pendingState = (action: WorkflowRowAction): { loading: boolean; disabledReason?: string } => ({ + loading: pendingAction === action, + disabledReason: pendingAction && pendingAction !== action ? 'Wait for the current change to finish' : undefined, + }) + return ( + <> + {status !== 'archived' && ( + + + {status === 'draft' ? 'Enable' : 'Disable'} + + + )} + + Duplicate + + + + + {status === 'archived' ? 'Restore' : 'Archive'} + + + {status === 'archived' && ( + + + Delete + + + )} + + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowStatusTag.tsx b/products/workflows/frontend/Workflows/WorkflowStatusTag.tsx new file mode 100644 index 000000000000..5863d33587b6 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowStatusTag.tsx @@ -0,0 +1,8 @@ +import { LemonTag } from '@posthog/lemon-ui' + +import { WORKFLOW_STATUS_CONFIG, WorkflowStatusValue } from './workflowStatus' + +export function WorkflowStatusTag({ status }: { status: string }): JSX.Element { + const config = WORKFLOW_STATUS_CONFIG[status as WorkflowStatusValue] || WORKFLOW_STATUS_CONFIG.draft + return {config.label} +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.stories.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.stories.tsx new file mode 100644 index 000000000000..4244f978bec0 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.stories.tsx @@ -0,0 +1,126 @@ +import type { Meta, StoryObj } from '@storybook/react' +import userEvent from '@testing-library/user-event' +import clsx from 'clsx' +import { useState } from 'react' + +import { FacetDefinition, FacetSearchValue } from './facetQuery' +import { FacetSearchBar } from './FacetSearchBar' + +interface Item { + name: string + status: string + channel: string[] + subjects: string[] +} + +const FACETS: FacetDefinition[] = [ + { + key: 'status', + label: 'Status', + description: 'Draft, active or archived', + showOnFocus: true, + order: 1, + getValues: (item) => [item.status], + formatValue: (value) => value[0].toUpperCase() + value.slice(1), + }, + { + key: 'channel', + label: 'Channel', + description: 'What it sends', + showOnFocus: true, + order: 2, + getValues: (item) => item.channel, + }, + { + key: 'sends', + aliases: ['subject'], + label: 'Sends', + description: 'Email subject', + showOnFocus: true, + order: 3, + getValues: (item) => item.subjects, + }, +] + +const ITEMS: Item[] = [ + { name: 'Welcome series', status: 'active', channel: ['email'], subjects: ['Welcome to Example'] }, + { name: 'Renewal reminder', status: 'draft', channel: ['email', 'sms'], subjects: ['Your plan renews soon'] }, + { name: 'Sync to CRM', status: 'draft', channel: ['webhook'], subjects: [] }, + { name: 'Spring promo', status: 'archived', channel: ['email'], subjects: ['Spring deals inside'] }, +] + +interface HarnessProps { + initial: FacetSearchValue + narrow?: boolean +} + +function Harness({ initial, narrow }: HarnessProps): JSX.Element { + const [value, setValue] = useState(initial) + return ( + // 520px is the scene width with the side panel open on a 1280px window. +
+ item.name.toLowerCase().includes(text.toLowerCase())} + placeholder="Search workflows, or filter with status:, channel: and more" + dataAttr="facet-search-bar-story" + /> +
+ ) +} + +const meta: Meta = { + title: 'Scenes-App/Workflows/Facet search bar', + component: Harness, + parameters: { layout: 'fullscreen' }, + args: { initial: { filters: [], text: '' } }, +} +export default meta + +type Story = StoryObj + +const typeInto = async (canvasElement: HTMLElement, text: string): Promise => { + const input = canvasElement.querySelector('input[data-attr="facet-search-bar-story"]')! + await userEvent.click(input) + if (text) { + await userEvent.keyboard(text) + } +} + +export const Empty: Story = {} + +export const Focused: Story = { + play: async ({ canvasElement }) => typeInto(canvasElement, ''), +} + +export const Typing: Story = { + play: async ({ canvasElement }) => typeInto(canvasElement, 'sta'), +} + +export const ValueDraftWithCounts: Story = { + args: { initial: { filters: [{ facet: 'channel', value: 'email', negated: false }], text: '' } }, + play: async ({ canvasElement }) => typeInto(canvasElement, 'status:'), +} + +export const NegatedDraft: Story = { + play: async ({ canvasElement }) => typeInto(canvasElement, '-status:'), +} + +export const ManyPillsNarrow: Story = { + args: { + narrow: true, + initial: { + filters: [ + { facet: 'status', value: 'active', negated: false }, + { facet: 'status', value: 'draft', negated: false }, + { facet: 'channel', value: 'email', negated: false }, + { facet: 'channel', value: 'webhook', negated: true }, + { facet: 'sends', value: 'Your plan renews soon', negated: false }, + ], + text: 'renewal', + }, + }, +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.test.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.test.tsx new file mode 100644 index 000000000000..1fddc11c4e10 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.test.tsx @@ -0,0 +1,208 @@ +import '@testing-library/jest-dom' + +import { cleanup, fireEvent, render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' + +import { initKeaTests } from '~/test/init' + +import { FacetDefinition, FacetSearchValue, serializeFacetQuery } from './facetQuery' +import { FacetSearchBar } from './FacetSearchBar' + +interface Item { + name: string + status: string + subjects: string[] +} + +const FACETS: FacetDefinition[] = [ + { + key: 'status', + label: 'Status', + description: 'Lifecycle', + showOnFocus: true, + getValues: (item) => [item.status], + formatValue: (value) => value[0].toUpperCase() + value.slice(1), + }, + { key: 'sends', label: 'Sends', description: 'Email subject', getValues: (item) => item.subjects }, +] + +const ITEMS: Item[] = [ + { name: 'Welcome', status: 'active', subjects: ['Your trial ends'] }, + { name: 'Renewal', status: 'draft', subjects: [] }, + { name: 'Promo', status: 'archived', subjects: [] }, +] + +function Harness({ initial }: { initial: FacetSearchValue }): JSX.Element { + const [value, setValue] = useState(initial) + return ( +
+ + item.name.toLowerCase().includes(text.toLowerCase())} + placeholder="Search" + dataAttr="test-search" + /> + {serializeFacetQuery(value.filters)} + {value.text} + +
+ ) +} + +const input = (): HTMLInputElement => document.querySelector('input[data-attr="test-search"]')! +const shown = (attr: string): string => document.querySelector(`[data-attr="${attr}"]`)?.textContent ?? '' +const listbox = (): Element | null => document.querySelector('[role="listbox"]') + +describe('FacetSearchBar', () => { + beforeEach(() => initKeaTests()) + afterEach(() => cleanup()) + + const setup = (initial: FacetSearchValue = { filters: [], text: '' }): ReturnType => { + render() + return userEvent.setup() + } + + it('Tab and → take the first filter row, never the search row', async () => { + const user = setup() + await user.click(input()) + await user.keyboard('sta{ArrowDown}') + expect(document.querySelector('[role="option"][aria-selected="true"]')).toHaveTextContent('Search for "sta"') + + await user.keyboard('{Tab}') + expect(input()).toHaveValue('status:') + expect(shown('text')).toEqual('') + + await user.keyboard('act{ArrowRight}') + expect(shown('query')).toEqual('status:active') + expect(input()).toHaveValue('') + expect(input()).toHaveFocus() + }) + + it('Enter on the search row closes the popover and keeps the text', async () => { + const user = setup() + await user.click(input()) + await user.keyboard('renew') + expect(listbox()).not.toBeNull() + + await user.keyboard('{Enter}') + expect(listbox()).toBeNull() + expect(input()).toHaveValue('renew') + expect(shown('text')).toEqual('renew') + expect(shown('query')).toEqual('') + }) + + it('Esc closes the popover', async () => { + const user = setup() + await user.click(input()) + expect(listbox()).not.toBeNull() + await user.keyboard('{Escape}') + expect(listbox()).toBeNull() + }) + + it('Backspace on an empty input removes the last pill', async () => { + const user = setup({ + filters: [ + { facet: 'status', value: 'draft', negated: false }, + { facet: 'status', value: 'archived', negated: true }, + ], + text: '', + }) + await user.click(input()) + await user.keyboard('{Backspace}') + expect(shown('query')).toEqual('status:draft') + }) + + it('Tab on an empty input moves focus out of the bar', async () => { + const user = setup() + await user.click(input()) + await user.keyboard('{Tab}') + expect(document.querySelector('[data-attr="after"]')).toHaveFocus() + }) + + it.each([ + ['a typed facet value followed by a space', 'status:active ', 'status:active', ''], + ['a closed quoted value', 'sends:"Your trial ends"', 'sends:"Your trial ends"', ''], + ['text around a typed facet value', 'wel status:active ren', 'status:active', 'wel ren'], + ])('turns %s into a pill', async (_, typed, query, text) => { + const user = setup() + await user.click(input()) + await user.keyboard(typed) + expect(shown('query')).toEqual(query) + expect(shown('text')).toEqual(text) + }) + + it('turns a pasted query into pills', async () => { + const user = setup() + await user.click(input()) + await user.paste('status:draft -status:archived ') + expect(shown('query')).toEqual('status:draft -status:archived') + expect(input()).toHaveValue('') + }) + + it('adds a pasted token once, even when the paste repeats it', async () => { + const user = setup() + await user.click(input()) + await user.paste('status:active status:active ') + expect(shown('query')).toEqual('status:active') + expect(document.querySelectorAll('[aria-label="Remove filter Status: Active"]')).toHaveLength(1) + }) + + it('Shift+Tab moves focus back instead of applying a filter', async () => { + const user = setup() + await user.click(input()) + await user.keyboard('sta') + await user.keyboard('{Shift>}{Tab}{/Shift}') + expect(document.querySelector('[data-attr="before"]')).toHaveFocus() + expect(shown('query')).toEqual('') + }) + + it('Enter while an IME is composing does not apply the highlighted row', async () => { + const user = setup() + await user.click(input()) + await user.keyboard('status:') + fireEvent.keyDown(input(), { key: 'Enter', isComposing: true }) + expect(shown('query')).toEqual('') + expect(input()).toHaveValue('status:') + }) + + it('keeps focus in the input when the popover chrome is pressed', async () => { + const user = setup() + await user.click(input()) + const hints = document.querySelector('[data-attr="facet-search-bar-hints"]')! + // fireEvent returns false when the handler prevented the default, which is what keeps the focus. + expect(fireEvent.mouseDown(hints)).toBe(false) + }) + + it('shows the no-values message as text, not as an option', async () => { + const user = setup() + await user.click(input()) + await user.keyboard('status:zzz') + expect(document.querySelectorAll('[role="option"]')).toHaveLength(0) + expect(listbox()).toHaveTextContent('No values match your other filters') + expect(input()).not.toHaveAttribute('aria-activedescendant') + }) + + it('exposes the combobox and labels each pill remove button', async () => { + const user = setup({ filters: [{ facet: 'status', value: 'draft', negated: true }], text: '' }) + expect(input()).toHaveAttribute('role', 'combobox') + expect(input()).toHaveAttribute('aria-expanded', 'false') + expect(document.querySelector('[aria-label="Remove filter Status is not: Draft"]')).not.toBeNull() + + await user.click(input()) + await user.keyboard('status:') + expect(input()).toHaveAttribute('aria-expanded', 'true') + expect(input().getAttribute('aria-controls')).toEqual(listbox()!.id) + const selected = document.querySelector('[role="option"][aria-selected="true"]')! + expect(input().getAttribute('aria-activedescendant')).toEqual(selected.id) + expect(shown('facet-search-bar-hints')).toContain('Esc to close') + }) +}) diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.tsx new file mode 100644 index 000000000000..e82b77fd6fe6 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.tsx @@ -0,0 +1,200 @@ +import clsx from 'clsx' +import { useActions, useValues } from 'kea' +import { useEffect } from 'react' + +import { IconSearch } from '@posthog/icons' +import { LemonButton, LemonInput, LemonSnack, Popover } from '@posthog/lemon-ui' + +import { + FacetDefinition, + FacetFilter, + FacetSearchValue, + MatchesText, + facetFilterKey, + findFacet, + formatFacetValue, +} from './facetQuery' +import { facetSearchBarLogic } from './facetSearchBarLogic' + +export interface FacetSearchBarProps { + facets: FacetDefinition[] + /** Loaded items. Suggestions and their counts come from these; the bar never fetches. */ + items: TItem[] + value: FacetSearchValue + onChange: (value: FacetSearchValue) => void + matchesText: MatchesText + /** Shown while no pill is set. */ + placeholder: string + /** Keys the bar's state and marks the input for autocapture. */ + dataAttr: string +} + +function pillLabel(facets: FacetDefinition[], filter: FacetFilter): string { + const facet = findFacet(facets, filter.facet) + return `${facet?.label ?? filter.facet}${filter.negated ? ' is not' : ''}: ${formatFacetValue(facet, filter.value)}` +} + +export function FacetSearchBar({ + facets, + items, + value, + onChange, + matchesText, + placeholder, + dataAttr, +}: FacetSearchBarProps): JSX.Element { + const logic = facetSearchBarLogic({ id: dataAttr, facets, items, value, onChange, matchesText }) + const { input, open, suggestions, highlightedIndex, highlightedSuggestion, tabTarget, title, hints } = + useValues(logic) + const { + setInput, + setOpen, + moveHighlight, + applySuggestion, + applyHighlighted, + applyTabTarget, + removeFilter, + removeLastFilter, + } = useActions(logic) + + const listboxId = `${dataAttr}-listbox` + const optionId = (index: number): string => `${listboxId}-option-${index}` + const activeOptionId = open && highlightedSuggestion ? optionId(highlightedIndex) : undefined + + useEffect(() => { + if (activeOptionId) { + document.getElementById(activeOptionId)?.scrollIntoView?.({ block: 'nearest' }) + } + }, [activeOptionId]) + + const onKeyDown = (event: React.KeyboardEvent): void => { + // Keys during IME composition belong to the input method, not to the suggestions. + if (event.nativeEvent.isComposing) { + return + } + const caretAtEnd = + event.currentTarget.selectionStart === input.length && event.currentTarget.selectionEnd === input.length + if (event.key === 'ArrowDown') { + event.preventDefault() + if (open) { + moveHighlight(1) + } else { + setOpen(true) + } + } else if (event.key === 'ArrowUp') { + event.preventDefault() + moveHighlight(-1) + } else if (event.key === 'Enter') { + event.preventDefault() + if (open) { + applyHighlighted() + } + } else if ( + ((event.key === 'Tab' && !event.shiftKey) || (event.key === 'ArrowRight' && caretAtEnd)) && + open && + tabTarget + ) { + event.preventDefault() + applyTabTarget() + } else if (event.key === 'Escape') { + setOpen(false) + } else if (event.key === 'Backspace' && input === '' && value.filters.length > 0) { + event.preventDefault() + removeLastFilter() + } + } + + const overlay = open ? ( + // Pressing anywhere in the popover (scrollbar, title, hint row) keeps the focus, and so the popover, in the input. +
event.preventDefault()}> +
{title}
+
+ {suggestions.map((suggestion, index) => + suggestion.kind === 'none' ? ( +
+ {suggestion.label} +
+ ) : ( + applySuggestion(suggestion)} + > + + + {suggestion.label} + + {suggestion.detail && ( + {suggestion.detail} + )} + {suggestion.count !== undefined && ( + + {suggestion.count} + + )} + + + ) + )} +
+
+ {hints.map((hint) => ( + {hint} + ))} +
+
+ ) : null + + return ( + // The input's blur is the one close path: pressing outside moves the focus away. + +
+ setOpen(true)} + onBlur={() => setOpen(false)} + prefix={ + <> + + {value.filters.map((filter) => { + const label = pillLabel(facets, filter) + return ( + removeFilter(filter)} + className="max-w-80" + > + {label} + + ) + })} + + } + /> +
+
+ ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.test.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.test.ts new file mode 100644 index 000000000000..10d27548049a --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.test.ts @@ -0,0 +1,143 @@ +import { + FacetDefinition, + FacetSearchValue, + countFacetValues, + matchesFacetQuery, + parseFacetQuery, + serializeFacetQuery, +} from './facetQuery' + +interface Item { + name: string + status: string + subjects: string[] + senders: string[] +} + +const FACETS: FacetDefinition[] = [ + { key: 'status', label: 'Status', description: 'Lifecycle', getValues: (item) => [item.status] }, + { + key: 'sends', + aliases: ['subject'], + label: 'Sends', + description: 'Email subject', + getValues: (item) => item.subjects, + }, + { key: 'from', label: 'From', description: 'Sender', getValues: (item) => item.senders }, +] + +const ITEMS: Item[] = [ + { name: 'Welcome', status: 'active', subjects: ['Hi there', 'Your trial ends'], senders: ['a@example.com'] }, + { name: 'Renewal', status: 'draft', subjects: ['Renew now'], senders: ['b@example.com'] }, + { name: 'Sync', status: 'draft', subjects: [], senders: [] }, + { name: 'Promo', status: 'archived', subjects: ['Deals'], senders: ['a@example.com', 'b@example.com'] }, +] + +const matchesText = (item: Item, text: string): boolean => item.name.toLowerCase().includes(text.toLowerCase()) + +const names = (value: FacetSearchValue): string[] => + ITEMS.filter((item) => matchesFacetQuery(item, value, FACETS, matchesText)).map((item) => item.name) + +describe('facetQuery', () => { + it.each([ + ['status:active', [{ facet: 'status', value: 'active', negated: false }]], + ['-status:archived', [{ facet: 'status', value: 'archived', negated: true }]], + ['sends:"Your trial ends"', [{ facet: 'sends', value: 'Your trial ends', negated: false }]], + ['subject:Deals', [{ facet: 'sends', value: 'Deals', negated: false }]], + ['sends:"Say \\"hi\\""', [{ facet: 'sends', value: 'Say "hi"', negated: false }]], + ['nope:x status:draft', [{ facet: 'status', value: 'draft', negated: false }]], + ['status: status:""', []], + ['STATUS:active', [{ facet: 'status', value: 'active', negated: false }]], + ])('parses %s', (query, filters) => { + expect(parseFacetQuery(query, FACETS)).toEqual(filters) + }) + + it.each([ + 'status:active', + '-status:archived status:draft', + 'sends:"Your trial ends"', + 'sends:"Say \\"hi\\""', + 'from:a@example.com -sends:Deals', + ])('round-trips %s', (query) => { + const filters = parseFacetQuery(query, FACETS) + expect(serializeFacetQuery(filters)).toEqual(query) + expect(parseFacetQuery(serializeFacetQuery(filters), FACETS)).toEqual(filters) + }) + + it('serializes an alias to the canonical key', () => { + expect(serializeFacetQuery(parseFacetQuery('subject:Deals', FACETS))).toEqual('sends:Deals') + }) + + it.each<[string, FacetSearchValue, string[]]>([ + ['no filters', { filters: [], text: '' }, ['Welcome', 'Renewal', 'Sync', 'Promo']], + [ + 'OR within a facet', + { + filters: [ + { facet: 'status', value: 'active', negated: false }, + { facet: 'status', value: 'archived', negated: false }, + ], + text: '', + }, + ['Welcome', 'Promo'], + ], + [ + 'AND across facets', + { + filters: [ + { facet: 'status', value: 'draft', negated: false }, + { facet: 'from', value: 'b@example.com', negated: false }, + ], + text: '', + }, + ['Renewal'], + ], + [ + 'negation excludes, and an item with no values passes it', + { filters: [{ facet: 'from', value: 'a@example.com', negated: true }], text: '' }, + ['Renewal', 'Sync'], + ], + [ + 'an item with no values fails a positive pill', + { filters: [{ facet: 'sends', value: 'Renew now', negated: false }], text: '' }, + ['Renewal'], + ], + [ + 'a value on any of several steps matches, case-insensitively', + { filters: [{ facet: 'sends', value: 'your TRIAL ends', negated: false }], text: '' }, + ['Welcome'], + ], + [ + 'text is AND with the pills', + { filters: [{ facet: 'status', value: 'draft', negated: false }], text: 'syn' }, + ['Sync'], + ], + ])('matches: %s', (_, value, expected) => { + expect(names(value)).toEqual(expected) + }) + + it('counts values ignoring the same facet pills but respecting other facets and the text', () => { + const value: FacetSearchValue = { + filters: [ + { facet: 'status', value: 'draft', negated: false }, + { facet: 'from', value: 'b@example.com', negated: false }, + ], + text: '', + } + expect(countFacetValues(ITEMS, 'status', value, FACETS, matchesText)).toEqual([ + { value: 'archived', count: 1 }, + { value: 'draft', count: 1 }, + ]) + expect(countFacetValues(ITEMS, 'from', value, FACETS, matchesText)).toEqual([ + { value: 'b@example.com', count: 1 }, + ]) + expect(countFacetValues(ITEMS, 'status', { filters: [], text: 'e' }, FACETS, matchesText)).toEqual([ + { value: 'active', count: 1 }, + { value: 'draft', count: 1 }, + ]) + expect(countFacetValues(ITEMS, 'status', { filters: [], text: '' }, FACETS, matchesText)[0]).toEqual({ + value: 'draft', + count: 2, + }) + }) +}) diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.ts new file mode 100644 index 000000000000..95b105fbdfa0 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.ts @@ -0,0 +1,203 @@ +export interface FacetDefinition { + /** Typed before the colon, lowercase: `status`. */ + key: string + /** Other keys that resolve to this facet: `creator` for `created-by`. */ + aliases?: string[] + /** Sentence case, shown in pills and suggestions: `Created by`. */ + label: string + /** One short line shown next to the key in the facet list. */ + description: string + /** Every value the item has. An item with no values never matches a positive pill and always passes a negated one. */ + getValues: (item: TItem) => string[] + /** Display form of a stored value: `active` → `Active`, a user uuid → a name. */ + formatValue?: (value: string) => string + /** Listed when the input is empty. Other facets are found by typing. */ + showOnFocus?: boolean + /** Lower sorts first. */ + order?: number +} + +export interface FacetFilter { + facet: string + value: string + negated: boolean +} + +export interface FacetSearchValue { + filters: FacetFilter[] + text: string +} + +export interface FacetValueCount { + value: string + count: number +} + +export type MatchesText = (item: TItem, text: string) => boolean + +export function findFacet(facets: FacetDefinition[], key: string): FacetDefinition | undefined { + const lowered = key.toLowerCase() + return facets.find((facet) => facet.key === lowered || facet.aliases?.includes(lowered)) +} + +export function formatFacetValue(facet: FacetDefinition | undefined, value: string): string { + return facet?.formatValue ? facet.formatValue(value) : value +} + +export function sortFacets(facets: FacetDefinition[]): FacetDefinition[] { + return [...facets].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) +} + +export function facetFilterKey(filter: FacetFilter): string { + return `${filter.negated ? '-' : ''}${filter.facet}:${filter.value.toLowerCase()}` +} + +const TOKEN = /(-?)([\w-]+):(?:"((?:[^"\\]|\\.)*)"|(\S*))/y + +/** Reads `facet:value`, `-facet:value` and `facet:"quoted value"`. Unknown facets, empty values and repeats are dropped. */ +export function parseFacetQuery(query: string, facets: FacetDefinition[]): FacetFilter[] { + const filters: FacetFilter[] = [] + const seen = new Set() + let index = 0 + while (index < query.length) { + if (/\s/.test(query[index])) { + index++ + continue + } + TOKEN.lastIndex = index + const match = TOKEN.exec(query) + if (!match) { + const nextSpace = query.slice(index).search(/\s/) + index = nextSpace === -1 ? query.length : index + nextSpace + continue + } + index = TOKEN.lastIndex + const facet = findFacet(facets, match[2]) + const value = match[3] !== undefined ? match[3].replace(/\\(.)/g, '$1') : match[4] + if (!facet || !value) { + continue + } + const filter = { facet: facet.key, value, negated: match[1] === '-' } + const key = facetFilterKey(filter) + if (!seen.has(key)) { + seen.add(key) + filters.push(filter) + } + } + return filters +} + +function serializeFacetValue(value: string): string { + return /[\s"]/.test(value) ? `"${value.replace(/[\\"]/g, (char) => `\\${char}`)}"` : value +} + +export function serializeFacetQuery(filters: FacetFilter[]): string { + return filters + .map((filter) => `${filter.negated ? '-' : ''}${filter.facet}:${serializeFacetValue(filter.value)}`) + .join(' ') +} + +interface FacetFilterGroup { + facet: FacetDefinition + positive: Set + negative: Set +} + +/** Groups the pills by facet once, so matching many items doesn't regroup them per item. */ +function groupFilters(filters: FacetFilter[], facets: FacetDefinition[]): FacetFilterGroup[] { + const groups = new Map>() + for (const filter of filters) { + const facet = findFacet(facets, filter.facet) + if (!facet) { + continue + } + const group = groups.get(facet.key) ?? { facet, positive: new Set(), negative: new Set() } + ;(filter.negated ? group.negative : group.positive).add(filter.value.toLowerCase()) + groups.set(facet.key, group) + } + return [...groups.values()] +} + +function passesGroups(item: TItem, groups: FacetFilterGroup[], skipFacet?: string): boolean { + for (const { facet, positive, negative } of groups) { + if (facet.key === skipFacet) { + continue + } + const values = facet.getValues(item).map((value) => value.toLowerCase()) + if (positive.size && !values.some((value) => positive.has(value))) { + return false + } + if (values.some((value) => negative.has(value))) { + return false + } + } + return true +} + +/** Pills on one facet are OR, pills on different facets are AND, and the text is AND with the pills. */ +export function createFacetMatcher( + value: FacetSearchValue, + facets: FacetDefinition[], + matchesText: MatchesText +): (item: TItem) => boolean { + const groups = groupFilters(value.filters, facets) + const text = value.text.trim() + return (item) => passesGroups(item, groups) && (!text || matchesText(item, text)) +} + +export function matchesFacetQuery( + item: TItem, + value: FacetSearchValue, + facets: FacetDefinition[], + matchesText: MatchesText +): boolean { + return createFacetMatcher(value, facets, matchesText)(item) +} + +/** + * Returns a counter of each facet's values over the items the other pills and the text let through. + * A facet's own pills are left out, so picking one value keeps the counts of its OR alternatives. + * The text is matched once per item, however many facets get counted. + */ +export function createFacetCounter( + items: TItem[], + value: FacetSearchValue, + facets: FacetDefinition[], + matchesText: MatchesText +): (facetKey: string) => FacetValueCount[] { + const text = value.text.trim() + const textMatches = text ? items.filter((item) => matchesText(item, text)) : items + const groups = groupFilters(value.filters, facets) + return (facetKey) => { + const facet = findFacet(facets, facetKey) + if (!facet) { + return [] + } + const counts = new Map() + for (const item of textMatches) { + if (!passesGroups(item, groups, facet.key)) { + continue + } + const itemValues = new Map(facet.getValues(item).map((itemValue) => [itemValue.toLowerCase(), itemValue])) + for (const [lowered, itemValue] of itemValues) { + const entry = counts.get(lowered) + if (entry) { + entry.count++ + } else { + counts.set(lowered, { value: itemValue, count: 1 }) + } + } + } + return [...counts.values()].sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)) + } +} + +export function countFacetValues( + items: TItem[], + facetKey: string, + value: FacetSearchValue, + facets: FacetDefinition[], + matchesText: MatchesText +): FacetValueCount[] { + return createFacetCounter(items, value, facets, matchesText)(facetKey) +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.test.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.test.ts new file mode 100644 index 000000000000..d50867ece6d8 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.test.ts @@ -0,0 +1,158 @@ +import { initKeaTests } from '~/test/init' + +import { FacetDefinition, FacetSearchValue } from './facetQuery' +import { facetSearchBarLogic } from './facetSearchBarLogic' + +interface Item { + name: string + status: string + subjects: string[] +} + +const FACETS: FacetDefinition[] = [ + { + key: 'status', + label: 'Status', + description: 'Lifecycle', + showOnFocus: true, + order: 1, + getValues: (item) => [item.status], + formatValue: (value) => value[0].toUpperCase() + value.slice(1), + }, + { + key: 'sends', + aliases: ['subject'], + label: 'Sends', + description: 'Email subject', + showOnFocus: true, + order: 2, + getValues: (item) => item.subjects, + }, + { key: 'stage', label: 'Stage', description: 'Found by typing', order: 3, getValues: () => ['one'] }, +] + +const ITEMS: Item[] = [ + { name: 'Welcome', status: 'active', subjects: ['Start here', 'Status update'] }, + { name: 'Renewal', status: 'draft', subjects: ['Renew now'] }, + { name: 'Sync', status: 'draft', subjects: [] }, + { name: 'Promo', status: 'archived', subjects: ['Deals'] }, +] + +const matchesText = (item: Item, text: string): boolean => item.name.toLowerCase().includes(text.toLowerCase()) + +describe('facetSearchBarLogic', () => { + let onChange: jest.Mock + let logic: ReturnType + + const mountWith = (value: FacetSearchValue): void => { + logic = facetSearchBarLogic({ id: 'test', facets: FACETS, items: ITEMS, value, onChange, matchesText }) + logic.mount() + } + + beforeEach(() => { + initKeaTests() + onChange = jest.fn() + }) + + afterEach(() => logic?.unmount()) + + it('lists the on-focus facets for an empty input, in order', () => { + mountWith({ filters: [], text: '' }) + expect(logic.values.suggestions.map((s) => [s.kind, s.label, s.detail])).toEqual([ + ['facet', 'status:', 'Lifecycle'], + ['facet', 'sends:', 'Email subject'], + ]) + expect(logic.values.title).toEqual('Filter by') + }) + + it('offers matching facets, then the search, then values from any facet with counts', () => { + mountWith({ filters: [], text: '' }) + logic.actions.setInput('sta') + expect(logic.values.suggestions.map((s) => [s.kind, s.label, s.count])).toEqual([ + ['facet', 'status:', undefined], + ['facet', 'stage:', undefined], + ['search', 'Search for "sta"', undefined], + ['value', 'Sends: Start here', 1], + ['value', 'Sends: Status update', 1], + ]) + expect(logic.values.title).toEqual('Search or filter') + expect(onChange).toHaveBeenLastCalledWith({ filters: [], text: 'sta' }) + }) + + it('lists a facet draft values with counts that ignore the facet pills, minus picked values', () => { + mountWith({ + filters: [{ facet: 'status', value: 'draft', negated: false }], + text: '', + }) + logic.actions.setInput('status:') + expect(logic.values.suggestions.map((s) => [s.kind, s.label, s.count])).toEqual([ + ['value', 'Active', 1], + ['value', 'Archived', 1], + ]) + expect(logic.values.title).toEqual('Status') + + logic.actions.setInput('status:arch') + expect(logic.values.suggestions.map((s) => s.label)).toEqual(['Archived']) + }) + + it('offers "Not" values with how many rows they hide for a negated draft', () => { + mountWith({ filters: [], text: '' }) + logic.actions.setInput('-status:') + expect(logic.values.suggestions.map((s) => [s.label, s.detail, s.count])).toEqual([ + ['Not Draft', 'Hides 2', undefined], + ['Not Active', 'Hides 1', undefined], + ['Not Archived', 'Hides 1', undefined], + ]) + expect(logic.values.title).toEqual('Status is not') + }) + + it('says when no values are left', () => { + mountWith({ filters: [], text: 'promo' }) + logic.actions.setInput('promo sends:renew') + expect(logic.values.suggestions.map((s) => [s.kind, s.label])).toEqual([ + ['none', 'No values match your other filters'], + ]) + }) + + it('adds a pill from a value row and keeps the text before the draft', () => { + mountWith({ filters: [], text: '' }) + logic.actions.setInput('wel status:') + logic.actions.moveHighlight(1) + logic.actions.applyHighlighted() + expect(onChange).toHaveBeenLastCalledWith({ + filters: [{ facet: 'status', value: 'active', negated: false }], + text: 'wel', + }) + expect(logic.values.input).toEqual('wel ') + }) + + it('matches the text once per item for a suggestion build, not once per facet', () => { + const countingMatch = jest.fn(matchesText) + logic = facetSearchBarLogic({ + id: 'test', + facets: FACETS, + items: ITEMS, + value: { filters: [], text: 'e' }, + onChange, + matchesText: countingMatch, + }) + logic.mount() + logic.actions.setInput('e st') + countingMatch.mockClear() + expect(logic.values.suggestions.length).toBeGreaterThan(0) + expect(countingMatch.mock.calls.length).toBeLessThanOrEqual(ITEMS.length) + }) + + it.each([ + ['status:zzz', 0, ['↑↓ to move', 'Esc to close']], + ['', 0, ['Enter to pick status:', '↑↓ to move', 'Esc to close']], + ['sta', 2, ['Enter to search', 'Tab or → to pick status:', '↑↓ to move', 'Esc to close']], + ['sta', 0, ['Enter or Tab to pick status:', '↑↓ to move', 'Esc to close']], + ['status:', 0, ['Enter or Tab to add filter', '↑↓ to move', 'Esc to close']], + ])('hint row for %j with row %s highlighted', (input, highlight, hints) => { + mountWith({ filters: [], text: '' }) + logic.actions.setInput(input) + logic.actions.moveHighlight(highlight) + expect(logic.values.hints).toEqual(hints) + }) +}) diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.ts new file mode 100644 index 000000000000..915b0c5c6189 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.ts @@ -0,0 +1,468 @@ +import { MakeLogicType, actions, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea' + +import { + FacetDefinition, + FacetFilter, + FacetSearchValue, + MatchesText, + createFacetCounter, + facetFilterKey, + findFacet, + formatFacetValue, + sortFacets, +} from './facetQuery' + +const MAX_VALUE_SUGGESTIONS = 50 +const MAX_CROSS_FACET_SUGGESTIONS = 8 +const MIN_CROSS_FACET_TOKEN_LENGTH = 2 + +// The logic serves any item type. The typed `FacetSearchBar` component keeps callers consistent. +/* oxlint-disable @typescript-eslint/no-explicit-any */ +export interface FacetSearchBarLogicProps { + id: string + facets: FacetDefinition[] + items: any[] + value: FacetSearchValue + onChange: (value: FacetSearchValue) => void + matchesText: MatchesText +} +/* oxlint-enable @typescript-eslint/no-explicit-any */ + +type FacetSuggestionKind = 'facet' | 'value' | 'search' | 'none' + +export interface FacetSuggestion { + id: string + kind: FacetSuggestionKind + label: string + detail?: string + count?: number + /** `facet` rows: the input after picking the facet. */ + nextInput?: string + /** `value` rows: the pill to add. */ + filter?: FacetFilter + /** `value` rows: the text typed before the value, kept as the search text. */ + rest?: string +} + +export interface FacetDraft { + facetKey: string + facetLabel: string + negated: boolean + partial: string + /** The input before the draft token. It stays as the free text. */ + rest: string +} + +const DRAFT_PATTERN = /(^|\s)(-?)([\w-]+):(?:"((?:[^"\\]|\\.)*)"?|(\S*))$/ +// A token is complete when whitespace follows it, or when its quoted value is closed. +const COMPLETE_TOKEN = /(^|\s)(-?)([\w-]+):(?:"((?:[^"\\]|\\.)*)"|([^\s"]+)(?=\s))/g + +/** Takes every complete, known `facet:value` token out of the input. The rest stays as typed. */ +function extractCompleteFilters( + input: string, + facets: FacetDefinition[] +): { filters: FacetFilter[]; remaining: string } { + const filters: FacetFilter[] = [] + const remaining = input.replace( + COMPLETE_TOKEN, + (token: string, lead: string, minus: string, key: string, quoted?: string, bare?: string) => { + const facet = findFacet(facets, key) + const value = quoted !== undefined ? quoted.replace(/\\(.)/g, '$1') : bare + if (!facet || !value) { + return token + } + filters.push({ facet: facet.key, value, negated: minus === '-' }) + return lead + } + ) + return { filters, remaining: remaining.replace(/\s{2,}/g, ' ').replace(/^\s+/, '') } +} + +function parseFacetDraft(input: string, facets: FacetDefinition[]): FacetDraft | null { + const match = input.match(DRAFT_PATTERN) + if (!match || match.index === undefined) { + return null + } + const facet = findFacet(facets, match[3]) + if (!facet) { + return null + } + return { + facetKey: facet.key, + facetLabel: facet.label, + negated: match[2] === '-', + partial: match[4] !== undefined ? match[4].replace(/\\(.)/g, '$1') : match[5], + rest: input.slice(0, match.index + match[1].length), + } +} + +function splitLastToken(input: string): { rest: string; token: string } { + const match = input.match(/^([\s\S]*?)(\S*)$/) + return { rest: match?.[1] ?? '', token: match?.[2] ?? '' } +} + +function textOf(input: string, draft: FacetDraft | null): string { + return (draft ? draft.rest : input).trim() +} + +const isFilterRow = (suggestion: FacetSuggestion | null | undefined): boolean => + suggestion?.kind === 'facet' || suggestion?.kind === 'value' + +const verbOf = (suggestion: FacetSuggestion): string => + suggestion.kind === 'facet' ? `pick ${suggestion.label}` : 'add filter' + +function buildSuggestions( + input: string, + draft: FacetDraft | null, + { facets, items, value, matchesText }: Pick +): FacetSuggestion[] { + const chosen = new Set(value.filters.map(facetFilterKey)) + + if (draft) { + const facet = findFacet(facets, draft.facetKey)! + const partial = draft.partial.toLowerCase() + const counts = createFacetCounter( + items, + { filters: value.filters, text: draft.rest }, + facets, + matchesText + )(facet.key) + const rows = counts + .filter( + ({ value: v }) => !chosen.has(facetFilterKey({ facet: facet.key, value: v, negated: draft.negated })) + ) + .filter(({ value: v }) => { + const label = formatFacetValue(facet, v).toLowerCase() + return !partial || label.includes(partial) || v.toLowerCase().includes(partial) + }) + .slice(0, MAX_VALUE_SUGGESTIONS) + .map( + ({ value: v, count }): FacetSuggestion => ({ + id: `value-${facet.key}-${v}`, + kind: 'value', + label: `${draft.negated ? 'Not ' : ''}${formatFacetValue(facet, v)}`, + detail: draft.negated ? `Hides ${count}` : undefined, + count: draft.negated ? undefined : count, + filter: { facet: facet.key, value: v, negated: draft.negated }, + rest: draft.rest, + }) + ) + return rows.length ? rows : [{ id: 'none', kind: 'none', label: 'No values match your other filters' }] + } + + const ordered = sortFacets(facets) + const { rest, token } = splitLastToken(input) + + if (!token) { + return ordered + .filter((facet) => facet.showOnFocus) + .map((facet) => ({ + id: `facet-${facet.key}`, + kind: 'facet', + label: `${facet.key}:`, + detail: facet.description, + nextInput: `${input}${facet.key}:`, + })) + } + + const negated = token.startsWith('-') + const bare = (negated ? token.slice(1) : token).toLowerCase() + const result: FacetSuggestion[] = [] + if (bare) { + for (const facet of ordered) { + const names = [facet.key, facet.label.toLowerCase(), ...(facet.aliases ?? [])] + if (names.some((name) => name.startsWith(bare))) { + result.push({ + id: `facet-${facet.key}`, + kind: 'facet', + label: `${negated ? '-' : ''}${facet.key}:`, + detail: facet.description, + nextInput: `${rest}${negated ? '-' : ''}${facet.key}:`, + }) + } + } + } + result.push({ id: 'search', kind: 'search', label: `Search for "${input.trim()}"` }) + + if (bare.length >= MIN_CROSS_FACET_TOKEN_LENGTH) { + const matches: FacetSuggestion[] = [] + const countValues = createFacetCounter(items, { filters: value.filters, text: rest }, facets, matchesText) + for (const facet of ordered) { + for (const { value: v, count } of countValues(facet.key)) { + const label = formatFacetValue(facet, v) + if (!label.toLowerCase().includes(bare) && !v.toLowerCase().includes(bare)) { + continue + } + const filter = { facet: facet.key, value: v, negated } + if (chosen.has(facetFilterKey(filter))) { + continue + } + matches.push({ + id: `value-${facet.key}-${v}`, + kind: 'value', + label: `${negated ? 'Not ' : ''}${facet.label}: ${label}`, + count, + filter, + rest, + }) + } + } + result.push(...matches.slice(0, MAX_CROSS_FACET_SUGGESTIONS)) + } + return result +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface facetSearchBarLogicValues { + draft: FacetDraft | null + highlightedIndex: number + highlightedSuggestion: FacetSuggestion | null + hints: string[] + input: string + open: boolean + rawHighlightedIndex: number + suggestions: FacetSuggestion[] + tabTarget: FacetSuggestion | null + title: string +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface facetSearchBarLogicActions { + applyHighlighted: () => { + value: true + } + applySuggestion: (suggestion: FacetSuggestion) => { + suggestion: FacetSuggestion + } + applyTabTarget: () => { + value: true + } + moveHighlight: (delta: number) => { + delta: number + } + removeFilter: (filter: FacetFilter) => { + filter: FacetFilter + } + removeLastFilter: () => { + value: true + } + setHighlightedIndex: (index: number) => { + index: number + } + setInput: (input: string) => { + input: string + } + setOpen: (open: boolean) => { + open: boolean + } + syncInput: (input: string) => { + input: string + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface facetSearchBarLogicMeta { + key: string + __keaTypeGenInternalSelectorTypes: { + draft: (input: any, facets: FacetDefinition[]) => FacetDraft | null + suggestions: ( + input: any, + draft: FacetDraft | null, + facets: FacetDefinition[], + items: any[], + value: FacetSearchValue, + matchesText: MatchesText + ) => FacetSuggestion[] + highlightedIndex: (rawHighlightedIndex: number, suggestions: FacetSuggestion[]) => number + highlightedSuggestion: (suggestions: FacetSuggestion[], highlightedIndex: number) => FacetSuggestion | null + tabTarget: ( + input: any, + suggestions: FacetSuggestion[], + highlightedSuggestion: FacetSuggestion | null + ) => FacetSuggestion | null + title: (input: any, draft: FacetDraft | null) => string + hints: (highlightedSuggestion: FacetSuggestion | null, tabTarget: FacetSuggestion | null) => string[] + } +} + +export type facetSearchBarLogicType = MakeLogicType< + facetSearchBarLogicValues, + facetSearchBarLogicActions, + FacetSearchBarLogicProps, + facetSearchBarLogicMeta +> + +export const facetSearchBarLogic = kea([ + props({} as FacetSearchBarLogicProps), + key((props) => props.id), + path((key) => ['products', 'workflows', 'frontend', 'facetSearchBarLogic', key]), + actions({ + setInput: (input: string) => ({ input }), + syncInput: (input: string) => ({ input }), + setOpen: (open: boolean) => ({ open }), + setHighlightedIndex: (index: number) => ({ index }), + moveHighlight: (delta: number) => ({ delta }), + applySuggestion: (suggestion: FacetSuggestion) => ({ suggestion }), + applyHighlighted: true, + applyTabTarget: true, + removeFilter: (filter: FacetFilter) => ({ filter }), + removeLastFilter: true, + }), + reducers(({ props }) => ({ + input: [ + (props.value?.text ?? '') as string, + { + setInput: (_, { input }) => input, + syncInput: (_, { input }) => input, + }, + ], + open: [ + false, + { + setOpen: (_, { open }) => open, + setInput: () => true, + }, + ], + rawHighlightedIndex: [ + 0, + { + setHighlightedIndex: (_, { index }) => index, + setInput: () => 0, + syncInput: () => 0, + setOpen: () => 0, + }, + ], + })), + selectors({ + draft: [ + (s, p) => [s.input, p.facets], + (input: string, facets: FacetDefinition[]): FacetDraft | null => parseFacetDraft(input, facets), + ], + suggestions: [ + (s, p) => [s.input, s.draft, p.facets, p.items, p.value, p.matchesText], + ( + input, + draft: FacetDraft | null, + facets: FacetDefinition[], + items: any[], + value: FacetSearchValue, + matchesText: MatchesText + ): FacetSuggestion[] => buildSuggestions(input, draft, { facets, items, value, matchesText }), + ], + highlightedIndex: [ + (s) => [s.rawHighlightedIndex, s.suggestions], + (raw: number, suggestions: FacetSuggestion[]): number => Math.max(0, Math.min(raw, suggestions.length - 1)), + ], + highlightedSuggestion: [ + (s) => [s.suggestions, s.highlightedIndex], + (suggestions: FacetSuggestion[], index: number): FacetSuggestion | null => + suggestions[index]?.kind === 'none' ? null : (suggestions[index] ?? null), + ], + tabTarget: [ + (s) => [s.input, s.suggestions, s.highlightedSuggestion], + ( + input: string, + suggestions: FacetSuggestion[], + highlighted: FacetSuggestion | null + ): FacetSuggestion | null => { + // Tab and → never run a plain search. With an empty input, Tab moves focus as usual. + if (!input.trim()) { + return null + } + return isFilterRow(highlighted) ? highlighted : (suggestions.find(isFilterRow) ?? null) + }, + ], + title: [ + (s) => [s.input, s.draft], + (input: string, draft: FacetDraft | null): string => + draft + ? `${draft.facetLabel}${draft.negated ? ' is not' : ''}` + : input.trim() + ? 'Search or filter' + : 'Filter by', + ], + hints: [ + (s) => [s.highlightedSuggestion, s.tabTarget], + (highlighted: FacetSuggestion | null, tabTarget: FacetSuggestion | null): string[] => { + const hints: string[] = [] + if (highlighted?.kind === 'search') { + hints.push('Enter to search') + } else if (highlighted && isFilterRow(highlighted)) { + hints.push(`Enter${tabTarget === highlighted ? ' or Tab' : ''} to ${verbOf(highlighted)}`) + } + if (tabTarget && tabTarget !== highlighted) { + hints.push(`Tab or → to ${verbOf(tabTarget)}`) + } + return [...hints, '↑↓ to move', 'Esc to close'] + }, + ], + }), + listeners(({ actions, values, props }) => ({ + setInput: ({ input }) => { + // Typed or pasted `facet:value` tokens become pills as soon as they are complete. + const { filters, remaining } = extractCompleteFilters(input, props.facets) + const known = new Set(props.value.filters.map(facetFilterKey)) + const added = filters.filter((filter) => { + const key = facetFilterKey(filter) + // A pasted query can repeat a token; each pill is added once. + if (known.has(key)) { + return false + } + known.add(key) + return true + }) + const nextInput = filters.length ? remaining : input + const text = textOf(nextInput, parseFacetDraft(nextInput, props.facets)) + if (filters.length) { + actions.syncInput(nextInput) + } + if (added.length || text !== props.value.text) { + props.onChange({ filters: [...props.value.filters, ...added], text }) + } + }, + moveHighlight: ({ delta }) => { + const last = Math.max(0, values.suggestions.length - 1) + actions.setHighlightedIndex(Math.max(0, Math.min(values.highlightedIndex + delta, last))) + }, + applySuggestion: ({ suggestion }) => { + if (suggestion.kind === 'facet' && suggestion.nextInput !== undefined) { + actions.setInput(suggestion.nextInput) + } else if (suggestion.kind === 'value' && suggestion.filter) { + const rest = (suggestion.rest ?? '').trimEnd() + props.onChange({ filters: [...props.value.filters, suggestion.filter], text: rest.trim() }) + actions.syncInput(rest ? `${rest} ` : '') + actions.setOpen(true) + } else { + actions.setOpen(false) + } + }, + applyHighlighted: () => { + if (values.highlightedSuggestion) { + actions.applySuggestion(values.highlightedSuggestion) + } + }, + applyTabTarget: () => { + if (values.tabTarget) { + actions.applySuggestion(values.tabTarget) + } + }, + removeFilter: ({ filter }) => { + const removed = facetFilterKey(filter) + props.onChange({ + filters: props.value.filters.filter((f) => facetFilterKey(f) !== removed), + text: props.value.text, + }) + }, + removeLastFilter: () => { + if (props.value.filters.length) { + props.onChange({ filters: props.value.filters.slice(0, -1), text: props.value.text }) + } + }, + })), + propsChanged(({ actions, values, props }) => { + // The URL or a "Clear filters" button can change the text without this input. + if (props.value.text !== textOf(values.input, values.draft)) { + actions.syncInput(props.value.text) + } + }), +]) diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx new file mode 100644 index 000000000000..2f7ea8c315e2 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx @@ -0,0 +1,30 @@ +import { Tooltip } from '@posthog/lemon-ui' + +import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' +import { urls } from 'scenes/urls' + +import { WorkflowListRow } from './workflowListRows' + +export function WorkflowListNameCell({ row }: { row: WorkflowListRow }): JSX.Element { + const archived = row.workflow.status === 'archived' + const name = ( + + {row.name || 'Untitled'} + + ) + const description = row.workflow.description ? ( + + + {row.workflow.description} + + + ) : undefined + return ( + {name} : name} + description={description} + truncateTitle + /> + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowRowMenu.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowRowMenu.tsx new file mode 100644 index 000000000000..efe6ff1f1ae7 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowRowMenu.tsx @@ -0,0 +1,31 @@ +import { useActions, useValues } from 'kea' + +import { More } from 'lib/lemon-ui/LemonButton/More' + +import { AccessControlLevel } from '~/types' + +import { WorkflowRowMenuOverlay } from '../WorkflowRowMenuOverlay' +import { WorkflowListRow } from './workflowListRows' +import { workflowsListV2Logic } from './workflowsListV2Logic' + +export function WorkflowRowMenu({ row }: { row: WorkflowListRow }): JSX.Element { + const { pendingRowActions } = useValues(workflowsListV2Logic) + const { toggleWorkflowStatus, duplicateWorkflow, archiveWorkflow, restoreWorkflow, deleteWorkflow } = + useActions(workflowsListV2Logic) + return ( + toggleWorkflowStatus(row)} + onDuplicate={() => duplicateWorkflow(row)} + onArchive={() => archiveWorkflow(row)} + onRestore={() => restoreWorkflow(row)} + onDelete={() => deleteWorkflow(row)} + /> + } + /> + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.stories.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.stories.tsx new file mode 100644 index 000000000000..5f2f01c7efba --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.stories.tsx @@ -0,0 +1,142 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { FEATURE_FLAGS } from 'lib/constants' +import { App } from 'scenes/App' +import { urls } from 'scenes/urls' + +import { mswDecorator } from '~/mocks/browser' + +import { OPTIONAL_COLUMNS } from './workflowListLabels' +import { FIXTURE_METRICS, FIXTURE_WORKFLOWS, paginated } from './workflowsListV2Fixtures' + +const COLUMNS_STORAGE_KEY = 'products.workflows.frontend.workflowsListV2Logic.visibleColumns' + +const workflowsUrl = (params: Record = {}): string => { + const search = new URLSearchParams(params).toString() + return search ? `${urls.workflows()}?${search}` : urls.workflows() +} + +const withAllColumns: NonNullable = [ + (Story) => { + localStorage.setItem(COLUMNS_STORAGE_KEY, JSON.stringify(OPTIONAL_COLUMNS)) + return + }, +] + +const meta: Meta = { + component: App, + title: 'Scenes-App/Workflows/List v2', + parameters: { + layout: 'fullscreen', + viewMode: 'story', + mockDate: '2026-09-25', + pageUrl: workflowsUrl(), + featureFlags: [FEATURE_FLAGS.WORKFLOWS_LIST_V2], + testOptions: { viewport: { width: 1440, height: 900 } }, + }, + decorators: [ + // Story decorators run inside this one, so only the stories that set columns show them. + (Story) => { + localStorage.removeItem(COLUMNS_STORAGE_KEY) + return + }, + mswDecorator({ + get: { + // The empty-state gate counts workflows before the scene renders, and the flag-off list + // reads their steps. + '/api/projects/:team_id/hog_flows/': paginated( + FIXTURE_WORKFLOWS.map((workflow) => ({ ...workflow, actions: [], edges: [] })) + ), + // The server search finds nothing beyond the names and descriptions the list matches itself. + '/api/projects/:team_id/hog_flows/summaries/': ({ request }) => [ + 200, + paginated(new URL(request.url).searchParams.has('search') ? [] : FIXTURE_WORKFLOWS), + ], + '/api/projects/:team_id/hog_flows/metrics/global/': FIXTURE_METRICS, + }, + }), + ], +} +export default meta + +type Story = StoryObj<{}> + +export const Default: Story = {} + +export const AllColumns: Story = { + decorators: withAllColumns, +} + +export const MetricsLoading: Story = { + parameters: { testOptions: { waitForLoadersToDisappear: false } }, + decorators: [ + ...withAllColumns, + mswDecorator({ + get: { + '/api/projects/:team_id/hog_flows/metrics/global/': () => new Promise(() => {}), + }, + }), + ], +} + +export const MetricsUnavailable: Story = { + decorators: [ + ...withAllColumns, + mswDecorator({ + get: { + '/api/projects/:team_id/hog_flows/metrics/global/': () => [500, { detail: 'Server error' }], + }, + }), + ], +} + +export const NoMatches: Story = { + parameters: { pageUrl: workflowsUrl({ q: 'status:active', text: 'nothing like this' }) }, +} + +export const ServerSearchFailed: Story = { + parameters: { pageUrl: workflowsUrl({ text: 'renewal' }) }, + decorators: [ + mswDecorator({ + get: { + '/api/projects/:team_id/hog_flows/summaries/': ({ request }) => + new URL(request.url).searchParams.has('search') + ? [500, { detail: 'Server error' }] + : [200, paginated(FIXTURE_WORKFLOWS)], + }, + }), + ], +} + +export const Loading: Story = { + parameters: { testOptions: { waitForLoadersToDisappear: false } }, + decorators: [ + mswDecorator({ + get: { + '/api/projects/:team_id/hog_flows/summaries/': () => new Promise(() => {}), + }, + }), + ], +} + +export const LoadError: Story = { + decorators: [ + mswDecorator({ + get: { + '/api/projects/:team_id/hog_flows/summaries/': () => [500, { detail: 'Server error' }], + }, + }), + ], +} + +export const NarrowScene: Story = { + parameters: { + pageUrl: workflowsUrl({ q: 'type:messaging -status:archived', text: 'reminder' }), + // The navigation collapses at this width, which leaves a 520px scene. + testOptions: { viewport: { width: 552, height: 900 } }, + }, +} + +export const FlagOff: Story = { + parameters: { featureFlags: [] }, +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.tsx new file mode 100644 index 000000000000..333ac9c2b574 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.tsx @@ -0,0 +1,107 @@ +import { useActions, useValues } from 'kea' + +import { LemonButton } from '@posthog/lemon-ui' + +import { useOnMountEffect } from 'lib/hooks/useOnMountEffect' +import { LemonTable } from 'lib/lemon-ui/LemonTable' + +import { workflowLogic } from '../workflowLogic' +import { serializeFacetQuery } from './FacetSearchBar/facetQuery' +import { FacetSearchBar } from './FacetSearchBar/FacetSearchBar' +import { buildWorkflowsListV2Columns } from './workflowsListV2Columns' +import { workflowsListV2Logic } from './workflowsListV2Logic' + +const PAGE_SIZE = 100 + +export function WorkflowsListV2(): JSX.Element { + const { + rows, + filteredRows, + facets, + matchesText, + value, + listLoaded, + workflowsLoading, + loadFailed, + shownColumns, + metricsLoading, + serverSearchStatus, + } = useValues(workflowsListV2Logic) + const { setValue, loadWorkflows, clearFilters } = useActions(workflowsListV2Logic) + + useOnMountEffect(() => { + // Leaving the new-workflow scene keeps its logic mounted, so drop it here as WorkflowsTable does. + workflowLogic.findMounted({ id: 'new' })?.unmount() + }) + + const renderBody = (): JSX.Element => { + if (loadFailed) { + return ( +
+ Couldn't load workflows + + Retry + +
+ ) + } + const searchPending = serverSearchStatus === 'pending' + if (listLoaded && rows.length > 0 && filteredRows.length === 0 && !searchPending) { + return ( +
+ No workflows match these filters + + Clear filters + +
+ ) + } + return ( + + ) + } + + return ( +
+ + {serverSearchStatus === 'failed' && ( +
+ Couldn't search step names and email content. Showing matches on name and description only. +
+ )} + {renderBody()} +
+ ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2ColumnsMenu.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2ColumnsMenu.tsx new file mode 100644 index 000000000000..d6b112b121dd --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2ColumnsMenu.tsx @@ -0,0 +1,46 @@ +import { useActions, useValues } from 'kea' + +import { IconCheck, IconEllipsis } from '@posthog/icons' +import { LemonButton, LemonMenu } from '@posthog/lemon-ui' + +import { OPTIONAL_COLUMNS, OPTIONAL_COLUMN_TITLES } from './workflowListLabels' +import { workflowsListV2Logic } from './workflowsListV2Logic' + +export function WorkflowsListV2ColumnsMenu(): JSX.Element { + const { shownColumns } = useValues(workflowsListV2Logic) + const { toggleColumn, resetColumns } = useActions(workflowsListV2Logic) + + return ( + ({ + label: OPTIONAL_COLUMN_TITLES[column], + icon: shownColumns.includes(column) ? : , + onClick: () => toggleColumn(column), + 'data-attr': `workflows-list-v2-column-${column}`, + })), + }, + { + items: [ + { + label: 'Reset to default columns', + onClick: resetColumns, + 'data-attr': 'workflows-list-v2-reset-columns', + }, + ], + }, + ]} + > + } + aria-label="List options" + data-attr="workflows-list-v2-options" + /> + + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListFacets.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListFacets.ts new file mode 100644 index 000000000000..3d7f4b36f460 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListFacets.ts @@ -0,0 +1,87 @@ +import type { FacetDefinition } from './FacetSearchBar/facetQuery' +import { HEALTH_TAGS, STATUS_LABELS, TRIGGER_LABELS, TYPE_LABELS } from './workflowListLabels' +import { WorkflowListRow } from './workflowListRows' + +const HEALTH_LABELS: Record = Object.fromEntries( + Object.entries(HEALTH_TAGS).map(([health, { label }]) => [health, label]) +) + +const labelFrom = + (labels: Record) => + (value: string): string => + labels[value] ?? value + +/** Every word of the text must appear in the name or description. */ +export function matchesWorkflowListText(row: WorkflowListRow, text: string): boolean { + return text + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((word) => row.searchText.includes(word)) +} + +/** `rows` supplies the names shown for creator uuids. */ +export function buildWorkflowListFacets(rows: WorkflowListRow[]): FacetDefinition[] { + const creatorNames = new Map() + for (const { workflow } of rows) { + if (workflow.created_by) { + creatorNames.set(workflow.created_by.uuid, workflow.created_by.first_name || workflow.created_by.email) + } + } + + return [ + { + key: 'status', + label: 'Status', + description: 'Draft, active or archived', + showOnFocus: true, + order: 1, + getValues: (row) => [row.workflow.status], + formatValue: labelFrom(STATUS_LABELS), + }, + { + key: 'type', + label: 'Type', + description: 'Messaging, automation or loop', + showOnFocus: true, + order: 2, + getValues: (row) => [row.workflow.type], + formatValue: labelFrom(TYPE_LABELS), + }, + { + key: 'trigger', + label: 'Trigger', + description: 'What starts the workflow', + showOnFocus: true, + order: 3, + getValues: (row) => (row.triggerType ? [row.triggerType] : []), + formatValue: labelFrom(TRIGGER_LABELS), + }, + { + key: 'owner', + label: 'Owner', + description: 'Owner: @name in the description, else the creator', + showOnFocus: true, + order: 4, + getValues: (row) => row.owners, + formatValue: (value) => `@${value}`, + }, + { + key: 'health', + label: 'Health', + description: 'Failed runs in the last 7 days', + showOnFocus: true, + order: 5, + getValues: (row) => [row.health], + formatValue: labelFrom(HEALTH_LABELS), + }, + { + key: 'created-by', + label: 'Created by', + description: 'Who created it', + order: 6, + getValues: (row) => (row.workflow.created_by ? [row.workflow.created_by.uuid] : []), + formatValue: (value) => creatorNames.get(value) ?? value, + }, + ] +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListLabels.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListLabels.ts new file mode 100644 index 000000000000..d271ae01fc34 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListLabels.ts @@ -0,0 +1,38 @@ +import { WORKFLOW_TRIGGER_TYPE_OPTIONS } from '../workflowsLogic' +import { WORKFLOW_STATUS_CONFIG } from '../workflowStatus' +import type { WorkflowHealth } from './workflowListRows' + +export const STATUS_LABELS: Record = Object.fromEntries( + Object.entries(WORKFLOW_STATUS_CONFIG).map(([status, { label }]) => [status, label]) +) + +/** The workflow types this list covers. Broadcasts have their own page. */ +export const TYPE_LABELS = { messaging: 'Messaging', automation: 'Automation', loop: 'Loop' } as const +export const LIST_TYPES = Object.keys(TYPE_LABELS) as (keyof typeof TYPE_LABELS)[] + +export const TRIGGER_LABELS: Record = Object.fromEntries( + WORKFLOW_TRIGGER_TYPE_OPTIONS.filter((option) => option.value !== 'all').map((option) => [ + option.value, + option.label, + ]) +) + +export const HEALTH_TAGS: Record = { + failing: { label: 'Failing', type: 'danger' }, + healthy: { label: 'Healthy', type: 'success' }, + idle: { label: 'No runs', type: 'muted' }, +} + +export const OPTIONAL_COLUMN_TITLES = { + type: 'Type', + trigger: 'Trigger', + owner: 'Owner', + created_by: 'Created by', + last_7_days: 'Last 7 days', + health: 'Health', +} as const + +export type OptionalColumn = keyof typeof OPTIONAL_COLUMN_TITLES +export const OPTIONAL_COLUMNS = Object.keys(OPTIONAL_COLUMN_TITLES) as OptionalColumn[] + +export const DEFAULT_COLUMNS: OptionalColumn[] = ['owner'] diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.test.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.test.ts new file mode 100644 index 000000000000..572d9849d6db --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.test.ts @@ -0,0 +1,61 @@ +import type { UserBasicApi } from 'products/workflows/frontend/generated/api.schemas' + +import { findFacet } from './FacetSearchBar/facetQuery' +import { buildWorkflowListFacets } from './workflowListFacets' +import { WorkflowListRow, buildWorkflowListRows } from './workflowListRows' +import { FIXTURE_USERS, buildWorkflowRow } from './workflowsListV2Fixtures' + +const valuesOf = (facetKey: string, row: WorkflowListRow): string[] => + findFacet(buildWorkflowListFacets([]), facetKey)!.getValues(row) + +describe('buildWorkflowListRows', () => { + it.each([ + [ + 'an explicit owner wins over the creator', + 'Owner: @Maya. Sends the welcome mail.', + FIXTURE_USERS.ada, + ['maya'], + ], + [ + 'every explicit owner counts, trailing dots and dashes trimmed', + 'owner: @sam-; OWNER: @li.wei.', + null, + ['sam', 'li.wei'], + ], + ['any other mention does not count', 'Ask @maya first', FIXTURE_USERS.ada, ['ada']], + ['a co-owner is not the owner', 'Co-owner: @sam', FIXTURE_USERS.ada, ['ada']], + ['a dash bullet counts', 'Sends reminders.\n- Owner: @kim', FIXTURE_USERS.lin, ['kim']], + ['a star bullet counts', '* Owner: @kim', FIXTURE_USERS.lin, ['kim']], + ['an indented line counts', 'Sends reminders\n Owner: @kim', FIXTURE_USERS.lin, ['kim']], + ['a previous owner is not the owner', 'Previous owner: @jo', FIXTURE_USERS.ada, ['ada']], + ['an owner in parentheses counts', 'Weekly digest (owner: @kim)', FIXTURE_USERS.ada, ['kim']], + ['the creator without a first name gives their email name', '', FIXTURE_USERS.lin, ['lin.ops']], + ['no owner and no creator gives nothing', '', null, []], + ])('owner: %s', (_, description, createdBy, expected) => { + const [row] = buildWorkflowListRows( + [buildWorkflowRow({ id: 'wf', description, created_by: createdBy as UserBasicApi })], + null + ) + expect(valuesOf('owner', row)).toEqual(expected) + }) + + it.each([ + ['a failed run', [{ workflow_id: 'wf', succeeded: 10, failed: 1 }], 'failing'], + ['only succeeded runs', [{ workflow_id: 'wf', succeeded: 10, failed: 0 }], 'healthy'], + ['a row with no runs', [{ workflow_id: 'wf', succeeded: 0, failed: 0 }], 'idle'], + ['no row for the workflow', [], 'idle'], + ['metrics not loaded', null, 'idle'], + ])('health with %s', (_, metrics, expected) => { + const [row] = buildWorkflowListRows([buildWorkflowRow({ id: 'wf' })], metrics) + expect(valuesOf('health', row)).toEqual([expected]) + }) + + it.each([ + ['an event trigger', { type: 'event' }, ['event']], + ['no trigger', null, []], + ['a trigger without a type', {}, []], + ])('trigger from %s', (_, trigger, expected) => { + const [row] = buildWorkflowListRows([buildWorkflowRow({ id: 'wf', trigger })], null) + expect(valuesOf('trigger', row)).toEqual(expected) + }) +}) diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.ts new file mode 100644 index 000000000000..5473bace2dd5 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.ts @@ -0,0 +1,92 @@ +import type { + HogFlowListSummaryApi, + UserBasicApi, + WorkflowStatsRowApi, +} from 'products/workflows/frontend/generated/api.schemas' + +export type WorkflowHealth = 'failing' | 'healthy' | 'idle' + +export interface WorkflowRunCounts { + succeeded: number + failed: number +} + +export interface WorkflowListRow { + id: string + name: string + workflow: HogFlowListSummaryApi + triggerType: string | null + /** Explicit `Owner: @handle` names from the description, else the creator's handle. */ + owners: string[] + /** Null while the metrics load, and when they fail. */ + last7Days: WorkflowRunCounts | null + health: WorkflowHealth + /** Lowercased name and description, built once for text search. */ + searchText: string +} + +// `Owner:` must start the text, a line (after indent, `-`, `*`, `•` or `>`) or a clause, so `Co-owner:` +// and `Previous owner:` don't count. +const OWNER_PATTERN = /(?<=^[\s>*•-]*|[.(,;|]\s*)owner:\s*@([\w.-]+)/gim + +function creatorHandle(user: UserBasicApi | null): string | null { + if (!user) { + return null + } + return (user.first_name || user.email.split('@')[0]).toLowerCase() || null +} + +function workflowOwners(workflow: HogFlowListSummaryApi): string[] { + const explicit = [...(workflow.description ?? '').matchAll(OWNER_PATTERN)] + .map((match) => match[1].replace(/[.-]+$/, '').toLowerCase()) + .filter(Boolean) + if (explicit.length) { + return [...new Set(explicit)] + } + const creator = creatorHandle(workflow.created_by) + return creator ? [creator] : [] +} + +function triggerTypeOf(trigger: unknown): string | null { + if (trigger && typeof trigger === 'object' && 'type' in trigger && typeof trigger.type === 'string') { + return trigger.type + } + return null +} + +function healthOf(counts: WorkflowRunCounts | null): WorkflowHealth { + if (counts && counts.failed > 0) { + return 'failing' + } + if (counts && counts.succeeded > 0) { + return 'healthy' + } + return 'idle' +} + +/** + * Most recently updated first. `metrics` is null until the metrics load. Once loaded, a workflow + * without a metrics row had no runs. + */ +export function buildWorkflowListRows( + workflows: HogFlowListSummaryApi[], + metrics: WorkflowStatsRowApi[] | null +): WorkflowListRow[] { + const countsById = new Map(metrics?.map((row) => [row.workflow_id, row])) + return workflows + .map((workflow): WorkflowListRow => { + const stats = countsById.get(workflow.id) + const last7Days = metrics ? { succeeded: stats?.succeeded ?? 0, failed: stats?.failed ?? 0 } : null + return { + id: workflow.id, + name: workflow.name ?? '', + workflow, + triggerType: triggerTypeOf(workflow.trigger), + owners: workflowOwners(workflow), + last7Days, + health: healthOf(last7Days), + searchText: [workflow.name, workflow.description].filter(Boolean).join('\n').toLowerCase(), + } + }) + .sort((a, b) => Date.parse(b.workflow.updated_at) - Date.parse(a.workflow.updated_at)) +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Columns.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Columns.tsx new file mode 100644 index 000000000000..861f31cae63e --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Columns.tsx @@ -0,0 +1,126 @@ +import { LemonTag, Link, Spinner, Tooltip } from '@posthog/lemon-ui' + +import { TZLabel } from 'lib/components/TZLabel' +import { LemonTableColumn } from 'lib/lemon-ui/LemonTable' +import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture' +import { urls } from 'scenes/urls' + +import { UserBasicType } from '~/types' + +import { WorkflowStatusTag } from '../WorkflowStatusTag' +import { HEALTH_TAGS, OPTIONAL_COLUMN_TITLES, OptionalColumn, TRIGGER_LABELS, TYPE_LABELS } from './workflowListLabels' +import { WorkflowListNameCell } from './WorkflowListNameCell' +import { WorkflowListRow, WorkflowRunCounts } from './workflowListRows' +import { WorkflowRowMenu } from './WorkflowRowMenu' + +type Column = LemonTableColumn + +const TYPE_TAGS = { messaging: 'completion', automation: 'default', loop: 'highlight' } as const + +const countsLabel = ({ failed, succeeded }: WorkflowRunCounts): string => `${failed} failed · ${succeeded} succeeded` + +function MetricsPending({ metricsLoading }: { metricsLoading: boolean }): JSX.Element { + return metricsLoading ? ( + + ) : ( + + Unavailable + + ) +} + +function optionalRenderers(metricsLoading: boolean): Record { + return { + type: (_, row) => { + if (row.workflow.type === 'broadcast') { + return null + } + const tag = {TYPE_LABELS[row.workflow.type]} + return row.workflow.type === 'loop' ? {tag} : tag + }, + trigger: (_, row) => + row.triggerType ? ( + {TRIGGER_LABELS[row.triggerType] ?? row.triggerType} + ) : null, + owner: (_, row) => ( + {row.owners.map((owner) => `@${owner}`).join(', ')} + ), + created_by: (_, row) => { + const user = row.workflow.created_by + if (!user) { + return Unknown + } + return ( +
+ + {user.first_name || user.email} +
+ ) + }, + last_7_days: (_, row) => + row.last7Days ? ( + + {countsLabel(row.last7Days)} + + ) : ( + + ), + health: (_, row) => { + if (!row.last7Days) { + return + } + const tag = HEALTH_TAGS[row.health] + return ( + + {tag.label} + + ) + }, + } +} + +export function buildWorkflowsListV2Columns(visibleColumns: OptionalColumn[], metricsLoading: boolean): Column[] { + const renderers = optionalRenderers(metricsLoading) + const optionalColumn = (column: OptionalColumn): Column => ({ + title: OPTIONAL_COLUMN_TITLES[column], + key: column, + width: 0, + render: renderers[column], + }) + return [ + { + title: 'Name', + key: 'name', + // Takes the leftover width and truncates, so the other columns and the menu stay in view. With + // optional columns on a narrow scene it keeps a readable minimum and the table scrolls sideways. + className: 'w-full max-w-0 min-w-40', + sorter: (a, b) => a.name.localeCompare(b.name), + render: (_, row) => , + }, + ...(visibleColumns.includes('type') ? [optionalColumn('type')] : []), + { + title: 'Status', + key: 'status', + width: 0, + render: (_, row) => , + }, + ...visibleColumns.filter((column) => column !== 'type').map(optionalColumn), + { + title: 'Updated', + key: 'updated_at', + width: 0, + align: 'right', + sorter: (a, b) => Date.parse(a.workflow.updated_at) - Date.parse(b.workflow.updated_at), + render: (_, row) => ( +
+ +
+ ), + }, + { + key: 'actions', + width: 0, + render: (_, row) => , + }, + ] +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Fixtures.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Fixtures.ts new file mode 100644 index 000000000000..dcb8ae8a2221 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Fixtures.ts @@ -0,0 +1,98 @@ +import type { + HogFlowListSummaryApi, + UserBasicApi, + WorkflowStatsRowApi, +} from 'products/workflows/frontend/generated/api.schemas' + +export const FIXTURE_USERS: Record<'ada' | 'lin', UserBasicApi> = { + ada: { + id: 1, + uuid: '0190a1b2-0000-7000-8000-00000000a001', + first_name: 'Ada', + email: 'ada@example.com', + hedgehog_config: null, + }, + lin: { + id: 2, + uuid: '0190a1b2-0000-7000-8000-00000000a002', + first_name: '', + email: 'lin.ops@example.com', + hedgehog_config: null, + }, +} + +export function buildWorkflowRow( + overrides: Partial & Pick +): HogFlowListSummaryApi { + return { + name: `Workflow ${overrides.id}`, + description: '', + version: 1, + status: 'draft', + type: 'automation', + origin_product: null, + trigger: { type: 'event' }, + created_by: FIXTURE_USERS.ada, + created_at: '2026-08-01T10:00:00Z', + updated_at: '2026-08-01T10:00:00Z', + user_access_level: 'editor', + ...overrides, + } +} + +/** A small invented project with a mix of statuses, types, triggers and owners. */ +export const FIXTURE_WORKFLOWS: HogFlowListSummaryApi[] = [ + buildWorkflowRow({ + id: 'wf-welcome', + name: 'Welcome series', + description: 'Owner: @maya. Greets new sign-ups.', + status: 'active', + type: 'messaging', + updated_at: '2026-09-20T09:00:00Z', + }), + buildWorkflowRow({ + id: 'wf-renewal', + name: 'Renewal reminder', + description: 'Reminds customers a week before their plan renews.', + status: 'draft', + type: 'messaging', + updated_at: '2026-09-18T09:00:00Z', + }), + buildWorkflowRow({ + id: 'wf-sync', + name: 'Sync accounts to CRM', + description: + 'Owner: @kim. Copies new accounts and plan changes to the CRM every night, retries failed rows once, and emails a summary to the ops inbox.', + status: 'draft', + type: 'automation', + trigger: { type: 'schedule' }, + created_by: FIXTURE_USERS.lin, + updated_at: '2026-09-10T09:00:00Z', + }), + buildWorkflowRow({ + id: 'wf-old-promo', + name: 'Spring promo', + status: 'archived', + type: 'messaging', + updated_at: '2026-04-01T09:00:00Z', + }), +] + +/** Last-7-days counts for the fixture workflows. `wf-old-promo` had no runs, so it has no row. */ +export const FIXTURE_METRICS: WorkflowStatsRowApi[] = [ + { workflow_id: 'wf-renewal', succeeded: 3, failed: 2 }, + { workflow_id: 'wf-welcome', succeeded: 40, failed: 0 }, + { workflow_id: 'wf-sync', succeeded: 0, failed: 0 }, +] + +export function paginated( + results: T[], + next: string | null = null +): { + count: number + next: string | null + previous: null + results: T[] +} { + return { count: results.length, next, previous: null, results } +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.test.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.test.ts new file mode 100644 index 000000000000..00c5db5db893 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.test.ts @@ -0,0 +1,453 @@ +import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' + +import { router } from 'kea-router' +import { expectLogic } from 'kea-test-utils' + +import { lemonToast } from '@posthog/lemon-ui' + +import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import type { WorkflowStatsRowApi } from 'products/workflows/frontend/generated/api.schemas' + +import type { WorkflowListRow } from './workflowListRows' +import { + FIXTURE_METRICS, + FIXTURE_USERS, + FIXTURE_WORKFLOWS, + buildWorkflowRow, + paginated, +} from './workflowsListV2Fixtures' +import { workflowsListV2Logic } from './workflowsListV2Logic' + +const shownIds = (logic: ReturnType): string[] => + logic.values.filteredRows.map((row) => row.id) + +describe('workflowsListV2Logic', () => { + let logic: ReturnType + let workflowRequests: URLSearchParams[] + let serverSearch: (search: string) => Promise + let metricsResponse: () => Promise<[number, WorkflowStatsRowApi[] | { detail: string }]> + + beforeEach(() => { + workflowRequests = [] + serverSearch = async () => [] + metricsResponse = async () => [200, FIXTURE_METRICS] + const byId = new Map(FIXTURE_WORKFLOWS.map((workflow) => [workflow.id, workflow])) + // A workflow created during the load shifts the offsets, so page two repeats the last row of page one. + const secondPage = [ + FIXTURE_WORKFLOWS[FIXTURE_WORKFLOWS.length - 1], + buildWorkflowRow({ id: 'wf-page-two', name: 'Second page', updated_at: '2026-09-19T12:00:00Z' }), + ] + useMocks({ + get: { + '/api/projects/:team_id/hog_flows/summaries/': async ({ request }) => { + const params = new URL(request.url).searchParams + workflowRequests.push(params) + const search = params.get('search') + if (search) { + try { + const ids = await serverSearch(search) + return [200, paginated(ids.map((id) => byId.get(id)!))] + } catch { + return [500, { detail: 'Server error' }] + } + } + if (params.get('offset') === '500') { + return [200, paginated(secondPage)] + } + return [ + 200, + paginated( + FIXTURE_WORKFLOWS, + 'http://localhost/api/projects/997/hog_flows/summaries/?limit=500&offset=500' + ), + ] + }, + '/api/projects/:team_id/hog_flows/metrics/global/': () => metricsResponse(), + }, + }) + initKeaTests() + }) + + afterEach(() => logic?.unmount()) + + it('loads every page of workflows, newest first', async () => { + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess']) + expect(shownIds(logic)).toEqual(['wf-welcome', 'wf-page-two', 'wf-renewal', 'wf-sync', 'wf-old-promo']) + expect( + workflowRequests.map((params) => [params.get('limit'), params.get('offset'), params.get('type')]) + ).toEqual([ + ['500', null, 'messaging,automation,loop'], + ['500', '500', 'messaging,automation,loop'], + ]) + }) + + it('stops following a next link that never ends and shows the load error', async () => { + let requests = 0 + useMocks({ + get: { + '/api/projects/:team_id/hog_flows/summaries/': () => { + requests++ + return [ + 200, + paginated( + [buildWorkflowRow({ id: `wf-loop-${requests}` })], + 'http://localhost/api/projects/997/hog_flows/summaries/?limit=500&offset=500' + ), + ] + }, + }, + }) + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + + await expectLogic(logic).toDispatchActions(['loadWorkflowsFailure']) + expect(logic.values.loadFailed).toBe(true) + expect(requests).toBeLessThanOrEqual(100) + }) + + it('sends every request to the team id, which can differ from the project id', async () => { + const TEAM_ID = 4242 + const paths: string[] = [] + const record = + (body: unknown) => + ({ request }: { request: Request }): [number, unknown] => { + paths.push(`${request.method} ${new URL(request.url).pathname}`) + return [200, body] + } + useMocks({ + get: { + '/api/projects/:team_id/hog_flows/summaries/': record(paginated(FIXTURE_WORKFLOWS)), + '/api/projects/:team_id/hog_flows/metrics/global/': record(FIXTURE_METRICS), + '/api/projects/:team_id/hog_flows/:id/': record({ ...FIXTURE_WORKFLOWS[0], actions: [], edges: [] }), + }, + post: { '/api/projects/:team_id/hog_flows/': record({}) }, + patch: { '/api/projects/:team_id/hog_flows/:id/': record({}) }, + }) + teamLogic.actions.loadCurrentTeamSuccess({ ...MOCK_DEFAULT_TEAM, id: TEAM_ID }) + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess', 'loadMetricsSuccess']) + + const workflow = logic.values.rows.find((row) => row.id === 'wf-renewal')! + await expectLogic(logic, () => logic.actions.toggleWorkflowStatus(workflow)).toFinishAllListeners() + await expectLogic(logic, () => logic.actions.duplicateWorkflow(workflow)).toDispatchActions([ + 'loadWorkflowsSuccess', + ]) + + expect(paths.filter((path) => !path.includes(`/projects/${TEAM_ID}/`))).toEqual([]) + expect(new Set(paths)).toEqual( + new Set([ + `GET /api/projects/${TEAM_ID}/hog_flows/summaries/`, + `GET /api/projects/${TEAM_ID}/hog_flows/metrics/global/`, + `PATCH /api/projects/${TEAM_ID}/hog_flows/wf-renewal/`, + `GET /api/projects/${TEAM_ID}/hog_flows/wf-renewal/`, + `POST /api/projects/${TEAM_ID}/hog_flows/`, + ]) + ) + }) + + it('shows a load error instead of an empty list', async () => { + useMocks({ get: { '/api/projects/:team_id/hog_flows/summaries/': () => [500, { detail: 'Boom' }] } }) + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + + await expectLogic(logic).toDispatchActions(['loadWorkflowsFailure']) + expect(logic.values.loadFailed).toBe(true) + expect(logic.values.listLoaded).toBe(false) + }) + + it('shows the list before the metrics arrive, then fills in health', async () => { + let answerMetrics: () => void = () => {} + const metricsSent = new Promise((markSent) => { + metricsResponse = () => + new Promise((resolve) => { + answerMetrics = () => resolve([200, FIXTURE_METRICS]) + markSent() + }) + }) + router.actions.push(urls.workflows(), { q: 'health:idle' }) + logic = workflowsListV2Logic() + logic.mount() + + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess', 'loadMetrics']) + await metricsSent + expect(logic.values.metricsLoading).toBe(true) + expect(shownIds(logic)).toEqual(['wf-welcome', 'wf-page-two', 'wf-renewal', 'wf-sync', 'wf-old-promo']) + + answerMetrics() + await expectLogic(logic).toDispatchActions(['loadMetricsSuccess']) + expect(shownIds(logic)).toEqual(['wf-page-two', 'wf-sync', 'wf-old-promo']) + expect(logic.values.rows.find((row) => row.id === 'wf-renewal')).toMatchObject({ + health: 'failing', + last7Days: { succeeded: 3, failed: 2 }, + }) + expect(logic.values.rows.find((row) => row.id === 'wf-old-promo')?.last7Days).toEqual({ + succeeded: 0, + failed: 0, + }) + }) + + it('keeps the list and skips the error toast when the metrics fail', async () => { + const toastError = jest.spyOn(lemonToast, 'error') + metricsResponse = async () => [500, { detail: 'Server error' }] + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess', 'loadMetricsSuccess']) + expect(logic.values).toMatchObject({ metrics: null, metricsLoading: false, loadFailed: false }) + expect(shownIds(logic)).toHaveLength(5) + expect(toastError).not.toHaveBeenCalled() + toastError.mockRestore() + }) + + it('moves old filter params into q and text once, replacing the history entry', async () => { + router.actions.push(urls.workflows(), { + status: 'active', + type: 'loop', + trigger_type: 'schedule', + created_by: FIXTURE_USERS.lin.uuid, + search: 'renew', + page: '3', + other: 'kept', + }) + logic = workflowsListV2Logic() + logic.mount() + + expect(router.values.searchParams).toEqual({ + q: `status:active type:loop trigger:schedule created-by:${FIXTURE_USERS.lin.uuid}`, + text: 'renew', + other: 'kept', + }) + expect(router.values.lastMethod).toEqual('REPLACE') + expect(logic.values.value).toEqual({ + filters: [ + { facet: 'status', value: 'active', negated: false }, + { facet: 'type', value: 'loop', negated: false }, + { facet: 'trigger', value: 'schedule', negated: false }, + { facet: 'created-by', value: FIXTURE_USERS.lin.uuid, negated: false }, + ], + text: 'renew', + }) + }) + + it('keeps a later page param, because only the first URL is a bookmarked old link', async () => { + router.actions.push(urls.workflows(), { q: 'status:active' }) + logic = workflowsListV2Logic() + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess']) + + router.actions.push(router.values.location.pathname, { ...router.values.searchParams, page: 2 }) + expect(router.values.searchParams).toEqual({ q: 'status:active', page: 2 }) + }) + + it.each(['text', 'search'])('keeps number-like free text from the %s param as written', async (param) => { + // A pasted link reaches the router as written; `router.actions.push` would already parse `007` to 7. + router.actions.locationChanged({ + method: 'PUSH', + pathname: urls.workflows(), + search: `?${param}=007`, + searchParams: { [param]: 7 }, + hash: '', + hashParams: {}, + url: `${urls.workflows()}?${param}=007`, + }) + logic = workflowsListV2Logic() + logic.mount() + + expect(logic.values.value.text).toEqual('007') + logic.actions.setValue({ filters: [], text: '0070' }) + expect(logic.values.value.text).toEqual('0070') + expect(router.values.location.search).toEqual('?text=0070') + }) + + it('drops old params with values it does not know', () => { + router.actions.push(urls.workflows(), { status: 'all', trigger_type: 'bogus', created_by: 'not-a-uuid' }) + logic = workflowsListV2Logic() + logic.mount() + + expect(router.values.searchParams).toEqual({}) + }) + + it('ORs the server search into the text match and ignores a stale answer', async () => { + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess']) + + let answerSlowSearch: (ids: string[]) => void = () => {} + let markSlowSearchSent: () => void = () => {} + const slowSearchSent = new Promise((resolve) => { + markSlowSearchSent = resolve + }) + serverSearch = (search) => { + if (search === 'spring') { + markSlowSearchSent() + return new Promise((resolve) => { + answerSlowSearch = resolve + }) + } + return Promise.resolve(search === 'renewal' ? ['wf-sync'] : []) + } + + logic.actions.setValue({ filters: [], text: 'spring' }) + await slowSearchSent + await expectLogic(logic, () => logic.actions.setValue({ filters: [], text: 'renewal' })).toDispatchActions([ + 'searchWorkflowsSuccess', + ]) + // Only the client match on the name, plus the workflow the server found in an email body. + expect(shownIds(logic)).toEqual(['wf-renewal', 'wf-sync']) + + answerSlowSearch(['wf-old-promo']) + await expectLogic(logic).toFinishAllListeners() + expect(shownIds(logic)).toEqual(['wf-renewal', 'wf-sync']) + expect(workflowRequests.map((params) => params.get('search')).filter(Boolean)).toEqual(['spring', 'renewal']) + + // Back to the earlier text while a newer search is still out: the late answer must not replace it. + let answerDetour: (ids: string[]) => void = () => {} + let markDetourSent: () => void = () => {} + const detourSent = new Promise((resolve) => { + markDetourSent = resolve + }) + serverSearch = (search) => { + if (search === 'renewalx') { + markDetourSent() + return new Promise((resolve) => { + answerDetour = resolve + }) + } + return Promise.resolve(search === 'renewal' ? ['wf-sync'] : []) + } + logic.actions.setValue({ filters: [], text: 'renewalx' }) + await detourSent + await expectLogic(logic, () => logic.actions.setValue({ filters: [], text: 'renewal' })).toDispatchActions([ + 'searchWorkflowsSuccess', + ]) + answerDetour([]) + await expectLogic(logic).toFinishAllListeners() + expect(shownIds(logic)).toEqual(['wf-renewal', 'wf-sync']) + }) + + it('does not ask the server for text under 3 characters', async () => { + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess']) + + await expectLogic(logic, () => logic.actions.setValue({ filters: [], text: 're' })).toFinishAllListeners() + expect(workflowRequests.map((params) => params.get('search')).filter(Boolean)).toEqual([]) + expect(logic.values.serverSearchStatus).toEqual('off') + }) + + it('holds the no-match verdict until the server search answers, and reports a failed search without a toast', async () => { + const toastError = jest.spyOn(lemonToast, 'error') + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess']) + + let answerSearch: (ids: string[]) => void = () => {} + const searchSent = new Promise((markSent) => { + serverSearch = () => + new Promise((resolve) => { + answerSearch = resolve + markSent() + }) + }) + logic.actions.setValue({ filters: [], text: 'invoice' }) + expect(logic.values.serverSearchStatus).toEqual('pending') + expect(shownIds(logic)).toEqual([]) + + await searchSent + answerSearch(['wf-sync']) + await expectLogic(logic).toDispatchActions(['searchWorkflowsSuccess']) + expect(logic.values.serverSearchStatus).toEqual('done') + expect(shownIds(logic)).toEqual(['wf-sync']) + + serverSearch = () => Promise.reject(new Error('down')) + await expectLogic(logic, () => logic.actions.setValue({ filters: [], text: 'renewal' })).toDispatchActions([ + 'searchWorkflowsSuccess', + ]) + expect(logic.values.serverSearchStatus).toEqual('failed') + expect(shownIds(logic)).toEqual(['wf-renewal']) + expect(toastError).not.toHaveBeenCalled() + toastError.mockRestore() + }) + + it.each([ + ['succeeds', 200, 1], + ['fails', 500, 0], + ])('sends one duplicate at a time and clears the pending state when the copy %s', async (_, status, copies) => { + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess', 'loadMetricsSuccess']) + + let answerRetrieve: () => void = () => {} + let posts = 0 + const retrieveSent = new Promise((markSent) => { + useMocks({ + get: { + '/api/projects/:team_id/hog_flows/summaries/': () => [200, paginated(FIXTURE_WORKFLOWS)], + '/api/projects/:team_id/hog_flows/:id/': () => + new Promise((resolve) => { + answerRetrieve = () => + resolve( + status === 200 + ? [200, { ...FIXTURE_WORKFLOWS[1], actions: [], edges: [] }] + : [500, {}] + ) + markSent() + }), + }, + post: { + '/api/projects/:team_id/hog_flows/': () => { + posts++ + return [200, { id: `wf-copy-${posts}` }] + }, + }, + }) + }) + const row = logic.values.rows.find((r) => r.id === 'wf-renewal')! + + logic.actions.duplicateWorkflow(row) + logic.actions.duplicateWorkflow(row) + expect(logic.values.pendingRowActions).toEqual({ 'wf-renewal': 'duplicate' }) + + await retrieveSent + answerRetrieve() + await expectLogic(logic).toFinishAllListeners() + expect(posts).toEqual(copies) + expect(logic.values.pendingRowActions).toEqual({}) + }) + + it.each([ + ['enable', 'wf-sync', 'active', (row: WorkflowListRow) => logic.actions.toggleWorkflowStatus(row)], + ['restore', 'wf-old-promo', 'draft', (row: WorkflowListRow) => logic.actions.restoreWorkflow(row)], + ])('%s takes the status and updated_at from the server answer', async (_, id, status, run) => { + useMocks({ + patch: { + '/api/projects/:team_id/hog_flows/:id/': () => [200, { status, updated_at: '2026-09-27T12:00:00Z' }], + }, + }) + router.actions.push(urls.workflows()) + logic = workflowsListV2Logic() + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess']) + + await expectLogic(logic, () => run(logic.values.rows.find((row) => row.id === id)!)).toFinishAllListeners() + expect(logic.values.rows[0].workflow).toMatchObject({ id, status, updated_at: '2026-09-27T12:00:00Z' }) + }) +}) diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.ts new file mode 100644 index 000000000000..94709b2f7af7 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.ts @@ -0,0 +1,625 @@ +import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import { router, urlToAction } from 'kea-router' + +import { lemonToast } from '@posthog/lemon-ui' + +import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' + +import { + hogFlowsCreate, + hogFlowsMetricsGlobalRetrieve, + hogFlowsRetrieve, + hogFlowsSummariesList, +} from 'products/workflows/frontend/generated/api' +import type { + HogFlowListSummaryApi, + PaginatedHogFlowListSummaryListApi, + WorkflowStatsRowApi, +} from 'products/workflows/frontend/generated/api.schemas' + +import { prepareWorkflowDuplicate } from '../workflowDuplication' +import { + confirmArchiveWorkflow, + confirmDeleteWorkflow, + restoreWorkflowToDraft, + setWorkflowStatus, + WorkflowRowAction, + workflowActionErrorDetail, +} from '../workflowRowActions' +import { + FacetDefinition, + FacetFilter, + FacetSearchValue, + MatchesText, + createFacetMatcher, + parseFacetQuery, + serializeFacetQuery, +} from './FacetSearchBar/facetQuery' +import { buildWorkflowListFacets, matchesWorkflowListText } from './workflowListFacets' +import { + LIST_TYPES, + DEFAULT_COLUMNS, + OPTIONAL_COLUMNS, + OptionalColumn, + STATUS_LABELS, + TRIGGER_LABELS, + TYPE_LABELS, +} from './workflowListLabels' +import { WorkflowListRow, buildWorkflowListRows } from './workflowListRows' + +const WORKFLOWS_PAGE_TYPES = LIST_TYPES.join(',') +const PAGE_LIMIT = 500 +// A `next` link that never ends shows the load error instead of looping. +const MAX_PAGES = 40 +const MIN_SERVER_SEARCH_LENGTH = 3 +const SERVER_SEARCH_DEBOUNCE_MS = 300 + +export interface ServerSearchResult { + text: string + ids: string[] + failed: boolean +} + +/** `pending` while a server search for the current text is debouncing or in flight. */ +export type ServerSearchStatus = 'off' | 'pending' | 'done' | 'failed' + +const EMPTY_VALUE: FacetSearchValue = { filters: [], text: '' } +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const LEGACY_PARAMS = ['status', 'type', 'trigger_type', 'created_by', 'search', 'page'] as const +const LEGACY_VALUES: Record boolean> = { + status: (value) => value in STATUS_LABELS, + type: (value) => value in TYPE_LABELS, + trigger: (value) => value in TRIGGER_LABELS, + 'created-by': (value) => UUID_PATTERN.test(value), +} + +// Parsing only needs facet keys and aliases, which don't depend on the loaded rows. +const QUERY_FACETS = buildWorkflowListFacets([]) + +/** Follows `next` to the end. Rows are keyed by id, because a row created mid-load shifts later offsets. */ +async function loadAllPages( + fetchPage: (offset: number | undefined) => Promise +): Promise { + const byId = new Map() + let offset: number | undefined = undefined + for (let pages = 0; pages < MAX_PAGES; pages++) { + const page: PaginatedHogFlowListSummaryListApi = await fetchPage(offset) + for (const row of page.results) { + byId.set(row.id, byId.get(row.id) ?? row) + } + const nextOffset = page.next ? new URL(page.next, window.location.origin).searchParams.get('offset') : null + if (!nextOffset || !page.results.length) { + return [...byId.values()] + } + offset = Number(nextOffset) + } + throw new Error(`Stopped loading after ${MAX_PAGES} pages`) +} + +/** Reads a param as written. kea-router turns number-like values such as `007` into numbers. */ +function rawSearchParam(name: string): string { + return new URLSearchParams(router.values.location.search).get(name) ?? '' +} + +function withFacetParams(searchParams: Record, value: FacetSearchValue): Record { + const next: Record = { ...searchParams } + delete next.q + delete next.text + const q = serializeFacetQuery(value.filters) + const text = value.text.trim() + if (q) { + next.q = q + } + if (text) { + next.text = text + } + return next +} + +/** Reads the filter params the flag-off list writes, so bookmarked links keep working. */ +function legacyParamsToValue(searchParams: Record): FacetSearchValue { + const filters: FacetFilter[] = [] + const pairs: [string, string][] = [ + ['status', 'status'], + ['type', 'type'], + ['trigger_type', 'trigger'], + ['created_by', 'created-by'], + ] + for (const [param, facet] of pairs) { + const raw = searchParams[param] + const value = raw === undefined || raw === null ? '' : String(raw) + if (value && LEGACY_VALUES[facet](value)) { + filters.push({ facet, value, negated: false }) + } + } + return { filters, text: rawSearchParam('search') } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowsListV2LogicValues { + currentTeamId: number | null // teamLogic + facets: FacetDefinition[] + filteredRows: WorkflowListRow[] + listLoaded: boolean + loadFailed: boolean + matchesText: MatchesText + metrics: WorkflowStatsRowApi[] | null + metricsLoading: boolean + pendingRowActions: Record + requestedSearchText: string | null + rows: WorkflowListRow[] + serverSearch: ServerSearchResult | null + serverSearchLoading: boolean + serverSearchStatus: ServerSearchStatus + shownColumns: OptionalColumn[] + value: FacetSearchValue + visibleColumns: OptionalColumn[] + workflows: HogFlowListSummaryApi[] | null + workflowsLoading: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowsListV2LogicActions { + archiveWorkflow: (row: WorkflowListRow) => { + row: WorkflowListRow + } + clearFilters: () => { + value: true + } + deleteWorkflow: (row: WorkflowListRow) => { + row: WorkflowListRow + } + duplicateWorkflow: (row: WorkflowListRow) => { + row: WorkflowListRow + } + loadMetrics: () => { + value: true + } + loadMetricsFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadMetricsSuccess: ( + metrics: WorkflowStatsRowApi[] | null, + payload?: { + value: true + } + ) => { + metrics: WorkflowStatsRowApi[] | null + payload?: { + value: true + } + } + loadWorkflows: () => { + value: true + } + loadWorkflowsFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadWorkflowsSuccess: ( + workflows: HogFlowListSummaryApi[], + payload?: { + value: true + } + ) => { + workflows: HogFlowListSummaryApi[] + payload?: { + value: true + } + } + patchWorkflow: ( + id: string, + patch: Partial + ) => { + id: string + patch: Partial + } + removeWorkflow: (id: string) => { + id: string + } + resetColumns: () => { + value: true + } + restoreWorkflow: (row: WorkflowListRow) => { + row: WorkflowListRow + } + searchWorkflows: (text: string) => string + searchWorkflowsFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + searchWorkflowsSuccess: ( + serverSearch: ServerSearchResult, + payload?: string + ) => { + serverSearch: ServerSearchResult + payload?: string + } + setRowActionPending: ( + id: string, + action: WorkflowRowAction | null + ) => { + action: WorkflowRowAction | null + id: string + } + setValue: (value: FacetSearchValue) => { + value: FacetSearchValue + } + toggleColumn: (column: OptionalColumn) => { + column: 'created_by' | 'health' | 'last_7_days' | 'owner' | 'trigger' | 'type' + } + toggleWorkflowStatus: (row: WorkflowListRow) => { + row: WorkflowListRow + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowsListV2LogicMeta { + __keaTypeGenInternalSelectorTypes: { + rows: (workflows: HogFlowListSummaryApi[] | null, metrics: WorkflowStatsRowApi[] | null) => WorkflowListRow[] + listLoaded: (workflows: HogFlowListSummaryApi[] | null) => boolean + facets: (rows: WorkflowListRow[]) => FacetDefinition[] + matchesText: (serverSearch: ServerSearchResult | null) => MatchesText + serverSearchStatus: (value: FacetSearchValue, serverSearch: ServerSearchResult | null) => ServerSearchStatus + filteredRows: ( + rows: WorkflowListRow[], + value: FacetSearchValue, + facets: FacetDefinition[], + matchesText: MatchesText + ) => WorkflowListRow[] + shownColumns: ( + visibleColumns: ('created_by' | 'health' | 'last_7_days' | 'owner' | 'trigger' | 'type')[] + ) => OptionalColumn[] + } +} + +export type workflowsListV2LogicType = MakeLogicType< + workflowsListV2LogicValues, + workflowsListV2LogicActions, + Record, + workflowsListV2LogicMeta +> + +export const workflowsListV2Logic = kea([ + path(['products', 'workflows', 'frontend', 'workflowsListV2Logic']), + // `hog_flows` is looked up by team, and a child environment's team id differs from its project id. + connect(() => ({ values: [teamLogic, ['currentTeamId']] })), + actions({ + loadWorkflows: true, + loadMetrics: true, + setValue: (value: FacetSearchValue) => ({ value }), + clearFilters: true, + toggleColumn: (column: OptionalColumn) => ({ column }), + resetColumns: true, + patchWorkflow: (id: string, patch: Partial) => ({ id, patch }), + removeWorkflow: (id: string) => ({ id }), + setRowActionPending: (id: string, action: WorkflowRowAction | null) => ({ id, action }), + toggleWorkflowStatus: (row: WorkflowListRow) => ({ row }), + duplicateWorkflow: (row: WorkflowListRow) => ({ row }), + archiveWorkflow: (row: WorkflowListRow) => ({ row }), + restoreWorkflow: (row: WorkflowListRow) => ({ row }), + deleteWorkflow: (row: WorkflowListRow) => ({ row }), + }), + loaders(({ values, cache }) => ({ + workflows: [ + null as HogFlowListSummaryApi[] | null, + { + loadWorkflows: async (_, breakpoint) => { + const teamId = String(values.currentTeamId) + const workflows = await loadAllPages((offset) => + hogFlowsSummariesList(teamId, { type: WORKFLOWS_PAGE_TYPES, limit: PAGE_LIMIT, offset }) + ) + breakpoint() + return workflows + }, + }, + ], + metrics: [ + null as WorkflowStatsRowApi[] | null, + { + loadMetrics: async (_, breakpoint) => { + try { + const metrics = await hogFlowsMetricsGlobalRetrieve(String(values.currentTeamId), { + after: '-7d', + }) + breakpoint() + return metrics + } catch { + breakpoint() + // The list works without metrics, so a failure shows "Unavailable" in the + // metrics columns instead of an error toast. + return null + } + }, + }, + ], + serverSearch: [ + null as ServerSearchResult | null, + { + searchWorkflows: async (text: string, breakpoint): Promise => { + await breakpoint(SERVER_SEARCH_DEBOUNCE_MS) + cache.searchAbort?.abort() + const controller = new AbortController() + cache.searchAbort = controller + const teamId = String(values.currentTeamId) + try { + const workflows = await loadAllPages((offset) => + hogFlowsSummariesList( + teamId, + { type: WORKFLOWS_PAGE_TYPES, search: text, limit: PAGE_LIMIT, offset }, + { signal: controller.signal } + ) + ) + breakpoint() + return { text, ids: workflows.map((workflow) => workflow.id), failed: false } + } catch { + // `breakpoint()` drops a search that a newer one aborted. Other failures keep the client + // matches and show a notice instead of an error toast. + breakpoint() + return { text, ids: [], failed: true } + } + }, + }, + ], + })), + reducers({ + value: [ + EMPTY_VALUE, + { + setValue: (_, { value }) => value, + clearFilters: () => EMPTY_VALUE, + }, + ], + requestedSearchText: [ + null as string | null, + { + searchWorkflows: (_, text) => text, + setValue: (state, { value }) => (value.text.trim().length < MIN_SERVER_SEARCH_LENGTH ? null : state), + clearFilters: () => null, + }, + ], + loadFailed: [ + false, + { + loadWorkflows: () => false, + loadWorkflowsSuccess: () => false, + loadWorkflowsFailure: () => true, + }, + ], + visibleColumns: [ + DEFAULT_COLUMNS as OptionalColumn[], + { persist: true }, + { + toggleColumn: (state, { column }) => + state.includes(column) + ? state.filter((c) => c !== column) + : OPTIONAL_COLUMNS.filter((c) => c === column || state.includes(c)), + resetColumns: () => DEFAULT_COLUMNS, + }, + ], + pendingRowActions: [ + {} as Record, + { + setRowActionPending: (state, { id, action }) => { + const next = { ...state } + if (action) { + next[id] = action + } else { + delete next[id] + } + return next + }, + }, + ], + workflows: { + patchWorkflow: (state, { id, patch }) => + state && state.map((workflow) => (workflow.id === id ? { ...workflow, ...patch } : workflow)), + removeWorkflow: (state, { id }) => state && state.filter((workflow) => workflow.id !== id), + }, + }), + selectors({ + rows: [ + (s) => [s.workflows, s.metrics], + (workflows: HogFlowListSummaryApi[] | null, metrics: WorkflowStatsRowApi[] | null): WorkflowListRow[] => + workflows ? buildWorkflowListRows(workflows, metrics) : [], + ], + listLoaded: [(s) => [s.workflows], (workflows: HogFlowListSummaryApi[] | null): boolean => workflows !== null], + facets: [ + (s) => [s.rows], + (rows: WorkflowListRow[]): FacetDefinition[] => buildWorkflowListFacets(rows), + ], + matchesText: [ + (s) => [s.serverSearch], + (serverSearch: ServerSearchResult | null): MatchesText => { + const serverIds = new Set(serverSearch?.ids ?? []) + // The server also searches step names and email subjects and bodies, which the rows don't carry. + return (row, text) => + matchesWorkflowListText(row, text) || (serverSearch?.text === text.trim() && serverIds.has(row.id)) + }, + ], + serverSearchStatus: [ + (s) => [s.value, s.serverSearch], + (value: FacetSearchValue, serverSearch: ServerSearchResult | null): ServerSearchStatus => { + const text = value.text.trim() + if (text.length < MIN_SERVER_SEARCH_LENGTH) { + return 'off' + } + if (serverSearch?.text !== text) { + return 'pending' + } + return serverSearch.failed ? 'failed' : 'done' + }, + ], + filteredRows: [ + (s) => [s.rows, s.value, s.facets, s.matchesText], + ( + rows: WorkflowListRow[], + value: FacetSearchValue, + facets: FacetDefinition[], + matchesText: MatchesText + ): WorkflowListRow[] => rows.filter(createFacetMatcher(value, facets, matchesText)), + ], + shownColumns: [ + (s) => [s.visibleColumns], + // A saved choice can name a column this version doesn't have. + (visibleColumns: OptionalColumn[]): OptionalColumn[] => + OPTIONAL_COLUMNS.filter((column) => visibleColumns.includes(column)), + ], + }), + listeners(({ actions, values }) => { + /** Runs one network action per row at a time, so a second press can't send a second request. */ + const runRowAction = async ( + row: WorkflowListRow, + action: WorkflowRowAction, + run: () => Promise + ): Promise => { + if (values.pendingRowActions[row.id]) { + return + } + actions.setRowActionPending(row.id, action) + try { + await run() + } finally { + actions.setRowActionPending(row.id, null) + } + } + // Not `actionToUrl`: it hands kea-router a URL string, which parses `007` to 7 before writing it back. + const writeUrl = (): void => { + if ( + rawSearchParam('q') === serializeFacetQuery(values.value.filters) && + rawSearchParam('text') === values.value.text.trim() + ) { + return + } + router.actions.replace( + router.values.location.pathname, + withFacetParams(router.values.searchParams, values.value), + router.values.hashParams + ) + } + return { + clearFilters: writeUrl, + setValue: ({ value }) => { + writeUrl() + const text = value.text.trim() + if (text.length >= MIN_SERVER_SEARCH_LENGTH && values.requestedSearchText !== text) { + actions.searchWorkflows(text) + } + }, + loadWorkflowsSuccess: () => { + if (values.metrics === null && !values.metricsLoading) { + actions.loadMetrics() + } + }, + toggleWorkflowStatus: async ({ row }) => { + await runRowAction(row, 'toggle', async () => { + const status = row.workflow.status === 'active' ? 'draft' : 'active' + const updated = await setWorkflowStatus(String(values.currentTeamId), row.workflow, status) + if (updated) { + actions.patchWorkflow(row.id, { + status: updated.status ?? status, + updated_at: updated.updated_at, + }) + } + }) + }, + duplicateWorkflow: async ({ row }) => { + await runRowAction(row, 'duplicate', async () => { + const teamId = String(values.currentTeamId) + try { + // The summary row has no step graph, so the copy starts from the full workflow. + const full = await hogFlowsRetrieve(teamId, row.id) + await hogFlowsCreate(teamId, prepareWorkflowDuplicate(full)) + lemonToast.success(`Workflow "${row.name}" duplicated`) + actions.loadWorkflows() + } catch (error) { + lemonToast.error(`Failed to duplicate workflow: ${workflowActionErrorDetail(error)}`) + } + }) + }, + archiveWorkflow: ({ row }) => { + if (values.pendingRowActions[row.id]) { + return + } + confirmArchiveWorkflow( + String(values.currentTeamId), + row.workflow, + (updated) => + actions.patchWorkflow(row.id, { + status: updated.status ?? 'archived', + updated_at: updated.updated_at, + }), + (pending) => actions.setRowActionPending(row.id, pending ? 'archive' : null) + ) + }, + restoreWorkflow: async ({ row }) => { + await runRowAction(row, 'restore', async () => { + const updated = await restoreWorkflowToDraft(String(values.currentTeamId), row.workflow) + if (updated) { + actions.patchWorkflow(row.id, { + status: updated.status ?? 'draft', + updated_at: updated.updated_at, + }) + } + }) + }, + deleteWorkflow: ({ row }) => { + if (values.pendingRowActions[row.id]) { + return + } + confirmDeleteWorkflow( + String(values.currentTeamId), + row.workflow, + () => actions.removeWorkflow(row.id), + (pending) => actions.setRowActionPending(row.id, pending ? 'delete' : null) + ) + }, + } + }), + urlToAction(({ actions, values, cache }) => ({ + [urls.workflows()]: (_, searchParams, hashParams) => { + const current: FacetSearchValue = { + filters: parseFacetQuery(rawSearchParam('q'), QUERY_FACETS), + text: rawSearchParam('text'), + } + // Old filter links are read once, when the list opens. Later URLs are the list's own. + if (!cache.legacyChecked) { + cache.legacyChecked = true + if (LEGACY_PARAMS.some((param) => param in searchParams)) { + const legacy = legacyParamsToValue(searchParams) + const next = { ...searchParams } + for (const param of LEGACY_PARAMS) { + delete next[param] + } + const merged = { + filters: [...current.filters, ...legacy.filters], + text: current.text || legacy.text, + } + router.actions.replace(urls.workflows(), withFacetParams(next, merged), hashParams) + return + } + } + if ( + serializeFacetQuery(current.filters) !== serializeFacetQuery(values.value.filters) || + current.text !== values.value.text + ) { + actions.setValue(current) + } + }, + })), + afterMount(({ actions }) => { + actions.loadWorkflows() + }), +]) diff --git a/products/workflows/frontend/Workflows/WorkflowsTable.tsx b/products/workflows/frontend/Workflows/WorkflowsTable.tsx index 3222ab3ef6d7..965232ee5b6d 100644 --- a/products/workflows/frontend/Workflows/WorkflowsTable.tsx +++ b/products/workflows/frontend/Workflows/WorkflowsTable.tsx @@ -1,9 +1,8 @@ import { useActions, useValues } from 'kea' import { useMemo } from 'react' -import { LemonCheckbox, LemonDivider, LemonInput, LemonSelect, LemonTag, Link, Tooltip } from '@posthog/lemon-ui' +import { LemonCheckbox, LemonInput, LemonSelect, LemonTag, Link, Tooltip } from '@posthog/lemon-ui' -import { AccessControlAction } from 'lib/components/AccessControlAction' import { AppMetricsSparkline } from 'lib/components/AppMetrics/AppMetricsSparkline' import { MemberSelect } from 'lib/components/MemberSelect' import { useOnMountEffect } from 'lib/hooks/useOnMountEffect' @@ -16,11 +15,10 @@ import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture' import { capitalizeFirstLetter } from 'lib/utils/strings' import { urls } from 'scenes/urls' -import { AccessControlLevel, AccessControlResourceType } from '~/types' - import { getHogFlowStep } from './hogflows/steps/HogFlowSteps' import { HogFlow } from './hogflows/types' import { workflowLogic } from './workflowLogic' +import { WorkflowRowMenuOverlay } from './WorkflowRowMenuOverlay' import { findMatchingWorkflowSteps } from './workflowSearchMatches' import { WORKFLOW_TRIGGER_TYPE_OPTIONS, @@ -29,14 +27,9 @@ import { WorkflowTypeFilter, workflowsLogic, } from './workflowsLogic' +import { WorkflowStatusTag } from './WorkflowStatusTag' import { WorkflowStepMatches } from './WorkflowStepMatches' -const STATUS_CONFIG: Record = { - active: { label: 'Active', type: 'success' }, - draft: { label: 'Draft', type: 'default' }, - archived: { label: 'Archived', type: 'muted' }, -} - function WorkflowTypeTag({ workflow }: { workflow: HogFlow }): JSX.Element { const hasMessagingAction = useMemo(() => { // Keep in sync with MESSAGING_ACTION_TYPES in products/workflows/backend/models/hog_flow/hog_flow.py, @@ -276,10 +269,7 @@ export function WorkflowsTable(): JSX.Element { { title: 'Status', width: 0, - render: (_, item) => { - const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.draft - return {config.label} - }, + render: (_, item) => , }, { width: 0, @@ -287,71 +277,15 @@ export function WorkflowsTable(): JSX.Element { return ( - {workflow.status !== 'archived' && ( - - toggleWorkflowStatus(workflow)} - tooltip={ - workflow.status === 'draft' - ? 'Enables the workflow to start sending messages' - : 'Disables the workflow from sending any new messages. In-progress workflows will end immediately.' - } - > - {workflow.status === 'draft' ? 'Enable' : 'Disable'} - - - )} - duplicateWorkflow(workflow)} - > - Duplicate - - - - { - workflow.status === 'archived' - ? restoreWorkflow(workflow) - : archiveWorkflow(workflow) - }} - > - {workflow.status === 'archived' ? 'Restore' : 'Archive'} - - - {workflow.status === 'archived' && ( - - deleteWorkflow(workflow)} - > - Delete - - - )} - + toggleWorkflowStatus(workflow)} + onDuplicate={() => duplicateWorkflow(workflow)} + onArchive={() => archiveWorkflow(workflow)} + onRestore={() => restoreWorkflow(workflow)} + onDelete={() => deleteWorkflow(workflow)} + /> } /> ) diff --git a/products/workflows/frontend/Workflows/workflowDuplication.ts b/products/workflows/frontend/Workflows/workflowDuplication.ts index c99f09c9b55b..6881a9f2d8c8 100644 --- a/products/workflows/frontend/Workflows/workflowDuplication.ts +++ b/products/workflows/frontend/Workflows/workflowDuplication.ts @@ -1,7 +1,10 @@ -import type { HogFlow } from './hogflows/types' +type ServerOwnedField = 'id' | 'team_id' | 'created_at' | 'updated_at' | 'origin_product' -export function prepareWorkflowDuplicate(workflow: HogFlow): Partial { - const duplicate: Partial & { origin_product?: unknown } = { +/** Works on the hand-written `HogFlow` and on the generated `HogFlowApi` alike. */ +export function prepareWorkflowDuplicate( + workflow: T +): Omit & { name: string; status: 'draft' } { + const duplicate: Record = { ...workflow, name: `${workflow.name} (copy)`, status: 'draft', @@ -13,5 +16,5 @@ export function prepareWorkflowDuplicate(workflow: HogFlow): Partial { delete duplicate.updated_at delete duplicate.origin_product - return duplicate + return duplicate as Omit & { name: string; status: 'draft' } } diff --git a/products/workflows/frontend/Workflows/workflowRowActions.test.ts b/products/workflows/frontend/Workflows/workflowRowActions.test.ts new file mode 100644 index 000000000000..c84820014e04 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowRowActions.test.ts @@ -0,0 +1,114 @@ +import { LemonDialog, lemonToast } from '@posthog/lemon-ui' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { + confirmArchiveWorkflow, + confirmDeleteWorkflow, + restoreWorkflowToDraft, + setWorkflowStatus, +} from './workflowRowActions' + +// A child environment's team id differs from its project id; these calls address the team. +const TEAM_ID = '4242' +const UPDATED = { status: 'draft', updated_at: '2026-09-27T12:00:00Z' } + +describe('workflowRowActions', () => { + let requests: { method: string; path: string; body?: unknown }[] + let confirm: () => Promise + + beforeEach(() => { + requests = [] + const respond = async ({ + request, + params, + }: { + request: Request + params: Record + }): Promise<[number, unknown?]> => { + const body = request.method === 'PATCH' ? await request.json() : undefined + requests.push({ method: request.method, path: new URL(request.url).pathname, body }) + if (params.id === 'wf-broken') { + return [500, { detail: 'Server error' }] + } + return request.method === 'DELETE' ? [204] : [200, UPDATED] + } + useMocks({ + patch: { '/api/projects/:team_id/hog_flows/:id/': respond }, + delete: { '/api/projects/:team_id/hog_flows/:id/': respond }, + }) + initKeaTests() + jest.spyOn(LemonDialog, 'open').mockImplementation((props) => { + confirm = async () => { + await (props.primaryButton as { onClick: () => Promise }).onClick() + } + }) + jest.spyOn(lemonToast, 'error') + }) + + afterEach(() => jest.restoreAllMocks()) + + it.each([ + [ + 'restores a workflow to draft', + () => restoreWorkflowToDraft(TEAM_ID, { id: 'wf-1', name: 'Welcome' }), + 'draft', + ], + ['sets a status', () => setWorkflowStatus(TEAM_ID, { id: 'wf-1', name: 'Welcome' }, 'active'), 'active'], + ])('%s through the team-scoped endpoint', async (_, run, status) => { + await expect(run()).resolves.toEqual(UPDATED) + expect(requests).toEqual([ + { method: 'PATCH', path: `/api/projects/${TEAM_ID}/hog_flows/wf-1/`, body: { status } }, + ]) + }) + + it('reports a failed update and leaves the caller to keep its row', async () => { + await expect(restoreWorkflowToDraft(TEAM_ID, { id: 'wf-broken', name: 'Broken' })).resolves.toBeNull() + expect(lemonToast.error).toHaveBeenCalledWith('Failed to restore workflow: Server error') + }) + + it.each([ + [ + 'archive', + (onDone: () => void) => confirmArchiveWorkflow(TEAM_ID, { id: 'wf-1', name: 'Welcome' }, onDone), + { method: 'PATCH', path: `/api/projects/${TEAM_ID}/hog_flows/wf-1/`, body: { status: 'archived' } }, + ], + [ + 'delete', + (onDone: () => void) => confirmDeleteWorkflow(TEAM_ID, { id: 'wf-1', name: 'Welcome' }, onDone), + { method: 'DELETE', path: `/api/projects/${TEAM_ID}/hog_flows/wf-1/`, body: undefined }, + ], + ])('confirming the %s dialog sends the request, then runs the refresh', async (_, open, request) => { + const onDone = jest.fn() + open(onDone) + expect(requests).toEqual([]) + + await confirm() + expect(requests).toEqual([request]) + expect(onDone).toHaveBeenCalledTimes(1) + }) + + it.each([ + [ + 'archive', + (onDone: () => void, onPending: (pending: boolean) => void) => + confirmArchiveWorkflow(TEAM_ID, { id: 'wf-broken', name: 'Broken' }, onDone, onPending), + 'Failed to archive workflow: Server error', + ], + [ + 'delete', + (onDone: () => void, onPending: (pending: boolean) => void) => + confirmDeleteWorkflow(TEAM_ID, { id: 'wf-broken', name: 'Broken' }, onDone, onPending), + 'Failed to delete workflow: Server error', + ], + ])('a failed %s shows the error, skips the refresh and clears the pending state', async (_, open, message) => { + const onDone = jest.fn() + const onPending = jest.fn() + open(onDone, onPending) + await confirm() + expect(lemonToast.error).toHaveBeenCalledWith(message) + expect(onDone).not.toHaveBeenCalled() + expect(onPending.mock.calls).toEqual([[true], [false]]) + }) +}) diff --git a/products/workflows/frontend/Workflows/workflowRowActions.ts b/products/workflows/frontend/Workflows/workflowRowActions.ts new file mode 100644 index 000000000000..73c7312c7b4e --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowRowActions.ts @@ -0,0 +1,124 @@ +import { LemonDialog, lemonToast } from '@posthog/lemon-ui' + +import { deleteFromTree } from '~/layout/panel-layout/ProjectTree/projectTreeLogic' + +import { hogFlowsDestroy, hogFlowsPartialUpdate } from 'products/workflows/frontend/generated/api' +import type { HogFlowUpdateApi } from 'products/workflows/frontend/generated/api.schemas' + +/** What the row actions need to know about a workflow, in either list. */ +export interface WorkflowRowTarget { + id: string + name: string | null + status?: string +} + +type WorkflowStatus = 'draft' | 'active' | 'archived' + +export type WorkflowRowAction = 'toggle' | 'duplicate' | 'archive' | 'restore' | 'delete' + +export function workflowActionErrorDetail(error: unknown): string { + const e = error as { detail?: string; message?: string } | undefined + return e?.detail || e?.message || 'Unknown error' +} + +/** Resolves to the updated workflow, or null after it showed an error. */ +export async function setWorkflowStatus( + teamId: string, + workflow: WorkflowRowTarget, + status: WorkflowStatus +): Promise { + try { + return await hogFlowsPartialUpdate(teamId, workflow.id, { status }) + } catch (error) { + lemonToast.error(`Failed to update workflow: ${workflowActionErrorDetail(error)}`) + return null + } +} + +export async function restoreWorkflowToDraft( + teamId: string, + workflow: WorkflowRowTarget +): Promise { + try { + const updated = await hogFlowsPartialUpdate(teamId, workflow.id, { status: 'draft' }) + lemonToast.success(`Workflow "${workflow.name}" restored to draft status`) + return updated + } catch (error) { + lemonToast.error(`Failed to restore workflow: ${workflowActionErrorDetail(error)}`) + return null + } +} + +/** + * Asks first, then archives. `onArchived` runs only after the server accepted the change. + * `onPendingChange` brackets the request, from the confirm press to the answer. + */ +export function confirmArchiveWorkflow( + teamId: string, + workflow: WorkflowRowTarget, + onArchived: (updated: HogFlowUpdateApi) => void, + onPendingChange?: (pending: boolean) => void +): void { + LemonDialog.open({ + width: 500, + title: 'Archive workflow?', + description: `Are you sure you want to archive "${workflow.name}"?${ + workflow.status === 'active' ? ' In-progress workflow invocations will end without completing.' : '' + }`, + primaryButton: { + children: 'Archive', + type: 'primary', + status: 'danger', + onClick: async () => { + onPendingChange?.(true) + try { + const updated = await hogFlowsPartialUpdate(teamId, workflow.id, { status: 'archived' }) + lemonToast.success(`Workflow "${workflow.name}" archived`) + onArchived(updated) + } catch (error) { + lemonToast.error(`Failed to archive workflow: ${workflowActionErrorDetail(error)}`) + } finally { + onPendingChange?.(false) + } + }, + }, + secondaryButton: { + children: 'Cancel', + }, + }) +} + +/** Asks first, then deletes. `onDeleted` runs only after the server deleted the workflow. */ +export function confirmDeleteWorkflow( + teamId: string, + workflow: WorkflowRowTarget, + onDeleted: () => void, + onPendingChange?: (pending: boolean) => void +): void { + LemonDialog.open({ + width: 500, + title: 'Delete workflow?', + description: `Are you sure you want to permanently delete "${workflow.name}"? This action cannot be undone.`, + primaryButton: { + children: 'Delete', + type: 'primary', + status: 'danger', + onClick: async () => { + onPendingChange?.(true) + try { + await hogFlowsDestroy(teamId, workflow.id) + lemonToast.success(`Workflow "${workflow.name}" deleted`) + deleteFromTree('hog_flow/', workflow.id) + onDeleted() + } catch (error) { + lemonToast.error(`Failed to delete workflow: ${workflowActionErrorDetail(error)}`) + } finally { + onPendingChange?.(false) + } + }, + }, + secondaryButton: { + children: 'Cancel', + }, + }) +} diff --git a/products/workflows/frontend/Workflows/workflowStatus.ts b/products/workflows/frontend/Workflows/workflowStatus.ts new file mode 100644 index 000000000000..8dcda250d5a7 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowStatus.ts @@ -0,0 +1,10 @@ +export type WorkflowStatusValue = 'draft' | 'active' | 'archived' + +export const WORKFLOW_STATUS_CONFIG: Record< + WorkflowStatusValue, + { label: string; type: 'success' | 'default' | 'muted' } +> = { + active: { label: 'Active', type: 'success' }, + draft: { label: 'Draft', type: 'default' }, + archived: { label: 'Archived', type: 'muted' }, +} diff --git a/products/workflows/frontend/Workflows/workflowsLogic.test.ts b/products/workflows/frontend/Workflows/workflowsLogic.test.ts new file mode 100644 index 000000000000..503031cc787b --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowsLogic.test.ts @@ -0,0 +1,110 @@ +import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' + +import { router } from 'kea-router' +import { expectLogic } from 'kea-test-utils' + +import { LemonDialog } from '@posthog/lemon-ui' + +import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import type { HogFlow } from './hogflows/types' +import { workflowsLogic } from './workflowsLogic' + +// A child environment: its team id differs from the project id (997 in the test setup). +const TEAM_ID = 4242 + +const WORKFLOW = { + id: 'wf-1', + name: 'Welcome series', + status: 'draft', + actions: [], + edges: [], +} as unknown as HogFlow + +describe('workflowsLogic', () => { + let logic: ReturnType + let requests: string[] + let confirm: () => Promise + + beforeEach(() => { + requests = [] + const record = ({ request }: { request: Request }): [number, unknown] => { + requests.push(`${request.method} ${new URL(request.url).pathname}`) + return request.method === 'DELETE' ? [204, null] : [200, { ...WORKFLOW, results: [WORKFLOW], count: 1 }] + } + useMocks({ + get: { '/api/projects/:team_id/hog_flows/': record, '/api/projects/:team_id/hog_flows/:id/': record }, + post: { '/api/projects/:team_id/hog_flows/': record }, + patch: { '/api/projects/:team_id/hog_flows/:id/': record }, + delete: { '/api/projects/:team_id/hog_flows/:id/': record }, + }) + initKeaTests() + teamLogic.actions.loadCurrentTeamSuccess({ ...MOCK_DEFAULT_TEAM, id: TEAM_ID }) + jest.spyOn(LemonDialog, 'open').mockImplementation((props) => { + confirm = async () => { + await (props.primaryButton as { onClick: () => Promise }).onClick() + } + }) + router.actions.push(urls.workflows(), { status: 'all' }) + logic = workflowsLogic() + logic.mount() + // The table loads the list when it mounts; the logic alone doesn't. + logic.actions.loadWorkflows() + }) + + afterEach(() => { + logic.unmount() + jest.restoreAllMocks() + }) + + it.each([ + [ + 'archive', + () => logic.actions.archiveWorkflow(WORKFLOW), + true, + `PATCH /api/projects/${TEAM_ID}/hog_flows/wf-1/`, + ], + [ + 'restore', + () => logic.actions.restoreWorkflow(WORKFLOW), + false, + `PATCH /api/projects/${TEAM_ID}/hog_flows/wf-1/`, + ], + [ + 'delete', + () => logic.actions.deleteWorkflow(WORKFLOW), + true, + `DELETE /api/projects/${TEAM_ID}/hog_flows/wf-1/`, + ], + [ + 'toggle', + () => logic.actions.toggleWorkflowStatus(WORKFLOW), + false, + `PATCH /api/projects/${TEAM_ID}/hog_flows/wf-1/`, + ], + [ + 'duplicate', + () => logic.actions.duplicateWorkflow(WORKFLOW), + false, + `POST /api/projects/${TEAM_ID}/hog_flows/`, + ], + ])('%s goes to the team id and reloads the list', async (_, run, confirmFirst, request) => { + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess']) + requests = [] + + await expectLogic(logic, run).toFinishAllListeners() + if (confirmFirst) { + await confirm() + } + // The list reloads from the same team once the change lands. + await expectLogic(logic).toDispatchActions(['loadWorkflowsSuccess']) + + expect(requests.filter((r) => !r.startsWith('GET'))).toEqual([request]) + expect(requests).toContain(`GET /api/projects/${TEAM_ID}/hog_flows`) + expect(requests.filter((r) => r.includes('/projects/997/'))).toEqual([]) + }) +}) diff --git a/products/workflows/frontend/Workflows/workflowsLogic.ts b/products/workflows/frontend/Workflows/workflowsLogic.ts index a4399ebe1faa..576088e10826 100644 --- a/products/workflows/frontend/Workflows/workflowsLogic.ts +++ b/products/workflows/frontend/Workflows/workflowsLogic.ts @@ -1,4 +1,4 @@ -import { MakeLogicType, actions, kea, key, listeners, path, reducers, selectors } from 'kea' +import { MakeLogicType, actions, connect, kea, key, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { actionToUrl, router, urlToAction } from 'kea-router' @@ -6,12 +6,14 @@ import { LemonDialog, PaginationManual, lemonToast } from '@posthog/lemon-ui' import api, { CountedPaginatedResponse, HogFlowListType } from 'lib/api' import { objectsEqual } from 'lib/utils/objects' +import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' import { deleteFromTree } from '~/layout/panel-layout/ProjectTree/projectTreeLogic' import type { HogFlow } from './hogflows/types' import { prepareWorkflowDuplicate } from './workflowDuplication' +import { confirmArchiveWorkflow, confirmDeleteWorkflow, restoreWorkflowToDraft } from './workflowRowActions' export type WorkflowStatusFilter = 'all' | 'active' | 'draft' | 'archived' @@ -77,6 +79,7 @@ interface WorkflowsListParams { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface workflowsLogicValues { + currentTeamId: number | null // teamLogic allArchivedSelected: boolean filters: WorkflowsFilters hasLoadedWorkflows: boolean @@ -252,6 +255,8 @@ export type workflowsLogicType = MakeLogicType< export const workflowsLogic = kea([ key(() => 'workflowsLogic'), path(['products', 'workflows', 'frontend', 'workflowsLogic']), + // `hog_flows` is looked up by team, and a child environment's team id differs from its project id. + connect(() => ({ values: [teamLogic, ['currentTeamId']] })), actions({ toggleWorkflowStatus: (workflow: HogFlow) => ({ workflow }), duplicateWorkflow: (workflow: HogFlow) => ({ workflow }), @@ -332,83 +337,23 @@ export const workflowsLogic = kea([ return values.workflows }, archiveWorkflow: async ({ workflow }) => { - LemonDialog.open({ - width: 500, - title: 'Archive workflow?', - description: `Are you sure you want to archive "${workflow.name}"?${ - workflow.status === 'active' - ? ' In-progress workflow invocations will end without completing.' - : '' - }`, - primaryButton: { - children: 'Archive', - type: 'primary', - status: 'danger', - onClick: async () => { - try { - await api.hogFlows.updateHogFlow(workflow.id, { - status: 'archived', - }) - lemonToast.success(`Workflow "${workflow.name}" archived`) - router.actions.push(urls.workflows()) - actions.loadWorkflows() - } catch (error: any) { - lemonToast.error( - `Failed to archive workflow: ${error.detail || error.message || 'Unknown error'}` - ) - } - }, - }, - secondaryButton: { - children: 'Cancel', - }, + confirmArchiveWorkflow(String(values.currentTeamId), workflow, () => { + router.actions.push(urls.workflows()) + actions.loadWorkflows() }) // Return unchanged workflows since dialog handles the update return values.workflows }, restoreWorkflow: async ({ workflow }) => { - try { - await api.hogFlows.updateHogFlow(workflow.id, { - status: 'draft', - }) - lemonToast.success(`Workflow "${workflow.name}" restored to draft status`) - // Restored workflows become drafts, so they drop out of the archived filter — - // reload rather than keep the stale row in the current filtered page. + // Restored workflows become drafts, so they drop out of the archived filter — + // reload rather than keep the stale row in the current filtered page. + if (await restoreWorkflowToDraft(String(values.currentTeamId), workflow)) { actions.loadWorkflows() - return values.workflows - } catch (error: any) { - lemonToast.error( - `Failed to restore workflow: ${error?.detail || error?.message || 'Unknown error'}` - ) - return values.workflows } + return values.workflows }, deleteWorkflow: async ({ workflow }) => { - LemonDialog.open({ - width: 500, - title: 'Delete workflow?', - description: `Are you sure you want to permanently delete "${workflow.name}"? This action cannot be undone.`, - primaryButton: { - children: 'Delete', - type: 'primary', - status: 'danger', - onClick: async () => { - try { - await api.hogFlows.deleteHogFlow(workflow.id) - lemonToast.success(`Workflow "${workflow.name}" deleted`) - deleteFromTree('hog_flow/', workflow.id) - actions.loadWorkflows() - } catch (error: any) { - lemonToast.error( - `Failed to delete workflow: ${error.detail || error.message || 'Unknown error'}` - ) - } - }, - }, - secondaryButton: { - children: 'Cancel', - }, - }) + confirmDeleteWorkflow(String(values.currentTeamId), workflow, () => actions.loadWorkflows()) return values.workflows }, }, diff --git a/products/workflows/frontend/WorkflowsScene.test.tsx b/products/workflows/frontend/WorkflowsScene.test.tsx new file mode 100644 index 000000000000..e214d8e70ef7 --- /dev/null +++ b/products/workflows/frontend/WorkflowsScene.test.tsx @@ -0,0 +1,146 @@ +import '@testing-library/jest-dom' + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Provider } from 'kea' +import { router } from 'kea-router' + +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { urls } from 'scenes/urls' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import type { HogFlowListSummaryApi } from 'products/workflows/frontend/generated/api.schemas' + +import { + FIXTURE_METRICS, + FIXTURE_WORKFLOWS, + buildWorkflowRow, + paginated, +} from './Workflows/WorkflowsListV2/workflowsListV2Fixtures' +import { workflowsListV2Logic } from './Workflows/WorkflowsListV2/workflowsListV2Logic' +import { WorkflowsScene } from './WorkflowsScene' + +const shownRowNames = (): string[] => + Array.from(document.querySelectorAll('[data-attr="workflows-list-v2-name"]')).map((el) => el.textContent ?? '') + +describe('WorkflowsScene', () => { + let listRequests: string[] + let workflows: HogFlowListSummaryApi[] + + beforeEach(() => { + listRequests = [] + workflows = FIXTURE_WORKFLOWS + localStorage.clear() + useMocks({ + get: { + '/api/projects/:team_id/hog_flows/summaries/': ({ request }) => { + listRequests.push(request.url) + return [200, paginated(workflows)] + }, + '/api/projects/:team_id/hog_flows/metrics/global/': ({ request }) => { + listRequests.push(request.url) + return [200, FIXTURE_METRICS] + }, + }, + }) + initKeaTests() + router.actions.push(urls.workflows()) + }) + + afterEach(() => cleanup()) + + it('filters the list to active workflows from the keyboard and writes the pills to the URL', async () => { + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_LIST_V2]: true }) + const user = userEvent.setup() + render( + + + + ) + + await waitFor(() => expect(shownRowNames()).toContain('Welcome series')) + + const input = document.querySelector('input[data-attr="workflows-search"]') + expect(input).not.toBeNull() + await user.click(input!) + await user.keyboard('sta') + await user.keyboard('{Tab}') + expect(input).toHaveValue('status:') + await user.keyboard('{ArrowDown}{Enter}') + + await waitFor(() => expect(shownRowNames()).toEqual(['Welcome series'])) + expect(router.values.searchParams.q).toEqual('status:active') + expect(input).toHaveValue('') + }) + + it('pages past the first 100 rows, and a new filter starts again on page one', async () => { + workflows = Array.from({ length: 250 }, (_, i) => + buildWorkflowRow({ + id: `wf-${i}`, + name: `Flow ${String(i).padStart(3, '0')} ${i % 2 ? 'odd' : 'even'}`, + updated_at: new Date(Date.UTC(2026, 9, 1) - i * 60_000).toISOString(), + }) + ) + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_LIST_V2]: true }) + const user = userEvent.setup() + render( + + + + ) + await waitFor(() => expect(shownRowNames()[0]).toEqual('Flow 000 even')) + + await user.click(document.querySelector('[aria-label="Next page"]')!) + await waitFor(() => expect(shownRowNames()[0]).toEqual('Flow 100 even')) + expect(shownRowNames()).toHaveLength(100) + + // 125 rows match, so a kept page would still show matches 101 and up. + act(() => workflowsListV2Logic.actions.setValue({ filters: [], text: 'odd' })) + await waitFor(() => expect(shownRowNames()[0]).toEqual('Flow 001 odd')) + }) + + it.each([ + ['shows Owner when no columns are saved', null, ['Name', 'Status', 'Owner', 'Updated', '', '']], + [ + 'ignores a saved column the list no longer has', + ['health', 'tags'], + ['Name', 'Status', 'Health', 'Updated', '', ''], + ], + ])('%s', async (_, savedColumns, expectedHeaders) => { + if (savedColumns) { + localStorage.setItem( + 'products.workflows.frontend.workflowsListV2Logic.visibleColumns', + JSON.stringify(savedColumns) + ) + } + featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_LIST_V2]: true }) + render( + + + + ) + + await waitFor(() => expect(shownRowNames()).toContain('Welcome series')) + const headers = Array.from(document.querySelectorAll('[data-attr="workflows-list-v2"] th')).map( + (th) => th.textContent + ) + expect(headers).toEqual(expectedHeaders) + }) + + it('keeps the old list when the flag is off and never asks for the v2 list', async () => { + featureFlagLogic.actions.setFeatureFlags([], {}) + render( + + + + ) + + await waitFor(() => expect(document.querySelector('[data-attr="workflows-table"]')).not.toBeNull()) + expect(document.querySelector('input[data-attr="workflows-search"]')).toBeNull() + await act(async () => {}) + expect(listRequests).toEqual([]) + }) +}) diff --git a/products/workflows/frontend/WorkflowsScene.tsx b/products/workflows/frontend/WorkflowsScene.tsx index 7aba15ff2148..b195eab2f19b 100644 --- a/products/workflows/frontend/WorkflowsScene.tsx +++ b/products/workflows/frontend/WorkflowsScene.tsx @@ -4,7 +4,9 @@ import { urlToAction } from 'kea-router' import { LemonButton } from '@posthog/lemon-ui' import { AccessControlAction } from 'lib/components/AccessControlAction' +import { FEATURE_FLAGS } from 'lib/constants' import { LemonTab, LemonTabs } from 'lib/lemon-ui/LemonTabs' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { trackedActionToUrl } from 'lib/logic/scenes/trackedActionToUrl' import { addProductIntent } from 'lib/utils/product-intents' import { capitalizeFirstLetter } from 'lib/utils/strings' @@ -23,6 +25,8 @@ import { MessagingTabActions } from './MessagingTabActions' import { messagingNavTabs } from './messagingTabs' import { newWorkflowLogic } from './Workflows/newWorkflowLogic' import { NewWorkflowModal } from './Workflows/NewWorkflowModal' +import { WorkflowsListV2 } from './Workflows/WorkflowsListV2/WorkflowsListV2' +import { WorkflowsListV2ColumnsMenu } from './Workflows/WorkflowsListV2/WorkflowsListV2ColumnsMenu' import { WorkflowsTable } from './Workflows/WorkflowsTable' const WORKFLOW_SCENE_TABS = ['workflows', 'library', 'channels', 'opt-outs', 'suppression', 'reputation'] as const @@ -125,11 +129,13 @@ export const scene: SceneExport = { export function WorkflowsScene(props: WorkflowsSceneProps = {}): JSX.Element { const { currentTab } = useValues(workflowsSceneLogic(props)) const { startNewWorkflow } = useActions(newWorkflowLogic) + const { featureFlags } = useValues(featureFlagLogic) + const listV2 = !!featureFlags[FEATURE_FLAGS.WORKFLOWS_LIST_V2] const tabs: LemonTab[] = [ { label: 'Workflows', key: 'workflows', - content: , + content: listV2 ? : , link: urls.workflows(), }, ...messagingNavTabs((tab) => urls.workflows(tab)), @@ -166,6 +172,7 @@ export function WorkflowsScene(props: WorkflowsSceneProps = {}): JSX.Element { )} + {currentTab === 'workflows' && listV2 && } {currentTab !== 'workflows' && ( )} diff --git a/products/workflows/frontend/generated/api.schemas.ts b/products/workflows/frontend/generated/api.schemas.ts index 239539bc8893..b93fa1af9f93 100644 --- a/products/workflows/frontend/generated/api.schemas.ts +++ b/products/workflows/frontend/generated/api.schemas.ts @@ -1688,6 +1688,59 @@ export interface TeamEmailReputationResponseApi { readonly sending_allowance: EmailSendingAllowanceApi | null } +/** + * * `messaging` - Messaging + * * `automation` - Automation + * * `loop` - Loop + * * `broadcast` - Broadcast + */ +export type HogFlowTypeEnumApi = (typeof HogFlowTypeEnumApi)[keyof typeof HogFlowTypeEnumApi] + +export const HogFlowTypeEnumApi = { + Messaging: 'messaging', + Automation: 'automation', + Loop: 'loop', + Broadcast: 'broadcast', +} as const + +/** + * One row of the workflows list: the summary fields and the workflow type, without the step graph. + */ +export interface HogFlowListSummaryApi { + readonly id: string + /** @nullable */ + readonly name: string | null + readonly description: string + readonly version: number + readonly status: HogFlowStateEnumApi + readonly origin_product: HogFlowOriginProductEnumApi | null + readonly created_at: string + readonly created_by: UserBasicApi + readonly updated_at: string + readonly trigger: unknown + /** + * The effective access level the user has for this object + * @nullable + */ + readonly user_access_level: string | null + /** `loop` and `broadcast` for workflows those surfaces own. Otherwise `messaging` when the workflow has an email, SMS or push step, else `automation`. The same rules as the `type` filter. + * + * * `messaging` - Messaging + * * `automation` - Automation + * * `loop` - Loop + * * `broadcast` - Broadcast */ + readonly type: HogFlowTypeEnumApi +} + +export interface PaginatedHogFlowListSummaryListApi { + count: number + /** @nullable */ + next?: string | null + /** @nullable */ + previous?: string | null + results: HogFlowListSummaryApi[] +} + /** * Property filters to apply */ @@ -2145,3 +2198,63 @@ export type HogFlowsReputationRetrieveParams = { */ search?: string } + +export type HogFlowsSummariesListParams = { + /** + * Pass `true` to return broadcasts plus the ordinary workflows the broadcasts UI can render: a batch trigger and a single email step. + */ + broadcast_eligible?: boolean + created_at?: string + /** + * Filter to workflows created by the user with this uuid. + */ + created_by?: string + id?: string + /** + * Number of results to return per page. + */ + limit?: number + /** + * The initial index from which to return the results. + */ + offset?: number + /** + * Filter to workflows owned by a product surface, e.g. `loops` for Desktop loops. + */ + origin_product?: HogFlowsSummariesListOriginProduct + /** + * Case-insensitive search over workflow name, description, step names and the subject line, preheader and body text of email steps, in both the live workflow and its pending draft. + */ + search?: string + /** + * * `draft` - Draft + * * `active` - Active + * * `archived` - Archived + */ + status?: HogFlowsSummariesListStatus + /** + * Filter by trigger config as a JSON object. Returns workflows whose trigger contains the given object, e.g. {"type": "event"}. + */ + trigger?: string + /** + * Comma-separated workflow types. `loop` and `broadcast` return the workflows those surfaces own; `messaging` returns the remaining workflows with an email, SMS, or push action, and `automation` the rest. + */ + type?: string + updated_at?: string +} + +export type HogFlowsSummariesListOriginProduct = + (typeof HogFlowsSummariesListOriginProduct)[keyof typeof HogFlowsSummariesListOriginProduct] + +export const HogFlowsSummariesListOriginProduct = { + Broadcasts: 'broadcasts', + Loops: 'loops', +} as const + +export type HogFlowsSummariesListStatus = (typeof HogFlowsSummariesListStatus)[keyof typeof HogFlowsSummariesListStatus] + +export const HogFlowsSummariesListStatus = { + Active: 'active', + Archived: 'archived', + Draft: 'draft', +} as const diff --git a/products/workflows/frontend/generated/api.ts b/products/workflows/frontend/generated/api.ts index 5b47b1fcb725..001c6f7b61c5 100644 --- a/products/workflows/frontend/generated/api.ts +++ b/products/workflows/frontend/generated/api.ts @@ -40,6 +40,7 @@ import type { HogFlowsMetricsTotalsRetrieveParams, HogFlowsReputationRetrieveParams, HogFlowsRevisionsListParams, + HogFlowsSummariesListParams, HogInvocationCancelRequestApi, HogInvocationCancelResponseApi, HogInvocationRerunRequestApi, @@ -48,6 +49,7 @@ import type { HogInvocationResultDetailApi, HogInvocationResultsCountApi, MessageAssetApi, + PaginatedHogFlowListSummaryListApi, PaginatedHogFlowMinimalListApi, PaginatedHogFlowRevisionBasicListApi, PaginatedHogFlowTemplateListApi, @@ -1067,6 +1069,37 @@ export const hogFlowsReputationRetrieve = async ( }) } +export const getHogFlowsSummariesListUrl = (projectId: string, params?: HogFlowsSummariesListParams) => { + const normalizedParams = new URLSearchParams() + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }) + + const stringifiedParams = normalizedParams.toString() + + return stringifiedParams.length > 0 + ? `/api/projects/${projectId}/hog_flows/summaries/?${stringifiedParams}` + : `/api/projects/${projectId}/hog_flows/summaries/` +} + +/** + * Workflow rows without the step graph, for loading a whole project's list page by page. Sorted newest created first. Takes the same filters as the list. + * @summary List workflow summaries + */ +export const hogFlowsSummariesList = async ( + projectId: string, + params?: HogFlowsSummariesListParams, + options?: RequestInit +): Promise => { + return apiMutator(getHogFlowsSummariesListUrl(projectId, params), { + ...options, + method: 'GET', + }) +} + export const getHogFlowsUserBlastRadiusCreateUrl = (projectId: string) => { return `/api/projects/${projectId}/hog_flows/user_blast_radius/` } diff --git a/products/workflows/mcp/tools.yaml b/products/workflows/mcp/tools.yaml index c93550b9b4c8..e29b60faa5f5 100644 --- a/products/workflows/mcp/tools.yaml +++ b/products/workflows/mcp/tools.yaml @@ -111,6 +111,9 @@ tools: hog-flows-schedules-list: operation: hog_flows_schedules_list enabled: false + hog-flows-summaries-list: + operation: hog_flows_summaries_list + enabled: false hog-flows-update: operation: hog_flows_update enabled: false diff --git a/products/workflows/package.json b/products/workflows/package.json index e250226db894..1c852f9e0e20 100644 --- a/products/workflows/package.json +++ b/products/workflows/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@storybook/react": "catalog:", "@testing-library/react": "*", + "@testing-library/user-event": "*", "@types/papaparse": "*", "kea-test-utils": "catalog:" }, diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 4497cb55fcbb..06762e55cb22 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -50116,6 +50116,51 @@ export namespace Schemas { use_draft?: boolean; } + /** + * * `messaging` - Messaging + * * `automation` - Automation + * * `loop` - Loop + * * `broadcast` - Broadcast + */ + export type HogFlowTypeEnum = typeof HogFlowTypeEnum[keyof typeof HogFlowTypeEnum]; + + + export const HogFlowTypeEnum = { + Messaging: 'messaging', + Automation: 'automation', + Loop: 'loop', + Broadcast: 'broadcast', + } as const; + + /** + * One row of the workflows list: the summary fields and the workflow type, without the step graph. + */ + export interface HogFlowListSummary { + readonly id: string; + /** @nullable */ + readonly name: string | null; + readonly description: string; + readonly version: number; + readonly status: HogFlowStateEnum; + readonly origin_product: HogFlowOriginProductEnum | null; + readonly created_at: string; + readonly created_by: UserBasic; + readonly updated_at: string; + readonly trigger: unknown; + /** + * The effective access level the user has for this object + * @nullable + */ + readonly user_access_level: string | null; + /** `loop` and `broadcast` for workflows those surfaces own. Otherwise `messaging` when the workflow has an email, SMS or push step, else `automation`. The same rules as the `type` filter. + * + * * `messaging` - Messaging + * * `automation` - Automation + * * `loop` - Loop + * * `broadcast` - Broadcast */ + readonly type: HogFlowTypeEnum; + } + /** * Mixin for serializers to add user access control fields */ @@ -64242,6 +64287,15 @@ export namespace Schemas { results: HealthIssue[]; } + export interface PaginatedHogFlowListSummaryList { + count: number; + /** @nullable */ + next?: string | null; + /** @nullable */ + previous?: string | null; + results: HogFlowListSummary[]; + } + export interface PaginatedHogFlowMinimalList { count: number; /** @nullable */ @@ -112509,6 +112563,67 @@ export namespace Schemas { search?: string; }; + export type HogFlowsSummariesListParams = { + /** + * Pass `true` to return broadcasts plus the ordinary workflows the broadcasts UI can render: a batch trigger and a single email step. + */ + broadcast_eligible?: boolean; + created_at?: string; + /** + * Filter to workflows created by the user with this uuid. + */ + created_by?: string; + id?: string; + /** + * Number of results to return per page. + */ + limit?: number; + /** + * The initial index from which to return the results. + */ + offset?: number; + /** + * Filter to workflows owned by a product surface, e.g. `loops` for Desktop loops. + */ + origin_product?: HogFlowsSummariesListOriginProduct; + /** + * Case-insensitive search over workflow name, description, step names and the subject line, preheader and body text of email steps, in both the live workflow and its pending draft. + */ + search?: string; + /** + * * `draft` - Draft + * * `active` - Active + * * `archived` - Archived + */ + status?: HogFlowsSummariesListStatus; + /** + * Filter by trigger config as a JSON object. Returns workflows whose trigger contains the given object, e.g. {"type": "event"}. + */ + trigger?: string; + /** + * Comma-separated workflow types. `loop` and `broadcast` return the workflows those surfaces own; `messaging` returns the remaining workflows with an email, SMS, or push action, and `automation` the rest. + */ + type?: string; + updated_at?: string; + }; + + export type HogFlowsSummariesListOriginProduct = typeof HogFlowsSummariesListOriginProduct[keyof typeof HogFlowsSummariesListOriginProduct]; + + + export const HogFlowsSummariesListOriginProduct = { + Broadcasts: 'broadcasts', + Loops: 'loops', + } as const; + + export type HogFlowsSummariesListStatus = typeof HogFlowsSummariesListStatus[keyof typeof HogFlowsSummariesListStatus]; + + + export const HogFlowsSummariesListStatus = { + Active: 'active', + Archived: 'archived', + Draft: 'draft', + } as const; + export type HogFunctionTemplatesListParams = { /** * Number of results to return per page.