From 2bd04659765dae0ae8c3339c713207fca98c3c5d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:55:14 +0000 Subject: [PATCH 1/4] feat(workflows): add a slim hog_flows summaries endpoint `GET hog_flows/summaries/` returns the MCP summary fields plus the workflow type, without the step graph, so the workflows list can load every row. It takes the list's filters and search, sorts on created_at so a save during a paged load does not move rows, applies the access-level filter itself, and is gzipped. Both list actions now select the creator in the same query. Co-Authored-By: Claude Opus 5.5 (1M context) --- posthog/settings/web.py | 2 + products/workflows/CONTRIBUTING.md | 12 ++ products/workflows/backend/api/hog_flow.py | 165 ++++++++++++------ .../backend/api/test/test_hog_flow.py | 21 +++ .../api/test/test_hog_flow_access_control.py | 7 +- .../frontend/generated/api.schemas.ts | 113 ++++++++++++ products/workflows/frontend/generated/api.ts | 33 ++++ products/workflows/mcp/tools.yaml | 3 + services/mcp/src/api/generated.ts | 115 ++++++++++++ 9 files changed, 420 insertions(+), 51 deletions(-) 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..7ac2dee971c5 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,50 @@ 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.", + ), +] + + @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 +4095,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 +4156,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 +4172,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 +4233,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() @@ -4213,6 +4262,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 and search as the list." + ), + parameters=LIST_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..f4744f2c2b81 100644 --- a/products/workflows/backend/api/test/test_hog_flow.py +++ b/products/workflows/backend/api/test/test_hog_flow.py @@ -448,6 +448,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 +556,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/generated/api.schemas.ts b/products/workflows/frontend/generated/api.schemas.ts index 239539bc8893..f126f470821a 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. 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. + */ + 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..9bd67a8cd92d 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 and search 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/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 4497cb55fcbb..95742c1dbed7 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. 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. + */ + 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. From aafae70932068dfeeafffd58d11d672683a48d39 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:30:39 +0000 Subject: [PATCH 2/4] feat(workflows): search names and step content together on summaries The list v2 applies its facets in the browser, so the name-first search tier hid step content matches that the facets would have kept. The summaries action now matches name, description and step content in one pass. The existing list keeps its tiered search. Co-Authored-By: Claude Opus 5.5 (1M context) --- products/workflows/backend/api/hog_flow.py | 20 ++++++++++++++++--- .../backend/api/test/test_hog_flow.py | 16 +++++++++++++++ .../frontend/generated/api.schemas.ts | 2 +- products/workflows/frontend/generated/api.ts | 2 +- services/mcp/src/api/generated.ts | 2 +- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/products/workflows/backend/api/hog_flow.py b/products/workflows/backend/api/hog_flow.py index 7ac2dee971c5..8316125b33bd 100644 --- a/products/workflows/backend/api/hog_flow.py +++ b/products/workflows/backend/api/hog_flow.py @@ -4052,6 +4052,16 @@ def mint_audience_confirm_token( ] +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=LIST_QUERY_PARAMETERS)) class HogFlowViewSet( @@ -4250,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 @@ -4266,9 +4280,9 @@ def _is_mcp_request(request: Request) -> bool: 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 and search as the list." + "Sorted newest created first. Takes the same filters as the list." ), - parameters=LIST_QUERY_PARAMETERS, + parameters=SUMMARIES_QUERY_PARAMETERS, responses={200: HogFlowListSummarySerializer(many=True)}, ) @action(detail=False, methods=["GET"], url_path="summaries") diff --git a/products/workflows/backend/api/test/test_hog_flow.py b/products/workflows/backend/api/test/test_hog_flow.py index f4744f2c2b81..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( diff --git a/products/workflows/frontend/generated/api.schemas.ts b/products/workflows/frontend/generated/api.schemas.ts index f126f470821a..b93fa1af9f93 100644 --- a/products/workflows/frontend/generated/api.schemas.ts +++ b/products/workflows/frontend/generated/api.schemas.ts @@ -2223,7 +2223,7 @@ export type HogFlowsSummariesListParams = { */ origin_product?: HogFlowsSummariesListOriginProduct /** - * 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. + * 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 /** diff --git a/products/workflows/frontend/generated/api.ts b/products/workflows/frontend/generated/api.ts index 9bd67a8cd92d..001c6f7b61c5 100644 --- a/products/workflows/frontend/generated/api.ts +++ b/products/workflows/frontend/generated/api.ts @@ -1086,7 +1086,7 @@ export const getHogFlowsSummariesListUrl = (projectId: string, params?: HogFlows } /** - * Workflow rows without the step graph, for loading a whole project's list page by page. Sorted newest created first. Takes the same filters and search as the list. + * 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 ( diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 95742c1dbed7..06762e55cb22 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -112587,7 +112587,7 @@ export namespace Schemas { */ origin_product?: HogFlowsSummariesListOriginProduct; /** - * 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. + * 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; /** From 88aa3b8c425a14df1ee3abd819cd0a66321822c0 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:30:51 +0000 Subject: [PATCH 3/4] feat(workflows): add a pill search bar to the workflows list Behind the `workflows-list-v2` flag, the Workflows tab shows a compact list with a pill search bar over it. - Loads every row from `hog_flows/summaries/` (500 per page) through the generated client. - Filters on status, type, trigger, owner, health and created by, and matches free text on name and description. Text of 3+ characters also asks the server, so step names and email content still match. - Health and the "Last 7 days" column come from one lazy call to `hog_flows/metrics/global/` after the list renders. A failed call shows "Unavailable" and leaves the list working. - Optional columns are picked from the "..." menu and persist. - Filters live in `q` and `text` URL params. Old list params redirect once. - Shares the row menu, status tag and archive, restore and delete dialogs with the flag-off list, which now sends those calls to the team id through the generated client. - `FacetSearchBar` in `lib/components` holds the generic bar. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../FacetSearchBar/FacetSearchBar.stories.tsx | 126 ++++ .../FacetSearchBar/FacetSearchBar.test.tsx | 208 +++++++ .../FacetSearchBar/FacetSearchBar.tsx | 200 +++++++ .../FacetSearchBar/facetQuery.test.ts | 143 +++++ .../components/FacetSearchBar/facetQuery.ts | 203 +++++++ .../facetSearchBarLogic.test.ts | 158 +++++ .../FacetSearchBar/facetSearchBarLogic.ts | 468 +++++++++++++++ frontend/src/lib/components/owners.yaml | 2 + frontend/src/lib/constants.tsx | 3 +- .../lib/lemon-ui/LemonButton/LemonButton.tsx | 1 + .../lib/lemon-ui/LemonInput/LemonInput.tsx | 5 + .../lib/lemon-ui/LemonSnack/LemonSnack.tsx | 5 +- pnpm-lock.yaml | 3 + .../Workflows/WorkflowRowMenuOverlay.tsx | 81 +++ .../frontend/Workflows/WorkflowStatusTag.tsx | 8 + .../WorkflowsListV2/WorkflowListNameCell.tsx | 29 + .../WorkflowsListV2/WorkflowRowMenu.tsx | 29 + .../WorkflowsListV2.stories.tsx | 128 +++++ .../WorkflowsListV2/WorkflowsListV2.tsx | 99 ++++ .../WorkflowsListV2ColumnsMenu.tsx | 46 ++ .../WorkflowsListV2/workflowListFacets.ts | 88 +++ .../WorkflowsListV2/workflowListLabels.ts | 36 ++ .../WorkflowsListV2/workflowListRows.test.ts | 62 ++ .../WorkflowsListV2/workflowListRows.ts | 92 +++ .../workflowsListV2Columns.tsx | 126 ++++ .../workflowsListV2Fixtures.ts | 95 ++++ .../workflowsListV2Logic.test.ts | 347 +++++++++++ .../WorkflowsListV2/workflowsListV2Logic.ts | 538 ++++++++++++++++++ .../frontend/Workflows/WorkflowsTable.tsx | 92 +-- .../frontend/Workflows/workflowDuplication.ts | 11 +- .../Workflows/workflowRowActions.test.ts | 109 ++++ .../frontend/Workflows/workflowRowActions.ts | 100 ++++ .../frontend/Workflows/workflowStatus.ts | 10 + .../frontend/Workflows/workflowsLogic.test.ts | 110 ++++ .../frontend/Workflows/workflowsLogic.ts | 83 +-- .../frontend/WorkflowsScene.test.tsx | 137 +++++ .../workflows/frontend/WorkflowsScene.tsx | 9 +- products/workflows/package.json | 1 + 38 files changed, 3836 insertions(+), 155 deletions(-) create mode 100644 frontend/src/lib/components/FacetSearchBar/FacetSearchBar.stories.tsx create mode 100644 frontend/src/lib/components/FacetSearchBar/FacetSearchBar.test.tsx create mode 100644 frontend/src/lib/components/FacetSearchBar/FacetSearchBar.tsx create mode 100644 frontend/src/lib/components/FacetSearchBar/facetQuery.test.ts create mode 100644 frontend/src/lib/components/FacetSearchBar/facetQuery.ts create mode 100644 frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.test.ts create mode 100644 frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.ts create mode 100644 products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowStatusTag.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowRowMenu.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.stories.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2ColumnsMenu.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/workflowListFacets.ts create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/workflowListLabels.ts create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.test.ts create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.ts create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Columns.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Fixtures.ts create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.test.ts create mode 100644 products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.ts create mode 100644 products/workflows/frontend/Workflows/workflowRowActions.test.ts create mode 100644 products/workflows/frontend/Workflows/workflowRowActions.ts create mode 100644 products/workflows/frontend/Workflows/workflowStatus.ts create mode 100644 products/workflows/frontend/Workflows/workflowsLogic.test.ts create mode 100644 products/workflows/frontend/WorkflowsScene.test.tsx diff --git a/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.stories.tsx b/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.stories.tsx new file mode 100644 index 000000000000..561fee5c62fc --- /dev/null +++ b/frontend/src/lib/components/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: 'Components/FacetSearchBar', + 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/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.test.tsx b/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.test.tsx new file mode 100644 index 000000000000..1fddc11c4e10 --- /dev/null +++ b/frontend/src/lib/components/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/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.tsx b/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.tsx new file mode 100644 index 000000000000..e82b77fd6fe6 --- /dev/null +++ b/frontend/src/lib/components/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/frontend/src/lib/components/FacetSearchBar/facetQuery.test.ts b/frontend/src/lib/components/FacetSearchBar/facetQuery.test.ts new file mode 100644 index 000000000000..10d27548049a --- /dev/null +++ b/frontend/src/lib/components/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/frontend/src/lib/components/FacetSearchBar/facetQuery.ts b/frontend/src/lib/components/FacetSearchBar/facetQuery.ts new file mode 100644 index 000000000000..95b105fbdfa0 --- /dev/null +++ b/frontend/src/lib/components/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/frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.test.ts b/frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.test.ts new file mode 100644 index 000000000000..d50867ece6d8 --- /dev/null +++ b/frontend/src/lib/components/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/frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.ts b/frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.ts new file mode 100644 index 000000000000..4f99da3990ca --- /dev/null +++ b/frontend/src/lib/components/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) => ['lib', 'components', 'FacetSearchBar', '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/frontend/src/lib/components/owners.yaml b/frontend/src/lib/components/owners.yaml index 5f595c25f788..99d79eedcc76 100644 --- a/frontend/src/lib/components/owners.yaml +++ b/frontend/src/lib/components/owners.yaml @@ -1,6 +1,8 @@ version: 1 owners: [] rules: + - match: '/FacetSearchBar/' + owners: team-workflows - match: '/HogQLEditor/' owners: team-data-tools - match: '/MarkdownNotebook/' 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/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx b/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx new file mode 100644 index 000000000000..c73fdeea50e5 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx @@ -0,0 +1,81 @@ +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' + +export interface WorkflowRowMenuOverlayProps { + status: string + userAccessLevel?: AccessControlLevel + 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, + onToggleStatus, + onDuplicate, + onArchive, + onRestore, + onDelete, +}: WorkflowRowMenuOverlayProps): JSX.Element { + 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/WorkflowListNameCell.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx new file mode 100644 index 000000000000..be3908fbddf7 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx @@ -0,0 +1,29 @@ +import { Link, Tooltip } from '@posthog/lemon-ui' + +import { urls } from 'scenes/urls' + +import { WorkflowListRow } from './workflowListRows' + +export function WorkflowListNameCell({ row }: { row: WorkflowListRow }): JSX.Element { + const name = row.name || 'Untitled' + if (row.workflow.status === 'archived') { + return ( + + + {name} + + + ) + } + return ( + + + {name} + + + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowRowMenu.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowRowMenu.tsx new file mode 100644 index 000000000000..6715ee0d0ecc --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowRowMenu.tsx @@ -0,0 +1,29 @@ +import { useActions } 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 { 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..ef06c43b4acb --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.stories.tsx @@ -0,0 +1,128 @@ +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 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..f7c6b8ccd2df --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.tsx @@ -0,0 +1,99 @@ +import { useActions, useValues } from 'kea' + +import { LemonButton } from '@posthog/lemon-ui' + +import { serializeFacetQuery } from 'lib/components/FacetSearchBar/facetQuery' +import { FacetSearchBar } from 'lib/components/FacetSearchBar/FacetSearchBar' +import { useOnMountEffect } from 'lib/hooks/useOnMountEffect' +import { LemonTable } from 'lib/lemon-ui/LemonTable' + +import { workflowLogic } from '../workflowLogic' +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, + } = 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 + +
+ ) + } + if (listLoaded && rows.length > 0 && filteredRows.length === 0) { + return ( +
+ No workflows match these filters + + Clear filters + +
+ ) + } + return ( + + ) + } + + return ( +
+ + {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..6698c6688e17 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListFacets.ts @@ -0,0 +1,88 @@ +import type { FacetDefinition } from 'lib/components/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..70acb3b05b03 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListLabels.ts @@ -0,0 +1,36 @@ +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[] 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..066f96162d25 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.test.ts @@ -0,0 +1,62 @@ +import { findFacet } from 'lib/components/FacetSearchBar/facetQuery' + +import type { UserBasicApi } from 'products/workflows/frontend/generated/api.schemas' + +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..676bafb9ead0 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Fixtures.ts @@ -0,0 +1,95 @@ +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', + status: 'draft', + type: 'messaging', + updated_at: '2026-09-18T09:00:00Z', + }), + buildWorkflowRow({ + id: 'wf-sync', + name: 'Sync accounts to CRM', + 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..4ea5eff328c3 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.test.ts @@ -0,0 +1,347 @@ +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 { + 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) { + const ids = await serverSearch(search) + return [200, paginated(ids.map((id) => byId.get(id)!))] + } + 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([]) + }) +}) diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.ts new file mode 100644 index 000000000000..043b70c35587 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.ts @@ -0,0 +1,538 @@ +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 { + FacetDefinition, + FacetFilter, + FacetSearchValue, + MatchesText, + createFacetMatcher, + parseFacetQuery, + serializeFacetQuery, +} from 'lib/components/FacetSearchBar/facetQuery' +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, + workflowActionErrorDetail, +} from '../workflowRowActions' +import { buildWorkflowListFacets, matchesWorkflowListText } from './workflowListFacets' +import { + LIST_TYPES, + 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[] +} + +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 + requestedSearchText: string | null + rows: WorkflowListRow[] + serverSearch: ServerSearchResult | null + serverSearchLoading: boolean + 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: { + ids: string[] + text: string + }, + payload?: string + ) => { + serverSearch: { + ids: string[] + text: string + } + payload?: 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 + 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 }), + 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) => { + 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) } + } catch (error) { + // A newer search aborted this one; drop it quietly. + breakpoint() + throw error + } + }, + }, + ], + })), + 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: [ + [] 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: () => [], + }, + ], + 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)) + }, + ], + 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 }) => { + // 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 }) => { + const status = row.workflow.status === 'active' ? 'draft' : 'active' + if (await setWorkflowStatus(String(values.currentTeamId), row.workflow, status)) { + actions.patchWorkflow(row.id, { status }) + } + }, + duplicateWorkflow: async ({ row }) => { + const teamId = String(values.currentTeamId) + try { + // The slim 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 }) => { + confirmArchiveWorkflow(String(values.currentTeamId), row.workflow, () => + actions.patchWorkflow(row.id, { status: 'archived' }) + ) + }, + restoreWorkflow: async ({ row }) => { + if (await restoreWorkflowToDraft(String(values.currentTeamId), row.workflow)) { + actions.patchWorkflow(row.id, { status: 'draft' }) + } + }, + deleteWorkflow: ({ row }) => { + confirmDeleteWorkflow(String(values.currentTeamId), row.workflow, () => actions.removeWorkflow(row.id)) + }, + } + }), + 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..e1f4d83399de --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowRowActions.test.ts @@ -0,0 +1,109 @@ +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' + +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, {}] + } + 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.toBe(true) + 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.toBe(false) + 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) => confirmArchiveWorkflow(TEAM_ID, { id: 'wf-broken', name: 'Broken' }, onDone), + 'Failed to archive workflow: Server error', + ], + [ + 'delete', + (onDone: () => void) => confirmDeleteWorkflow(TEAM_ID, { id: 'wf-broken', name: 'Broken' }, onDone), + 'Failed to delete workflow: Server error', + ], + ])('a failed %s shows the error and skips the refresh', async (_, open, message) => { + const onDone = jest.fn() + open(onDone) + await confirm() + expect(lemonToast.error).toHaveBeenCalledWith(message) + expect(onDone).not.toHaveBeenCalled() + }) +}) diff --git a/products/workflows/frontend/Workflows/workflowRowActions.ts b/products/workflows/frontend/Workflows/workflowRowActions.ts new file mode 100644 index 000000000000..faf9eaf29f26 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowRowActions.ts @@ -0,0 +1,100 @@ +import { LemonDialog, lemonToast } from '@posthog/lemon-ui' + +import { deleteFromTree } from '~/layout/panel-layout/ProjectTree/projectTreeLogic' + +import { hogFlowsDestroy, hogFlowsPartialUpdate } from 'products/workflows/frontend/generated/api' + +/** 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 function workflowActionErrorDetail(error: unknown): string { + const e = error as { detail?: string; message?: string } | undefined + return e?.detail || e?.message || 'Unknown error' +} + +/** Resolves true when the status changed, false after it showed an error. */ +export async function setWorkflowStatus( + teamId: string, + workflow: WorkflowRowTarget, + status: WorkflowStatus +): Promise { + try { + await hogFlowsPartialUpdate(teamId, workflow.id, { status }) + return true + } catch (error) { + lemonToast.error(`Failed to update workflow: ${workflowActionErrorDetail(error)}`) + return false + } +} + +export async function restoreWorkflowToDraft(teamId: string, workflow: WorkflowRowTarget): Promise { + try { + await hogFlowsPartialUpdate(teamId, workflow.id, { status: 'draft' }) + lemonToast.success(`Workflow "${workflow.name}" restored to draft status`) + return true + } catch (error) { + lemonToast.error(`Failed to restore workflow: ${workflowActionErrorDetail(error)}`) + return false + } +} + +/** Asks first, then archives. `onArchived` runs only after the server accepted the change. */ +export function confirmArchiveWorkflow(teamId: string, workflow: WorkflowRowTarget, onArchived: () => 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 () => { + try { + await hogFlowsPartialUpdate(teamId, workflow.id, { status: 'archived' }) + lemonToast.success(`Workflow "${workflow.name}" archived`) + onArchived() + } catch (error) { + lemonToast.error(`Failed to archive workflow: ${workflowActionErrorDetail(error)}`) + } + }, + }, + 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): 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 () => { + 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)}`) + } + }, + }, + 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..144e8b4baa6d --- /dev/null +++ b/products/workflows/frontend/WorkflowsScene.test.tsx @@ -0,0 +1,137 @@ +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('ignores a saved column the list no longer has', async () => { + localStorage.setItem( + 'products.workflows.frontend.workflowsListV2Logic.visibleColumns', + JSON.stringify(['health', 'tags']) + ) + 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(['Name', 'Status', 'Health', 'Updated', '', '']) + }) + + 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/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:" }, From 97972c5d7296765015f963e356e913fed23e74a1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:53:35 +0000 Subject: [PATCH 4/4] fix(workflows): show owner and description in list v2 rows - Each row shows its description under the name as one muted line, with the full text in a tooltip. Owner is a default column. - FacetSearchBar moves from lib/components into the list v2 folder of the workflows product, with its tests and stories. - Row actions run one at a time per workflow. A repeated press is ignored and the menu item shows a loading state until it finishes. - Enable, disable, archive and restore take status and updated_at from the server answer, so Updated and the sort stay current. - While the server search for the current text is pending, the table shows its loading state instead of "No workflows match". A failed server search keeps the client matches and shows a notice. Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/src/lib/components/owners.yaml | 2 - .../Workflows/WorkflowRowMenuOverlay.tsx | 20 +- .../FacetSearchBar/FacetSearchBar.stories.tsx | 2 +- .../FacetSearchBar/FacetSearchBar.test.tsx | 0 .../FacetSearchBar/FacetSearchBar.tsx | 0 .../FacetSearchBar/facetQuery.test.ts | 0 .../FacetSearchBar/facetQuery.ts | 0 .../facetSearchBarLogic.test.ts | 0 .../FacetSearchBar/facetSearchBarLogic.ts | 2 +- .../WorkflowsListV2/WorkflowListNameCell.tsx | 41 ++-- .../WorkflowsListV2/WorkflowRowMenu.tsx | 4 +- .../WorkflowsListV2.stories.tsx | 14 ++ .../WorkflowsListV2/WorkflowsListV2.tsx | 16 +- .../WorkflowsListV2/workflowListFacets.ts | 3 +- .../WorkflowsListV2/workflowListLabels.ts | 2 + .../WorkflowsListV2/workflowListRows.test.ts | 3 +- .../workflowsListV2Fixtures.ts | 3 + .../workflowsListV2Logic.test.ts | 110 ++++++++++- .../WorkflowsListV2/workflowsListV2Logic.ts | 175 +++++++++++++----- .../Workflows/workflowRowActions.test.ts | 19 +- .../frontend/Workflows/workflowRowActions.ts | 52 ++++-- .../frontend/WorkflowsScene.test.tsx | 21 ++- 22 files changed, 381 insertions(+), 108 deletions(-) rename {frontend/src/lib/components => products/workflows/frontend/Workflows/WorkflowsListV2}/FacetSearchBar/FacetSearchBar.stories.tsx (98%) rename {frontend/src/lib/components => products/workflows/frontend/Workflows/WorkflowsListV2}/FacetSearchBar/FacetSearchBar.test.tsx (100%) rename {frontend/src/lib/components => products/workflows/frontend/Workflows/WorkflowsListV2}/FacetSearchBar/FacetSearchBar.tsx (100%) rename {frontend/src/lib/components => products/workflows/frontend/Workflows/WorkflowsListV2}/FacetSearchBar/facetQuery.test.ts (100%) rename {frontend/src/lib/components => products/workflows/frontend/Workflows/WorkflowsListV2}/FacetSearchBar/facetQuery.ts (100%) rename {frontend/src/lib/components => products/workflows/frontend/Workflows/WorkflowsListV2}/FacetSearchBar/facetSearchBarLogic.test.ts (100%) rename {frontend/src/lib/components => products/workflows/frontend/Workflows/WorkflowsListV2}/FacetSearchBar/facetSearchBarLogic.ts (99%) diff --git a/frontend/src/lib/components/owners.yaml b/frontend/src/lib/components/owners.yaml index 99d79eedcc76..5f595c25f788 100644 --- a/frontend/src/lib/components/owners.yaml +++ b/frontend/src/lib/components/owners.yaml @@ -1,8 +1,6 @@ version: 1 owners: [] rules: - - match: '/FacetSearchBar/' - owners: team-workflows - match: '/HogQLEditor/' owners: team-data-tools - match: '/MarkdownNotebook/' diff --git a/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx b/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx index c73fdeea50e5..0da8a29b410b 100644 --- a/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx +++ b/products/workflows/frontend/Workflows/WorkflowRowMenuOverlay.tsx @@ -4,9 +4,12 @@ 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 @@ -18,12 +21,17 @@ export interface WorkflowRowMenuOverlayProps { 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' && ( @@ -37,6 +45,7 @@ export function WorkflowRowMenuOverlay({ fullWidth status={status === 'draft' ? 'default' : 'danger'} onClick={onToggleStatus} + {...pendingState('toggle')} tooltip={ status === 'draft' ? 'Enables the workflow to start sending messages' @@ -47,7 +56,7 @@ export function WorkflowRowMenuOverlay({ )} - + Duplicate @@ -61,6 +70,7 @@ export function WorkflowRowMenuOverlay({ fullWidth status={status === 'archived' ? 'default' : 'danger'} onClick={status === 'archived' ? onRestore : onArchive} + {...pendingState(status === 'archived' ? 'restore' : 'archive')} > {status === 'archived' ? 'Restore' : 'Archive'} @@ -71,7 +81,13 @@ export function WorkflowRowMenuOverlay({ minAccessLevel={AccessControlLevel.Editor} userAccessLevel={userAccessLevel} > - + Delete diff --git a/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.stories.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.stories.tsx similarity index 98% rename from frontend/src/lib/components/FacetSearchBar/FacetSearchBar.stories.tsx rename to products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.stories.tsx index 561fee5c62fc..4244f978bec0 100644 --- a/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.stories.tsx +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.stories.tsx @@ -73,7 +73,7 @@ function Harness({ initial, narrow }: HarnessProps): JSX.Element { } const meta: Meta = { - title: 'Components/FacetSearchBar', + title: 'Scenes-App/Workflows/Facet search bar', component: Harness, parameters: { layout: 'fullscreen' }, args: { initial: { filters: [], text: '' } }, diff --git a/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.test.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.test.tsx similarity index 100% rename from frontend/src/lib/components/FacetSearchBar/FacetSearchBar.test.tsx rename to products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.test.tsx diff --git a/frontend/src/lib/components/FacetSearchBar/FacetSearchBar.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.tsx similarity index 100% rename from frontend/src/lib/components/FacetSearchBar/FacetSearchBar.tsx rename to products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/FacetSearchBar.tsx diff --git a/frontend/src/lib/components/FacetSearchBar/facetQuery.test.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.test.ts similarity index 100% rename from frontend/src/lib/components/FacetSearchBar/facetQuery.test.ts rename to products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.test.ts diff --git a/frontend/src/lib/components/FacetSearchBar/facetQuery.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.ts similarity index 100% rename from frontend/src/lib/components/FacetSearchBar/facetQuery.ts rename to products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetQuery.ts diff --git a/frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.test.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.test.ts similarity index 100% rename from frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.test.ts rename to products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.test.ts diff --git a/frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.ts similarity index 99% rename from frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.ts rename to products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.ts index 4f99da3990ca..915b0c5c6189 100644 --- a/frontend/src/lib/components/FacetSearchBar/facetSearchBarLogic.ts +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/FacetSearchBar/facetSearchBarLogic.ts @@ -295,7 +295,7 @@ export type facetSearchBarLogicType = MakeLogicType< export const facetSearchBarLogic = kea([ props({} as FacetSearchBarLogicProps), key((props) => props.id), - path((key) => ['lib', 'components', 'FacetSearchBar', 'facetSearchBarLogic', key]), + path((key) => ['products', 'workflows', 'frontend', 'facetSearchBarLogic', key]), actions({ setInput: (input: string) => ({ input }), syncInput: (input: string) => ({ input }), diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx index be3908fbddf7..2f7ea8c315e2 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowListNameCell.tsx @@ -1,29 +1,30 @@ -import { Link, Tooltip } from '@posthog/lemon-ui' +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 name = row.name || 'Untitled' - if (row.workflow.status === 'archived') { - return ( - - - {name} - - - ) - } - return ( - - - {name} - + 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 index 6715ee0d0ecc..efe6ff1f1ae7 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowRowMenu.tsx +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowRowMenu.tsx @@ -1,4 +1,4 @@ -import { useActions } from 'kea' +import { useActions, useValues } from 'kea' import { More } from 'lib/lemon-ui/LemonButton/More' @@ -9,6 +9,7 @@ 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 ( @@ -17,6 +18,7 @@ export function WorkflowRowMenu({ row }: { row: WorkflowListRow }): JSX.Element toggleWorkflowStatus(row)} onDuplicate={() => duplicateWorkflow(row)} onArchive={() => archiveWorkflow(row)} diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.stories.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.stories.tsx index ef06c43b4acb..5f2f01c7efba 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.stories.tsx +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.stories.tsx @@ -94,6 +94,20 @@ 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: [ diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.tsx b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.tsx index f7c6b8ccd2df..333ac9c2b574 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.tsx +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/WorkflowsListV2.tsx @@ -2,12 +2,12 @@ import { useActions, useValues } from 'kea' import { LemonButton } from '@posthog/lemon-ui' -import { serializeFacetQuery } from 'lib/components/FacetSearchBar/facetQuery' -import { FacetSearchBar } from 'lib/components/FacetSearchBar/FacetSearchBar' 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' @@ -25,6 +25,7 @@ export function WorkflowsListV2(): JSX.Element { loadFailed, shownColumns, metricsLoading, + serverSearchStatus, } = useValues(workflowsListV2Logic) const { setValue, loadWorkflows, clearFilters } = useActions(workflowsListV2Logic) @@ -50,7 +51,8 @@ export function WorkflowsListV2(): JSX.Element { ) } - if (listLoaded && rows.length > 0 && filteredRows.length === 0) { + const searchPending = serverSearchStatus === 'pending' + if (listLoaded && rows.length > 0 && filteredRows.length === 0 && !searchPending) { return (
No workflows match these filters @@ -71,7 +73,8 @@ export function WorkflowsListV2(): JSX.Element { key={`${serializeFacetQuery(value.filters)}\n${value.text}`} size="small" dataSource={filteredRows} - loading={!listLoaded} + // Until the server search answers, a match in an email body can still add rows. + loading={!listLoaded || searchPending} rowKey="id" columns={buildWorkflowsListV2Columns(shownColumns, metricsLoading)} // Client-side pages stay out of the URL; `page` there is an old list param. @@ -93,6 +96,11 @@ export function WorkflowsListV2(): JSX.Element { placeholder="Search workflows, or filter with status:, owner:, health: and more" dataAttr="workflows-search" /> + {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/workflowListFacets.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListFacets.ts index 6698c6688e17..3d7f4b36f460 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListFacets.ts +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListFacets.ts @@ -1,5 +1,4 @@ -import type { FacetDefinition } from 'lib/components/FacetSearchBar/facetQuery' - +import type { FacetDefinition } from './FacetSearchBar/facetQuery' import { HEALTH_TAGS, STATUS_LABELS, TRIGGER_LABELS, TYPE_LABELS } from './workflowListLabels' import { WorkflowListRow } from './workflowListRows' diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListLabels.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListLabels.ts index 70acb3b05b03..d271ae01fc34 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListLabels.ts +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListLabels.ts @@ -34,3 +34,5 @@ export const OPTIONAL_COLUMN_TITLES = { 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 index 066f96162d25..572d9849d6db 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.test.ts +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowListRows.test.ts @@ -1,7 +1,6 @@ -import { findFacet } from 'lib/components/FacetSearchBar/facetQuery' - 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' diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Fixtures.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Fixtures.ts index 676bafb9ead0..dcb8ae8a2221 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Fixtures.ts +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Fixtures.ts @@ -53,6 +53,7 @@ export const FIXTURE_WORKFLOWS: HogFlowListSummaryApi[] = [ 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', @@ -60,6 +61,8 @@ export const FIXTURE_WORKFLOWS: HogFlowListSummaryApi[] = [ 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' }, diff --git a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.test.ts b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.test.ts index 4ea5eff328c3..00c5db5db893 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.test.ts +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.test.ts @@ -13,6 +13,7 @@ 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, @@ -48,8 +49,12 @@ describe('workflowsListV2Logic', () => { workflowRequests.push(params) const search = params.get('search') if (search) { - const ids = await serverSearch(search) - return [200, paginated(ids.map((id) => byId.get(id)!))] + 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)] @@ -343,5 +348,106 @@ describe('workflowsListV2Logic', () => { 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 index 043b70c35587..94709b2f7af7 100644 --- a/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.ts +++ b/products/workflows/frontend/Workflows/WorkflowsListV2/workflowsListV2Logic.ts @@ -4,15 +4,6 @@ import { router, urlToAction } from 'kea-router' import { lemonToast } from '@posthog/lemon-ui' -import { - FacetDefinition, - FacetFilter, - FacetSearchValue, - MatchesText, - createFacetMatcher, - parseFacetQuery, - serializeFacetQuery, -} from 'lib/components/FacetSearchBar/facetQuery' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' @@ -34,11 +25,22 @@ import { 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, @@ -57,8 +59,12 @@ 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 @@ -141,10 +147,12 @@ export interface workflowsListV2LogicValues { 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[] @@ -233,18 +241,19 @@ export interface workflowsListV2LogicActions { errorObject?: any } searchWorkflowsSuccess: ( - serverSearch: { - ids: string[] - text: string - }, + serverSearch: ServerSearchResult, payload?: string ) => { - serverSearch: { - ids: string[] - text: string - } + serverSearch: ServerSearchResult payload?: string } + setRowActionPending: ( + id: string, + action: WorkflowRowAction | null + ) => { + action: WorkflowRowAction | null + id: string + } setValue: (value: FacetSearchValue) => { value: FacetSearchValue } @@ -263,6 +272,7 @@ export interface workflowsListV2LogicMeta { 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, @@ -295,6 +305,7 @@ export const workflowsListV2Logic = kea([ 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 }), @@ -337,7 +348,7 @@ export const workflowsListV2Logic = kea([ serverSearch: [ null as ServerSearchResult | null, { - searchWorkflows: async (text: string, breakpoint) => { + searchWorkflows: async (text: string, breakpoint): Promise => { await breakpoint(SERVER_SEARCH_DEBOUNCE_MS) cache.searchAbort?.abort() const controller = new AbortController() @@ -352,11 +363,12 @@ export const workflowsListV2Logic = kea([ ) ) breakpoint() - return { text, ids: workflows.map((workflow) => workflow.id) } - } catch (error) { - // A newer search aborted this one; drop it quietly. + 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() - throw error + return { text, ids: [], failed: true } } }, }, @@ -387,14 +399,28 @@ export const workflowsListV2Logic = kea([ }, ], visibleColumns: [ - [] as OptionalColumn[], + 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: () => [], + 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: { @@ -423,6 +449,19 @@ export const workflowsListV2Logic = kea([ 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], ( @@ -440,6 +479,22 @@ export const workflowsListV2Logic = kea([ ], }), 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 ( @@ -469,35 +524,67 @@ export const workflowsListV2Logic = kea([ } }, toggleWorkflowStatus: async ({ row }) => { - const status = row.workflow.status === 'active' ? 'draft' : 'active' - if (await setWorkflowStatus(String(values.currentTeamId), row.workflow, status)) { - actions.patchWorkflow(row.id, { status }) - } + 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 }) => { - const teamId = String(values.currentTeamId) - try { - // The slim 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)}`) - } + 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 }) => { - confirmArchiveWorkflow(String(values.currentTeamId), row.workflow, () => - actions.patchWorkflow(row.id, { status: 'archived' }) + 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 }) => { - if (await restoreWorkflowToDraft(String(values.currentTeamId), row.workflow)) { - actions.patchWorkflow(row.id, { status: 'draft' }) - } + 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 }) => { - confirmDeleteWorkflow(String(values.currentTeamId), row.workflow, () => actions.removeWorkflow(row.id)) + if (values.pendingRowActions[row.id]) { + return + } + confirmDeleteWorkflow( + String(values.currentTeamId), + row.workflow, + () => actions.removeWorkflow(row.id), + (pending) => actions.setRowActionPending(row.id, pending ? 'delete' : null) + ) }, } }), diff --git a/products/workflows/frontend/Workflows/workflowRowActions.test.ts b/products/workflows/frontend/Workflows/workflowRowActions.test.ts index e1f4d83399de..c84820014e04 100644 --- a/products/workflows/frontend/Workflows/workflowRowActions.test.ts +++ b/products/workflows/frontend/Workflows/workflowRowActions.test.ts @@ -12,6 +12,7 @@ import { // 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 }[] @@ -31,7 +32,7 @@ describe('workflowRowActions', () => { if (params.id === 'wf-broken') { return [500, { detail: 'Server error' }] } - return request.method === 'DELETE' ? [204] : [200, {}] + return request.method === 'DELETE' ? [204] : [200, UPDATED] } useMocks({ patch: { '/api/projects/:team_id/hog_flows/:id/': respond }, @@ -56,14 +57,14 @@ describe('workflowRowActions', () => { ], ['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.toBe(true) + 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.toBe(false) + await expect(restoreWorkflowToDraft(TEAM_ID, { id: 'wf-broken', name: 'Broken' })).resolves.toBeNull() expect(lemonToast.error).toHaveBeenCalledWith('Failed to restore workflow: Server error') }) @@ -91,19 +92,23 @@ describe('workflowRowActions', () => { it.each([ [ 'archive', - (onDone: () => void) => confirmArchiveWorkflow(TEAM_ID, { id: 'wf-broken', name: 'Broken' }, onDone), + (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) => confirmDeleteWorkflow(TEAM_ID, { id: 'wf-broken', name: 'Broken' }, onDone), + (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 and skips the refresh', async (_, open, message) => { + ])('a failed %s shows the error, skips the refresh and clears the pending state', async (_, open, message) => { const onDone = jest.fn() - open(onDone) + 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 index faf9eaf29f26..73c7312c7b4e 100644 --- a/products/workflows/frontend/Workflows/workflowRowActions.ts +++ b/products/workflows/frontend/Workflows/workflowRowActions.ts @@ -3,6 +3,7 @@ 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 { @@ -13,39 +14,51 @@ export interface WorkflowRowTarget { 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 true when the status changed, false after it showed an error. */ +/** Resolves to the updated workflow, or null after it showed an error. */ export async function setWorkflowStatus( teamId: string, workflow: WorkflowRowTarget, status: WorkflowStatus -): Promise { +): Promise { try { - await hogFlowsPartialUpdate(teamId, workflow.id, { status }) - return true + return await hogFlowsPartialUpdate(teamId, workflow.id, { status }) } catch (error) { lemonToast.error(`Failed to update workflow: ${workflowActionErrorDetail(error)}`) - return false + return null } } -export async function restoreWorkflowToDraft(teamId: string, workflow: WorkflowRowTarget): Promise { +export async function restoreWorkflowToDraft( + teamId: string, + workflow: WorkflowRowTarget +): Promise { try { - await hogFlowsPartialUpdate(teamId, workflow.id, { status: 'draft' }) + const updated = await hogFlowsPartialUpdate(teamId, workflow.id, { status: 'draft' }) lemonToast.success(`Workflow "${workflow.name}" restored to draft status`) - return true + return updated } catch (error) { lemonToast.error(`Failed to restore workflow: ${workflowActionErrorDetail(error)}`) - return false + return null } } -/** Asks first, then archives. `onArchived` runs only after the server accepted the change. */ -export function confirmArchiveWorkflow(teamId: string, workflow: WorkflowRowTarget, onArchived: () => void): void { +/** + * 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?', @@ -57,12 +70,15 @@ export function confirmArchiveWorkflow(teamId: string, workflow: WorkflowRowTarg type: 'primary', status: 'danger', onClick: async () => { + onPendingChange?.(true) try { - await hogFlowsPartialUpdate(teamId, workflow.id, { status: 'archived' }) + const updated = await hogFlowsPartialUpdate(teamId, workflow.id, { status: 'archived' }) lemonToast.success(`Workflow "${workflow.name}" archived`) - onArchived() + onArchived(updated) } catch (error) { lemonToast.error(`Failed to archive workflow: ${workflowActionErrorDetail(error)}`) + } finally { + onPendingChange?.(false) } }, }, @@ -73,7 +89,12 @@ export function confirmArchiveWorkflow(teamId: string, workflow: WorkflowRowTarg } /** Asks first, then deletes. `onDeleted` runs only after the server deleted the workflow. */ -export function confirmDeleteWorkflow(teamId: string, workflow: WorkflowRowTarget, onDeleted: () => void): void { +export function confirmDeleteWorkflow( + teamId: string, + workflow: WorkflowRowTarget, + onDeleted: () => void, + onPendingChange?: (pending: boolean) => void +): void { LemonDialog.open({ width: 500, title: 'Delete workflow?', @@ -83,6 +104,7 @@ export function confirmDeleteWorkflow(teamId: string, workflow: WorkflowRowTarge type: 'primary', status: 'danger', onClick: async () => { + onPendingChange?.(true) try { await hogFlowsDestroy(teamId, workflow.id) lemonToast.success(`Workflow "${workflow.name}" deleted`) @@ -90,6 +112,8 @@ export function confirmDeleteWorkflow(teamId: string, workflow: WorkflowRowTarge onDeleted() } catch (error) { lemonToast.error(`Failed to delete workflow: ${workflowActionErrorDetail(error)}`) + } finally { + onPendingChange?.(false) } }, }, diff --git a/products/workflows/frontend/WorkflowsScene.test.tsx b/products/workflows/frontend/WorkflowsScene.test.tsx index 144e8b4baa6d..e214d8e70ef7 100644 --- a/products/workflows/frontend/WorkflowsScene.test.tsx +++ b/products/workflows/frontend/WorkflowsScene.test.tsx @@ -102,11 +102,20 @@ describe('WorkflowsScene', () => { await waitFor(() => expect(shownRowNames()[0]).toEqual('Flow 001 odd')) }) - it('ignores a saved column the list no longer has', async () => { - localStorage.setItem( - 'products.workflows.frontend.workflowsListV2Logic.visibleColumns', - JSON.stringify(['health', 'tags']) - ) + 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( @@ -118,7 +127,7 @@ describe('WorkflowsScene', () => { const headers = Array.from(document.querySelectorAll('[data-attr="workflows-list-v2"] th')).map( (th) => th.textContent ) - expect(headers).toEqual(['Name', 'Status', 'Health', 'Updated', '', '']) + expect(headers).toEqual(expectedHeaders) }) it('keeps the old list when the flag is off and never asks for the v2 list', async () => {