From ad96d71b1b8bd7e565b439daed0b5ada1a5a5f73 Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:06:36 +0300 Subject: [PATCH 1/2] feat(builder): reusable property input widgets [stack 1/3] (#14501) --- .agents/skills/piece-builder/SKILL.md | 5 +- .../piece-builder/property-ui-selection.md | 183 +++++++ brain/knowledge/flows-execution/flows.md | 1 + brain/knowledge/flows-execution/formulas.md | 2 +- .../piece-reference/properties.mdx | 279 ++++++++++- docs/custom.css | 169 +++++++ docs/snippets/prop-previews.jsx | 412 +++++++++++++++ .../pieces/framework/src/lib/action/action.ts | 8 +- .../framework/src/lib/piece-metadata.ts | 22 + .../framework/src/lib/property/index.ts | 2 + .../lib/property/input/checkbox-property.ts | 7 +- .../src/lib/property/input/common.ts | 10 +- .../input/date-range-property.spec.ts | 56 +++ .../lib/property/input/date-range-property.ts | 77 +++ .../src/lib/property/input/dropdown/common.ts | 4 + .../input/dropdown/static-dropdown.ts | 5 + .../framework/src/lib/property/input/index.ts | 28 ++ .../src/lib/property/input/number-property.ts | 12 +- .../src/lib/property/input/property-type.ts | 2 + .../lib/property/input/rich-text-property.ts | 20 + .../pieces/framework/src/lib/property/util.ts | 7 + .../framework/src/lib/trigger/trigger.ts | 14 +- .../app/ee/agent/tools/piece-input-plan.ts | 7 + .../admin/admin-platform.controller.ts | 4 +- .../src/lib/variables/props-processor.ts | 5 +- .../test/variables/props-validator.test.ts | 40 ++ .../web/public/locales/en/translation.json | 35 ++ .../action-error-handling.tsx | 23 +- .../piece-properties/advanced-section.tsx | 114 +++++ .../auto-form-field-wrapper.tsx | 48 +- .../piece-properties/date-range-property.tsx | 144 ++++++ .../dynamic-value-toggle-button.tsx | 50 ++ .../filter-builder-layout.tsx | 472 ++++++++++++++++++ .../piece-properties/filter-layout.tsx | 316 ++++++++++++ .../piece-properties/filter-property-utils.ts | 144 ++++++ .../generic-properties-form.tsx | 176 +++++-- .../piece-properties/mention-chips-input.tsx | 243 +++++++++ .../piece-properties/number-stepper.tsx | 79 +++ .../piece-properties/properties-utils.tsx | 126 ++++- .../piece-properties/property-group-tabs.tsx | 316 ++++++++++++ .../piece-properties/property-icons.ts | 48 ++ .../piece-properties/rich-text-property.tsx | 113 +++++ .../static-dropdown-cards.tsx | 73 +++ .../text-input-with-mentions/index.tsx | 5 + .../tiptap-editor.tsx | 246 ++++++++- .../generic-piece-selector-item.tsx | 28 +- .../src/app/builder/step-settings/index.tsx | 10 +- .../step-settings/piece-settings/index.tsx | 273 ++++++++-- packages/web/src/components/ui/badge.tsx | 1 + packages/web/src/components/ui/switch.tsx | 7 +- .../src/features/pieces/utils/form-utils.tsx | 4 + packages/web/src/styles/globals.css | 14 + .../filter-property-utils.test.ts | 94 ++++ 53 files changed, 4386 insertions(+), 197 deletions(-) create mode 100644 .agents/skills/piece-builder/property-ui-selection.md create mode 100644 docs/custom.css create mode 100644 docs/snippets/prop-previews.jsx create mode 100644 packages/pieces/framework/src/lib/property/input/date-range-property.spec.ts create mode 100644 packages/pieces/framework/src/lib/property/input/date-range-property.ts create mode 100644 packages/pieces/framework/src/lib/property/input/rich-text-property.ts create mode 100644 packages/web/src/app/builder/piece-properties/advanced-section.tsx create mode 100644 packages/web/src/app/builder/piece-properties/date-range-property.tsx create mode 100644 packages/web/src/app/builder/piece-properties/dynamic-value-toggle-button.tsx create mode 100644 packages/web/src/app/builder/piece-properties/filter-builder-layout.tsx create mode 100644 packages/web/src/app/builder/piece-properties/filter-layout.tsx create mode 100644 packages/web/src/app/builder/piece-properties/filter-property-utils.ts create mode 100644 packages/web/src/app/builder/piece-properties/mention-chips-input.tsx create mode 100644 packages/web/src/app/builder/piece-properties/number-stepper.tsx create mode 100644 packages/web/src/app/builder/piece-properties/property-group-tabs.tsx create mode 100644 packages/web/src/app/builder/piece-properties/property-icons.ts create mode 100644 packages/web/src/app/builder/piece-properties/rich-text-property.tsx create mode 100644 packages/web/src/app/builder/piece-properties/static-dropdown-cards.tsx create mode 100644 packages/web/test/app/builder/piece-properties/filter-property-utils.test.ts diff --git a/.agents/skills/piece-builder/SKILL.md b/.agents/skills/piece-builder/SKILL.md index bb2b4cfa8d6a..0081799d5d7c 100644 --- a/.agents/skills/piece-builder/SKILL.md +++ b/.agents/skills/piece-builder/SKILL.md @@ -65,7 +65,8 @@ The condensed rules in this file (Quick Auth Reference, Quick Piece Definition T | A connection needs a human-readable label in the UI (account email, workspace name) | `auth-patterns.md` (Connection Identifier) | | Your first action in this piece (full file shape) | `action-patterns.md` | | A trigger — polling, webhook, handshake, or renewal | `trigger-patterns.md` | -| A prop type you haven't used (dropdowns, dynamic, arrays, files) | `props-patterns.md` | +| **Choosing which prop component, display mode, or layout/grouping fits a use case** | `property-ui-selection.md` | +| The exact syntax of a prop type (dropdowns, dynamic, arrays, files) | `props-patterns.md` | | Shared API helper, pagination, or `createCustomApiCallAction` | `common-patterns.md` | | An advanced UX pattern (source selectors, AWS-style auth) | `ux-guidelines.md` | | Flattening a deeply nested API response | `output-quality.md` | @@ -192,6 +193,8 @@ export const myApp = createPiece({ Pieces are used by people who have never seen an API — props, dropdowns, and descriptions must be self-explanatory. +**Before defining any `props`, read `property-ui-selection.md`** to pick the right component, display mode (`cards` / `stepper` / rich-text / date-range), and layout/grouping (`tabs` / `section` / filter `builder`) for each field. + 1. **Never ask users to type IDs** — Use dynamic dropdowns so they pick items by name (`"Jane Doe (jane@x.com)"` not `"cus_abc123"`). 2. **Descriptions must teach** — Don't say "Enter the thread timestamp." Say "Click the three dots next to the message, select Copy Link, and paste the number at the end." 3. **Use Markdown instructions** for complex setup — Add `Property.MarkDown()` with numbered steps when a prop requires configuration in the third-party app. diff --git a/.agents/skills/piece-builder/property-ui-selection.md b/.agents/skills/piece-builder/property-ui-selection.md new file mode 100644 index 000000000000..e670592fd0f6 --- /dev/null +++ b/.agents/skills/piece-builder/property-ui-selection.md @@ -0,0 +1,183 @@ +# Property UI Selection Guide + +**Read this before defining `props` on any action or trigger.** `props-patterns.md` tells you the *syntax* of each property type; this file tells you *which component, display mode, and layout to pick for the use case* so the step form reads well for a non-technical user. + +The mental model: **choose the input type → apply a display upgrade if one fits → arrange with layout hints → group with `propertyGroups` only when the form is large or has distinct modes.** Most props need only the first step. + +--- + +## 1. Pick the input component (by use case) + +| The user needs to enter… | Use | Notes | +|---|---|---| +| A short single-line value (name, email, id) | `Property.ShortText` | Add `placeholder`. Prefer a dropdown over a raw ID field — see rule below. | +| A long free-form value (notes, description) | `Property.LongText` | Multi-line textarea. | +| A formatted message body (email, chat post) | `Property.RichText` + `formatProperty` | Toolbar + `{{ variables }}`. Pair with a sibling format dropdown. See §2. | +| Yes/no, on/off | `Property.Checkbox` | Use `reveals` to show dependent fields only when on. See §2. | +| A number | `Property.Number` | Add `display: 'stepper'` for bounded counts. See §2. | +| One choice from a **fixed** list | `Property.StaticDropdown` | Add `display: 'cards'` for ≤4 visual choices. See §2. | +| Many choices from a fixed list | `Property.StaticMultiSelectDropdown` | | +| One choice **fetched from the API** | `Property.Dropdown` | `refreshers`, `options` async. This is the answer to "don't make users type IDs". | +| Many choices fetched from the API | `Property.MultiSelectDropdown` | | +| A date + time | `Property.DateTime` | Single instant. | +| A time **window** (last 7 days, custom range) | `Property.DateRange` | For search/filter actions. `display: 'dropdown'` inside a filter builder. See §2. | +| Fields that change based on earlier input/API | `Property.DynamicProperties` | Build the sub-form in `props()`. Heaviest option — use only when the shape is genuinely runtime-dependent. | +| A file (upload or URL) | `Property.File` | Returns `ApFile`. | +| A color | `Property.Color` | Swatch + hex. | +| Raw JSON | `Property.Json` | Only when there is no better structured option. | +| Free-form key→value pairs | `Property.Object` | Dictionary editor. | +| A list of plain strings | `Property.Array` (no `properties`) | Tags, emails. | +| A list of structured rows | `Property.Array` **with** `properties` | Repeating record editor (e.g. line items). | +| Read-only instructions / setup steps | `Property.MarkDown` | Display-only, collects nothing. Use `variant` (INFO/WARNING/TIP/BORDERLESS). | +| A fully custom widget (embedding only) | `Property.Custom` (BETA) | DOM injection. Requires `minimumSupportedRelease >= 0.58.0`. Avoid unless embedding. | + +**Never make users type an opaque ID.** If a value exists behind the API (channel, project, contact, board), use `Property.Dropdown` so they pick it by name. Raw `ShortText` for an ID is a last resort and needs a `description` that explains exactly where to find it. + +--- + +## 2. Display upgrades (opt-in, per property) + +These are optional `display`/pairing hints on an otherwise normal property. They are safe to add and ignored where they don't apply. + +### `StaticDropdown` → `display: 'cards'` +Use for a **small set (2–4) of mutually exclusive modes** where each choice benefits from an icon + one-line explanation (e.g. Plain text / HTML / Markdown). Do **not** use cards for long lists — they don't scroll well. +```typescript +Property.StaticDropdown({ + displayName: 'Format', required: true, defaultValue: 'plain_text', display: 'cards', + options: { options: [ + { label: 'Plain text', value: 'plain_text', description: 'Simple', icon: 'text' }, + { label: 'HTML', value: 'html', description: 'Rich + styled', icon: 'code' }, + ] }, +}); +``` + +### `Number` → `display: 'stepper'` +Use for a **bounded** count the user nudges (max results, retries, quantity). Requires sensible `min`/`max`; add `step`. +```typescript +Property.Number({ displayName: 'Max results', required: false, defaultValue: 10, display: 'stepper', min: 1, max: 500, step: 1 }); +``` + +### `RichText` + `formatProperty` +Use for any **message body** the user composes. Pair it with a sibling `StaticDropdown` (ideally `display: 'cards'`) and point `formatProperty` at that dropdown's **name**. The returned value is a plain string in the chosen format. +- Sibling value mapping (by convention): `plain_text` / `plain` / `text` → plain · `html` → HTML · `markdown` / `md` → markdown · anything else → plain. + +### `DateRange` (+ `display: 'dropdown'`) +Use for "limit results to a time window" on search/list actions. Omit `display` for pill buttons; set `display: 'dropdown'` when it lives inside a filter builder. Resolve in `run()`: +```typescript +import { dateRangeUtils } from '@activepieces/pieces-framework'; +const { after, before } = dateRangeUtils.resolve(context.propsValue.date_range); // ISO strings or undefined +``` + +### `Checkbox` → `reveals` +Use to **progressively disclose** fields that only matter when the toggle is on. List the dependent prop names; they render indented beneath the toggle. +```typescript +has_attachment: Property.Checkbox({ displayName: 'Has attachment', required: false, defaultValue: false, reveals: ['attachment_name'] }), +attachment_name: Property.ShortText({ displayName: 'Attachment name', required: false, placeholder: 'e.g. invoice.pdf' }), +``` + +--- + +## 3. Layout hints (any property) + +These live directly on the property. They fine-tune placement without changing the input. + +| Hint | Value | Use when | +|---|---|---| +| `placeholder` | string | Any text input — show an example value (`you@example.com`). | +| `width` | `'half'` | Two short related fields should sit side-by-side (First / Last name). Only takes effect **inside a `section` group**. | +| `icon` | icon name | Give a filter-builder row or section field a leading glyph. Must be a **valid name** — see §5. | +| `advanced` | `true` / `false` | `false` promotes a normally-optional field into the main form (e.g. a message body). `true` pushes an important-looking field into the collapsible **Advanced** section. | + +**Advanced section rule:** non-required props collapse into *Advanced* by default. Reach for `advanced: false` when an optional field is actually central to the action. + +--- + +## 4. Grouping props with `propertyGroups` + +Only add groups when the form is large or has distinct concerns. A short form (≤4 fields) needs none. Declare `propertyGroups` on the action/trigger; each group lists members by `name`: + +```typescript +propertyGroups: [{ key, display, label?, description?, icon?, props: ['fieldA', 'fieldB'] }] +``` +(Threaded through both `createAction` and `createTrigger`.) + +**Pick the layout by intent:** + +| Intent | `display` | Behaviour | +|---|---|---| +| Mutually-exclusive **modes** of the same concept (To / Cc / Bcc; by-URL vs by-ID) | `'tabs'` | Segmented control; one tab's fields visible at a time. | +| **Related fields as a titled card** (a "Send to" card, a "Message" card) | `'section'` | Titled card; `width: 'half'` packs two-up. **Keeps the Advanced section** — ungrouped optional props still collapse as usual. | +| A **search/filter** action where users add only the filters they need | `'builder'` | Progressive "Add filter" picker; each `builder` group is a category. A filter row persists only when its value is set — give each filter a `placeholder` + `icon`. | +| A pinned control **below** a filter builder (result limit) | `'footer'` | Pins its prop (e.g. a `stepper`) under the builder list. Pair with `'builder'` groups. | + +**Rules:** +- Every prop named in a group must exist in `props`. +- Props left out of every group follow the normal essential/Advanced rule (only `section` preserves this — `tabs` and `builder` take full control of their members). +- Give `section` and `builder` groups a `label` and `icon` so cards/categories read clearly. + +--- + +## 5. Valid `icon` names + +`icon` accepts only these keys (each maps to a Lucide icon). Any other string renders nothing: + +``` +text · code · markdown · reply · reply-all · users · user · send · type +file · paperclip · tag · inbox · calendar · trash · filter · sliders · blank +``` + +--- + +## 6. Worked examples + +**Send a chat message — sectioned cards + card dropdown + rich body:** +```typescript +propertyGroups: [ + { key: 'destination', display: 'section', label: 'Send to', icon: 'send', props: ['chat_id'] }, + { key: 'message', display: 'section', label: 'Message', icon: 'text', props: ['format', 'message'] }, +], +props: { + chat_id: Property.ShortText({ displayName: 'Chat Id', required: true, placeholder: '@channel or 123456789' }), + format: Property.StaticDropdown({ displayName: 'Format', required: false, display: 'cards', options: { options: [/* Markdown / HTML / Plain */] } }), + message: Property.RichText({ displayName: 'Message', required: true, formatProperty: 'format' }), + disable_notification: Property.Checkbox({ displayName: 'Disable notification', required: false }), // → Advanced +}, +``` + +**Search emails — filter builder + footer stepper + date range:** +```typescript +propertyGroups: [ + { key: 'people', display: 'builder', label: 'People', icon: 'users', props: ['from', 'to'] }, + { key: 'time', display: 'builder', label: 'Time', icon: 'calendar', props: ['date_range'] }, + { key: 'footer', display: 'footer', props: ['max_results'] }, +], +props: { + from: Property.ShortText({ displayName: 'From', required: false, icon: 'user', placeholder: 'sender@example.com' }), + to: Property.ShortText({ displayName: 'To', required: false, icon: 'send', placeholder: 'recipient@example.com' }), + date_range: Property.DateRange({ displayName: 'Date', required: false, display: 'dropdown', icon: 'calendar' }), + max_results: Property.Number({ displayName: 'Max results', required: false, defaultValue: 10, display: 'stepper', min: 1, max: 500 }), +}, +``` + +**Recipients — segmented tabs:** +```typescript +propertyGroups: [{ key: 'recipients', display: 'tabs', label: 'Recipients', props: ['to', 'cc', 'bcc'] }], +props: { + to: Property.Array({ displayName: 'To', required: true }), + cc: Property.Array({ displayName: 'Cc', required: false }), + bcc: Property.Array({ displayName: 'Bcc', required: false }), +}, +``` + +--- + +## 7. Anti-patterns + +- **Raw ID `ShortText` where a `Dropdown` is possible.** Pick-by-name beats copy-paste-an-id every time. +- **`display: 'cards'` on a long list.** Cards are for 2–4 modes; use a plain dropdown otherwise. +- **`propertyGroups` on a 3-field form.** Grouping is overhead — only add it for large or multi-mode forms. +- **Invalid `icon` name.** Anything outside the §5 list silently renders nothing; verify before shipping. +- **`Property.Json` as an escape hatch.** If the shape is known, model it with real props or an `Array` of fields. +- **`Property.DynamicProperties` for a static form.** It's the heaviest widget; only use it when fields truly depend on runtime data. + +Full type syntax and dynamic-dropdown/refresher mechanics: `props-patterns.md`. Rendered previews of every option: `docs/build-pieces/piece-reference/properties.mdx`. diff --git a/brain/knowledge/flows-execution/flows.md b/brain/knowledge/flows-execution/flows.md index c98b761a1c53..2aa988a30886 100644 --- a/brain/knowledge/flows-execution/flows.md +++ b/brain/knowledge/flows-execution/flows.md @@ -32,6 +32,7 @@ Flows are the core automation primitive: a versioned directed graph of trigger + - **Draft creation is now atomic, in two different ways — know which one you're in.** `createNewDraftIfVersionIsPublished` runs `createEmptyVersion` + the IMPORT_FLOW loop in one `transaction()`, threading the `entityManager` down through `applyOperation` to the `updateLastModified` side effect. The *user's* operation deliberately stays outside that transaction (it would hold Postgres open across `prepareRequest` piece-metadata fetches and non-rollbackable file/webhook side effects) and is instead undone by a compensating `delete` of the freshly created draft. `flowService.create` wraps the flow row + first empty version the same way, so a failure can't leave a zero-version, unopenable flow. - **`flowVersionSideEffects.preApplyOperation` writes on the default connection, so it escapes any caller transaction.** `handleSampleDataDeletion` and `handleUpdateTriggerWebhookSimulation` take no `entityManager`; a write they make from inside a `transaction()` survives its rollback. They early-return for `IMPORT_FLOW`/`UPDATE_SAMPLE_DATA_INFO`, so the transactional path above is safe today — but adding an operation type to it silently reintroduces partial commits. `updateLastModified` sits outside that swallow-all catch on purpose: a swallowed statement failure inside a transaction poisons it and resurfaces as a confusing "transaction is aborted" error on the next statement. - **`transaction()` (`core/db/transaction.ts`) is a bare `dataSource.transaction()`** — it acquires a *new* connection, not a savepoint. Nesting it deadlocks, so check every caller before wrapping a service method that others may already call inside a transaction. +- Step settings split a piece's props into an always-visible **essential** set and a collapsed **Advanced** section: a prop is Advanced only when it sets `advanced: true` (everything else — incl. `MARKDOWN`, tab/section group members, and checkbox reveal targets — stays essential). `propertyGroups` render as tabs, sectioned cards, or the "Add filter" builder. - **Flows stuck in `DELETING` keep eating the active-flow limit.** Deletion is a durable BullMQ system job (`delete-flow-`), not synchronous: `delete()` sets `operationStatus=DELETING` and enqueues, and the row plus `status=ENABLED` only go away when the job finishes. That job runs `sampleDataService.deleteForFlow`, whose `DELETE FROM file … metadata->>'flowId'=?` had no index — on the large prod `file` table it seq-scans, blows `statement_timeout`, exhausts its 2 attempts and lands **permanently** in the failed set. The flow is then hidden from the UI list (which filters `!=DELETING`) but still counted by the active-flows quota (`getUsage` counts `status=ENABLED`), so Publish silently shows the "Purchase Extra Active Flows" dialog instead of publishing — this is what breaks the `webhook-should-return-response` e2e monitor. Stuck flows are functionally dead (`preDelete` disables the trigger before the failing delete), so forcing their rows away is safe. Fixes on `fix/flow-delete-sample-data-timeout`: a partial expression index `idx_file_sample_data_flow_id` on `file (type, (metadata->>'flowId'))`, plus `operationStatus != DELETING` in the active-flow counts so the quota stops depending on delete-job success. ### Editions diff --git a/brain/knowledge/flows-execution/formulas.md b/brain/knowledge/flows-execution/formulas.md index f1d5d1db152e..6dde5c1a548b 100644 --- a/brain/knowledge/flows-execution/formulas.md +++ b/brain/knowledge/flows-execution/formulas.md @@ -13,7 +13,7 @@ In-builder data transformation: users transform any text input using ~104 functi ### Entities & files - `core/shared/src/lib/formula/` — `formula-evaluator.ts`, `function-registry.ts` (`AP_FUNCTIONS`, the single source of truth), `function-implementations.ts`, `function-type-checker.ts`. -- Editor: `web/.../text-input-with-mentions/tiptap-editor.tsx` (always registers `FunctionSlashExtension` + the three inline atom badge nodes — no plan flag), search/hover popovers, `text-input-utils.ts` (doc ⇄ wrapped-string serializer). +- Editor: `web/.../text-input-with-mentions/tiptap-editor.tsx` (always registers `FunctionSlashExtension` + the three inline atom badge nodes — no plan flag), search/hover popovers, `text-input-utils.ts` (doc ⇄ wrapped-string serializer). An `outputFormat: 'text' | 'html'` prop (default `'text'`) switches it into a rich-text WYSIWYG (StarterKit + toolbar) that serializes to/from HTML with mentions kept as `{{...}}` tokens; consumed by the `RICH_TEXT` property widget. ### Gotchas - On **every** edition, unconditionally on — no plan flag or license toggle. The pre-pass runs regardless of any editor flag, so saved formulas keep evaluating even where the editor is off. Only embed difference: the search popover hides the external "See All" docs link. diff --git a/docs/build-pieces/piece-reference/properties.mdx b/docs/build-pieces/piece-reference/properties.mdx index 7f35d8a28faa..8e91210413a3 100644 --- a/docs/build-pieces/piece-reference/properties.mdx +++ b/docs/build-pieces/piece-reference/properties.mdx @@ -4,7 +4,16 @@ description: 'Learn about different types of properties used in triggers / actio icon: 'input-pipe' --- -Properties are used in actions and triggers to collect information from the user. They are also displayed to the user for input. Here are some commonly used properties: +import { + ShortTextPreview, LongTextPreview, RichTextPreview, CheckboxPreview, CheckboxRevealsPreview, + MarkdownPreview, DateTimePreview, DateRangePreview, NumberPreview, NumberStepperPreview, + StaticDropdownPreview, CardsPreview, StaticMultiSelectPreview, JsonPreview, DictionaryPreview, + FilePreview, ColorPreview, ArrayStringsPreview, ArrayFieldsPreview, DropdownPreview, + MultiSelectDropdownPreview, DynamicPropertiesPreview, CustomPreview, HalfWidthPreview, + SegmentedTabsPreview, FilterBuilderPreview, SectionCardsPreview, +} from '/snippets/prop-previews.jsx'; + +Properties are used in actions and triggers to collect information from the user. They are also displayed to the user for input. Each property renders as a labelled field in the step settings form — the previews below show exactly what the user sees. ## Basic Properties @@ -14,7 +23,7 @@ These properties collect basic information from the user. This property collects a short text input from the user. -**Example:** + ```typescript Property.ShortText({ @@ -22,6 +31,7 @@ Property.ShortText({ description: 'Enter your name', required: true, defaultValue: 'John Doe', + placeholder: 'Enter your name', }); ``` @@ -29,7 +39,7 @@ Property.ShortText({ This property collects a long text input from the user. -**Example:** + ```typescript Property.LongText({ @@ -39,11 +49,45 @@ Property.LongText({ }); ``` +### Rich Text + +This property gives the user a formatting toolbar (bold, italic, underline, links, lists) and preserves `{{ variables }}` inserted from previous steps. Pair it with a sibling dropdown via `formatProperty` to let the user switch between **plain text** and **HTML** — the returned value is a plain string in the chosen format. + + + +```typescript +props: { + body_type: Property.StaticDropdown({ + displayName: 'Body Type', + required: true, + defaultValue: 'plain_text', + display: 'cards', + options: { + options: [ + { label: 'Plain text', value: 'plain_text', description: 'Simple', icon: 'text' }, + { label: 'HTML', value: 'html', description: 'Rich + styled', icon: 'code' }, + ], + }, + }), + body: Property.RichText({ + displayName: 'Body', + description: 'Body of the email', + required: true, + // Name of the sibling dropdown whose value selects the editing mode. + formatProperty: 'body_type', + }), +} +``` + + + `formatProperty` maps the sibling value by convention: `plain_text` / `plain` / `text` → plain, `html` → rich HTML, `markdown` / `md` → markdown. Anything else falls back to plain. + + ### Checkbox -This property presents a checkbox for the user to select or deselect. +This property presents a toggle for the user to switch on or off. -**Example:** + ```typescript Property.Checkbox({ @@ -54,6 +98,27 @@ Property.Checkbox({ }); ``` +You can also **reveal nested fields** only when the checkbox is on by listing their names in `reveals`. The revealed fields appear indented beneath the toggle. + + + +```typescript +props: { + has_attachment: Property.Checkbox({ + displayName: 'Has attachment', + description: 'Only match emails with a file', + required: false, + defaultValue: false, + reveals: ['attachment_name'], + }), + attachment_name: Property.ShortText({ + displayName: 'Attachment name', + required: false, + placeholder: 'e.g. invoice.pdf', + }), +} +``` + ### Markdown This property displays a markdown snippet to the user, useful for documentation or instructions. It includes a `variant` option to style the markdown, using the `MarkdownVariant` enum: @@ -65,7 +130,7 @@ This property displays a markdown snippet to the user, useful for documentation The default value for `variant` is **INFO**. -**Example:** + ```typescript Property.MarkDown({ @@ -83,7 +148,7 @@ Property.MarkDown({ This property collects a date and time from the user. -**Example:** + ```typescript Property.DateTime({ @@ -94,11 +159,35 @@ Property.DateTime({ }); ``` +### Date Range + +This property collects a relative or absolute time window. The user picks a preset (last 24 hours, 7 / 30 / 90 days, this month) or a **custom range** with explicit *after* / *before* dates. Set `display: 'dropdown'` to render the presets as a compact select (used inside the filter builder); omit it for pill buttons. + + + +```typescript +Property.DateRange({ + displayName: 'Date', + description: 'Limit results to a time window', + required: false, + display: 'dropdown', +}); +``` + +The value is `{ preset, after?, before? }`. Resolve it to concrete ISO bounds inside `run()` with `dateRangeUtils.resolve` — relative presets resolve against "now", so recurring flows roll the window forward: + +```typescript +import { dateRangeUtils } from '@activepieces/pieces-framework'; + +const { after, before } = dateRangeUtils.resolve(context.propsValue.date_range); +// after / before are ISO strings (or undefined for an open bound) +``` + ### Number This property collects a numeric input from the user. -**Example:** + ```typescript Property.Number({ @@ -108,11 +197,27 @@ Property.Number({ }); ``` +Set `display: 'stepper'` with `min` / `max` / `step` to render a compact −/value/+ control for bounded numbers. + + + +```typescript +Property.Number({ + displayName: 'Max results', + required: false, + defaultValue: 10, + display: 'stepper', + min: 1, + max: 500, + step: 1, +}); +``` + ### Static Dropdown This property presents a dropdown menu with predefined options. -**Example:** + ```typescript Property.StaticDropdown({ @@ -135,11 +240,30 @@ Property.StaticDropdown({ }); ``` +For a small set of choices, set `display: 'cards'` to render the options as selectable cards. Each option may carry an `icon` and a short `description`. + + + +```typescript +Property.StaticDropdown({ + displayName: 'Body Type', + required: true, + defaultValue: 'plain_text', + display: 'cards', + options: { + options: [ + { label: 'Plain text', value: 'plain_text', description: 'Simple', icon: 'text' }, + { label: 'HTML', value: 'html', description: 'Rich + styled', icon: 'code' }, + ], + }, +}); +``` + ### Static Multiple Dropdown This property presents a dropdown menu with multiple selection options. -**Example:** + ```typescript Property.StaticMultiSelectDropdown({ @@ -169,7 +293,7 @@ Property.StaticMultiSelectDropdown({ This property collects JSON data from the user. -**Example:** + ```typescript Property.Json({ @@ -184,7 +308,7 @@ Property.Json({ This property collects key-value pairs from the user. -**Example:** + ```typescript Property.Object({ @@ -202,7 +326,7 @@ Property.Object({ This property collects a file from the user, either by providing a URL or uploading a file. -**Example:** + ```typescript Property.File({ @@ -212,11 +336,25 @@ Property.File({ }); ``` +### Color + +This property collects a color from the user via a swatch and hex input. + + + +```typescript +Property.Color({ + displayName: 'Brand color', + description: 'Pick a color', + required: false, +}); +``` + ### Array of Strings This property collects an array of strings from the user. -**Example:** + ```typescript Property.Array({ @@ -231,7 +369,7 @@ Property.Array({ This property collects an array of objects from the user. -**Example:** + ```typescript Property.Array({ @@ -266,7 +404,7 @@ These properties provide more advanced options for collecting user input. This property allows for dynamically loaded options based on the user's input. -**Example:** + ```typescript Property.Dropdown({ @@ -308,7 +446,7 @@ Property.Dropdown({ This property allows for multiple selections from dynamically loaded options. -**Example:** + ```typescript Property.MultiSelectDropdown({ @@ -348,7 +486,7 @@ Property.MultiSelectDropdown({ This property is used to construct forms dynamically based on API responses or user input. -**Example:** + ```typescript @@ -390,13 +528,118 @@ Property.DynamicProperties({ }); ``` +## Layout & display options + +Every property accepts a few optional hints that fine-tune how it renders. They are ignored where they don't apply, so they're always safe to add. + +| Hint | Applies to | Effect | +| --- | --- | --- | +| `placeholder` | text inputs | Grey hint text shown inside an empty field (e.g. `you@example.com`). | +| `width: 'half'` | any prop inside a group | Renders two fields side-by-side instead of full-width. | +| `icon` | any prop | A named icon shown beside the field in the filter builder. | +| `advanced: false` | non-required props | Forces a normally-optional field to stay **outside** the collapsible *Advanced* section. | + +Non-required properties are collapsed into an **Advanced** section by default. Set `advanced: false` to promote an important optional field (like a message body) back into the main form, or `advanced: true` to push a field into Advanced. + +**Half-width fields** + + + +```typescript +props: { + first_name: Property.ShortText({ displayName: 'First name', required: false, width: 'half' }), + last_name: Property.ShortText({ displayName: 'Last name', required: false, width: 'half' }), +} +``` + +## Grouping properties + +Actions and triggers can declare `propertyGroups` to organize related fields. Each group references its members by name and chooses how they render with `display`. + +### Segmented tabs + +`display: 'tabs'` groups a set of props into a segmented tab control — for example To / Cc / Bcc recipients. + + + +```typescript +createAction({ + // ... + propertyGroups: [ + { + key: 'recipients', + display: 'tabs', + label: 'Recipients', + description: 'Who receives this email. Use Cc and Bcc for additional recipients.', + props: ['to', 'cc', 'bcc'], + }, + ], + props: { + to: Property.Array({ displayName: 'To', required: true }), + cc: Property.Array({ displayName: 'Cc', required: false }), + bcc: Property.Array({ displayName: 'Bcc', required: false }), + }, +}); +``` + +### Filter builder + +For search / list actions, `display: 'builder'` renders a progressive **"Add filter"** builder: the user starts with an empty step and adds only the filters they need from a searchable, categorized picker. Each `builder` group becomes a picker category; a `footer` group pins a control (such as a result limit) below the list. + + + +```typescript +createAction({ + // ... + propertyGroups: [ + { key: 'people', display: 'builder', label: 'People', icon: 'users', props: ['from', 'to'] }, + { key: 'time', display: 'builder', label: 'Time', icon: 'calendar', props: ['date_range'] }, + { key: 'footer', display: 'footer', props: ['max_results'] }, + ], + props: { + from: Property.ShortText({ displayName: 'From', required: false, icon: 'user', placeholder: 'sender@example.com' }), + to: Property.ShortText({ displayName: 'To', required: false, icon: 'send', placeholder: 'recipient@example.com' }), + date_range: Property.DateRange({ displayName: 'Date', required: false, display: 'dropdown', icon: 'calendar' }), + max_results: Property.Number({ displayName: 'Max results', required: false, defaultValue: 10, display: 'stepper', min: 1, max: 500 }), + }, +}); +``` + + + A filter row is shown when its value is set, so there's nothing extra to persist. Give filters short `placeholder` hints and an `icon` so each row reads clearly. + + +### Sectioned cards + +`display: 'section'` groups related props into titled cards — for example a *Send to* card and a *Message* card. Unlike tabs and the filter builder, sectioned layouts **keep the collapsible _Advanced_ section**: any prop you don't place in a section still follows the normal essential/advanced rule, so secondary options stay tucked away. Give each group a `label` and `icon`, and use `width: 'half'` on members to pack two fields per row. + + +```typescript +createAction({ + // ... + propertyGroups: [ + { key: 'destination', display: 'section', label: 'Send to', icon: 'send', props: ['chat_id'] }, + { key: 'message', display: 'section', label: 'Message', icon: 'text', props: ['format', 'message'] }, + ], + props: { + chat_id: Property.ShortText({ displayName: 'Chat Id', required: true, placeholder: '@channelusername or 123456789' }), + format: Property.StaticDropdown({ displayName: 'Format', required: false, display: 'cards', options: { options: [/* Markdown / HTML / Plain */] } }), + message: Property.RichText({ displayName: 'Message', required: true, formatProperty: 'format' }), + // props left out of every group collapse into Advanced as usual + disable_notification: Property.Checkbox({ displayName: 'Disable notification', required: false }), + }, +}); +``` ### Custom Property (BETA) This feature is still in BETA and not fully released yet, please let us know if you use it and face any issues and consider it a possibility could have breaking changes in the future + + + This is a property that lets you inject JS code into the frontend and manipulate the DOM of this content however you like, it is extremely useful in case you are [embedding](/embedding/overview) Activepieces and want to have a way to communicate with the SaaS embedding it. It has a `code` property which is a function that takes in an object parameter which will have the following schema: diff --git a/docs/custom.css b/docs/custom.css new file mode 100644 index 000000000000..12cdf27c1342 --- /dev/null +++ b/docs/custom.css @@ -0,0 +1,169 @@ +/* ========================================================================== + Live property previews for build-pieces/piece-reference/properties + Faithful, theme-independent renders of the Activepieces step-settings form. + All rules are scoped under .pp to avoid touching Mintlify's own styles. + ========================================================================== */ +.pp { + --pp-primary: hsl(257 74% 57%); + --pp-primary-wash: hsl(257 74% 57% / 0.10); + --pp-fg: #171717; + --pp-muted: #737373; + --pp-faint: #a3a3a3; + --pp-border: #e5e5e5; + --pp-soft: #f5f5f5; + --pp-bg: #ffffff; + --pp-switch-off: #d4d4d4; + + box-sizing: border-box; + border: 1px solid var(--pp-border); + border-radius: 12px; + background: var(--pp-bg); + padding: 18px 16px; + margin: 12px 0 20px; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--pp-fg); + max-width: 560px; +} +.pp *, .pp *::before, .pp *::after { box-sizing: border-box; } + +.pp-field { display: flex; flex-direction: column; gap: 6px; } +.pp-label { display: flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 500; color: var(--pp-fg); margin: 0; } +.pp-req { color: hsl(350 89% 60%); } +.pp-desc { font-size: 12px; color: var(--pp-muted); } + +.pp-input { display: flex; align-items: center; height: 36px; border: 1px solid var(--pp-border); border-radius: 6px; padding: 0 10px; font-size: 14px; color: var(--pp-fg); background: var(--pp-bg); } +.pp-input.pp-ph { color: var(--pp-faint); } +.pp-textarea { min-height: 76px; align-items: flex-start; padding: 8px 10px; } +.pp-select { justify-content: space-between; } +.pp-caret { width: 8px; height: 8px; border-right: 1.5px solid var(--pp-faint); border-bottom: 1.5px solid var(--pp-faint); transform: rotate(45deg); margin-top: -3px; flex: none; } + +.pp-switch { width: 34px; height: 20px; border-radius: 999px; background: var(--pp-primary); position: relative; flex: none; } +.pp-switch::after { content: ""; position: absolute; top: 2px; left: 16px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left .15s ease; } +.pp-switch.pp-off { background: var(--pp-switch-off); } +.pp-switch.pp-off::after { left: 2px; } +.pp-switch-row { display: flex; align-items: center; gap: 10px; } +.pp-switch-row .pp-t { font-size: 14px; font-weight: 500; } +.pp-switch-row .pp-d { font-size: 12px; color: var(--pp-muted); } + +.pp-chip { display: inline-flex; align-items: center; gap: 5px; background: var(--pp-primary-wash); color: var(--pp-primary); font-size: 12.5px; font-weight: 500; border-radius: 999px; padding: 2px 6px 2px 9px; } +.pp-chip .pp-x { color: var(--pp-primary); opacity: .7; font-size: 13px; } +.pp-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-height: 40px; border: 1px solid var(--pp-border); border-radius: 6px; padding: 6px 8px; } +.pp-chips .pp-ph { color: var(--pp-faint); font-size: 14px; } + +.pp-code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.6; background: var(--pp-soft); border: 1px solid var(--pp-border); border-radius: 6px; padding: 10px 12px; color: #3f3f46; white-space: pre; overflow-x: auto; } +.pp-key { color: hsl(257 74% 45%); } +.pp-str { color: hsl(160 84% 30%); } + +.pp-kv { display: grid; grid-template-columns: 1fr 1fr 24px; gap: 6px; align-items: center; } +.pp-kv.pp-one { grid-template-columns: 1fr 24px; } +.pp-iconbtn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; color: var(--pp-faint); } +.pp-add { color: var(--pp-primary); font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 5px; margin-top: 8px; } +.pp-list { display: flex; flex-direction: column; gap: 6px; } + +.pp-swatch { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--pp-border); flex: none; background: hsl(257 74% 57%); } +.pp-row { display: flex; align-items: center; gap: 8px; } + +.pp-item { border: 1px solid var(--pp-border); border-radius: 8px; padding: 12px; display: flex; flex-direction: column; gap: 10px; position: relative; } +.pp-item .pp-rm { position: absolute; top: 8px; right: 8px; color: var(--pp-faint); } + +.pp-note { border: 1px solid hsl(257 74% 57% / 0.18); background: var(--pp-primary-wash); border-radius: 8px; padding: 12px 14px; font-size: 13.5px; line-height: 1.5; color: #3f3f46; } +.pp-note strong { display: block; margin-bottom: 2px; color: var(--pp-fg); } + +/* rich text */ +.pp-rt { border: 1px solid var(--pp-border); border-radius: 6px; overflow: hidden; } +.pp-rt-toolbar { display: flex; align-items: center; gap: 2px; padding: 5px 6px; border-bottom: 1px solid var(--pp-border); background: #fcfcfd; } +.pp-rt-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 5px; font-size: 13px; color: #52525b; } +.pp-rt-btn.pp-b { font-weight: 800; } +.pp-rt-btn.pp-i { font-style: italic; font-family: Georgia, serif; } +.pp-rt-btn.pp-u { text-decoration: underline; } +.pp-rt-sep { width: 1px; height: 16px; background: var(--pp-border); margin: 0 3px; } +.pp-rt-body { padding: 10px 12px; font-size: 14px; line-height: 1.6; min-height: 60px; } +.pp-mention { background: var(--pp-primary-wash); color: var(--pp-primary); border-radius: 5px; padding: 1px 5px; font-size: 13px; font-weight: 500; } + +/* cards */ +.pp-cards { display: flex; gap: 8px; flex-wrap: wrap; } +.pp-rcard { flex: 1 1 0; min-width: 120px; display: flex; align-items: center; gap: 10px; border: 1px solid var(--pp-border); border-radius: 8px; padding: 11px 12px; } +.pp-rcard.pp-sel { border-color: var(--pp-primary); background: hsl(257 74% 57% / 0.05); } +.pp-rcard .pp-ic { width: 28px; height: 28px; border-radius: 6px; background: var(--pp-soft); display: flex; align-items: center; justify-content: center; color: #52525b; flex: none; font-family: ui-monospace, monospace; font-size: 12px; } +.pp-rcard.pp-sel .pp-ic { background: var(--pp-primary-wash); color: var(--pp-primary); } +.pp-rcard .pp-t { font-size: 13.5px; font-weight: 600; } +.pp-rcard .pp-d { font-size: 11.5px; color: var(--pp-muted); } + +/* stepper */ +.pp-stepper { display: inline-flex; align-items: center; border: 1px solid var(--pp-border); border-radius: 6px; overflow: hidden; height: 36px; } +.pp-stepper button { width: 34px; height: 100%; border: none; background: var(--pp-bg); color: var(--pp-fg); font-size: 16px; border-right: 1px solid var(--pp-border); cursor: pointer; } +.pp-stepper button:last-child { border-right: none; border-left: 1px solid var(--pp-border); } +.pp-stepper .pp-val { width: 46px; text-align: center; font-size: 14px; font-variant-numeric: tabular-nums; } +.pp-stepper-row { display: flex; align-items: center; justify-content: space-between; } + +/* reveal */ +.pp-reveal { border: 1px solid var(--pp-border); background: #fafafa; border-radius: 8px; padding: 12px 13px; } +.pp-reveal .pp-divider { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--pp-border); } + +/* date range */ +.pp-presets { display: flex; gap: 6px; flex-wrap: wrap; } +.pp-pill { border: 1px solid var(--pp-border); border-radius: 999px; padding: 4px 12px; font-size: 13px; color: var(--pp-muted); } +.pp-pill.pp-sel { border-color: var(--pp-primary); background: hsl(257 74% 57% / 0.05); color: var(--pp-primary); font-weight: 600; } +.pp-daterow { display: flex; align-items: flex-end; gap: 8px; margin-top: 10px; } +.pp-daterow .pp-col { flex: 1; } +.pp-daterow .pp-cl { font-size: 11px; color: var(--pp-muted); margin-bottom: 3px; } + +/* tabs */ +.pp-tabs { display: flex; background: var(--pp-soft); border-radius: 8px; padding: 3px; gap: 2px; } +.pp-tab { flex: 1; text-align: center; font-size: 13px; font-weight: 500; padding: 6px 0; border-radius: 6px; color: var(--pp-muted); } +.pp-tab.pp-sel { background: var(--pp-bg); color: var(--pp-fg); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); } + +/* two-up */ +.pp-twoup { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } + +/* filter builder */ +.pp-fb { border: 1px solid var(--pp-border); border-radius: 14px; overflow: hidden; } +.pp-fb-row { display: flex; align-items: center; gap: 10px; padding: 11px 12px; border-bottom: 1px solid #f0f0f0; } +.pp-fb-badge { width: 28px; height: 28px; border-radius: 8px; background: var(--pp-primary-wash); color: var(--pp-primary); display: flex; align-items: center; justify-content: center; flex: none; } +.pp-fb-lbl { width: 72px; flex: none; font-size: 13.5px; font-weight: 600; } +.pp-fb-ctrl { flex: 1; min-width: 0; } +.pp-fb-x { color: var(--pp-faint); flex: none; } +.pp-fb-add { display: flex; align-items: center; gap: 6px; margin: 10px 12px; border: 1px solid var(--pp-border); border-radius: 9px; padding: 8px 12px; width: fit-content; color: var(--pp-primary); font-size: 13px; font-weight: 600; } +.pp-fb-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid var(--pp-border); border-radius: 14px; padding: 11px 14px; margin-top: 10px; } +.pp-fb-foot .pp-ic { width: 28px; height: 28px; border-radius: 8px; background: var(--pp-primary-wash); color: var(--pp-primary); display: flex; align-items: center; justify-content: center; flex: none; } +.pp-fb-foot .pp-t { font-size: 13.5px; font-weight: 600; } +.pp-fb-foot .pp-s { font-size: 11.5px; color: var(--pp-muted); } + +/* icons drawn via CSS mask so they inherit the parent's color (currentColor) */ +.pp .pp-i { + display: inline-block; width: 15px; height: 15px; flex: none; + background-color: currentColor; + -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; + -webkit-mask-position: center; mask-position: center; + -webkit-mask-size: contain; mask-size: contain; +} +.pp .pp-i-cal { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cpath d='M16 2v4M8 2v4M3 10h18'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cpath d='M16 2v4M8 2v4M3 10h18'/%3E%3C/svg%3E"); } +.pp .pp-i-clip { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Cpath d='M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3 3 0 0 1 4.24 4.24l-9.2 9.19a1 1 0 0 1-1.41-1.41l8.48-8.49'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Cpath d='M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3 3 0 0 1 4.24 4.24l-9.2 9.19a1 1 0 0 1-1.41-1.41l8.48-8.49'/%3E%3C/svg%3E"); } +.pp .pp-i-search { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E"); } +.pp .pp-i-link { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Cpath d='M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1'/%3E%3Cpath d='M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Cpath d='M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1'/%3E%3Cpath d='M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1'/%3E%3C/svg%3E"); } +.pp .pp-i-user { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Cpath d='M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Cpath d='M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E"); } +.pp .pp-i-sliders { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Cline x1='4' y1='6' x2='20' y2='6'/%3E%3Cline x1='7' y1='12' x2='17' y2='12'/%3E%3Cline x1='10' y1='18' x2='14' y2='18'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2'%3E%3Cline x1='4' y1='6' x2='20' y2='6'/%3E%3Cline x1='7' y1='12' x2='17' y2='12'/%3E%3Cline x1='10' y1='18' x2='14' y2='18'/%3E%3C/svg%3E"); } + +/* ---- Dark mode (Mintlify sets .dark on ) ---- */ +.dark .pp { + --pp-fg: #e8e8ea; + --pp-muted: #9a9aa5; + --pp-faint: #6b6b74; + --pp-border: #2d2d34; + --pp-soft: #26262c; + --pp-bg: #1b1b20; + --pp-switch-off: #3f3f46; + --pp-primary: hsl(257 82% 72%); + --pp-primary-wash: hsl(257 74% 62% / 0.22); +} +.dark .pp .pp-code, +.dark .pp .pp-note { color: #c9c8d2; } +.dark .pp .pp-key { color: hsl(257 82% 76%); } +.dark .pp .pp-str { color: hsl(160 60% 62%); } +.dark .pp .pp-rt-btn { color: #a1a1aa; } +.dark .pp .pp-rt-toolbar { background: #232329; } +.dark .pp .pp-rcard .pp-ic { color: #c9c8d2; } +.dark .pp .pp-reveal { background: #202026; } +.dark .pp .pp-fb-row { border-bottom-color: #2d2d34; } diff --git a/docs/snippets/prop-previews.jsx b/docs/snippets/prop-previews.jsx new file mode 100644 index 000000000000..b72852840650 --- /dev/null +++ b/docs/snippets/prop-previews.jsx @@ -0,0 +1,412 @@ +/* Icons are drawn with CSS masks (see custom.css .pp-i-*). Mintlify's MDX + renderer drops inline inside custom components, so we avoid it. Each + icon inherits its parent's `color` via `background-color: currentColor`. */ + +/* ---------------- Basic ---------------- */ + +export const ShortTextPreview = () => ( +
+
+ Name * +
Enter your name
+
+
+); + +export const LongTextPreview = () => ( +
+
+ Description +
Enter a description
+
+
+); + +export const RichTextPreview = () => ( +
+
+ Body * +
+
+ B + I + U + + + + 1. +
+
Hi First name, thanks for reaching out — we'll reply shortly.
+
+
+
+); + +export const CheckboxPreview = () => { + const [on, setOn] = useState(true); + return ( +
+ +
+ ); +}; + +export const MarkdownPreview = () => ( +
+
+ Heads up + Paste your webhook URL into the service to start receiving events. +
+
+); + +export const DateTimePreview = () => ( +
+
+ Date and Time * +
2023-06-09 12:00
+
+
+); + +export const NumberPreview = () => ( +
+
+ Quantity * +
0
+
+
+); + +export const StaticDropdownPreview = () => ( +
+
+ Country * +
United States
+
+
+); + +export const StaticMultiSelectPreview = () => ( +
+
+ Colors * +
+ + Red × + Blue × + + +
+
+
+); + +export const JsonPreview = () => ( +
+
+ Data * +
{`{\n `}"key"{`: `}"value"{`,\n `}"count"{`: `}3{`\n}`}
+
+
+); + +export const DictionaryPreview = () => ( +
+
+ Options * +
+
key1
value1
×
+
key2
value2
×
+
+ + Add item +
+
+); + +export const FilePreview = () => ( +
+
+ File * +
Enter a URL or upload a file
+
+
+); + +export const ColorPreview = () => ( +
+
+ Brand color +
#8142E3
+
+
+); + +export const ArrayStringsPreview = () => ( +
+
+ Tags +
+
tag1
×
+
tag2
×
+
+ + Add item +
+
+); + +export const ArrayFieldsPreview = () => ( +
+
+ Fields +
+ × +
Field Name *
e.g. email
+
Field Type *
TEXT
+
+ + Add item +
+
+); + +/* ---------------- Dynamic ---------------- */ + +export const DropdownPreview = () => ( +
+
+ Board * +
+ Search a board… + +
+ Options load from the connected account. +
+
+); + +export const MultiSelectDropdownPreview = () => ( +
+
+ Labels * +
+ + Work × + Important × + + +
+
+
+); + +export const DynamicPropertiesPreview = () => ( +
+
+
Property 1 *
Enter property 1
+
Property 2
0
+ Fields are built at runtime from the API response. +
+
+); + +export const CustomPreview = () => ( +
+
+ Custom Property * +
Rendered by your own JS / DOM
+
+
+); + +/* ---------------- New: layout & grouping ---------------- */ + +export const CheckboxRevealsPreview = () => { + const [on, setOn] = useState(true); + return ( +
+
+ + {on && ( +
+
Attachment name
e.g. invoice.pdf
+
+ )} +
+
+ ); +}; + +export const DateRangePreview = () => { + const presets = [ + { v: 'any_time', l: 'Any time' }, + { v: 'last_7_days', l: 'Last 7 days' }, + { v: 'last_30_days', l: 'Last 30 days' }, + { v: 'this_month', l: 'This month' }, + { v: 'custom', l: 'Custom' }, + ]; + const [sel, setSel] = useState('custom'); + return ( +
+
+ Date +
+ {presets.map((p) => ( + setSel(p.v)}>{p.l} + ))} +
+ {sel === 'custom' && ( +
+
After
dd / mm / yyyy
+ +
Before
dd / mm / yyyy
+
+ )} +
+
+ ); +}; + +export const NumberStepperPreview = () => { + const [n, setN] = useState(10); + return ( +
+
+ Max results +
+ + {n} + +
+
+
+ ); +}; + +export const CardsPreview = () => { + const [sel, setSel] = useState('plain_text'); + const cards = [ + { v: 'plain_text', ic: '≡', t: 'Plain text', d: 'Simple' }, + { v: 'html', ic: '', t: 'HTML', d: 'Rich + styled' }, + ]; + return ( +
+
+ Body Type * +
+ {cards.map((c) => ( +
setSel(c.v)}> + {c.ic} +
{c.t}
{c.d}
+
+ ))} +
+
+
+ ); +}; + +export const HalfWidthPreview = () => ( +
+
+
First name
Jane
+
Last name
Doe
+
+
+); + +export const SegmentedTabsPreview = () => { + const [tab, setTab] = useState('to'); + const tabs = [{ v: 'to', l: 'To' }, { v: 'cc', l: 'Cc' }, { v: 'bcc', l: 'Bcc' }]; + return ( +
+
+ Recipients * +
+ {tabs.map((t) => ( + setTab(t.v)}>{t.l} + ))} +
+
+ {tab === 'to' && boss@acme.com ×} + Type an email and press Enter +
+
+
+ ); +}; + +export const FilterBuilderPreview = () => ( +
+
+
+ + From +
sender@example.com
+ × +
+
+ + Date +
Last 7 days
+ × +
+
+ Add filter
+
+
+
+ +
Returns up to 10 results
2 filters applied · newest first
+
+
10
+
+
+); + +export const SectionCardsPreview = () => { + const [fmt, setFmt] = useState('md'); + const cardBox = { border: '1px solid var(--pp-border)', borderRadius: '10px', padding: '14px', display: 'flex', flexDirection: 'column', gap: '10px' }; + const head = { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', fontWeight: 600, color: 'var(--pp-fg)' }; + const fmts = [{ v: 'md', ic: '#', t: 'Markdown' }, { v: 'html', ic: '', t: 'HTML' }, { v: 'plain', ic: '≡', t: 'Plain text' }]; + return ( +
+
+
Send to
+
+ Chat Id * +
@channelusername or 123456789
+
+
+
+
Message
+
+ Format +
+ {fmts.map((c) => ( +
setFmt(c.v)}> + {c.ic} +
{c.t}
+
+ ))} +
+
+
+ Message * +
The message to be sent
+
+
+
+ Advanced + 5 options › +
+
+ ); +}; diff --git a/packages/pieces/framework/src/lib/action/action.ts b/packages/pieces/framework/src/lib/action/action.ts index daa639f830c7..4eb2dfccc512 100644 --- a/packages/pieces/framework/src/lib/action/action.ts +++ b/packages/pieces/framework/src/lib/action/action.ts @@ -1,7 +1,7 @@ import * as z from "zod/mini"; import { ActionContext } from '../context'; import type { OutputSchema } from '../output-schema'; -import { ActionBase, Audience, AiMetadata } from '../piece-metadata'; +import { ActionBase, Audience, AiMetadata, ActionClassification, PropertyGroup } from '../piece-metadata'; import { InputPropertyMap } from '../property'; import { ExtractPieceAuthPropertyTypeForMethods, PieceAuthProperty } from '../property/authentication'; @@ -32,6 +32,7 @@ type CreateActionParams, ActionProps> test?: ActionRunner, ActionProps> requireAuth?: boolean @@ -39,6 +40,7 @@ type CreateActionParams, ActionProps>, public readonly test: ActionRunner, ActionProps>, public readonly requireAuth: boolean, @@ -55,6 +58,7 @@ export class IAction +export const ActionClassification = z.enum(['READ', 'WRITE']) +export type ActionClassification = z.infer + +export const PropertyGroupDisplay = z.enum(['tabs', 'section', 'summary', 'builder', 'footer']) +export type PropertyGroupDisplay = z.infer + +export const PropertyGroup = z.object({ + key: z.string(), + display: PropertyGroupDisplay, + label: z.optional(z.string()), + description: z.optional(z.string()), + icon: z.optional(z.string()), + props: z.array(z.string()), +}) +export type PropertyGroup = z.infer + export const ActionBase = z.object({ name: z.string(), displayName: z.string(), description: z.string(), props: PiecePropertyMap, + propertyGroups: z.optional(z.array(PropertyGroup)), requireAuth: z.boolean(), errorHandlingOptions: z.optional(ErrorHandlingOptionsParam), outputSchema: z.optional(z.custom()), audience: z.optional(Audience), aiMetadata: z.optional(AiMetadata), + classification: z.optional(ActionClassification), }) export type ActionBase = { @@ -75,11 +93,13 @@ export type ActionBase = { displayName: string, description: string, props: PiecePropertyMap, + propertyGroups?: PropertyGroup[]; requireAuth: boolean; errorHandlingOptions?: ErrorHandlingOptionsParam; outputSchema?: OutputSchema; audience?: Audience; aiMetadata?: AiMetadata; + classification?: ActionClassification; } export const TriggerBase = z.object({ @@ -87,6 +107,7 @@ export const TriggerBase = z.object({ displayName: z.string(), description: z.string(), props: PiecePropertyMap, + propertyGroups: z.optional(z.array(PropertyGroup)), errorHandlingOptions: z.optional(ErrorHandlingOptionsParam), type: z.enum(TriggerStrategy), sampleData: z.unknown(), @@ -95,6 +116,7 @@ export const TriggerBase = z.object({ testStrategy: z.enum(TriggerTestStrategy), outputSchema: z.optional(z.custom()), aiMetadata: z.optional(AiMetadata), + classification: z.optional(ActionClassification), }) export type TriggerBase = Omit & { type: TriggerStrategy; diff --git a/packages/pieces/framework/src/lib/property/index.ts b/packages/pieces/framework/src/lib/property/index.ts index 6cf046d3d043..397d60c1b801 100644 --- a/packages/pieces/framework/src/lib/property/index.ts +++ b/packages/pieces/framework/src/lib/property/index.ts @@ -18,10 +18,12 @@ export { DropdownOption,DropdownState } from './input/dropdown/common'; export { OAuth2PropertyValue } from './authentication/oauth2-prop'; export { PieceAuthProperty, DEFAULT_CONNECTION_DISPLAY_NAME} from './authentication'; export { ShortTextProperty } from './input/text-property'; +export { RichTextProperty } from './input/rich-text-property'; export { ArrayProperty, ArraySubProps } from './input/array-property'; export { BasePropertySchema } from './input/common'; export { CheckboxProperty } from './input/checkbox-property'; export { DateTimeProperty } from './input/date-time-property'; +export { DateRangeProperty, DateRangeValue, DateRangePreset, dateRangeUtils } from './input/date-range-property'; export { LongTextProperty } from './input/text-property'; export { NumberProperty } from './input/number-property'; export { ObjectProperty } from './input/object-property'; diff --git a/packages/pieces/framework/src/lib/property/input/checkbox-property.ts b/packages/pieces/framework/src/lib/property/input/checkbox-property.ts index 9a4c501a51c0..d6f9b32da573 100644 --- a/packages/pieces/framework/src/lib/property/input/checkbox-property.ts +++ b/packages/pieces/framework/src/lib/property/input/checkbox-property.ts @@ -4,8 +4,11 @@ import { PropertyType } from "./property-type"; export const CheckboxProperty = z.object({ ...BasePropertySchema.shape, + reveals: z.optional(z.array(z.string())), ...TPropertyValue(z.boolean(), PropertyType.CHECKBOX).shape, }) -export type CheckboxProperty = BasePropertySchema & - TPropertyValue; +export type CheckboxProperty = BasePropertySchema & { + /** Names of sibling props revealed (rendered nested) when this checkbox is on. Effective inside a 'section' group. */ + reveals?: string[]; +} & TPropertyValue; diff --git a/packages/pieces/framework/src/lib/property/input/common.ts b/packages/pieces/framework/src/lib/property/input/common.ts index 8e5da1ae66c1..d9687f8259b6 100644 --- a/packages/pieces/framework/src/lib/property/input/common.ts +++ b/packages/pieces/framework/src/lib/property/input/common.ts @@ -6,7 +6,11 @@ import { PropertyType } from "./property-type"; export const BasePropertySchema = z.object({ displayName: z.string(), - description: z.optional(z.string()) + description: z.optional(z.string()), + advanced: z.optional(z.boolean()), + width: z.optional(z.enum(['half', 'full'])), + icon: z.optional(z.string()), + placeholder: z.optional(z.string()), }) export type BasePropertySchema = z.infer @@ -34,6 +38,8 @@ export type TPropertyValue< ? boolean : U extends PropertyType.LONG_TEXT ? string + : U extends PropertyType.RICH_TEXT + ? string : U extends PropertyType.SHORT_TEXT ? string : U extends PropertyType.NUMBER @@ -48,6 +54,8 @@ export type TPropertyValue< ? unknown : U extends PropertyType.DATE_TIME ? string + : U extends PropertyType.DATE_RANGE + ? object : U extends PropertyType.FILE ? ApFile : U extends PropertyType.COLOR diff --git a/packages/pieces/framework/src/lib/property/input/date-range-property.spec.ts b/packages/pieces/framework/src/lib/property/input/date-range-property.spec.ts new file mode 100644 index 000000000000..1ce413a15dc6 --- /dev/null +++ b/packages/pieces/framework/src/lib/property/input/date-range-property.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { dateRangeUtils } from './date-range-property'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const TOLERANCE_MS = 60 * 1000; + +function assertDaysAgo(iso: string | undefined, days: number) { + expect(iso).toBeDefined(); + const actual = new Date(iso as string).getTime(); + const expected = Date.now() - days * DAY_MS; + expect(Math.abs(actual - expected)).toBeLessThan(TOLERANCE_MS); +} + +describe('dateRangeUtils.resolve', () => { + it('returns empty bounds for any_time / missing preset / nullish value', () => { + expect(dateRangeUtils.resolve({ preset: 'any_time' })).toEqual({}); + expect(dateRangeUtils.resolve({})).toEqual({}); + expect(dateRangeUtils.resolve(null)).toEqual({}); + expect(dateRangeUtils.resolve(undefined)).toEqual({}); + }); + + it('resolves last_24_hours to ~1 day ago with no upper bound', () => { + const { after, before } = dateRangeUtils.resolve({ preset: 'last_24_hours' }); + assertDaysAgo(after, 1); + expect(before).toBeUndefined(); + }); + + it('resolves last_7_days to ~7 days ago', () => { + assertDaysAgo(dateRangeUtils.resolve({ preset: 'last_7_days' }).after, 7); + }); + + it('resolves last_30_days to ~30 days ago', () => { + assertDaysAgo(dateRangeUtils.resolve({ preset: 'last_30_days' }).after, 30); + }); + + it('resolves last_90_days to ~90 days ago', () => { + assertDaysAgo(dateRangeUtils.resolve({ preset: 'last_90_days' }).after, 90); + }); + + it('passes through custom after/before when present', () => { + expect( + dateRangeUtils.resolve({ + preset: 'custom', + after: '2024-01-01', + before: '2024-02-01', + }), + ).toEqual({ after: '2024-01-01', before: '2024-02-01' }); + }); + + it('omits empty custom bounds', () => { + expect( + dateRangeUtils.resolve({ preset: 'custom', after: '', before: '' }), + ).toEqual({ after: undefined, before: undefined }); + }); +}); diff --git a/packages/pieces/framework/src/lib/property/input/date-range-property.ts b/packages/pieces/framework/src/lib/property/input/date-range-property.ts new file mode 100644 index 000000000000..cacba60f63fa --- /dev/null +++ b/packages/pieces/framework/src/lib/property/input/date-range-property.ts @@ -0,0 +1,77 @@ +import * as z from "zod/mini"; +import { BasePropertySchema, TPropertyValue } from "./common"; +import { PropertyType } from "./property-type"; + + +export const DateRangePreset = z.enum([ + 'any_time', + 'last_24_hours', + 'last_7_days', + 'last_30_days', + 'last_90_days', + 'this_month', + 'custom', +]) +export type DateRangePreset = z.infer + +export const DateRangeValue = z.object({ + preset: z.optional(DateRangePreset), + after: z.optional(z.string()), + before: z.optional(z.string()), +}) +export type DateRangeValue = { + preset?: DateRangePreset; + after?: string; + before?: string; +} + +export const DateRangeProperty = z.object({ + ...BasePropertySchema.shape, + display: z.optional(z.enum(['dropdown'])), + ...TPropertyValue(DateRangeValue, PropertyType.DATE_RANGE).shape, +}) + +export type DateRangeProperty = BasePropertySchema & { + display?: 'dropdown'; +} & TPropertyValue; + + +function isoDaysAgo(days: number): string { + return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); +} + +function startOfThisMonth(): string { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1).toISOString(); +} + +// Relative presets are resolved against "now" so recurring flows roll forward. +function resolve( + value: DateRangeValue | null | undefined, +): { after?: string; before?: string } { + if (!value || !value.preset || value.preset === 'any_time') { + return {}; + } + switch (value.preset) { + case 'last_24_hours': + return { after: isoDaysAgo(1) }; + case 'last_7_days': + return { after: isoDaysAgo(7) }; + case 'last_30_days': + return { after: isoDaysAgo(30) }; + case 'last_90_days': + return { after: isoDaysAgo(90) }; + case 'this_month': + return { after: startOfThisMonth() }; + case 'custom': + return { + after: value.after && value.after.length > 0 ? value.after : undefined, + before: + value.before && value.before.length > 0 ? value.before : undefined, + }; + default: + return {}; + } +} + +export const dateRangeUtils = { resolve }; diff --git a/packages/pieces/framework/src/lib/property/input/dropdown/common.ts b/packages/pieces/framework/src/lib/property/input/dropdown/common.ts index 1cfda5f0eafe..189534697a73 100644 --- a/packages/pieces/framework/src/lib/property/input/dropdown/common.ts +++ b/packages/pieces/framework/src/lib/property/input/dropdown/common.ts @@ -4,11 +4,15 @@ import * as z from "zod/mini"; export const DropdownOption = z.object({ label: z.string(), value: z.unknown(), + description: z.optional(z.string()), + icon: z.optional(z.string()), }) export type DropdownOption = { label: string; value: T; + description?: string; + icon?: string; } export const DropdownState = z.object({ diff --git a/packages/pieces/framework/src/lib/property/input/dropdown/static-dropdown.ts b/packages/pieces/framework/src/lib/property/input/dropdown/static-dropdown.ts index 0dd5ad565a4e..5c69ff242a08 100644 --- a/packages/pieces/framework/src/lib/property/input/dropdown/static-dropdown.ts +++ b/packages/pieces/framework/src/lib/property/input/dropdown/static-dropdown.ts @@ -3,9 +3,13 @@ import { BasePropertySchema, TPropertyValue } from "../common"; import { DropdownState } from "./common"; import { PropertyType } from "../property-type"; +export const StaticDropdownDisplay = z.enum(['cards']) +export type StaticDropdownDisplay = z.infer + export const StaticDropdownProperty = z.object({ ...BasePropertySchema.shape, options: DropdownState, + display: z.optional(StaticDropdownDisplay), ...TPropertyValue(z.unknown(), PropertyType.STATIC_DROPDOWN).shape, }) @@ -14,6 +18,7 @@ export type StaticDropdownProperty< R extends boolean > = BasePropertySchema & { options: DropdownState; + display?: StaticDropdownDisplay; } & TPropertyValue; diff --git a/packages/pieces/framework/src/lib/property/input/index.ts b/packages/pieces/framework/src/lib/property/input/index.ts index 9629adf9dd5d..bc028100a5f7 100644 --- a/packages/pieces/framework/src/lib/property/input/index.ts +++ b/packages/pieces/framework/src/lib/property/input/index.ts @@ -2,6 +2,7 @@ import * as z from "zod/mini"; import { ArrayProperty } from './array-property'; import { CheckboxProperty } from './checkbox-property'; import { DateTimeProperty } from './date-time-property'; +import { DateRangeProperty } from './date-range-property'; import { DropdownProperty, MultiSelectDropdownProperty, @@ -19,6 +20,7 @@ import { NumberProperty } from './number-property'; import { ObjectProperty } from './object-property'; import { PropertyType } from './property-type'; import { LongTextProperty, ShortTextProperty } from './text-property'; +import { RichTextProperty } from './rich-text-property'; import { CustomProperty, CustomPropertyCodeFunctionParams } from './custom-property'; import { ColorProperty } from './color-property'; import { PieceAuthProperty } from '../authentication'; @@ -26,6 +28,7 @@ import { PieceAuthProperty } from '../authentication'; export const InputProperty = z.union([ ShortTextProperty, LongTextProperty, + RichTextProperty, MarkDownProperty, CheckboxProperty, StaticDropdownProperty, @@ -38,6 +41,7 @@ export const InputProperty = z.union([ ObjectProperty, JsonProperty, DateTimeProperty, + DateRangeProperty, FileProperty, ColorProperty, ]); @@ -46,6 +50,7 @@ export const InputProperty = z.union([ export type InputProperty = | ShortTextProperty | LongTextProperty + | RichTextProperty | MarkDownProperty | CheckboxProperty | DropdownProperty @@ -58,6 +63,7 @@ export type InputProperty = | StaticMultiSelectDropdownProperty | DynamicProperties | DateTimeProperty + | DateRangeProperty | FileProperty | CustomProperty | ColorProperty; @@ -102,6 +108,17 @@ export const Property = { ? LongTextProperty : LongTextProperty; }, + RichText( + request: Properties> + ): R extends true ? RichTextProperty : RichTextProperty { + return { + ...request, + valueSchema: undefined, + type: PropertyType.RICH_TEXT, + } as unknown as R extends true + ? RichTextProperty + : RichTextProperty; + }, MarkDown(request: { value: string; variant?: MarkdownVariant; @@ -228,6 +245,17 @@ export const Property = { ? DateTimeProperty : DateTimeProperty; }, + DateRange( + request: Properties> + ): R extends true ? DateRangeProperty : DateRangeProperty { + return { + ...request, + valueSchema: undefined, + type: PropertyType.DATE_RANGE, + } as unknown as R extends true + ? DateRangeProperty + : DateRangeProperty; + }, File( request: Properties> ): FileProperty { diff --git a/packages/pieces/framework/src/lib/property/input/number-property.ts b/packages/pieces/framework/src/lib/property/input/number-property.ts index cbfd04a91e57..479d59f998f6 100644 --- a/packages/pieces/framework/src/lib/property/input/number-property.ts +++ b/packages/pieces/framework/src/lib/property/input/number-property.ts @@ -4,8 +4,16 @@ import { PropertyType } from "./property-type"; export const NumberProperty = z.object({ ...BasePropertySchema.shape, + display: z.optional(z.enum(['stepper'])), + min: z.optional(z.number()), + max: z.optional(z.number()), + step: z.optional(z.number()), ...TPropertyValue(z.number(), PropertyType.NUMBER).shape, }) -export type NumberProperty = BasePropertySchema & - TPropertyValue; +export type NumberProperty = BasePropertySchema & { + display?: 'stepper'; + min?: number; + max?: number; + step?: number; +} & TPropertyValue; diff --git a/packages/pieces/framework/src/lib/property/input/property-type.ts b/packages/pieces/framework/src/lib/property/input/property-type.ts index 70210fa495c4..2fd5fd4dfa95 100644 --- a/packages/pieces/framework/src/lib/property/input/property-type.ts +++ b/packages/pieces/framework/src/lib/property/input/property-type.ts @@ -1,6 +1,7 @@ export enum PropertyType { SHORT_TEXT = 'SHORT_TEXT', LONG_TEXT = 'LONG_TEXT', + RICH_TEXT = 'RICH_TEXT', MARKDOWN = 'MARKDOWN', DROPDOWN = 'DROPDOWN', STATIC_DROPDOWN = 'STATIC_DROPDOWN', @@ -18,6 +19,7 @@ export enum PropertyType { CUSTOM_AUTH = 'CUSTOM_AUTH', OIDC = 'OIDC', DATE_TIME = 'DATE_TIME', + DATE_RANGE = 'DATE_RANGE', FILE = 'FILE', CUSTOM = 'CUSTOM', COLOR = 'COLOR', diff --git a/packages/pieces/framework/src/lib/property/input/rich-text-property.ts b/packages/pieces/framework/src/lib/property/input/rich-text-property.ts new file mode 100644 index 000000000000..df93738ae1b1 --- /dev/null +++ b/packages/pieces/framework/src/lib/property/input/rich-text-property.ts @@ -0,0 +1,20 @@ +import * as z from "zod/mini"; +import { BasePropertySchema, TPropertyValue } from "./common"; +import { PropertyType } from "./property-type"; + + +export const RichTextProperty = z.object({ + ...BasePropertySchema.shape, + formatProperty: z.optional(z.string()), + ...TPropertyValue(z.string(), PropertyType.RICH_TEXT).shape, +}) + + +export type RichTextProperty = BasePropertySchema & { + /** + * Name of a sibling property whose value selects the editing mode. + * The sibling value is mapped by convention: 'plain_text' | 'plain' | 'text' -> plain, + * 'html' -> rich/html, 'markdown' | 'md' -> markdown. Anything else falls back to plain. + */ + formatProperty?: string; +} & TPropertyValue; diff --git a/packages/pieces/framework/src/lib/property/util.ts b/packages/pieces/framework/src/lib/property/util.ts index 4d24eb0a9aed..d6dbd2b776b0 100644 --- a/packages/pieces/framework/src/lib/property/util.ts +++ b/packages/pieces/framework/src/lib/property/util.ts @@ -16,6 +16,7 @@ function buildSchema(props: PiecePropertyMap, auth: PieceAuthProperty | PieceAut case PropertyType.DATE_TIME: case PropertyType.SHORT_TEXT: case PropertyType.LONG_TEXT: + case PropertyType.RICH_TEXT: case PropertyType.COLOR: case PropertyType.FILE: propsSchema[name] = property.required @@ -71,6 +72,12 @@ function buildSchema(props: PiecePropertyMap, auth: PieceAuthProperty | PieceAut property.required ? z.string().check(z.minLength(1)) : z.string(), ]); break; + case PropertyType.DATE_RANGE: + propsSchema[name] = z.union([ + z.record(z.string(), z.any()), + z.string(), + ]); + break; case PropertyType.JSON: propsSchema[name] = z.union([ z.record(z.string(), z.any()), diff --git a/packages/pieces/framework/src/lib/trigger/trigger.ts b/packages/pieces/framework/src/lib/trigger/trigger.ts index 8d601b779342..671d0bcb0a7a 100644 --- a/packages/pieces/framework/src/lib/trigger/trigger.ts +++ b/packages/pieces/framework/src/lib/trigger/trigger.ts @@ -1,7 +1,7 @@ import * as z from "zod/mini"; import { OnStartContext, TestOrRunHookContext, TriggerHookContext } from '../context'; import type { OutputSchema } from '../output-schema'; -import { AiMetadata, TriggerBase } from '../piece-metadata'; +import { ActionClassification, AiMetadata, PropertyGroup, TriggerBase } from '../piece-metadata'; import { InputPropertyMap } from '../property'; import { ExtractPieceAuthPropertyTypeForMethods, PieceAuthProperty } from '../property/authentication'; import { isNil } from '@activepieces/core-utils'; @@ -49,6 +49,7 @@ type BaseTriggerParams< requireAuth?: boolean auth?: PieceAuth props: TriggerProps + propertyGroups?: PropertyGroup[] type: TS onEnable: (context: TriggerHookContext, TriggerProps, TS>) => Promise onDisable: (context: TriggerHookContext, TriggerProps, TS>) => Promise @@ -58,6 +59,7 @@ type BaseTriggerParams< sampleData: unknown outputSchema?: OutputSchema aiMetadata?: AiMetadata + classification?: ActionClassification } type WebhookTriggerParams< @@ -104,6 +106,8 @@ export class ITrigger< public readonly testStrategy: TriggerTestStrategy, public readonly outputSchema?: OutputSchema, public readonly aiMetadata?: AiMetadata, + public readonly classification?: ActionClassification, + public readonly propertyGroups?: PropertyGroup[], ) { } } @@ -142,6 +146,8 @@ export const createTrigger = < params.test ? TriggerTestStrategy.TEST_FUNCTION : TriggerTestStrategy.SIMULATION, params.outputSchema, params.aiMetadata, + params.classification, + params.propertyGroups, ) case TriggerStrategy.POLLING: return new ITrigger( @@ -164,6 +170,8 @@ export const createTrigger = < TriggerTestStrategy.TEST_FUNCTION, params.outputSchema, params.aiMetadata, + params.classification, + params.propertyGroups, ) case TriggerStrategy.MANUAL: return new ITrigger( @@ -186,6 +194,8 @@ export const createTrigger = < TriggerTestStrategy.TEST_FUNCTION, params.outputSchema, params.aiMetadata, + params.classification, + params.propertyGroups, ) case TriggerStrategy.APP_WEBHOOK: return new ITrigger( @@ -208,6 +218,8 @@ export const createTrigger = < (isNil(params.sampleData) && isNil(params.test)) ? TriggerTestStrategy.SIMULATION : TriggerTestStrategy.TEST_FUNCTION, params.outputSchema, params.aiMetadata, + params.classification, + params.propertyGroups, ) } } diff --git a/packages/server/api/src/app/ee/agent/tools/piece-input-plan.ts b/packages/server/api/src/app/ee/agent/tools/piece-input-plan.ts index ba6b62a42e79..01ca2fc3e0fd 100644 --- a/packages/server/api/src/app/ee/agent/tools/piece-input-plan.ts +++ b/packages/server/api/src/app/ee/agent/tools/piece-input-plan.ts @@ -142,12 +142,19 @@ async function baseSchemaFor({ propertyName, property, resolvedInput, resolveDyn switch (property.type) { case PropertyType.SHORT_TEXT: case PropertyType.LONG_TEXT: + case PropertyType.RICH_TEXT: case PropertyType.MARKDOWN: case PropertyType.DATE_TIME: case PropertyType.FILE: case PropertyType.COLOR: case PropertyType.CUSTOM: return z.string() + case PropertyType.DATE_RANGE: + return z.object({ + preset: z.string().optional(), + after: z.string().optional(), + before: z.string().optional(), + }) case PropertyType.DROPDOWN: case PropertyType.STATIC_DROPDOWN: return z.union([z.string(), z.number(), z.object({}).loose()]) diff --git a/packages/server/api/src/app/ee/platform/admin/admin-platform.controller.ts b/packages/server/api/src/app/ee/platform/admin/admin-platform.controller.ts index 998f62e4c5e0..7b3ec33e47e8 100644 --- a/packages/server/api/src/app/ee/platform/admin/admin-platform.controller.ts +++ b/packages/server/api/src/app/ee/platform/admin/admin-platform.controller.ts @@ -1,5 +1,5 @@ import { isNil } from '@activepieces/core-utils' -import { AiMetadata, Audience, ErrorHandlingOptionsParam, type OutputSchema, PieceMetadata, PieceMetadataModel, WebhookRenewConfiguration } from '@activepieces/pieces-framework' +import { ActionClassification, AiMetadata, Audience, ErrorHandlingOptionsParam, type OutputSchema, PieceMetadata, PieceMetadataModel, PropertyGroup, WebhookRenewConfiguration } from '@activepieces/pieces-framework' import { AdminRetryRunsRequestBody, AgentConversation, AgentRunSource, ApplyLicenseKeyByEmailRequestBody, ExactVersionType, IncreaseAICreditsForPlatformRequestBody, PackageType, PieceCategory, PieceType, TriggerStrategy, TriggerTestStrategy, WebhookHandshakeConfiguration } from '@activepieces/shared' import { FastifyReply, FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' @@ -173,10 +173,12 @@ const Action = z.object({ description: z.string(), requireAuth: z.boolean(), props: z.unknown(), + propertyGroups: z.optional(z.array(PropertyGroup)), errorHandlingOptions: z.optional(ErrorHandlingOptionsParam), outputSchema: z.optional(z.custom()), aiMetadata: z.optional(AiMetadata), audience: z.optional(Audience), + classification: z.optional(ActionClassification), }) const Trigger = Action.omit({ audience: true }).extend({ diff --git a/packages/server/engine/src/lib/variables/props-processor.ts b/packages/server/engine/src/lib/variables/props-processor.ts index 753e922c3c5b..ef5f654225f7 100644 --- a/packages/server/engine/src/lib/variables/props-processor.ts +++ b/packages/server/engine/src/lib/variables/props-processor.ts @@ -1,6 +1,6 @@ import { Readable } from 'node:stream' import { isNil, isObject } from '@activepieces/core-utils' -import { getAuthPropertyForValue, InputPropertyMap, PieceAuthProperty, PieceProperty, PiecePropertyMap, PropertyType, StaticPropsValue } from '@activepieces/pieces-framework' +import { DateRangeValue, getAuthPropertyForValue, InputPropertyMap, PieceAuthProperty, PieceProperty, PiecePropertyMap, PropertyType, StaticPropsValue } from '@activepieces/pieces-framework' import { AppConnectionValue, AUTHENTICATION_PROPERTY_NAME, PropertySettings } from '@activepieces/shared' import { dynamicPropKeys } from '../helper/dynamic-prop-keys' import { processors } from './processors' @@ -160,6 +160,7 @@ const validateProperty = (property: PieceProperty, value: unknown, originalValue switch (property.type) { case PropertyType.SHORT_TEXT: case PropertyType.LONG_TEXT: + case PropertyType.RICH_TEXT: return typeof value === 'string' ? [] : [`Expected string, received: ${originalValue}`] case PropertyType.NUMBER: return typeof value === 'number' && !Number.isNaN(value) ? [] : [`Expected number, received: ${originalValue}`] @@ -167,6 +168,8 @@ const validateProperty = (property: PieceProperty, value: unknown, originalValue return typeof value === 'boolean' ? [] : [`Expected boolean, received: ${originalValue}`] case PropertyType.DATE_TIME: return typeof value === 'string' ? [] : [`Invalid datetime format. Expected ISO format (e.g. 2024-03-14T12:00:00.000Z), received: ${originalValue}`] + case PropertyType.DATE_RANGE: + return DateRangeValue.safeParse(value).success ? [] : [`Expected date range, received: ${originalValue}`] case PropertyType.ARRAY: case PropertyType.MULTI_SELECT_DROPDOWN: case PropertyType.STATIC_MULTI_SELECT_DROPDOWN: diff --git a/packages/server/engine/test/variables/props-validator.test.ts b/packages/server/engine/test/variables/props-validator.test.ts index 36d683bc5d7f..12c411484842 100644 --- a/packages/server/engine/test/variables/props-validator.test.ts +++ b/packages/server/engine/test/variables/props-validator.test.ts @@ -111,6 +111,46 @@ describe('Property Validation', () => { }) }) + it('should validate required date range property', async () => { + const props = { + range: Property.DateRange({ + displayName: 'Date Range', + required: true, + }), + } + + const { errors: validErrors } = await propsProcessor.applyProcessorsAndValidators( + { range: { preset: 'last_7_days' } }, + props, + PieceAuth.None(), + false, + {}, + ) + expect(validErrors).toEqual({}) + + const { errors: nullErrors } = await propsProcessor.applyProcessorsAndValidators( + { range: null }, + props, + PieceAuth.None(), + false, + {}, + ) + expect(nullErrors).toEqual({ + range: ['Expected date range, received: null'], + }) + + const { errors: typeErrors } = await propsProcessor.applyProcessorsAndValidators( + { range: 'not a range' }, + props, + PieceAuth.None(), + false, + {}, + ) + expect(typeErrors).toEqual({ + range: ['Expected date range, received: not a range'], + }) + }) + it('should validate required array property', async () => { const props = { array: Property.Array({ diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index f6e2d9526ce9..1093091bc692 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -42,6 +42,9 @@ "Dock": "Dock", "Minimize": "Minimize", "Result": "Result", + "Hide": "Hide", + "{count, plural, =1 {1 option} other {# options}}": "{count, plural, =1 {1 option} other {# options}}", + "Use dynamic value": "Use dynamic value", "Data Selector": "Data Selector", "Data": "Data", "Variables": "Variables", @@ -180,6 +183,38 @@ "to apply": "to apply", "See All": "See All", "Type to search functions...": "Type to search functions...", + "Type an email and press Enter": "Type an email and press Enter", + "Add another": "Add another", + "Click to edit": "Click to edit", + "Not a valid email address": "Not a valid email address", + "{count, plural, other {# chars}}": "{count, plural, other {# chars}}", + "Bold": "Bold", + "Italic": "Italic", + "Underline": "Underline", + "Bullet list": "Bullet list", + "Link": "Link", + "Link URL": "Link URL", + "Active filters": "Active filters", + "No filters yet": "No filters yet", + "Add filter": "Add filter", + "Added": "Added", + "Remove filter": "Remove filter", + "No filters added": "No filters added", + "Without filters, this step returns the most recent results. Add a filter to narrow them.": "Without filters, this step returns the most recent results. Add a filter to narrow them.", + "Filter by…": "Filter by…", + "No filters found": "No filters found", + "No filters — newest first": "No filters — newest first", + "Returns up to {count} results": "Returns up to {count} results", + "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first": "{count, plural, =1 {# filter applied} other {# filters applied}} · newest first", + "Last 24 hours": "Last 24 hours", + "Last 90 days": "Last 90 days", + "Custom range…": "Custom range…", + "Decrease": "Decrease", + "Increase": "Increase", + "Any time": "Any time", + "This month": "This month", + "After": "After", + "Before": "Before", "No functions found": "No functions found", "Error": "Error", "Preview": "Preview", diff --git a/packages/web/src/app/builder/piece-properties/action-error-handling.tsx b/packages/web/src/app/builder/piece-properties/action-error-handling.tsx index c8e2cc2f1367..ce71198be61f 100644 --- a/packages/web/src/app/builder/piece-properties/action-error-handling.tsx +++ b/packages/web/src/app/builder/piece-properties/action-error-handling.tsx @@ -33,10 +33,17 @@ const ActionErrorHandlingForm = React.memo( } return ( -
-
- - {t('Error handling')} +
+
+ + + {t('Error handling')} +
{hideContinueOnFailure !== true && ( - {t('Add Error Handler')} + {t('Add Error Handler')} - {t('Retry on Failure')} + {t('Retry on Failure')} , + prefix: string, +): string[] { + const paths: string[] = []; + for (const [key, value] of Object.entries(errors ?? {})) { + const path = prefix.length > 0 ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && 'message' in value) { + paths.push(path); + } else if (value && typeof value === 'object') { + paths.push( + ...getNestedErrorPaths(value as Record, path), + ); + } + } + return paths; +} + +function AdvancedSection({ + count, + watchPaths, + children, +}: AdvancedSectionProps) { + const [open, setOpen] = useState(false); + const autoOpenedRef = useRef(false); + const { errors } = useFormState({ name: watchPaths }); + + useEffect(() => { + const errorPaths = getNestedErrorPaths( + errors as Record, + '', + ); + const hasNestedError = watchPaths.some((watchPath) => + errorPaths.some((errorPath) => errorPath.startsWith(watchPath)), + ); + // Re-arm auto-open once the advanced errors clear, so a later error opens + // the section again — but never fight a manual close while an error persists. + if (!hasNestedError) { + autoOpenedRef.current = false; + return; + } + if (autoOpenedRef.current) return; + autoOpenedRef.current = true; + setOpen(true); + }, [errors, watchPaths]); + + if (count === 0) { + return null; + } + + return ( + + + + + {t('Advanced')} + + + {open + ? t('Hide') + : t('{count, plural, =1 {1 option} other {# options}}', { count })} + + + + +
+ {children} +
+
+
+ ); +} + +AdvancedSection.displayName = 'AdvancedSection'; +export { AdvancedSection }; + +type AdvancedSectionProps = { + count: number; + watchPaths: string[]; + children: React.ReactNode; +}; diff --git a/packages/web/src/app/builder/piece-properties/auto-form-field-wrapper.tsx b/packages/web/src/app/builder/piece-properties/auto-form-field-wrapper.tsx index d875164ee9b5..22250039159e 100644 --- a/packages/web/src/app/builder/piece-properties/auto-form-field-wrapper.tsx +++ b/packages/web/src/app/builder/piece-properties/auto-form-field-wrapper.tsx @@ -9,7 +9,7 @@ import { PropertyExecutionType, } from '@activepieces/shared'; import { t } from 'i18next'; -import { Calendar, SquareFunction, File } from 'lucide-react'; +import { Calendar, File } from 'lucide-react'; import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { ControllerRenderProps, useFormContext } from 'react-hook-form'; @@ -19,7 +19,6 @@ import { ReadMoreDescription } from '@/components/custom/read-more-description'; import { Button } from '@/components/ui/button'; import { FormItem, FormLabel } from '@/components/ui/form'; import { RequiredFieldAsterisk } from '@/components/ui/label'; -import { Toggle } from '@/components/ui/toggle'; import { Tooltip, TooltipContent, @@ -29,11 +28,13 @@ import { formUtils } from '@/features/pieces'; import { cn } from '@/lib/utils'; import { ArrayPiecePropertyInInlineItemMode } from './array-property-in-inline-item-mode'; +import { DynamicValueToggleButton } from './dynamic-value-toggle-button'; import { TextInputWithMentions } from './text-input-with-mentions'; function AutoFormFieldWrapper({ placeBeforeLabelText = false, hideLabel, + hideDescription, children, allowDynamicValues, propertyName, @@ -58,7 +59,7 @@ function AutoFormFieldWrapper({ {(!hideLabel || placeBeforeLabelText) && ( {placeBeforeLabelText && !dynamicInputModeToggled && children} -
+
{isAuthProperty ? t('Connection') : property.displayName} {' '} @@ -104,6 +105,7 @@ function AutoFormFieldWrapper({
{children}
)} {!isForConnectionSelect && + !hideDescription && !Array.isArray(property) && property.description && ( @@ -153,7 +155,7 @@ function AutoFormFielWrapperErrorBoundary({ ); } -function getValueForInputOnDynamicToggleChange( +export function getValueForInputOnDynamicToggleChange( property: PieceProperty | PieceAuthProperty[], newMode: PropertyExecutionType, currentValue: unknown, @@ -234,32 +236,17 @@ function DynamicValueToggle({ } } return ( -
- - - - handleDynamicValueToggleChange( - newIsToggled - ? PropertyExecutionType.DYNAMIC - : PropertyExecutionType.MANUAL, - ) - } - disabled={disabled} - size="sm" - > - - - - {t('Dynamic value')} - -
+ + handleDynamicValueToggleChange( + newIsToggled + ? PropertyExecutionType.DYNAMIC + : PropertyExecutionType.MANUAL, + ) + } + disabled={disabled} + /> ); } function PropertyTypeTooltip({ property }: { property: PieceProperty }) { @@ -318,6 +305,7 @@ type DynamicValueToggleProps = { type AutoFormFieldWrapperProps = { children: React.ReactNode; hideLabel?: boolean; + hideDescription?: boolean; allowDynamicValues: boolean; propertyName: string; placeBeforeLabelText?: boolean; diff --git a/packages/web/src/app/builder/piece-properties/date-range-property.tsx b/packages/web/src/app/builder/piece-properties/date-range-property.tsx new file mode 100644 index 000000000000..537d25b6370b --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/date-range-property.tsx @@ -0,0 +1,144 @@ +import { t } from 'i18next'; +import { ArrowRight } from 'lucide-react'; +import React from 'react'; + +import { inputClass } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; + +function DateRangeProperty({ + value, + onChange, + disabled, + display, +}: DateRangePropertyProps) { + const current = + value && typeof value === 'object' ? (value as DateRangeShape) : {}; + const presets = display === 'dropdown' ? DROPDOWN_PRESETS : PILL_PRESETS; + const fallbackPreset = display === 'dropdown' ? 'last_7_days' : 'any_time'; + const preset = current.preset ?? fallbackPreset; + + const selectPreset = (next: string) => { + onChange( + next === 'custom' + ? { preset: 'custom', after: current.after, before: current.before } + : { preset: next }, + ); + }; + + return ( +
+ {display === 'dropdown' ? ( + + ) : ( +
+ {presets.map((option) => { + const selected = preset === option.value; + return ( + + ); + })} +
+ )} + {preset === 'custom' && ( +
+ + + +
+ )} +
+ ); +} + +DateRangeProperty.displayName = 'DateRangeProperty'; + +const PILL_PRESETS: { value: string; label: string }[] = [ + { value: 'any_time', label: 'Any time' }, + { value: 'last_7_days', label: 'Last 7 days' }, + { value: 'last_30_days', label: 'Last 30 days' }, + { value: 'this_month', label: 'This month' }, + { value: 'custom', label: 'Custom' }, +]; + +const DROPDOWN_PRESETS: { value: string; label: string }[] = [ + { value: 'any_time', label: 'Any time' }, + { value: 'last_24_hours', label: 'Last 24 hours' }, + { value: 'last_7_days', label: 'Last 7 days' }, + { value: 'last_30_days', label: 'Last 30 days' }, + { value: 'last_90_days', label: 'Last 90 days' }, + { value: 'custom', label: 'Custom range…' }, +]; + +export { DateRangeProperty }; + +type DateRangeShape = { preset?: string; after?: string; before?: string }; + +type DateRangePropertyProps = { + value: unknown; + onChange: (value: DateRangeShape) => void; + disabled?: boolean; + display?: 'dropdown'; +}; diff --git a/packages/web/src/app/builder/piece-properties/dynamic-value-toggle-button.tsx b/packages/web/src/app/builder/piece-properties/dynamic-value-toggle-button.tsx new file mode 100644 index 000000000000..0fda20543640 --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/dynamic-value-toggle-button.tsx @@ -0,0 +1,50 @@ +import { t } from 'i18next'; +import { SquareFunction } from 'lucide-react'; +import React from 'react'; + +import { Toggle } from '@/components/ui/toggle'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +function DynamicValueToggleButton({ + pressed, + onPressedChange, + disabled, +}: DynamicValueToggleButtonProps) { + return ( + + + + + + + {t('Dynamic value')} + + ); +} + +DynamicValueToggleButton.displayName = 'DynamicValueToggleButton'; + +export { DynamicValueToggleButton }; + +type DynamicValueToggleButtonProps = { + pressed: boolean; + onPressedChange: (pressed: boolean) => void; + disabled?: boolean; +}; diff --git a/packages/web/src/app/builder/piece-properties/filter-builder-layout.tsx b/packages/web/src/app/builder/piece-properties/filter-builder-layout.tsx new file mode 100644 index 000000000000..95234671952c --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/filter-builder-layout.tsx @@ -0,0 +1,472 @@ +import { + PieceProperty, + PropertyGroup, + PropertyType, +} from '@activepieces/pieces-framework'; +import { t } from 'i18next'; +import { Check, Filter, Plus, X } from 'lucide-react'; +import React, { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { FormField } from '@/components/ui/form'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +import { filterPropertyUtils } from './filter-property-utils'; +import { NumberStepper } from './number-stepper'; +import { propertyIcons } from './property-icons'; + +const { inputNameFor, isFilterActive, emptyValueFor, collectRevealedNames } = + filterPropertyUtils; + +function FilterBuilderLayout({ + groups, + props, + prefixValue, + disabled, + renderField, +}: FilterBuilderLayoutProps) { + const form = useFormContext(); + const builderGroups = groups.filter((group) => group.display === 'builder'); + const footerGroup = groups.find((group) => group.display === 'footer'); + + const revealedNames = new Set(collectRevealedNames(props)); + const filterNames = builderGroups.flatMap((group) => + group.props.filter((name) => !!props[name] && !revealedNames.has(name)), + ); + + const watchedValues = form.watch( + filterNames.map((name) => inputNameFor(prefixValue, name)), + ); + const activeByValue = new Set( + filterNames.filter((name, index) => + isFilterActive(props[name], watchedValues[index]), + ), + ); + + // Rows render in the order they were opened. Seeded once from the filters + // that already hold a value (declared order is the stable baseline on reopen), + // then newly added filters append to the bottom. + const [order, setOrder] = useState(() => + filterNames.filter((name) => + isFilterActive( + props[name], + form.getValues(inputNameFor(prefixValue, name)), + ), + ), + ); + const openNames = [ + ...order.filter((name) => filterNames.includes(name)), + ...filterNames.filter( + (name) => activeByValue.has(name) && !order.includes(name), + ), + ]; + const openSet = new Set(openNames); + + const addFilter = (name: string) => { + setOrder((prev) => (prev.includes(name) ? prev : [...prev, name])); + const property = props[name]; + if (property.type === PropertyType.CHECKBOX) { + form.setValue(inputNameFor(prefixValue, name), true, { + shouldValidate: true, + }); + } else if (property.type === PropertyType.DATE_RANGE) { + form.setValue( + inputNameFor(prefixValue, name), + { preset: 'last_7_days' }, + { shouldValidate: true }, + ); + } + }; + + const removeFilter = (name: string) => { + setOrder((prev) => prev.filter((entry) => entry !== name)); + const property = props[name]; + form.setValue(inputNameFor(prefixValue, name), emptyValueFor(property), { + shouldValidate: true, + }); + if (property.type === PropertyType.CHECKBOX) { + (property.reveals ?? []).forEach((revealName) => { + if (props[revealName]) { + form.setValue( + inputNameFor(prefixValue, revealName), + emptyValueFor(props[revealName]), + { shouldValidate: true }, + ); + } + }); + } + }; + + return ( +
+
+ {openNames.length === 0 ? ( + + ) : ( +
+ {openNames.map((name) => ( + removeFilter(name)} + /> + ))} +
+ )} +
+ +
+
+ {footerGroup && ( + + )} +
+ ); +} + +FilterBuilderLayout.displayName = 'FilterBuilderLayout'; + +function EmptyFilterState() { + return ( +
+ + + + + {t('No filters added')} + + + {t( + 'Without filters, this step returns the most recent results. Add a filter to narrow them.', + )} + +
+ ); +} + +function FilterRow({ + name, + property, + props, + disabled, + renderField, + onRemove, +}: FilterRowProps) { + const Icon = propertyIcons.get(iconNameFor(property)); + const label = 'displayName' in property ? property.displayName : name; + const revealedInControl = + property.type === PropertyType.CHECKBOX + ? (property.reveals ?? []).filter((revealName) => !!props[revealName]) + : []; + // A checkbox with no reveals already renders its description as the row's control, + // so only surface the description below for the input-bearing filters. + const controlRendersDescription = + property.type === PropertyType.CHECKBOX && revealedInControl.length === 0; + const description = + 'description' in property && property.description + ? property.description + : ''; + const showDescription = !!description && !controlRendersDescription; + return ( +
+ + + {Icon ? : null} + + + + {t(label)} + +
+ + {showDescription && ( + + {t(description)} + + )} +
+ +
+ ); +} + +function FilterRowControl({ + name, + property, + props, + renderField, +}: FilterRowControlProps) { + if (property.type === PropertyType.CHECKBOX) { + const reveals = (property.reveals ?? []).filter( + (revealName) => !!props[revealName], + ); + if (reveals.length > 0) { + return ( +
+ {reveals.map((revealName) => ( + + {renderField(revealName, { + hideLabel: true, + hideDescription: true, + })} + + ))} +
+ ); + } + const description = + 'description' in property && property.description + ? property.description + : ''; + return ( + + {description ? t(description) : null} + + ); + } + return <>{renderField(name, { hideLabel: true, hideDescription: true })}; +} + +function AddFilterPopover({ + builderGroups, + props, + openSet, + disabled, + onAdd, + variant, +}: AddFilterPopoverProps) { + const [open, setOpen] = useState(false); + return ( + + + {variant === 'block' ? ( + + ) : ( + + )} + + + + + + {t('No filters found')} + {builderGroups.map((group) => { + const items = group.props.filter((name) => !!props[name]); + if (items.length === 0) { + return null; + } + return ( + + {items.map((name) => { + const property = props[name]; + const Icon = propertyIcons.get(iconNameFor(property)); + const label = + 'displayName' in property ? property.displayName : name; + const added = openSet.has(name); + return ( + { + if (!added) { + onAdd(name); + } + }} + > + {Icon ? ( + + ) : null} + {t(label)} + {added && ( + + + {t('Added')} + + )} + + ); + })} + + ); + })} + + + + + ); +} + +function FilterFooter({ + group, + props, + prefixValue, + disabled, + activeCount, +}: FilterFooterProps) { + const form = useFormContext(); + const Icon = propertyIcons.get(group.icon); + const memberNames = group.props.filter((name) => !!props[name]); + const numberName = memberNames.find( + (name) => props[name].type === PropertyType.NUMBER, + ); + const numberProperty = numberName ? props[numberName] : undefined; + const countRaw = numberName + ? form.watch(inputNameFor(prefixValue, numberName)) + : undefined; + const count = typeof countRaw === 'number' ? countRaw : Number(countRaw) || 0; + + return ( +
+
+ + {Icon ? : null} + +
+
+ {numberName + ? t('Returns up to {count} results', { count }) + : t(group.label ?? '')} +
+
+ {activeCount === 0 + ? t('No filters — newest first') + : t( + '{count, plural, =1 {# filter applied} other {# filters applied}} · newest first', + { count: activeCount }, + )} +
+
+
+ {numberName && + numberProperty && + numberProperty.type === PropertyType.NUMBER && ( + ( + + )} + /> + )} +
+ ); +} + +function iconNameFor(property: PieceProperty): string | undefined { + return 'icon' in property ? property.icon : undefined; +} + +export { FilterBuilderLayout }; + +type RenderFieldFn = ( + propertyName: string, + options?: { hideLabel?: boolean; hideDescription?: boolean }, +) => React.ReactNode; + +type FilterBuilderLayoutProps = { + groups: PropertyGroup[]; + props: Record; + prefixValue: string; + disabled: boolean; + renderField: RenderFieldFn; +}; + +type FilterRowProps = { + name: string; + property: PieceProperty; + props: Record; + disabled: boolean; + renderField: RenderFieldFn; + onRemove: () => void; +}; + +type FilterRowControlProps = { + name: string; + property: PieceProperty; + props: Record; + renderField: RenderFieldFn; +}; + +type AddFilterPopoverProps = { + builderGroups: PropertyGroup[]; + props: Record; + openSet: Set; + disabled: boolean; + onAdd: (name: string) => void; + variant: 'block' | 'inline'; +}; + +type FilterFooterProps = { + group: PropertyGroup; + props: Record; + prefixValue: string; + disabled: boolean; + activeCount: number; +}; diff --git a/packages/web/src/app/builder/piece-properties/filter-layout.tsx b/packages/web/src/app/builder/piece-properties/filter-layout.tsx new file mode 100644 index 000000000000..9de0ed5b5663 --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/filter-layout.tsx @@ -0,0 +1,316 @@ +import { + PieceProperty, + PropertyGroup, + PropertyType, +} from '@activepieces/pieces-framework'; +import { t } from 'i18next'; +import { Search, X } from 'lucide-react'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { FormField } from '@/components/ui/form'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; + +import { filterPropertyUtils } from './filter-property-utils'; +import { propertyIcons } from './property-icons'; + +const { + inputNameFor, + collectRevealedNames, + isFilterActive, + emptyValueFor, + chipLabel, +} = filterPropertyUtils; + +function FilterPropertiesLayout({ + groups, + props, + prefixValue, + disabled, + renderField, +}: FilterPropertiesLayoutProps) { + const sectionGroups = groups.filter((group) => group.display === 'section'); + const sectionPropNames = sectionGroups.flatMap((group) => + group.props.filter((name) => !!props[name]), + ); + const revealed = collectRevealedNames(props); + const groupedNames = new Set([...sectionPropNames, ...revealed]); + const ungroupedNames = Object.keys(props).filter( + (name) => !groupedNames.has(name), + ); + + return ( +
+ {groups.map((group) => { + if (group.display === 'summary') { + return ( + + ); + } + if (group.display === 'section') { + return ( + + ); + } + return null; + })} + {ungroupedNames.map((name) => ( + {renderField(name)} + ))} +
+ ); +} + +FilterPropertiesLayout.displayName = 'FilterPropertiesLayout'; + +function PropertySection({ + group, + props, + prefixValue, + disabled, + renderField, +}: PropertySectionProps) { + const Icon = propertyIcons.get(group.icon); + const memberNames = group.props.filter((name) => !!props[name]); + return ( +
+
+ {Icon && ( + + + + )} + + {group.label} + +
+
+ {memberNames.map((name) => { + const property = props[name]; + if ( + property.type === PropertyType.CHECKBOX && + (property.reveals?.length ?? 0) > 0 + ) { + return ( +
+ +
+ ); + } + const isHalf = 'width' in property && property.width === 'half'; + return ( +
+ {renderField(name)} +
+ ); + })} +
+
+ ); +} + +function ToggleRevealCard({ + checkboxName, + revealNames, + props, + prefixValue, + disabled, + renderField, +}: ToggleRevealCardProps) { + const form = useFormContext(); + const checkbox = props[checkboxName]; + const checkboxInputName = inputNameFor(prefixValue, checkboxName); + const checked = form.watch(checkboxInputName) === true; + const reveals = revealNames.filter((name) => !!props[name]); + const title = 'displayName' in checkbox ? checkbox.displayName : ''; + const description = + 'description' in checkbox ? checkbox.description : undefined; + + return ( +
+ ( + + )} + /> + {checked && reveals.length > 0 && ( +
+ {reveals.map((name) => ( + {renderField(name)} + ))} +
+ )} +
+ ); +} + +function FilterSummary({ + filterPropNames, + props, + prefixValue, + disabled, +}: FilterSummaryProps) { + const form = useFormContext(); + const watched = form.watch( + filterPropNames.map((name) => inputNameFor(prefixValue, name)), + ); + const active = filterPropNames + .map((name, index) => ({ + name, + property: props[name], + value: watched[index], + })) + .filter(({ property, value }) => isFilterActive(property, value)); + + const clearOne = (name: string, property: PieceProperty) => { + form.setValue(inputNameFor(prefixValue, name), emptyValueFor(property), { + shouldValidate: true, + }); + if (property.type === PropertyType.CHECKBOX) { + (property.reveals ?? []).forEach((revealName) => { + if (props[revealName]) { + form.setValue( + inputNameFor(prefixValue, revealName), + emptyValueFor(props[revealName]), + { shouldValidate: true }, + ); + } + }); + } + }; + + return ( +
+
+ + + {t('Active filters')} + + {active.length > 0 && ( + + )} +
+ {active.length === 0 ? ( + + {t('No filters yet')} + + ) : ( +
+ {active.map(({ name, property, value }) => ( + + {chipLabel(property, value)} + + + ))} +
+ )} +
+ ); +} + +export { FilterPropertiesLayout }; + +type FilterPropertiesLayoutProps = { + groups: PropertyGroup[]; + props: Record; + prefixValue: string; + disabled: boolean; + renderField: (propertyName: string) => React.ReactNode; +}; + +type PropertySectionProps = { + group: PropertyGroup; + props: Record; + prefixValue: string; + disabled: boolean; + renderField: (propertyName: string) => React.ReactNode; +}; + +type ToggleRevealCardProps = { + checkboxName: string; + revealNames: string[]; + props: Record; + prefixValue: string; + disabled: boolean; + renderField: (propertyName: string) => React.ReactNode; +}; + +type FilterSummaryProps = { + filterPropNames: string[]; + props: Record; + prefixValue: string; + disabled: boolean; +}; diff --git a/packages/web/src/app/builder/piece-properties/filter-property-utils.ts b/packages/web/src/app/builder/piece-properties/filter-property-utils.ts new file mode 100644 index 000000000000..3b50a726c034 --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/filter-property-utils.ts @@ -0,0 +1,144 @@ +import { PieceProperty, PropertyType } from '@activepieces/pieces-framework'; +import { t } from 'i18next'; + +import { formatUtils } from '@/lib/format-utils'; + +function inputNameFor(prefixValue: string, name: string): string { + return prefixValue.length > 0 ? `${prefixValue}.${name}` : name; +} + +function collectRevealedNames(props: Record): string[] { + return Object.values(props).flatMap((property) => + property.type === PropertyType.CHECKBOX ? property.reveals ?? [] : [], + ); +} + +function isFilterActive( + property: PieceProperty | undefined, + value: unknown, +): boolean { + if (isNilOrUndefined(value) || !property) { + return false; + } + switch (property.type) { + case PropertyType.CHECKBOX: + return value === true; + case PropertyType.NUMBER: + return value !== property.defaultValue; + case PropertyType.DATE_RANGE: { + const range = value as { + preset?: string; + after?: string; + before?: string; + }; + if (range.preset === 'custom') { + return !!range.after || !!range.before; + } + return !!range.preset && range.preset !== 'any_time'; + } + case PropertyType.ARRAY: + case PropertyType.MULTI_SELECT_DROPDOWN: + case PropertyType.STATIC_MULTI_SELECT_DROPDOWN: + return Array.isArray(value) && value.length > 0; + default: + if (typeof value === 'string') { + return value.trim().length > 0; + } + return value !== property.defaultValue; + } +} + +function emptyValueFor(property: PieceProperty): unknown { + switch (property.type) { + case PropertyType.CHECKBOX: + return false; + case PropertyType.DATE_RANGE: + return { preset: 'any_time' }; + case PropertyType.ARRAY: + case PropertyType.MULTI_SELECT_DROPDOWN: + case PropertyType.STATIC_MULTI_SELECT_DROPDOWN: + return []; + case PropertyType.NUMBER: + return property.defaultValue ?? null; + case PropertyType.STATIC_DROPDOWN: + case PropertyType.DROPDOWN: + return null; + default: + return ''; + } +} + +const DATE_RANGE_PRESET_LABELS: Record = { + last_24_hours: 'Last 24 hours', + last_7_days: 'Last 7 days', + last_30_days: 'Last 30 days', + last_90_days: 'Last 90 days', + this_month: 'This month', + custom: 'Custom', +}; + +function formatDateChip(isoDate: string): string { + const [year, month, day] = isoDate.split('-').map(Number); + if (!year || !month || !day) { + return isoDate; + } + return formatUtils.formatDateOnly(new Date(year, month - 1, day)); +} + +function chipLabel(property: PieceProperty, value: unknown): string { + const name = 'displayName' in property ? property.displayName : ''; + if (property.type === PropertyType.DATE_RANGE) { + const range = value as { + preset?: string; + after?: string; + before?: string; + }; + if (range.preset === 'custom') { + const after = range.after ? formatDateChip(range.after) : undefined; + const before = range.before ? formatDateChip(range.before) : undefined; + if (after && before) { + return `${name}: ${after} – ${before}`; + } + if (after) { + return `${name}: ${t('After')} ${after}`; + } + if (before) { + return `${name}: ${t('Before')} ${before}`; + } + return name; + } + const label = range.preset + ? DATE_RANGE_PRESET_LABELS[range.preset] + : undefined; + return label ? `${name}: ${t(label)}` : name; + } + if ( + property.type === PropertyType.CHECKBOX || + property.type === PropertyType.STATIC_DROPDOWN || + property.type === PropertyType.DROPDOWN + ) { + return name; + } + if (typeof value === 'number') { + return `${name}: ${value}`; + } + if (typeof value === 'string' && value.trim().length > 0) { + const trimmed = value.trim(); + const preview = trimmed.length > 24 ? `${trimmed.slice(0, 24)}…` : trimmed; + return `${name}: ${preview}`; + } + return name; +} + +function isNilOrUndefined(value: unknown): boolean { + return value === null || value === undefined; +} + +export const filterPropertyUtils = { + inputNameFor, + collectRevealedNames, + isFilterActive, + emptyValueFor, + chipLabel, + isNilOrUndefined, +}; diff --git a/packages/web/src/app/builder/piece-properties/generic-properties-form.tsx b/packages/web/src/app/builder/piece-properties/generic-properties-form.tsx index d72c1ff7e38e..faea872f7d0b 100644 --- a/packages/web/src/app/builder/piece-properties/generic-properties-form.tsx +++ b/packages/web/src/app/builder/piece-properties/generic-properties-form.tsx @@ -3,6 +3,8 @@ import { OAuth2Props, PiecePropertyMap, ArraySubProps, + PropertyGroup, + PieceProperty, } from '@activepieces/pieces-framework'; import { PropertyExecutionType, PropertySettings } from '@activepieces/shared'; import React from 'react'; @@ -11,10 +13,13 @@ import { useFormContext } from 'react-hook-form'; import { FormField } from '@/components/ui/form'; import { cn, GAP_SIZE_FOR_STEP_SETTINGS } from '@/lib/utils'; +import { FilterBuilderLayout } from './filter-builder-layout'; +import { FilterPropertiesLayout } from './filter-layout'; import { selectGenericFormComponentForProperty, SelectGenericFormComponentForPropertyParams, } from './properties-utils'; +import { PropertyGroupTabs } from './property-group-tabs'; export const GenericPropertiesForm = React.memo( ({ @@ -26,63 +31,142 @@ export const GenericPropertiesForm = React.memo( useMentionTextInput, onValueChange, dynamicPropsInfo, + propertyGroups, }: GenericPropertiesFormProps) => { const form = useFormContext(); + const groupByPropName = buildGroupByPropName(propertyGroups); + const renderedGroups = new Set(); + + const inputNameFor = (propertyName: string) => + prefixValue.length > 0 ? `${prefixValue}.${propertyName}` : propertyName; + + const renderField = ( + propertyName: string, + options?: { hideLabel?: boolean; hideDescription?: boolean }, + ) => { + const dynamicInputModeToggled = + propertySettings?.[propertyName]?.type === + PropertyExecutionType.DYNAMIC; + return ( + + selectGenericFormComponentForProperty({ + field: { + ...field, + onChange: (value) => { + field.onChange(value); + onValueChange?.({ value, propertyName }); + }, + }, + propertyName, + inputName: inputNameFor(propertyName), + property: props[propertyName], + allowDynamicValues: + !isNil(propertySettings) && !options?.hideLabel, + markdownVariables: markdownVariables ?? {}, + useMentionTextInput: useMentionTextInput, + disabled: disabled ?? false, + dynamicInputModeToggled, + form, + dynamicPropsInfo, + propertySettings, + hideLabel: options?.hideLabel, + hideDescription: options?.hideDescription, + }) + } + /> + ); + }; + + if (Object.keys(props).length === 0) { + return null; + } + + const isBuilder = (propertyGroups ?? []).some( + (group) => group.display === 'builder' || group.display === 'footer', + ); + + if (isBuilder) { + return ( + + ); + } + + const isSectioned = (propertyGroups ?? []).some( + (group) => group.display === 'section' || group.display === 'summary', + ); + + if (isSectioned) { + return ( + + ); + } + return ( - Object.keys(props).length > 0 && ( -
- {Object.entries(props).map(([propertyName]) => { - const dynamicInputModeToggled = - propertySettings?.[propertyName]?.type === - PropertyExecutionType.DYNAMIC; +
+ {Object.entries(props).map(([propertyName]) => { + const group = groupByPropName.get(propertyName); + if (group) { + if (renderedGroups.has(group.key)) { + return null; + } + renderedGroups.add(group.key); + const groupProperties = group.props.reduce< + Record + >((acc, key) => { + if (props[key]) { + acc[key] = props[key]; + } + return acc; + }, {}); return ( - 0 - ? `${prefixValue}.${propertyName}` - : propertyName - } - control={form.control} - render={({ field }) => - selectGenericFormComponentForProperty({ - field: { - ...field, - onChange: (value) => { - field.onChange(value); - onValueChange?.({ - value, - propertyName, - }); - }, - }, - propertyName, - inputName: - prefixValue.length > 0 - ? `${prefixValue}.${propertyName}` - : propertyName, - property: props[propertyName], - allowDynamicValues: !isNil(propertySettings), - markdownVariables: markdownVariables ?? {}, - useMentionTextInput: useMentionTextInput, - disabled: disabled ?? false, - dynamicInputModeToggled, - form, - dynamicPropsInfo, - propertySettings, - }) - } + ); - })} -
- ) + } + return renderField(propertyName); + })} +
); }, ); GenericPropertiesForm.displayName = 'GenericFormComponent'; +function buildGroupByPropName( + propertyGroups: PropertyGroup[] | undefined, +): Map { + const map = new Map(); + (propertyGroups ?? []) + .filter((group) => group.display === 'tabs') + .forEach((group) => { + group.props.forEach((propName) => map.set(propName, group)); + }); + return map; +} + type GenericPropertiesFormProps = { props: PiecePropertyMap | OAuth2Props | ArraySubProps; /**Use this to allow user toggling property execution type */ @@ -94,4 +178,6 @@ type GenericPropertiesFormProps = { onValueChange?: (val: { value: unknown; propertyName: string }) => void; /**for dynamic dropdowns and dynamic properties */ dynamicPropsInfo: SelectGenericFormComponentForPropertyParams['dynamicPropsInfo']; + /**groups multiple props into a single widget (e.g. tabbed recipients) */ + propertyGroups?: PropertyGroup[]; }; diff --git a/packages/web/src/app/builder/piece-properties/mention-chips-input.tsx b/packages/web/src/app/builder/piece-properties/mention-chips-input.tsx new file mode 100644 index 000000000000..a478a93ae999 --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/mention-chips-input.tsx @@ -0,0 +1,243 @@ +import { t } from 'i18next'; +import { X } from 'lucide-react'; +import React, { useRef, useState } from 'react'; + +import { cn } from '@/lib/utils'; + +import { TextInputWithMentions } from './text-input-with-mentions'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const isMention = (value: string): boolean => value.includes('{{'); + +const isValidChip = (value: string): boolean => + isMention(value) || EMAIL_REGEX.test(value.trim()); + +const splitPasted = (text: string): string[] => + text + .split(/[,;\n]/) + .map((part) => part.trim()) + .filter((part) => part.length > 0); + +const chipInnerClass = + 'border-0 bg-transparent p-0 min-h-0 h-auto leading-tight text-sm outline-none focus-visible:ring-0 focus-visible:ring-offset-0'; + +const composeInnerClass = + 'border-0 bg-transparent px-0 py-0.5 min-h-6 h-auto leading-tight text-sm outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'; + +// Render a data-selector mention as inline, baseline-aligned brand-colored +// text instead of a bordered badge, so it sits flush with any literal text +// typed after it (e.g. "{{step.email}}.com") and reads as a dynamic value. +const chipWrapperClass = cn( + 'w-auto', + '[&_.ProseMirror]:!cursor-pointer [&_.ProseMirror]:!opacity-100 [&_.ProseMirror]:pointer-events-none', + '[&_[data-type=mention]]:!inline [&_[data-type=mention]]:!align-baseline [&_[data-type=mention]]:!my-0 [&_[data-type=mention]]:!mx-0 [&_[data-type=mention]]:!border-0 [&_[data-type=mention]]:!bg-transparent [&_[data-type=mention]]:!px-0 [&_[data-type=mention]]:!py-0 [&_[data-type=mention]]:!rounded-none [&_[data-type=mention]]:!text-primary [&_[data-type=mention]]:!font-medium', + '[&_[data-type=mention]>*]:!hidden', +); + +function MentionChipsInput({ + value, + onChange, + disabled, + placeholder, +}: MentionChipsInputProps) { + const chips = (Array.isArray(value) ? value : []).filter( + (chip) => typeof chip === 'string' && chip.trim().length > 0, + ); + const [draft, setDraft] = useState(''); + const [composeInitial, setComposeInitial] = useState(''); + const [composeKey, setComposeKey] = useState(0); + const [autoFocusCompose, setAutoFocusCompose] = useState(false); + const rootRef = useRef(null); + + const resetCompose = () => { + setDraft(''); + setComposeInitial(''); + setComposeKey((key) => key + 1); + }; + + const commitChips = (newChips: string[], refocus = true) => { + const cleaned = newChips.map((chip) => chip.trim()).filter(Boolean); + if (cleaned.length === 0) { + return; + } + onChange([...chips, ...cleaned]); + setAutoFocusCompose(refocus); + resetCompose(); + }; + + const removeChip = (index: number) => { + onChange(chips.filter((_, i) => i !== index)); + }; + + const editChip = (index: number) => { + if (disabled) { + return; + } + const chip = chips[index]; + onChange(chips.filter((_, i) => i !== index)); + setDraft(chip); + setComposeInitial(chip); + setAutoFocusCompose(true); + setComposeKey((key) => key + 1); + }; + + const handleKeyDownCapture = (event: React.KeyboardEvent) => { + if (disabled) { + return; + } + if ( + event.key === 'Backspace' && + draft.trim().length === 0 && + chips.length > 0 + ) { + event.preventDefault(); + event.stopPropagation(); + removeChip(chips.length - 1); + return; + } + const shouldCommit = + event.key === 'Enter' || (event.key === ',' && !isMention(draft)); + if (!shouldCommit) { + return; + } + event.preventDefault(); + event.stopPropagation(); + commitChips([draft]); + }; + + const handlePasteCapture = (event: React.ClipboardEvent) => { + if (disabled) { + return; + } + const pasted = event.clipboardData.getData('text'); + const parts = splitPasted(pasted); + if (parts.length <= 1) { + return; + } + event.preventDefault(); + event.stopPropagation(); + commitChips([draft, ...parts]); + }; + + // Commit a pending draft when focus leaves the field entirely, so a typed or + // edited address isn't silently lost. Deferred so focus can settle: editing + // and committing re-mount the editor (re-focusing inside the widget), and we + // only treat focus that lands OUTSIDE the widget as "done editing". + const handleComposeBlur = () => { + if (disabled) { + return; + } + window.setTimeout(() => { + const root = rootRef.current; + if (!root || root.contains(document.activeElement)) { + return; + } + if (draft.trim().length === 0) { + return; + } + commitChips([draft], false); + }, 0); + }; + + return ( +
+ {chips.map((chip, index) => { + const invalid = !isValidChip(chip); + return ( +
editChip(index)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + editChip(index); + } + }} + className={cn( + 'inline-flex max-w-full items-start gap-1 rounded-md bg-muted py-0.5 pl-2.5 pr-1 text-sm outline-none transition-colors', + disabled + ? 'cursor-default' + : 'cursor-pointer hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring/50', + { + 'bg-destructive/10 text-destructive hover:bg-destructive/15': + invalid, + }, + )} + > + { + /* chips are read-only; click to edit, × to remove */ + }} + disabled + className={chipInnerClass} + wrapperClassName={chipWrapperClass} + /> + {!disabled && ( + + )} +
+ ); + })} + + {!disabled && ( +
+ 0 + ? t('Add another') + : placeholder ?? t('Type an email and press Enter') + } + /> +
+ )} +
+ ); +} + +MentionChipsInput.displayName = 'MentionChipsInput'; + +export { MentionChipsInput }; + +type MentionChipsInputProps = { + value: string[] | undefined; + onChange: (value: string[]) => void; + disabled?: boolean; + placeholder?: string; +}; diff --git a/packages/web/src/app/builder/piece-properties/number-stepper.tsx b/packages/web/src/app/builder/piece-properties/number-stepper.tsx new file mode 100644 index 000000000000..a3520d23bfe9 --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/number-stepper.tsx @@ -0,0 +1,79 @@ +import { t } from 'i18next'; +import { Minus, Plus } from 'lucide-react'; +import React from 'react'; + +const buttonClass = + 'flex size-8 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ring/50'; + +function NumberStepper({ + value, + onChange, + min, + max, + step, + disabled, +}: NumberStepperProps) { + const parsed = typeof value === 'number' ? value : Number(value); + const current = Number.isFinite(parsed) ? parsed : min ?? 0; + const stepBy = step ?? 1; + + const clamp = (next: number) => { + let result = next; + if (typeof min === 'number') result = Math.max(min, result); + if (typeof max === 'number') result = Math.min(max, result); + return result; + }; + + const atMin = typeof min === 'number' && current <= min; + const atMax = typeof max === 'number' && current >= max; + + return ( +
+ + + onChange( + event.target.value === '' ? undefined : Number(event.target.value), + ) + } + className="h-8 w-14 border-x border-input bg-transparent text-center text-sm font-medium tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + +
+ ); +} + +NumberStepper.displayName = 'NumberStepper'; + +export { NumberStepper }; + +type NumberStepperProps = { + value: unknown; + onChange: (value: number | undefined) => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; +}; diff --git a/packages/web/src/app/builder/piece-properties/properties-utils.tsx b/packages/web/src/app/builder/piece-properties/properties-utils.tsx index ba95d429207f..f0870786a067 100644 --- a/packages/web/src/app/builder/piece-properties/properties-utils.tsx +++ b/packages/web/src/app/builder/piece-properties/properties-utils.tsx @@ -14,16 +14,22 @@ import { DictionaryInput } from '@/components/custom/dictionary-input'; import { JsonEditor } from '@/components/custom/json-editor'; import { ApMarkdown } from '@/components/custom/markdown'; import { MultiSelectPieceProperty } from '@/components/custom/multi-select-piece-property'; +import { ReadMoreDescription } from '@/components/custom/read-more-description'; import { SearchableSelect } from '@/components/custom/searchable-select'; import { FormControl } from '@/components/ui/form'; +import { RequiredFieldAsterisk } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { ArrayPieceProperty } from './array-property'; import { AutoFormFieldWrapper } from './auto-form-field-wrapper'; import { BuilderJsonEditorWrapper } from './builder-json-wrapper'; import CustomProperty from './custom-property'; +import { DateRangeProperty } from './date-range-property'; import { DynamicDropdownPieceProperty } from './dynamic-dropdown-piece-property'; import { DynamicProperties } from './dynamic-piece-property'; +import { NumberStepper } from './number-stepper'; +import { RichTextProperty } from './rich-text-property'; +import { StaticDropdownCards } from './static-dropdown-cards'; import { TextInputWithMentions } from './text-input-with-mentions'; export const selectGenericFormComponentForProperty = ({ @@ -40,6 +46,7 @@ export const selectGenericFormComponentForProperty = ({ dynamicPropsInfo, propertySettings, hideLabel, + hideDescription, enableMarkdownForInputWithMention, }: SelectGenericFormComponentForPropertyParams) => { switch (property.type) { @@ -125,6 +132,16 @@ export const selectGenericFormComponentForProperty = ({ variant={property.variant} /> ); + case PropertyType.RICH_TEXT: + return ( + + ); case PropertyType.STATIC_DROPDOWN: return ( - + {property.display === 'cards' ? ( + + ) : ( + + )} ); case PropertyType.JSON: @@ -204,6 +233,7 @@ export const selectGenericFormComponentForProperty = ({ propertyName={propertyName} field={field} hideLabel={hideLabel} + hideDescription={hideDescription} disabled={disabled} allowDynamicValues={allowDynamicValues} dynamicInputModeToggled={dynamicInputModeToggled} @@ -229,11 +259,81 @@ export const selectGenericFormComponentForProperty = ({ )} ); + case PropertyType.NUMBER: + return property.display === 'stepper' ? ( +
+
+ + {property.displayName} + {property.required && } + + +
+ {property.description && ( + + )} +
+ ) : ( + + {useMentionTextInput ? ( + + ) : ( + + )} + + ); + case PropertyType.DATE_RANGE: + return ( + + + + ); case PropertyType.DATE_TIME: case PropertyType.SHORT_TEXT: case PropertyType.LONG_TEXT: case PropertyType.FILE: - case PropertyType.NUMBER: case PropertyType.SECRET_TEXT: return ( ) : ( @@ -259,6 +363,9 @@ export const selectGenericFormComponentForProperty = ({ value={field.value} onChange={field.onChange} disabled={disabled} + placeholder={ + 'placeholder' in property ? property.placeholder : undefined + } type={ property.type === PropertyType.SECRET_TEXT ? 'password' : 'text' } @@ -321,6 +428,7 @@ export const selectGenericFormComponentForProperty = ({ export type SelectGenericFormComponentForPropertyParams = { field: ControllerRenderProps, string>; hideLabel?: boolean; + hideDescription?: boolean; propertyName: string; inputName: string; property: PieceProperty; diff --git a/packages/web/src/app/builder/piece-properties/property-group-tabs.tsx b/packages/web/src/app/builder/piece-properties/property-group-tabs.tsx new file mode 100644 index 000000000000..190a3c53ee54 --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/property-group-tabs.tsx @@ -0,0 +1,316 @@ +import { + PieceProperty, + PropertyGroup, + PropertyType, +} from '@activepieces/pieces-framework'; +import { PropertyExecutionType, PropertySettings } from '@activepieces/shared'; +import { t } from 'i18next'; +import { Info, SquareFunction } from 'lucide-react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { FormItem, FormLabel } from '@/components/ui/form'; +import { RequiredFieldAsterisk } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +import { getValueForInputOnDynamicToggleChange } from './auto-form-field-wrapper'; +import { DynamicValueToggleButton } from './dynamic-value-toggle-button'; +import { MentionChipsInput } from './mention-chips-input'; +import { TextInputWithMentions } from './text-input-with-mentions'; + +function PropertyGroupTabs({ + group, + properties, + prefixValue, + propertySettings, + disabled, + renderField, +}: PropertyGroupTabsProps) { + const form = useFormContext(); + const tabKeys = group.props.filter((key) => !!properties[key]); + const [activeKey, setActiveKey] = useState(tabKeys[0]); + const safeActiveKey = tabKeys.includes(activeKey) ? activeKey : tabKeys[0]; + + const tabsWrapperRef = useRef(null); + const [indicator, setIndicator] = useState(null); + const tabsSignature = tabKeys.join('|'); + + useLayoutEffect(() => { + const wrapper = tabsWrapperRef.current; + if (!wrapper) return; + const list = wrapper.querySelector('[data-slot="tabs-list"]'); + const measure = () => { + const active = wrapper.querySelector( + '[data-slot="tabs-trigger"][data-state="active"]', + ); + if (!active) return; + setIndicator({ + left: active.offsetLeft, + top: active.offsetTop, + width: active.offsetWidth, + height: active.offsetHeight, + }); + }; + measure(); + const observer = new ResizeObserver(measure); + if (list) observer.observe(list); + wrapper + .querySelectorAll('[data-slot="tabs-trigger"]') + .forEach((trigger) => observer.observe(trigger)); + return () => observer.disconnect(); + }, [safeActiveKey, tabsSignature]); + + const inputNameFor = (key: string) => + prefixValue.length > 0 ? `${prefixValue}.${key}` : key; + + const watched = form.watch(tabKeys.map((key) => inputNameFor(key))); + const valueByKey = Object.fromEntries( + tabKeys.map((key, index) => [key, watched[index]]), + ); + + const allowDynamicValues = propertySettings != null; + + const isDynamicKey = (key: string): boolean => + propertySettings?.[key]?.type === PropertyExecutionType.DYNAMIC; + + const toggleDynamic = (key: string) => { + const inputName = inputNameFor(key); + const nextMode = isDynamicKey(key) + ? PropertyExecutionType.MANUAL + : PropertyExecutionType.DYNAMIC; + form.setValue( + `settings.propertySettings.${key}`, + { + ...form.getValues().settings?.propertySettings?.[key], + type: nextMode, + }, + { shouldValidate: true }, + ); + form.setValue( + inputName, + getValueForInputOnDynamicToggleChange( + properties[key], + nextMode, + form.getValues(inputName), + ), + { shouldValidate: true }, + ); + }; + + if (tabKeys.length === 0) { + return null; + } + + const anyRequired = tabKeys.some((key) => properties[key].required); + const activeDynamic = isDynamicKey(safeActiveKey); + const activeFieldState = form.getFieldState( + inputNameFor(safeActiveKey), + form.formState, + ); + const showActiveError = + !!activeFieldState.error && activeFieldState.isTouched; + const activeErrorMessage = activeFieldState.error?.message + ? t(String(activeFieldState.error.message)) + : null; + + return ( + + +
+ {group.label} + {anyRequired && } +
+ {group.description && ( + + + + + + {group.description} + + + )} + + + + {allowDynamicValues && ( + toggleDynamic(safeActiveKey)} + disabled={disabled} + /> + )} +
+ + +
+ + {indicator && ( + + )} + {tabKeys.map((key) => { + const property = properties[key]; + const fieldState = form.getFieldState( + inputNameFor(key), + form.formState, + ); + const hasError = !!fieldState.error && fieldState.isTouched; + const dynamic = isDynamicKey(key); + const count = countItems(valueByKey[key]); + const active = key === safeActiveKey; + return ( + + + {property.displayName} + + {property.required && } + {dynamic ? ( + + ) : count > 0 ? ( + + {count} + + ) : null} + {hasError && ( + + )} + + ); + })} + + + {tabKeys.map((key) => { + const inputName = inputNameFor(key); + const dynamic = isDynamicKey(key); + const tabProperty = properties[key]; + const isPlainArray = + tabProperty.type === PropertyType.ARRAY && + !('properties' in tabProperty && tabProperty.properties); + return ( + +
+ {dynamic ? ( + + form.setValue(inputName, newValue, { + shouldValidate: true, + }) + } + /> + ) : isPlainArray ? ( + + form.setValue(inputName, newValue, { + shouldValidate: true, + }) + } + /> + ) : ( + renderField(key, { hideLabel: true }) + )} +
+
+ ); + })} +
+
+ +
+ {showActiveError && activeErrorMessage && ( +

+ {activeErrorMessage} +

+ )} +
+
+ ); +} + +PropertyGroupTabs.displayName = 'PropertyGroupTabs'; + +function countItems(value: unknown): number { + return Array.isArray(value) + ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) + .length + : 0; +} + +export { PropertyGroupTabs }; + +type PropertyGroupTabsProps = { + group: PropertyGroup; + properties: Record; + prefixValue: string; + propertySettings: Record | null; + disabled: boolean; + renderField: ( + propertyName: string, + options?: { hideLabel?: boolean; hideDescription?: boolean }, + ) => React.ReactNode; +}; + +type IndicatorRect = { + left: number; + top: number; + width: number; + height: number; +}; diff --git a/packages/web/src/app/builder/piece-properties/property-icons.ts b/packages/web/src/app/builder/piece-properties/property-icons.ts new file mode 100644 index 000000000000..4387b250ea81 --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/property-icons.ts @@ -0,0 +1,48 @@ +import { + AlignLeft, + Calendar, + Code2, + FileText, + Filter, + Hash, + Inbox, + LucideIcon, + Paperclip, + Reply, + ReplyAll, + Send, + SlidersHorizontal, + SquareDashed, + Tag, + Trash2, + Type, + User, + Users, +} from 'lucide-react'; + +function getPropertyIcon(name: string | undefined): LucideIcon | undefined { + return name ? ICON_MAP[name] : undefined; +} + +const ICON_MAP: Record = { + text: AlignLeft, + code: Code2, + markdown: Hash, + reply: Reply, + 'reply-all': ReplyAll, + users: Users, + user: User, + send: Send, + type: Type, + file: FileText, + paperclip: Paperclip, + tag: Tag, + inbox: Inbox, + calendar: Calendar, + trash: Trash2, + filter: Filter, + sliders: SlidersHorizontal, + blank: SquareDashed, +}; + +export const propertyIcons = { get: getPropertyIcon }; diff --git a/packages/web/src/app/builder/piece-properties/rich-text-property.tsx b/packages/web/src/app/builder/piece-properties/rich-text-property.tsx new file mode 100644 index 000000000000..e2acf039ffef --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/rich-text-property.tsx @@ -0,0 +1,113 @@ +import { RichTextProperty as RichTextPropertySchema } from '@activepieces/pieces-framework'; +import { t } from 'i18next'; +import React, { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { ReadMoreDescription } from '@/components/custom/read-more-description'; +import { FormItem, FormLabel } from '@/components/ui/form'; +import { inputClass } from '@/components/ui/input'; +import { RequiredFieldAsterisk } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; + +import { TextInputWithMentions } from './text-input-with-mentions'; + +function resolveMode(value: unknown): RichTextMode { + if (typeof value !== 'string') { + return 'plain'; + } + const normalized = value.toLowerCase(); + if (normalized === 'html') { + return 'html'; + } + if (normalized === 'markdown' || normalized === 'md') { + // Markdown currently shares the plain-text editor (raw markdown is typed as + // text) — there is no dedicated markdown WYSIWYG yet. Kept as its own mode so + // format dropdowns can label it and a real editor can be wired in later. + return 'markdown'; + } + return 'plain'; +} + +function countCharacters(value: unknown, mode: RichTextMode): number { + if (typeof value !== 'string') { + return 0; + } + if (mode === 'html') { + const parsed = new DOMParser().parseFromString(value, 'text/html'); + return parsed.body.textContent?.length ?? 0; + } + return value.length; +} + +const editorClass = cn( + inputClass, + 'h-[unset] block min-h-20 max-h-72 overflow-y-auto py-2', +); + +function RichTextProperty({ + property, + inputName, + value, + onChange, + disabled, +}: RichTextPropertyProps) { + const form = useFormContext(); + + const formatInputName = useMemo(() => { + if (!property.formatProperty) { + return undefined; + } + const lastDotIndex = inputName.lastIndexOf('.'); + const prefix = + lastDotIndex >= 0 ? inputName.slice(0, lastDotIndex + 1) : ''; + return `${prefix}${property.formatProperty}`; + }, [inputName, property.formatProperty]); + + const watchedFormat = form.watch( + formatInputName ?? '__rich_text_no_format__', + ); + const mode = formatInputName ? resolveMode(watchedFormat) : 'plain'; + const charCount = countCharacters(value, mode); + + return ( + + +
+ {property.displayName} + {property.required && } +
+ + + {t('{count, plural, other {# chars}}', { count: charCount })} + +
+ + + + {property.description && ( + + )} +
+ ); +} + +RichTextProperty.displayName = 'RichTextProperty'; + +export { RichTextProperty }; + +type RichTextMode = 'plain' | 'html' | 'markdown'; + +type RichTextPropertyProps = { + property: RichTextPropertySchema; + inputName: string; + value: string; + onChange: (value: string) => void; + disabled: boolean; +}; diff --git a/packages/web/src/app/builder/piece-properties/static-dropdown-cards.tsx b/packages/web/src/app/builder/piece-properties/static-dropdown-cards.tsx new file mode 100644 index 000000000000..3490da9f3f44 --- /dev/null +++ b/packages/web/src/app/builder/piece-properties/static-dropdown-cards.tsx @@ -0,0 +1,73 @@ +import { DropdownOption } from '@activepieces/pieces-framework'; +import React from 'react'; + +import { cn } from '@/lib/utils'; + +import { propertyIcons } from './property-icons'; + +function StaticDropdownCards({ + options, + value, + onChange, + disabled, +}: StaticDropdownCardsProps) { + return ( +
+ {options.map((option, index) => { + const selected = value === option.value; + const Icon = propertyIcons.get(option.icon); + return ( + + ); + })} +
+ ); +} + +StaticDropdownCards.displayName = 'StaticDropdownCards'; + +export { StaticDropdownCards }; + +type StaticDropdownCardsProps = { + options: DropdownOption[]; + value: unknown; + onChange: (value: unknown) => void; + disabled?: boolean; +}; diff --git a/packages/web/src/app/builder/piece-properties/text-input-with-mentions/index.tsx b/packages/web/src/app/builder/piece-properties/text-input-with-mentions/index.tsx index 0e79fbcd363b..b84e48913138 100644 --- a/packages/web/src/app/builder/piece-properties/text-input-with-mentions/index.tsx +++ b/packages/web/src/app/builder/piece-properties/text-input-with-mentions/index.tsx @@ -2,13 +2,18 @@ import { TiptapEditor } from './tiptap-editor'; type TextInputWithMentionsProps = { className?: string; + wrapperClassName?: string; initialValue?: unknown; onChange: (value: string) => void; placeholder?: string; disabled?: boolean; enableMarkdown?: boolean; + autoFocus?: boolean; + outputFormat?: 'text' | 'html'; }; export const TextInputWithMentions = (props: TextInputWithMentionsProps) => { return ; }; + +export type { TextInputWithMentionsProps }; diff --git a/packages/web/src/app/builder/piece-properties/text-input-with-mentions/tiptap-editor.tsx b/packages/web/src/app/builder/piece-properties/text-input-with-mentions/tiptap-editor.tsx index 3d6bc072427b..26404f7a1b2f 100644 --- a/packages/web/src/app/builder/piece-properties/text-input-with-mentions/tiptap-editor.tsx +++ b/packages/web/src/app/builder/piece-properties/text-input-with-mentions/tiptap-editor.tsx @@ -18,7 +18,15 @@ import { TextSelection } from '@tiptap/pm/state'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { t } from 'i18next'; -import { ChevronRight, XCircle } from 'lucide-react'; +import { + Bold, + ChevronRight, + Italic, + Link as LinkIcon, + List, + Underline, + XCircle, +} from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CopyButton } from '@/components/custom/clipboard/copy-button'; @@ -58,11 +66,19 @@ import { textMentionUtils } from './text-input-utils'; type TiptapEditorProps = { className?: string; + wrapperClassName?: string; initialValue?: unknown; onChange: (value: string) => void; placeholder?: string; disabled?: boolean; enableMarkdown?: boolean; + autoFocus?: boolean; + /** + * 'html' turns the editor into a rich-text WYSIWYG (StarterKit core set) whose + * value is serialized to/from HTML with mentions kept as {{...}} tokens. + * Defaults to 'text' (plain value via the mention text converter). + */ + outputFormat?: 'text' | 'html'; }; const INITIAL_SLASH_STATE: SlashCommandState = { @@ -74,10 +90,12 @@ const INITIAL_SLASH_STATE: SlashCommandState = { function getExtensions({ enableMarkdown, + isHtml, placeholder, }: { placeholder?: string; enableMarkdown?: boolean; + isHtml?: boolean; }): Extensions { const baseExtensions = [ Placeholder.configure({ @@ -98,6 +116,20 @@ function getExtensions({ FunctionSlashExtension, ]; + if (isHtml) { + return [ + ...baseExtensions, + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + blockquote: false, + codeBlock: false, + code: false, + horizontalRule: false, + link: { openOnClick: false }, + }), + ] as Extensions; + } + if (enableMarkdown) { return [ ...baseExtensions, @@ -115,14 +147,94 @@ function getExtensions({ ] as Extensions; } +function applyLink(editor: import('@tiptap/react').Editor): void { + const previousUrl = (editor.getAttributes('link')?.['href'] as string) ?? ''; + const url = window.prompt(t('Link URL'), previousUrl || 'https://'); + if (url === null) { + return; + } + if (url.trim().length === 0) { + editor.chain().focus().extendMarkRange('link').unsetLink().run(); + return; + } + editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); +} + +function RichTextToolbar({ + editor, +}: { + editor: import('@tiptap/react').Editor; +}) { + const buttons = [ + { + key: 'bold', + label: t('Bold'), + icon: Bold, + run: () => editor.chain().focus().toggleBold().run(), + }, + { + key: 'italic', + label: t('Italic'), + icon: Italic, + run: () => editor.chain().focus().toggleItalic().run(), + }, + { + key: 'underline', + label: t('Underline'), + icon: Underline, + run: () => editor.chain().focus().toggleUnderline().run(), + }, + { + key: 'bulletList', + label: t('Bullet list'), + icon: List, + run: () => editor.chain().focus().toggleBulletList().run(), + }, + { + key: 'link', + label: t('Link'), + icon: LinkIcon, + run: () => applyLink(editor), + }, + ]; + return ( +
+ {buttons.map(({ key, label, icon: Icon, run }) => { + const active = editor.isActive(key); + return ( + + ); + })} +
+ ); +} + export const TiptapEditor = ({ className, + wrapperClassName, initialValue, onChange, disabled, placeholder, enableMarkdown, + autoFocus, + outputFormat, }: TiptapEditorProps) => { + const isHtml = outputFormat === 'html'; const { embedState } = useEmbedding(); const steps = useBuilderStateContext((state) => flowStructureUtil.getAllSteps(state.flowVersion.trigger), @@ -202,16 +314,23 @@ export const TiptapEditor = ({ const editor = useEditor({ editable: !disabled, - extensions: getExtensions({ placeholder, enableMarkdown }), - content: { - type: 'doc', - content: textMentionUtils.convertTextToTipTapJsonContent( - convertToText(initialValue), - steps, - stepsMetadata, - variableByName, - ), - }, + autofocus: autoFocus ? 'end' : false, + extensions: getExtensions({ + placeholder, + enableMarkdown, + isHtml, + }), + content: isHtml + ? convertToText(initialValue) + : { + type: 'doc', + content: textMentionUtils.convertTextToTipTapJsonContent( + convertToText(initialValue), + steps, + stepsMetadata, + variableByName, + ), + }, editorProps: { handleKeyDown: (view, event) => { if (event.key === 'Backspace') { @@ -337,13 +456,18 @@ export const TiptapEditor = ({ }, attributes: { class: cn( - className ?? cn(inputClass, 'py-2 h-[unset] block min-h-9 '), + isHtml + ? 'block min-h-20 max-h-72 overflow-y-auto px-2.5 py-2 outline-none' + : className ?? cn(inputClass, 'py-2 h-[unset] block min-h-9 '), textMentionUtils.inputWithMentionsCssClass, { 'cursor-not-allowed opacity-50': disabled }, ), }, }, onCreate: ({ editor: e }) => { + if (isHtml) { + convertMentionTokensToNodes(e, steps, stepsMetadata, variableByName); + } const editorContent = e.getJSON(); setHasFunctions(docHasFunctions(e)); setTypeErrors(collectTypeErrors(editorContent)); @@ -354,8 +478,9 @@ export const TiptapEditor = ({ }, onUpdate: ({ editor: e }) => { const editorContent = e.getJSON(); - const textResult = - textMentionUtils.convertTiptapJsonToText(editorContent); + const textResult = isHtml + ? serializeHtmlWithMentions(e) + : textMentionUtils.convertTiptapJsonToText(editorContent); if (onChange) onChange(textResult); const nowHasFunctions = docHasFunctions(e); setHasFunctions(nowHasFunctions); @@ -435,7 +560,16 @@ export const TiptapEditor = ({ const showPreview = isFocused && hasFunctions; return ( -
+
+ {isHtml && !disabled && } {showPreview && ( @@ -557,6 +691,88 @@ function convertToText(value: unknown): string { return JSON.stringify(value); } +type CreateMentionArgs = Parameters< + typeof textMentionUtils.createMentionNodeFromText +>; + +// HTML output: serialize the doc, then collapse mention badges back to their +// {{...}} server token so the engine resolves them (the editor shows badges, +// the stored value stays a portable HTML string with tokens). +function serializeHtmlWithMentions( + editor: import('@tiptap/react').Editor, +): string { + if (editor.isEmpty) return ''; + const html = editor.getHTML(); + const parsed = new DOMParser().parseFromString(html, 'text/html'); + parsed.querySelectorAll('[data-type="mention"]').forEach((element) => { + let serverValue = element.getAttribute('serverValue') ?? ''; + if (serverValue.length === 0) { + const labelAttr = element.getAttribute('data-label'); + if (labelAttr) { + try { + serverValue = JSON.parse(labelAttr).serverValue ?? ''; + } catch { + serverValue = ''; + } + } + } + element.replaceWith(parsed.createTextNode(serverValue)); + }); + return parsed.body.innerHTML; +} + +// HTML input: TipTap parses the saved HTML to a doc with {{...}} as plain text; +// convert each token back into a mention badge so it renders like everywhere else. +function convertMentionTokensToNodes( + editor: import('@tiptap/react').Editor, + steps: CreateMentionArgs[1], + stepsMetadata: CreateMentionArgs[2], + variableByName: CreateMentionArgs[3], +): void { + const matches: { from: number; to: number; token: string }[] = []; + editor.state.doc.descendants((node, pos) => { + if (!node.isText || !node.text) { + return; + } + const tokenRegex = /\{\{[^}]+\}\}/g; + let match: RegExpExecArray | null; + while ((match = tokenRegex.exec(node.text)) !== null) { + matches.push({ + from: pos + match.index, + to: pos + match.index + match[0].length, + token: match[0], + }); + } + }); + if (matches.length === 0) { + return; + } + let tr = editor.state.tr; + // Replace largest position first so earlier positions stay valid. + for (let index = matches.length - 1; index >= 0; index--) { + const { from, to, token } = matches[index]; + const mentionNode = textMentionUtils.createMentionNodeFromText( + token, + steps, + stepsMetadata, + variableByName, + ); + if (isNil(mentionNode)) { + continue; + } + try { + tr = tr.replaceWith( + from, + to, + editor.state.schema.nodeFromJSON(mentionNode), + ); + } catch { + // Leave the raw token in place if it can't be turned into a node. + } + } + editor.view.dispatch(tr); +} + function applyTypeErrors( doc: import('@tiptap/react').JSONContent, wrapperEl: HTMLElement | null, diff --git a/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx b/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx index 812273ecc549..da377a841086 100644 --- a/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx +++ b/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx @@ -1,6 +1,8 @@ import { FlowActionType, FlowTriggerType } from '@activepieces/shared'; +import { t } from 'i18next'; import { CardListItem } from '@/components/custom/card-list'; +import { Badge } from '@/components/ui/badge'; import { PieceIcon, PieceSelectorItem, @@ -23,11 +25,13 @@ const getPieceSelectorItemInfo = (item: PieceSelectorItem) => { return { displayName: item.actionOrTrigger.displayName, description: item.actionOrTrigger.description, + classification: item.actionOrTrigger.classification, }; } return { displayName: item.displayName, description: item.description, + classification: undefined, }; }; @@ -63,12 +67,24 @@ const GenericActionOrTriggerItem = ({ />
-
- {pieceSelectorItemInfo.displayName} +
+
+ {pieceSelectorItemInfo.displayName} +
+ {pieceSelectorItemInfo.classification && ( + + {pieceSelectorItemInfo.classification === 'READ' + ? t('Read') + : t('Write')} + + )}
{ const [isEditingStepOrBranchName, setIsEditingStepOrBranchName] = useState(false); - const showActionErrorHandlingForm = - [FlowActionType.CODE, FlowActionType.PIECE].includes( - modifiedStep.type as FlowActionType, - ) && !isNil(stepMetadata); - const runAgentStep = modifiedStep.settings.pieceName === '@activepieces/piece-ai' && modifiedStep.settings.actionName === 'run_agent'; + const showActionErrorHandlingForm = + !isNil(stepMetadata) && + (modifiedStep.type === FlowActionType.CODE || + (modifiedStep.type === FlowActionType.PIECE && runAgentStep)); + useEffect(() => { //RHF doesn't automatically trigger validation when the form is rendered, so we need to trigger it manually form.trigger(); diff --git a/packages/web/src/app/builder/step-settings/piece-settings/index.tsx b/packages/web/src/app/builder/step-settings/piece-settings/index.tsx index 7e3e063d0c8f..6cf05e399029 100644 --- a/packages/web/src/app/builder/step-settings/piece-settings/index.tsx +++ b/packages/web/src/app/builder/step-settings/piece-settings/index.tsx @@ -1,4 +1,9 @@ import { isNil } from '@activepieces/core-utils'; +import { + PieceProperty, + PiecePropertyMap, + PropertyGroup, +} from '@activepieces/pieces-framework'; import { ApFlagId, PieceAction, @@ -11,25 +16,15 @@ import React from 'react'; import { Skeleton } from '@/components/ui/skeleton'; import { flagsHooks } from '@/hooks/flags-hooks'; +import { ActionErrorHandlingForm } from '../../piece-properties/action-error-handling'; +import { AdvancedSection } from '../../piece-properties/advanced-section'; +import { filterPropertyUtils } from '../../piece-properties/filter-property-utils'; import { GenericPropertiesForm } from '../../piece-properties/generic-properties-form'; import { PieceNotAvailableAlert } from '../piece-not-available-alert'; import { useStepSettingsContext } from '../step-settings-context'; import { ConnectionSelect } from './connection-select'; -type PieceSettingsProps = { - step: PieceAction | PieceTrigger; - flowId: string; - readonly: boolean; -}; - -const removeAuthFromProps = ( - props: Record, -): Record => { - const { auth: _, ...rest } = props; - return rest; -}; - const PieceSettings = React.memo((props: PieceSettingsProps) => { const { pieceModel, @@ -80,6 +75,7 @@ const PieceSettings = React.memo((props: PieceSettingsProps) => { !isNil(selectedAction) && (selectedAction.requireAuth ?? true); const showAuthForTrigger = !isNil(selectedTrigger) && (selectedTrigger.requireAuth ?? true); + if (!pieceModel && pieceModelNotFound) { return ( { ); } + const actionForcedEssentialNames = collectForcedEssentialNames( + selectedAction?.propertyGroups, + actionPropsWithoutAuth, + ); + const triggerForcedEssentialNames = collectForcedEssentialNames( + selectedTrigger?.propertyGroups, + triggerPropsWithoutAuth, + ); + + const actionSplit = splitProps({ + props: actionPropsWithoutAuth, + forcedEssentialNames: actionForcedEssentialNames, + isFilterBuilder: hasFilterBuilderLayout(selectedAction?.propertyGroups), + }); + const triggerSplit = splitProps({ + props: triggerPropsWithoutAuth, + forcedEssentialNames: triggerForcedEssentialNames, + isFilterBuilder: hasFilterBuilderLayout(selectedTrigger?.propertyGroups), + }); + + const hideContinueOnFailure = + selectedAction?.errorHandlingOptions?.continueOnFailure?.hide ?? false; + const hideRetryOnFailure = + selectedAction?.errorHandlingOptions?.retryOnFailure?.hide ?? false; + const errorHandlingItemsCount = + selectedAction !== undefined + ? (hideContinueOnFailure ? 0 : 1) + (hideRetryOnFailure ? 0 : 1) + : 0; + + const actionAdvancedCount = Object.keys(actionSplit.advanced).length; + const triggerAdvancedCount = Object.keys(triggerSplit.advanced).length; + + const actionAdvancedWatchPaths = Object.keys(actionSplit.advanced).map( + (name) => `settings.input.${name}`, + ); + const triggerAdvancedWatchPaths = Object.keys(triggerSplit.advanced).map( + (name) => `settings.input.${name}`, + ); + return (
{!pieceModel && ( @@ -115,42 +150,99 @@ const PieceSettings = React.memo((props: PieceSettingsProps) => { > )} {selectedAction && ( - + <> + + + + + {errorHandlingItemsCount > 0 && ( + + )} + )} {selectedTrigger && ( - + <> + + + + + )} )} @@ -160,3 +252,90 @@ const PieceSettings = React.memo((props: PieceSettingsProps) => { PieceSettings.displayName = 'PieceSettings'; export { PieceSettings }; + +function removeAuthFromProps( + props: Record, +): Record { + const { auth: _, ...rest } = props; + return rest; +} + +function isAdvancedProp(property: PieceProperty): boolean { + if ('advanced' in property && property.advanced !== undefined) { + return property.advanced; + } + return false; +} + +function hasSectionLayout( + propertyGroups: PropertyGroup[] | undefined, +): boolean { + return (propertyGroups ?? []).some( + (group) => group.display === 'section' || group.display === 'summary', + ); +} + +function hasFilterBuilderLayout( + propertyGroups: PropertyGroup[] | undefined, +): boolean { + return (propertyGroups ?? []).some( + (group) => group.display === 'builder' || group.display === 'footer', + ); +} + +/** + * Props that must stay in the essential form regardless of their required flag: + * members of tabbed/sectioned groups, plus checkbox reveal targets in a section + * layout (they render inline within their toggle card, never in Advanced). + */ +function collectForcedEssentialNames( + propertyGroups: PropertyGroup[] | undefined, + props: PiecePropertyMap, +): Set { + const names = new Set(); + (propertyGroups ?? []) + .filter((group) => group.display === 'tabs' || group.display === 'section') + .forEach((group) => group.props.forEach((name) => names.add(name))); + if (hasSectionLayout(propertyGroups)) { + filterPropertyUtils + .collectRevealedNames(props) + .forEach((name) => names.add(name)); + } + return names; +} + +function splitProps({ + props, + forcedEssentialNames, + isFilterBuilder, +}: { + props: PiecePropertyMap; + forcedEssentialNames: Set; + isFilterBuilder: boolean; +}): { + essential: PiecePropertyMap; + advanced: PiecePropertyMap; +} { + if (isFilterBuilder) { + return { essential: props, advanced: {} as PiecePropertyMap }; + } + const essential: Record = {}; + const advanced: Record = {}; + for (const [name, property] of Object.entries(props)) { + if (!forcedEssentialNames.has(name) && isAdvancedProp(property)) { + advanced[name] = property; + } else { + essential[name] = property; + } + } + return { + essential: essential as PiecePropertyMap, + advanced: advanced as PiecePropertyMap, + }; +} + +type PieceSettingsProps = { + step: PieceAction | PieceTrigger; + flowId: string; + readonly: boolean; +}; diff --git a/packages/web/src/components/ui/badge.tsx b/packages/web/src/components/ui/badge.tsx index 8de26b237dd7..69f710f8815a 100644 --- a/packages/web/src/components/ui/badge.tsx +++ b/packages/web/src/components/ui/badge.tsx @@ -16,6 +16,7 @@ const badgeVariants = cva( 'bg-destructive-50 text-destructive-700 border-destructive-600 dark:bg-destructive-950 dark:text-destructive-300 dark:border-destructive-400', success: 'bg-success-50 text-success-700 border-success-600 dark:bg-success-950 dark:text-success-300 dark:border-success-400', + info: 'bg-blue-50 text-blue-700 border-blue-600 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-400', accent: 'bg-accent text-accent-foreground border-border', outline: 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', diff --git a/packages/web/src/components/ui/switch.tsx b/packages/web/src/components/ui/switch.tsx index 71daa6ff1694..5ea90fc95e86 100644 --- a/packages/web/src/components/ui/switch.tsx +++ b/packages/web/src/components/ui/switch.tsx @@ -48,7 +48,8 @@ function Switch({ , string> = { sm: 'h-4 w-8', - default: 'h-5 w-10', + default: 'h-[18px] w-8', lg: 'h-7 w-14', xl: 'h-8 w-16', }; const THUMB_SIZE_CLASSES: Record, string> = { sm: 'h-3 w-3 data-[state=checked]:translate-x-4', - default: 'h-4 w-4 data-[state=checked]:translate-x-5', + default: 'h-3.5 w-3.5 data-[state=checked]:translate-x-3.5', lg: 'h-5 w-5 data-[state=checked]:translate-x-6', xl: 'h-6 w-6 data-[state=checked]:translate-x-7', }; diff --git a/packages/web/src/features/pieces/utils/form-utils.tsx b/packages/web/src/features/pieces/utils/form-utils.tsx index e7dd78d123f8..e1d232436d33 100644 --- a/packages/web/src/features/pieces/utils/form-utils.tsx +++ b/packages/web/src/features/pieces/utils/form-utils.tsx @@ -142,8 +142,12 @@ function getDefaultPropertyValue({ } return property.defaultValue ?? {}; } + case PropertyType.DATE_RANGE: { + return property.defaultValue ?? { preset: 'any_time' }; + } case PropertyType.SHORT_TEXT: case PropertyType.LONG_TEXT: + case PropertyType.RICH_TEXT: case PropertyType.MARKDOWN: case PropertyType.FILE: case PropertyType.DATE_TIME: diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index 74277a965888..5b0f43bec6e9 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -96,4 +96,18 @@ span.ap-fn-badge.ap-fn-deprecated { line-height: 1.5rem; caret-color: currentColor; } + +/* ── Links inside the rich-text (HTML) editor ──────────────────────────── */ +/* Styled via CSS (not an element class) so the serialized HTML stays a clean + for the outgoing email. */ +.ap-text-with-mentions a { + color: #2563eb; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +.dark .ap-text-with-mentions a { + color: #60a5fa; +} .formula-editor .cm-line { padding: 0; } \ No newline at end of file diff --git a/packages/web/test/app/builder/piece-properties/filter-property-utils.test.ts b/packages/web/test/app/builder/piece-properties/filter-property-utils.test.ts new file mode 100644 index 000000000000..9d37c847af6e --- /dev/null +++ b/packages/web/test/app/builder/piece-properties/filter-property-utils.test.ts @@ -0,0 +1,94 @@ +import { Property } from '@activepieces/pieces-framework'; +import { describe, expect, it } from 'vitest'; + +import { filterPropertyUtils } from '@/app/builder/piece-properties/filter-property-utils'; + +const { isFilterActive, emptyValueFor, chipLabel } = filterPropertyUtils; + +const shortText = Property.ShortText({ displayName: 'Text', required: false }); +const checkbox = Property.Checkbox({ + displayName: 'Flag', + required: false, + defaultValue: false, +}); +const number = Property.Number({ + displayName: 'Count', + required: false, + defaultValue: 10, +}); +const dateRange = Property.DateRange({ displayName: 'Date', required: false }); +const dropdown = Property.StaticDropdown({ + displayName: 'Pick', + required: false, + options: { options: [] }, +}); + +describe('filterPropertyUtils.isFilterActive', () => { + it('treats empty / whitespace text as inactive and non-empty text as active', () => { + expect(isFilterActive(shortText, '')).toBe(false); + expect(isFilterActive(shortText, ' ')).toBe(false); + expect(isFilterActive(shortText, undefined)).toBe(false); + expect(isFilterActive(shortText, 'hello')).toBe(true); + }); + + it('treats a checkbox as active only when true', () => { + expect(isFilterActive(checkbox, false)).toBe(false); + expect(isFilterActive(checkbox, true)).toBe(true); + }); + + it('treats a number as active only when it differs from its default', () => { + expect(isFilterActive(number, 10)).toBe(false); + expect(isFilterActive(number, 11)).toBe(true); + }); + + it('treats a date range as active only for a real preset', () => { + expect(isFilterActive(dateRange, { preset: 'any_time' })).toBe(false); + expect(isFilterActive(dateRange, {})).toBe(false); + expect(isFilterActive(dateRange, { preset: 'last_7_days' })).toBe(true); + }); + + it('treats a custom date range as active only once a bound is set', () => { + expect(isFilterActive(dateRange, { preset: 'custom' })).toBe(false); + expect(isFilterActive(dateRange, { preset: 'custom', after: '2024-01-01' })).toBe(true); + expect(isFilterActive(dateRange, { preset: 'custom', before: '2024-12-31' })).toBe(true); + }); + + it('treats a null dropdown as inactive and a selected value as active', () => { + expect(isFilterActive(dropdown, null)).toBe(false); + expect(isFilterActive(dropdown, 'primary')).toBe(true); + }); +}); + +describe('filterPropertyUtils.emptyValueFor', () => { + it('returns the inactive default for each type', () => { + expect(emptyValueFor(shortText)).toBe(''); + expect(emptyValueFor(checkbox)).toBe(false); + expect(emptyValueFor(number)).toBe(10); + expect(emptyValueFor(dropdown)).toBeNull(); + expect(emptyValueFor(dateRange)).toEqual({ preset: 'any_time' }); + }); + + it('produces a value that isFilterActive reports as inactive', () => { + for (const property of [shortText, checkbox, number, dropdown, dateRange]) { + expect(isFilterActive(property, emptyValueFor(property))).toBe(false); + } + }); +}); + +describe('filterPropertyUtils.chipLabel', () => { + it('shows both selected bounds for a custom date range', () => { + const label = chipLabel(dateRange, { + preset: 'custom', + after: '2023-01-01', + before: '2024-12-31', + }); + expect(label).toContain('2023'); + expect(label).toContain('2024'); + }); + + it('shows a single bound when only one end of a custom range is set', () => { + expect( + chipLabel(dateRange, { preset: 'custom', after: '2024-06-15' }), + ).toContain('2024'); + }); +}); From 9ce7bb4e1b3d1016437e4a25cd0f7fd1cee9c646 Mon Sep 17 00:00:00 2001 From: Daniel Poon <17039704+danielpoonwj@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:53:59 +0800 Subject: [PATCH 2/2] feat(google-drive): Add new action Export Folder as Zip (#14773) Co-authored-by: odai thalji --- bun.lock | 5 +- .../community/google-drive/package.json | 3 +- .../community/google-drive/src/index.ts | 2 + .../lib/action/drive-export-folder-as-zip.ts | 709 ++++++++++++++++++ .../google-drive/src/lib/common/index.ts | 136 ++-- 5 files changed, 793 insertions(+), 62 deletions(-) create mode 100644 packages/pieces/community/google-drive/src/lib/action/drive-export-folder-as-zip.ts diff --git a/bun.lock b/bun.lock index a9638c11291b..2932d3a1b32e 100644 --- a/bun.lock +++ b/bun.lock @@ -3789,13 +3789,14 @@ }, "packages/pieces/community/google-drive": { "name": "@activepieces/piece-google-drive", - "version": "0.8.3", + "version": "0.8.4", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", "@googleapis/drive": "20.2.0", + "@zip.js/zip.js": "2.8.29", "dayjs": "1.11.9", "form-data": "4.0.6", "google-auth-library": "10.5.0", @@ -18135,6 +18136,8 @@ "@activepieces/piece-google-docs/google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + "@activepieces/piece-google-drive/@zip.js/zip.js": ["@zip.js/zip.js@2.8.29", "", {}, "sha512-0PBg2pUXrDrODlWjyTDoO7QlfPHaoxlpE9Lg4RTJk5nsWWFGOnhWo9BYxMaLfBAqeP/3TKj7P2zLWDvysTYbJA=="], + "@activepieces/piece-google-drive/google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], "@activepieces/piece-google-forms/google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], diff --git a/packages/pieces/community/google-drive/package.json b/packages/pieces/community/google-drive/package.json index 8e52b4534444..145845e22e95 100644 --- a/packages/pieces/community/google-drive/package.json +++ b/packages/pieces/community/google-drive/package.json @@ -1,12 +1,13 @@ { "name": "@activepieces/piece-google-drive", - "version": "0.8.3", + "version": "0.8.4", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", "@googleapis/drive": "20.2.0", + "@zip.js/zip.js": "2.8.29", "dayjs": "1.11.9", "form-data": "4.0.6", "google-auth-library": "10.5.0", diff --git a/packages/pieces/community/google-drive/src/index.ts b/packages/pieces/community/google-drive/src/index.ts index b6f5054e8129..93d942ddf29c 100644 --- a/packages/pieces/community/google-drive/src/index.ts +++ b/packages/pieces/community/google-drive/src/index.ts @@ -21,6 +21,7 @@ import { setPublicAccess } from './lib/action/set-public-access'; import { moveFileAction } from './lib/action/move-file'; import { googleDriveDeleteFile } from './lib/action/delete-file'; import { googleDriveTrashFile } from './lib/action/send-to-trash'; +import { driveExportFolderAsZip } from './lib/action/drive-export-folder-as-zip'; import { googleDriveAuth, getAccessToken, GoogleDriveAuthValue } from './lib/auth'; // Phase-3 audience:'ai' agent atomics (full Composio-parity agent surface) @@ -100,6 +101,7 @@ export const googleDrive = createPiece({ moveFileAction, googleDriveDeleteFile, googleDriveTrashFile, + driveExportFolderAsZip, // Phase-3 audience:'ai' agent atomics (full Composio-parity agent surface) driveCreateFolder, driveCreateFileFromText, diff --git a/packages/pieces/community/google-drive/src/lib/action/drive-export-folder-as-zip.ts b/packages/pieces/community/google-drive/src/lib/action/drive-export-folder-as-zip.ts new file mode 100644 index 000000000000..010a6e8f710b --- /dev/null +++ b/packages/pieces/community/google-drive/src/lib/action/drive-export-folder-as-zip.ts @@ -0,0 +1,709 @@ +import { + createAction, + Property, + PieceAuth, + MarkdownVariant, +} from '@activepieces/pieces-framework'; +import { httpClient, HttpMethod } from '@activepieces/pieces-common'; +import { Readable } from 'node:stream'; +import { ZipWriter, ZipWriterAddDataOptions } from '@zip.js/zip.js'; +import { extension } from 'mime-types'; +import querystring from 'querystring'; +import { googleDriveAuth, GoogleDriveAuthValue, getAccessToken } from '../auth'; +import { common } from '../common'; + +// A zip is a sequential format. Only the entry holding the writer lock streams straight into the +// archive -- that one is paced by backpressure and costs almost nothing. Every *other* in-flight +// add() compresses into a temporary stream zip.js creates with an unbounded high water mark, so +// it is read as fast as the network delivers and held whole until its turn comes. Peak memory +// therefore tracks the bytes waiting behind the lock, not the size of the output. +// +// So the thing that has to be bounded is bytes in flight, not the number of downloads. Batches +// are capped by both: enough small files overlap to hide Drive's per-request latency, while a +// large file ends up alone in its batch and streams through the cheap direct-write path. +// Measured on zip.js 2.8.29 with a 320 MB folder: 5 at a time peaked around 320 MB, 2 at a time +// around 140 MB, one at a time around 34 MB and flat as the folder grows. +const DOWNLOAD_CONCURRENCY = 4; +const MAX_IN_FLIGHT_BYTES = 16 * 1024 * 1024; + +// Native Google files are generated at export time, so Drive reports no size for them until the +// export runs. They are typically small, but the estimate is deliberately generous: guessing too +// high only costs a little parallelism, guessing too low costs worker memory. +const UNKNOWN_SIZE_ESTIMATE = 8 * 1024 * 1024; + +// Cap on how many validation problems are spelled out in the thrown error. The total count is +// always reported; this only bounds the itemised list. +const MAX_REPORTED_ERRORS = 20; + +const GOOGLE_FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'; + +interface NativeFormatChoice { + docs: 'skip' | 'pdf' | 'docx'; + sheets: 'skip' | 'pdf' | 'xlsx'; + slides: 'skip' | 'pdf' | 'pptx'; +} + +interface NativeTypeConfig { + key: keyof NativeFormatChoice; + exportMimeTypes: Record; +} + +const NATIVE_TYPES: Record = { + 'application/vnd.google-apps.document': { + key: 'docs', + exportMimeTypes: { + pdf: 'application/pdf', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + }, + 'application/vnd.google-apps.spreadsheet': { + key: 'sheets', + exportMimeTypes: { + pdf: 'application/pdf', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + }, + 'application/vnd.google-apps.presentation': { + key: 'slides', + exportMimeTypes: { + pdf: 'application/pdf', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + }, + }, +}; + +const encryptionMethodDescription = ` +- ZipCrypto: Legacy encryption method with wide compatibility (not recommended for sensitive data) +- AES-256: Modern encryption with strong security (may not be supported by older zip clients) +`; + +interface ZipFolderEntry { + relativePath: string; + fileId: string; + isEmptyFolder: boolean; + downloadUrl?: string; + sizeBytes: number; +} + +interface DriveListItem { + id: string; + name: string; + mimeType: string; + // Drive returns this as a string, and omits it entirely for native Google files (which have + // no stored bytes until an export generates them) and for folders. + size?: string; +} + +async function listFolderChildren({ + auth, + folderId, + includeTeamDrives, +}: { + auth: GoogleDriveAuthValue; + folderId: string; + includeTeamDrives: boolean; +}): Promise { + const accessToken = await getAccessToken(auth); + const items: DriveListItem[] = []; + + const params: Record = { + q: `'${folderId}' in parents and trashed=false`, + fields: 'nextPageToken,files(id,name,mimeType,size)', + supportsAllDrives: 'true', + includeItemsFromAllDrives: includeTeamDrives ? 'true' : 'false', + corpora: includeTeamDrives ? 'allDrives' : 'user', + pageSize: '1000', + }; + + let nextPageToken: string | undefined; + do { + if (nextPageToken) { + params.pageToken = nextPageToken; + } + const response = await httpClient.sendRequest<{ + files: DriveListItem[]; + nextPageToken?: string; + }>({ + method: HttpMethod.GET, + url: `https://www.googleapis.com/drive/v3/files?${querystring.stringify( + params + )}`, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + items.push(...(response.body.files ?? [])); + nextPageToken = response.body.nextPageToken; + } while (nextPageToken); + + return items; +} + +interface ExportError { + name: string; + location: string; + message: string; +} + +// Drive allows '/', '\' and '..' in item names (confirmed in the web UI). None of that can be +// normalized away without changing the directory structure or the file's actual name, so an +// unsafe name is rejected outright instead. Returns a description of the problem rather than +// throwing, so callers can collect every error across the whole tree into one final error +// instead of failing on the first one found. +function checkSafeItemName( + name: string, + relativePrefix: string +): ExportError | undefined { + const location = relativePrefix.length > 0 ? relativePrefix : '/'; + const quotedName = `"${name}"`; + + // '/' is the zip path separator itself, so a name containing one spans levels it has no + // business spanning -- it bypasses the sibling collision check above (which only compares + // names within one level) and fabricates directories that don't exist in Drive. + if (name.includes('/')) { + return { name: quotedName, location, message: 'contains "/"' }; + } + + // Not the zip separator, but some extraction tools (Windows-based ones especially) treat it + // as one -- same class of ambiguity as '/', rejected for the same reason. + if (name.includes('\\')) { + return { name: quotedName, location, message: 'contains "\\"' }; + } + + // Escapes the archive root entirely on extraction (zip-slip). Only reachable once '/' and '\' + // are ruled out above -- with those banned, a single Drive item name can never represent more + // than one path segment, so this must be an exact match rather than a substring search (a + // substring search would also reject a legitimate name like "my..file.pdf", which contains + // ".." but isn't the traversal segment ".." itself). + if (name === '..') { + return { name: quotedName, location, message: 'escapes the archive root on extraction' }; + } + + // A "current directory" segment is normalized away by most unzip tools, so a folder named "." + // containing "x.pdf" extracts to the same path as a real sibling "x.pdf" -- same collision + // this check exists to prevent for '/', just camouflaged by a name that looks like a no-op + // instead of an extra path segment. + if (name === '.') { + return { name: quotedName, location, message: 'collides with other paths once normalized on extraction' }; + } + + return undefined; +} + +interface ResolvedItem { + item: DriveListItem; + name: string; + downloadUrl?: string; +} + +// The name and download URL this item would occupy in the zip, or undefined if it produces no +// entry at all (a skipped native file, or an unsupported Google Workspace type). Folders have +// no downloadUrl -- walk() recurses into them instead of adding an entry directly. +function resolveItem( + item: DriveListItem, + nativeFormats: NativeFormatChoice +): ResolvedItem | undefined { + if (item.mimeType === GOOGLE_FOLDER_MIME_TYPE) { + return { item, name: item.name }; + } + + const nativeType = NATIVE_TYPES[item.mimeType]; + if (nativeType) { + const format = nativeFormats[nativeType.key]; + if (format === 'skip') { + return undefined; + } + const exportMimeType = nativeType.exportMimeTypes[format]; + const fileExtension = extension(exportMimeType); + return { + item, + name: fileExtension ? `${item.name}.${fileExtension}` : item.name, + downloadUrl: `https://www.googleapis.com/drive/v3/files/${ + item.id + }/export?mimeType=${encodeURIComponent( + exportMimeType + )}&supportsAllDrives=true`, + }; + } + + if (item.mimeType.startsWith('application/vnd.google-apps.')) { + return undefined; + } + + return { + item, + name: item.name, + downloadUrl: `https://www.googleapis.com/drive/v3/files/${item.id}?alt=media&supportsAllDrives=true`, + }; +} + +// A Drive folder and a file (or two folders, or two files) can share a name in the same parent +// -- Drive only guarantees uniqueness by ID. Any such collision would force one zip path to be +// both a file and a directory, so it's checked once per level, up front, before any of these +// children are processed. Each colliding name is its own independent cluster -- a folder full of +// duplicates can have several unrelated ones at the same level (e.g. "Report" appears twice AND, +// separately, "readme.txt" also appears twice), so each gets its own error rather than being +// bundled into one combined line. +// Names are grouped case-insensitively: Drive is case-sensitive, but Windows and the default +// macOS filesystem are not, so "Report.pdf" beside "report.pdf" extracts as one file there and +// the other's content is silently lost. Grouping by the folded name catches that too, and the +// distinct spellings are listed so the user can see which items actually clash. +function checkUniqueNames( + resolvedChildren: ResolvedItem[], + relativePrefix: string +): ExportError[] { + const groups = new Map(); + for (const { name } of resolvedChildren) { + const key = name.toLowerCase(); + const group = groups.get(key); + if (group) { + group.push(name); + } else { + groups.set(key, [name]); + } + } + + const location = relativePrefix.length > 0 ? relativePrefix : '/'; + const errors: ExportError[] = []; + for (const names of groups.values()) { + if (names.length <= 1) { + continue; + } + const spellings = [...new Set(names)]; + errors.push({ + name: spellings.map((name) => `"${name}"`).join(', '), + location, + message: + spellings.length > 1 + ? `${names.length} items would map to the same zip path (names differing only by case collide on Windows and macOS)` + : `${names.length} items would map to the same zip path`, + }); + } + return errors; +} + +async function walk({ + auth, + folderId, + relativePrefix, + nativeFormats, + includeTeamDrives, + out, + errors, +}: { + auth: GoogleDriveAuthValue; + folderId: string; + relativePrefix: string; + nativeFormats: NativeFormatChoice; + includeTeamDrives: boolean; + out: ZipFolderEntry[]; + errors: ExportError[]; +}): Promise { + const children = await listFolderChildren({ + auth, + folderId, + includeTeamDrives, + }); + + if (children.length === 0) { + if (relativePrefix.length > 0) { + out.push({ + relativePath: relativePrefix, + fileId: folderId, + isEmptyFolder: true, + sizeBytes: 0, + }); + } + return; + } + + const resolvedChildren = children + .map((item) => resolveItem(item, nativeFormats)) + .filter((resolved): resolved is ResolvedItem => resolved !== undefined); + + // Only validated for items that actually survive into the export -- a skipped native file or + // an unsupported type (e.g. a Google Form, which is always excluded) never produces a zip + // entry, so an unsafe character in its name should never fail the export. Errors are collected + // rather than thrown immediately, so the whole tree is still walked and every error -- not just + // the first one found -- ends up in the final error. + for (const { item } of resolvedChildren) { + const error = checkSafeItemName(item.name, relativePrefix); + if (error) { + errors.push(error); + } + } + errors.push(...checkUniqueNames(resolvedChildren, relativePrefix)); + + for (const resolved of resolvedChildren) { + const itemPath = + relativePrefix.length > 0 + ? `${relativePrefix}/${resolved.name}` + : resolved.name; + + if (resolved.item.mimeType === GOOGLE_FOLDER_MIME_TYPE) { + await walk({ + auth, + folderId: resolved.item.id, + relativePrefix: itemPath, + nativeFormats, + includeTeamDrives, + out, + errors, + }); + continue; + } + + const reportedSize = Number(resolved.item.size); + out.push({ + relativePath: itemPath, + fileId: resolved.item.id, + isEmptyFolder: false, + downloadUrl: resolved.downloadUrl, + sizeBytes: Number.isFinite(reportedSize) + ? reportedSize + : UNKNOWN_SIZE_ESTIMATE, + }); + } +} + +async function collectZipEntries({ + auth, + rootFolderId, + nativeFormats, + includeTeamDrives, +}: { + auth: GoogleDriveAuthValue; + rootFolderId: string; + nativeFormats: NativeFormatChoice; + includeTeamDrives: boolean; +}): Promise { + const out: ZipFolderEntry[] = []; + const errors: ExportError[] = []; + await walk({ + auth, + folderId: rootFolderId, + relativePrefix: '', + nativeFormats, + includeTeamDrives, + out, + errors, + }); + + // The whole tree is walked before failing, so a folder with several unrelated problems (unsafe + // names, path collisions) surfaces all of them in one error instead of one fix-and-rerun cycle + // per error. Sorted by location then name so errors in the same folder read together. + if (errors.length > 0) { + const sorted = [...errors].sort( + (a, b) => a.location.localeCompare(b.location) || a.name.localeCompare(b.name) + ); + // A folder can produce thousands of these, and the whole message ends up in the run log + // and the step error panel, so only the first few are listed and the rest are counted. + const listed = sorted.slice(0, MAX_REPORTED_ERRORS); + const lines = listed.map( + (error) => `- [${error.location}] ${error.name} ${error.message}` + ); + if (sorted.length > listed.length) { + lines.push(`- ...and ${sorted.length - listed.length} more`); + } + throw new Error( + `Cannot export (${errors.length} problem${ + errors.length > 1 ? 's' : '' + }) - Rename the conflicting or unsafe items in Drive and try again.\n${lines.join('\n')}` + ); + } + + return out; +} + +// Groups entries into batches that are downloaded concurrently, bounded by both the number of +// requests and the bytes they put in flight. A file bigger than the whole budget still gets its +// own batch rather than being skipped -- alone it holds the writer lock and streams straight +// into the archive, which is the one path zip.js paces with real backpressure. +function batchByBytes(entries: ZipFolderEntry[]): ZipFolderEntry[][] { + const batches: ZipFolderEntry[][] = []; + let current: ZipFolderEntry[] = []; + let currentBytes = 0; + + for (const entry of entries) { + const wouldExceedBytes = currentBytes + entry.sizeBytes > MAX_IN_FLIGHT_BYTES; + const wouldExceedCount = current.length >= DOWNLOAD_CONCURRENCY; + if (current.length > 0 && (wouldExceedBytes || wouldExceedCount)) { + batches.push(current); + current = []; + currentBytes = 0; + } + current.push(entry); + currentBytes += entry.sizeBytes; + } + + if (current.length > 0) { + batches.push(current); + } + return batches; +} + +async function downloadAndAddZipEntry({ + auth, + entry, + zipWriter, + fileAddOptions, +}: { + auth: GoogleDriveAuthValue; + entry: ZipFolderEntry; + zipWriter: ZipWriter; + fileAddOptions: ZipWriterAddDataOptions; +}): Promise { + if (!entry.downloadUrl) { + throw new Error(`No download URL for entry "${entry.relativePath}"`); + } + const accessToken = await getAccessToken(auth); + const response = await fetch(entry.downloadUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok || !response.body) { + const body = await response.text().catch(() => ''); + if (response.status === 403 && body.includes('exportSizeLimitExceeded')) { + throw new Error( + `Export failed for "${entry.relativePath}": exceeds Drive's ~10MB export size limit.` + ); + } + throw new Error( + `Failed to download "${entry.relativePath}" (HTTP ${response.status}): ${body}` + ); + } + // stream the response body straight into the zip entry rather than buffering it into a Blob first + await zipWriter.add(entry.relativePath, response.body, fileAddOptions); +} + +export const driveExportFolderAsZip = createAction({ + auth: googleDriveAuth, + name: 'drive_export_folder_as_zip', + displayName: 'Export Folder as Zip', + description: + 'Recursively export a Google Drive folder (with all subfolders) as a single zip file.', + audience: 'human', + aiMetadata: { + description: + 'Recursively downloads every file in a Drive folder (including subfolders) and packages them into a single zip whose internal paths mirror the folder hierarchy. Native Google Docs/Sheets/Slides are converted per user-chosen format (PDF/Office format) or skipped. Fails the whole action if any single file cannot be downloaded/exported. Optionally password-protects the zip.', + idempotent: true, + }, + props: { + duplicatePathWarning: Property.MarkDown({ + value: + 'Zip paths mirror the Drive folder exactly, with no renaming. The action fails before downloading anything if: a file and a folder share a name in the same Drive folder, two items share a name, or a Google Doc/Sheet/Slides export lands on a name that already exists (e.g. a Sheet named "Report" exported as PDF alongside an existing "Report.pdf"); or an included item\'s name contains "/" or "\\", or is exactly "." or ".." (not usable as a zip path segment). Rename the conflicting or unsafe item in Drive and re-run.', + variant: MarkdownVariant.WARNING, + }), + folderId: Property.Dropdown({ + displayName: 'Folder', + description: 'The Drive folder to export (including all subfolders).', + required: true, + auth: googleDriveAuth, + refreshers: ['includeTeamDrives'], + refreshOnSearch: true, + options: async ({ auth, includeTeamDrives }, ctx) => + common.fetchFolderDropdownOptions({ + auth: auth as GoogleDriveAuthValue | undefined, + searchValue: ctx?.searchValue, + includeTeamDrives: includeTeamDrives as boolean | undefined, + }), + }), + includeTeamDrives: Property.Checkbox({ + displayName: 'Include Team Drives', + required: false, + defaultValue: false, + }), + googleDocsFormat: Property.StaticDropdown({ + displayName: 'Google Docs', + description: 'How to include native Google Docs found in the folder.', + required: true, + defaultValue: 'docx', + options: { + options: [ + { label: 'Word (DOCX)', value: 'docx' }, + { label: 'PDF', value: 'pdf' }, + { label: 'Skip', value: 'skip' }, + ], + }, + }), + googleSheetsFormat: Property.StaticDropdown({ + displayName: 'Google Sheets', + description: 'How to include native Google Sheets found in the folder.', + required: true, + defaultValue: 'xlsx', + options: { + options: [ + { label: 'Excel (XLSX)', value: 'xlsx' }, + { label: 'PDF', value: 'pdf' }, + { label: 'Skip', value: 'skip' }, + ], + }, + }), + googleSlidesFormat: Property.StaticDropdown({ + displayName: 'Google Slides', + description: 'How to include native Google Slides found in the folder.', + required: true, + defaultValue: 'pptx', + options: { + options: [ + { label: 'PowerPoint (PPTX)', value: 'pptx' }, + { label: 'PDF', value: 'pdf' }, + { label: 'Skip', value: 'skip' }, + ], + }, + }), + outputFileName: Property.ShortText({ + displayName: 'Output Zip File Name', + required: true, + defaultValue: 'export.zip', + }), + usePassword: Property.Checkbox({ + displayName: 'Use password', + description: 'Enable password protection for the zip file', + required: false, + defaultValue: false, + }), + passwordOptions: Property.DynamicProperties({ + displayName: 'Password options', + required: false, + auth: PieceAuth.None(), + refreshers: ['usePassword'], + props: async ({ usePassword }) => { + if (!usePassword) { + return {}; + } + + const fields = { + password: Property.ShortText({ + displayName: 'Password', + required: true, + }), + encryptionMethod: Property.StaticDropdown({ + displayName: 'Encryption Method', + description: encryptionMethodDescription, + required: true, + defaultValue: 'zipcrypto', + options: { + disabled: false, + options: [ + { label: 'ZipCrypto (Most Compatible)', value: 'zipcrypto' }, + { label: 'AES-256 (Stronger Security)', value: 'aes-256' }, + ], + }, + }), + }; + + return fields; + }, + }), + }, + async run(context) { + const nativeFormats: NativeFormatChoice = { + docs: context.propsValue.googleDocsFormat as NativeFormatChoice['docs'], + sheets: context.propsValue + .googleSheetsFormat as NativeFormatChoice['sheets'], + slides: context.propsValue + .googleSlidesFormat as NativeFormatChoice['slides'], + }; + + const entries = await collectZipEntries({ + auth: context.auth, + rootFolderId: context.propsValue.folderId, + nativeFormats, + includeTeamDrives: context.propsValue.includeTeamDrives ?? false, + }); + + // Resolved before the archive stream exists: anything that throws past this point has to + // tear the upload down by hand, so every input check belongs above it. + const fileAddOptions: ZipWriterAddDataOptions = {}; + if (context.propsValue.usePassword) { + const password = context.propsValue.passwordOptions?.[ + 'password' + ] as string; + const encryptionMethod = context.propsValue.passwordOptions?.[ + 'encryptionMethod' + ] as string; + + // zip.js treats an empty/absent password as "no encryption" and writes the archive in the + // clear. Failing here is the only safe reading: the user asked for a password, so silently + // handing back an unencrypted zip is worse than not producing one at all. + if (!password) { + throw new Error( + 'Cannot export: "Use password" is enabled but no password was provided. Enter a password or turn the option off.' + ); + } + + fileAddOptions.password = password; + + switch (encryptionMethod) { + case 'aes-256': + fileAddOptions.encryptionStrength = 3; + break; + case 'zipcrypto': + default: + fileAddOptions.zipCrypto = true; + break; + } + } + + const zipStream = new TransformStream(); + const zipWriter = new ZipWriter(zipStream.writable); + // @ts-expect-error -- undici streams a Node web ReadableStream; the DOM fetch types omit the fromWeb overload + const zipReadable: Readable = Readable.fromWeb(zipStream.readable); + + // start consuming the zip output as it's produced, rather than waiting for the whole + // archive to be built in memory before writing it out + const writeFilePromise = context.files.write({ + data: zipReadable, + fileName: context.propsValue.outputFileName, + }); + // if the produce side below throws, the stream gets aborted and this promise settles on its + // own -- observe it here so that doesn't surface as an unhandled rejection; the real outcome + // is still surfaced via `return writeFilePromise` on the success path below + writeFilePromise.catch(() => undefined); + + const fileEntries: ZipFolderEntry[] = []; + const emptyFolderEntries: ZipFolderEntry[] = []; + for (const entry of entries) { + (entry.isEmptyFolder ? emptyFolderEntries : fileEntries).push(entry); + } + + try { + for (const batch of batchByBytes(fileEntries)) { + // zip.js supports adding multiple entries concurrently (see its own "Adding concurrently + // multiple entries" example). Only the entry holding the writer lock streams straight + // into the archive; the others are buffered until their turn, which is why the batch is + // bounded by bytes rather than by count alone. + await Promise.all( + batch.map((entry) => + downloadAndAddZipEntry({ + auth: context.auth, + entry, + zipWriter, + fileAddOptions, + }) + ) + ); + } + + for (const folder of emptyFolderEntries) { + await zipWriter.add(`${folder.relativePath}/`, undefined, { + directory: true, + }); + } + + await zipWriter.close(); + } catch (error) { + // a download/add failure leaves the file upload waiting on a stream that will never + // produce more data or end. zipStream.writable.abort() is not reliable here -- zip.js + // can still hold the native lock on it while other concurrent add() calls in the same + // batch are mid-write, which makes abort() throw and leaves the upload hanging anyway. + // Destroying the readable side works regardless of that lock state. + zipReadable.destroy( + error instanceof Error ? error : new Error(String(error)) + ); + throw error; + } + + return writeFilePromise; + }, +}); diff --git a/packages/pieces/community/google-drive/src/lib/common/index.ts b/packages/pieces/community/google-drive/src/lib/common/index.ts index 6b056c92ecc8..818007d70600 100644 --- a/packages/pieces/community/google-drive/src/lib/common/index.ts +++ b/packages/pieces/community/google-drive/src/lib/common/index.ts @@ -14,6 +14,74 @@ const FOLDER_DROPDOWN_PAGE_SIZE = 1000; const escapeDriveQueryLiteral = (value: string): string => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +async function fetchFolderDropdownOptions({ + auth, + searchValue, + includeTeamDrives, +}: { + auth: GoogleDriveAuthValue | undefined; + searchValue: string | undefined; + includeTeamDrives: boolean | undefined; +}) { + if (!auth) { + return { + disabled: true, + options: [], + placeholder: 'Please authenticate first', + }; + } + const accessToken = await getAccessToken(auth); + const trimmedSearchValue = searchValue?.trim() ?? ''; + const qParts = [ + "mimeType='application/vnd.google-apps.folder'", + 'trashed = false', + ]; + if (trimmedSearchValue.length > 0) { + qParts.push(`name contains '${escapeDriveQueryLiteral(trimmedSearchValue)}'`); + } + const request: HttpRequest = { + method: HttpMethod.GET, + url: `https://www.googleapis.com/drive/v3/files`, + queryParams: { + q: qParts.join(' and '), + includeItemsFromAllDrives: includeTeamDrives ? 'true' : 'false', + supportsAllDrives: 'true', + corpora: includeTeamDrives ? 'allDrives' : 'user', + pageSize: String(FOLDER_DROPDOWN_PAGE_SIZE), + fields: 'nextPageToken, files(id, name)', + }, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: accessToken, + }, + }; + let folders: { id: string; name: string }[] = []; + let truncated = false; + try { + const response = await httpClient.sendRequest<{ + files: { id: string; name: string }[]; + nextPageToken?: string; + }>(request); + folders = response.body.files ?? []; + truncated = Boolean(response.body.nextPageToken); + } catch (e) { + throw new Error(`Failed to get folders\nError:${e}`); + } + + return { + disabled: false, + placeholder: truncated + ? `Showing first ${folders.length} matches — type to narrow the list, or switch to Dynamic value to paste an ID.` + : undefined, + options: folders.map((folder: { id: string; name: string }) => { + return { + label: folder.name, + value: folder.id, + }; + }), + }; +} + export const common = { properties: { parentFolder: Property.Dropdown({ @@ -24,66 +92,12 @@ export const common = { auth: googleDriveAuth, refreshers: ['include_team_drives'], refreshOnSearch: true, - options: async ({ auth, include_team_drives }, ctx) => { - if (!auth) { - return { - disabled: true, - options: [], - placeholder: 'Please authenticate first', - }; - } - const authValue = auth as GoogleDriveAuthValue; - const accessToken = await getAccessToken(authValue); - const searchValue = ctx?.searchValue?.trim() ?? ''; - const qParts = [ - "mimeType='application/vnd.google-apps.folder'", - 'trashed = false', - ]; - if (searchValue.length > 0) { - qParts.push(`name contains '${escapeDriveQueryLiteral(searchValue)}'`); - } - const request: HttpRequest = { - method: HttpMethod.GET, - url: `https://www.googleapis.com/drive/v3/files`, - queryParams: { - q: qParts.join(' and '), - includeItemsFromAllDrives: include_team_drives ? 'true' : 'false', - supportsAllDrives: 'true', - corpora: include_team_drives ? 'allDrives' : 'user', - pageSize: String(FOLDER_DROPDOWN_PAGE_SIZE), - fields: 'nextPageToken, files(id, name)', - }, - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token: accessToken, - }, - }; - let folders: { id: string; name: string }[] = []; - let truncated = false; - try { - const response = await httpClient.sendRequest<{ - files: { id: string; name: string }[]; - nextPageToken?: string; - }>(request); - folders = response.body.files ?? []; - truncated = Boolean(response.body.nextPageToken); - } catch (e) { - throw new Error(`Failed to get folders\nError:${e}`); - } - - return { - disabled: false, - placeholder: truncated - ? `Showing first ${folders.length} matches — type to narrow the list, or switch to Dynamic value to paste an ID.` - : undefined, - options: folders.map((folder: { id: string; name: string }) => { - return { - label: folder.name, - value: folder.id, - }; - }), - }; - }, + options: async ({ auth, include_team_drives }, ctx) => + fetchFolderDropdownOptions({ + auth: auth as GoogleDriveAuthValue | undefined, + searchValue: ctx?.searchValue, + includeTeamDrives: include_team_drives as boolean | undefined, + }), }), include_team_drives: Property.Checkbox({ displayName: 'Include Team Drives', @@ -94,6 +108,8 @@ export const common = { }), }, + fetchFolderDropdownOptions, + async getFiles( auth: GoogleDriveAuthValue, search?: {