Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions scripts/build-mcp-openapi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -534,18 +534,35 @@ const coreMcpOperations = {
}
},
formsRowsList: {
summary: "List submissions or rows for a form",
summary: "List form submissions",
description:
"Lists submissions, also called rows or records, for a specific form in the active workspace. This may expose customer-submitted data.",
"Lists form submissions (rows) with dashboard-parity filters: page/page_size, search, sort_by, status, created_by/updated_by/tags, submit_time/created_at/updated_at ranges (_gte/_lte), and dynamic {fieldSlug}/{fieldSlug}_{operator} field filters. RowQueryUtils supports contains/has/equal/exact/lt/lte/gt/gte and not_ variants; comma-separated bare field values become list filters. Response includes rows, count, and often top_fields for table columns.",
parameterDescriptions: {
slug: "Form slug.",
slug: "Form slug whose submissions to list.",
page: "1-based page number.",
page_size: "Submissions per page.",
search: "Case-insensitive search across submission values.",
sort_by: "Comma-separated sort fields; prefix with - for descending.",
status: "Row status filter; use all (or omit) for every status.",
created_by: "Filter by creator first name or email.",
updated_by: "Filter by last updater first name or email.",
tags: "Comma-separated tag slugs.",
submit_number: "Submission number filter.",
tracking_code: "Submission tracking code filter."
tracking_code: "Submission tracking code filter.",
created_at: "Created-at date filter (YYYY-MM-DD); also created_at_gte/lte.",
updated_at: "Updated-at date filter (YYYY-MM-DD); also updated_at_gte/lte.",
submit_time: "Submit-time timestamp filter; also submit_time_gte/lte.",
created_at_gte: "Created-at lower bound (inclusive).",
created_at_lte: "Created-at upper bound (inclusive).",
updated_at_gte: "Updated-at lower bound (inclusive).",
updated_at_lte: "Updated-at upper bound (inclusive).",
submit_time_gte: "Submit-time lower bound (inclusive).",
submit_time_lte: "Submit-time upper bound (inclusive)."
},
mcp: {
tool_name: "list_form_rows",
aliases: ["list_submissions", "show_submissions", "list_form_records"],
intent: "List submitted rows for a Formaloo form.",
tool_name: "list_form_submissions",
aliases: ["list_form_rows", "list_rows", "list_submissions", "show_submissions", "list_form_records"],
intent: "List submitted rows/submissions for a Formaloo form with filters.",
requires_workspace: true,
read_only: true,
destructive: false,
Expand Down
211 changes: 211 additions & 0 deletions scripts/normalize-openapi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,216 @@ function enrichRowSchemas() {
}
}

function upsertQueryParameter(operation, parameter) {
if (!operation || !parameter?.name) {
return;
}
operation.parameters = Array.isArray(operation.parameters) ? operation.parameters : [];
const index = operation.parameters.findIndex(
(item) => item?.in === "query" && item?.name === parameter.name
);
if (index >= 0) {
operation.parameters[index] = {
...operation.parameters[index],
...parameter,
schema: {
...(operation.parameters[index].schema || {}),
...(parameter.schema || {})
},
description: parameter.description || operation.parameters[index].description
};
return;
}
operation.parameters.push(parameter);
}

function enrichFormsRowsListOperation() {
const listRows = spec.paths["/v3.0/forms/{slug}/rows/"]?.get;
if (!listRows) {
return;
}

listRows.summary = listRows.summary || "List form submissions";
const existingDescription = String(listRows.description || "").trim();
const filterNotice =
"Supports dashboard-parity filters: pagination (`page`, `page_size`), `search`, `sort_by`, `status`, meta (`created_by`, `updated_by`, `tags`, `tracking_code`, `submit_number`), timestamp/date ranges (`submit_time`/`created_at`/`updated_at` plus `_gte`/`_lte`/`_gt`/`_lt`), and dynamic `{fieldSlug}` / `{fieldSlug}_{operator}` field filters. `RowQueryUtils` supports contains/has/equal/exact/lt/lte/gt/gte and `not_` variants; comma-separated bare field values become list filters. Response includes `rows`, `count`, and often `top_fields` for table columns.";
if (!existingDescription.includes("dashboard-parity filters")) {
listRows.description = existingDescription
? `${existingDescription}\n\n${filterNotice}`
: filterNotice;
}

const queryParams = [
{
in: "query",
name: "page",
required: false,
schema: { type: "integer" },
description: "A page number within the paginated result set."
},
{
in: "query",
name: "page_size",
required: false,
schema: { type: "integer" },
description: "Number of results to return per page."
},
{
in: "query",
name: "pagination",
required: false,
schema: { type: "string" },
description: "Set to `0` to disable pagination for this list."
},
{
in: "query",
name: "search",
required: false,
schema: { type: "string" },
description: "Case-insensitive search across submission values."
},
{
in: "query",
name: "sort_by",
required: false,
schema: { type: "string" },
description:
"Comma-separated sort fields. Prefix with `-` for descending (for example `-submit_time`, `-created_at`, or a field slug)."
},
{
in: "query",
name: "status",
required: false,
schema: { type: "string" },
description: "Filter by row status. Use `all` (or omit) for every status."
},
{
in: "query",
name: "tags",
required: false,
schema: { type: "string" },
description: "Comma-separated list of tag slugs."
},
{
in: "query",
name: "tracking_code",
required: false,
schema: { type: "string" },
description: "Filter by tracking code."
},
{
in: "query",
name: "submit_number",
required: false,
schema: { type: "string" },
description: "Filter by submit number."
},
{
in: "query",
name: "created_by",
required: false,
schema: { type: "string" },
description: "Filter by creator first name or email (icontains)."
},
{
in: "query",
name: "updated_by",
required: false,
schema: { type: "string" },
description: "Filter by last updater first name or email (icontains)."
},
{
in: "query",
name: "created_at",
required: false,
schema: { type: "string", format: "date" },
description:
"Date filter for created_at (`YYYY-MM-DD`). Range variants: `created_at_gte`, `created_at_lte`, `created_at_gt`, `created_at_lt`."
},
{
in: "query",
name: "created_at_gte",
required: false,
schema: { type: "string", format: "date-time" },
description: "Created-at lower bound (inclusive)."
},
{
in: "query",
name: "created_at_lte",
required: false,
schema: { type: "string", format: "date-time" },
description: "Created-at upper bound (inclusive)."
},
{
in: "query",
name: "updated_at",
required: false,
schema: { type: "string", format: "date" },
description:
"Date filter for updated_at (`YYYY-MM-DD`). Range variants: `updated_at_gte`, `updated_at_lte`, `updated_at_gt`, `updated_at_lt`."
},
{
in: "query",
name: "updated_at_gte",
required: false,
schema: { type: "string", format: "date-time" },
description: "Updated-at lower bound (inclusive)."
},
{
in: "query",
name: "updated_at_lte",
required: false,
schema: { type: "string", format: "date-time" },
description: "Updated-at upper bound (inclusive)."
},
{
in: "query",
name: "submit_time",
required: false,
schema: { type: "string", format: "date-time" },
description:
"Timestamp filter for submit time (maps to row created_at). Also accepts `submit_time_gte`, `submit_time_lte`, `submit_time_gt`, `submit_time_lt`."
},
{
in: "query",
name: "submit_time_gte",
required: false,
schema: { type: "string", format: "date-time" },
description: "Submit-time lower bound (inclusive)."
},
{
in: "query",
name: "submit_time_lte",
required: false,
schema: { type: "string", format: "date-time" },
description: "Submit-time upper bound (inclusive)."
}
];

for (const parameter of queryParams) {
upsertQueryParameter(listRows, parameter);
}

const paginated = spec.components?.schemas?.PaginatedRowList;
if (paginated?.properties && !paginated.properties.top_fields) {
paginated.properties.top_fields = {
type: "array",
description:
"Optional column hints for table UIs. Typically form field references (slug/title/type). Table columns are the form’s fields; when present, prefer this order.",
items: {
type: "object",
additionalProperties: true,
properties: {
slug: { type: "string" },
title: { type: "string" },
type: { type: "string" },
alias: { type: "string" }
}
}
};
}
}

function enrichBlockSchemas() {
spec.components.schemas.FormalooAccessSettings = {
type: "object",
Expand Down Expand Up @@ -2550,6 +2760,7 @@ enrichFormDisplaySubmitContract();
enrichBoardDeleteOperation();
enrichChoiceFieldSchemas();
enrichRowSchemas();
enrichFormsRowsListOperation();
enrichBlockSchemas();
enrichBoardSchemas();
enrichFormSummarySchemas();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ If you want to search on the whole fields:
{"fields_filters":{}, "search": "lorem"}
```

- If you want to apply the search only on a given request, you can send it on the query params: `?seach=lorem`
- If you want to apply the search only on a given request, you can send it on the query params: `?search=lorem`

Notes:
- The search term is case-insensitive, so `Lorem` and `lorem` are the same.
Expand Down
2 changes: 1 addition & 1 deletion spec/docs/v3.0/forms/slug/rows/bulk-delete/post.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ Use this only when the user intends to remove multiple submissions from the form

## Behavior

This is a bulk delete via `POST` (not a `DELETE` verb). Request body includes `slugs_list`. Returns `200` with an empty `data` object on success. There is no separate confirmation step.
This is a bulk delete via `POST` (not a `DELETE` verb). Request body must include `slugs_list` (array of row/submission slugs). Returns success with an empty `data` object. The HTTP API does not add a confirmation flag; agent clients (Formaloo MCP) should require an explicit `confirm=true` before calling this operation.
89 changes: 86 additions & 3 deletions spec/docs/v3.0/forms/slug/rows/get.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,77 @@
Lists submissions for a specific form in admin context. Use this for response inboxes, table views, search, filtering, sorting, and back-office review of collected data.
Lists submissions (rows) for a specific form in admin context. Use this for response inboxes, table views, search, filtering, sorting, and back-office review of collected data.

Sources of truth for filter behavior: Formaloo dashboard row fetchers (`formsRowsList` query params) and `formz_core` `RowQueryUtils` / `FormRowsView` (`date_filter_fields`, `timestamp_filter_fields`, `filter_fields`, plus `status` in `get_extra_filters`).

## Query parameters

### Pagination and text search

| Param | Description |
| --- | --- |
| `page` | 1-based page number |
| `page_size` | Results per page |
| `pagination` | Set to `0` to disable pagination |
| `search` | Case-insensitive search across submission values |
| `sort_by` | Comma-separated fields; prefix with `-` for descending (for example `-submit_time`, `-created_at`, or a field slug) |

### Submission status and meta filters

| Param | Description |
| --- | --- |
| `status` | Row status filter. Omit or use `all` for every status |
| `tags` | Comma-separated tag slugs |
| `tracking_code` | Exact tracking code |
| `submit_number` | Exact submit number |
| `created_by` | Match creator first name or email (icontains) |
| `updated_by` | Match last updater first name or email (icontains) |

### Date and timestamp ranges

Exact and range filters use the dashboard/`idealib` convention:

- Date fields (`created_at`, `updated_at`): exact `YYYY-MM-DD` via the bare param
- Timestamp fields (`submit_time`, `created_at`, `updated_at`): bare value or `*_lt` / `*_lte` / `*_gt` / `*_gte`

Examples:

- `?submit_time_gte=2024-01-01T00:00:00Z&submit_time_lte=2024-12-31T23:59:59Z`
- `?created_at_gte=2024-01-01T00:00:00Z`
- `?updated_at=2024-08-07` (date-only exact day filter when sent as a date field)

`submit_time` maps to the row `created_at` timestamp on the backend.

### Dynamic field filters

Any form field slug can be used as a query key. Operators follow `{fieldSlug}` or `{fieldSlug}_{operator}` (see `RowQueryUtils`):

| Operator suffix | Meaning |
| --- | --- |
| _(none)_ / `exact` / `equal` | Exact match (text fields default to case-insensitive exact) |
| `icontains` / `contains` / `has` | Contains |
| `not_contains` / `not_has` | Does not contain |
| `not_equal` | Not equal |
| `gt` / `gte` / `lt` / `lte` | Comparisons (numbers/dates) |

Comma-separated values on the bare field key become a list filter (for example `?city=NYC,LA`). There is no `_in` or `not_in` suffix in `RowQueryUtils`.

Examples:

- `?field_email=amir@example.com`
- `?field_email_icontains=example.com`
- `?satisfaction_gte=4`
- `?multi_select_has=choice_abc`
- `?city=NYC,LA`

Unknown keys that do not match a form field slug (or meta/order patterns) are ignored by `RowQueryUtils`.

## Response

`data` includes:

- `rows` — submission objects (answer keys are field slugs in `data` / `rendered_data`)
- `count` — total matching rows
- pagination fields (`next`, `previous`, `page_size`, `page_count`, `current_page`) when pagination is enabled
- `top_fields` — optional column hints for table UIs (form field references). Table columns are the form’s fields; when present, `top_fields` is the preferred column order for dashboards

## Example response (`200`)

Expand All @@ -16,11 +89,19 @@ Lists submissions for a specific form in admin context. Use this for response in
"page_size": 10,
"page_count": 1,
"current_page": 1,
"top_fields": [
{ "slug": "field_JgKgX2vVPh", "title": "Name", "type": "short_text" },
{ "slug": "field_GFUOFRUTeg", "title": "Rating", "type": "dropdown" }
],
"rows": [
{
"slug": "row_a1b2c3d4",
"created_at": "2024-08-07T14:20:11.000000Z",
"updated_at": "2024-08-07T14:20:11.000000Z",
"data": {
"field_JgKgX2vVPh": "Jane Doe",
"field_GFUOFRUTeg": "choice_excellent"
},
"rendered_data": {
"field_JgKgX2vVPh": "Jane Doe",
"field_GFUOFRUTeg": "Excellent"
Expand All @@ -30,6 +111,10 @@ Lists submissions for a specific form in admin context. Use this for response in
"slug": "row_e5f6g7h8",
"created_at": "2024-08-07T15:02:44.000000Z",
"updated_at": "2024-08-07T15:02:44.000000Z",
"data": {
"field_JgKgX2vVPh": "John Smith",
"field_GFUOFRUTeg": "choice_good"
},
"rendered_data": {
"field_JgKgX2vVPh": "John Smith",
"field_GFUOFRUTeg": "Good"
Expand All @@ -39,5 +124,3 @@ Lists submissions for a specific form in admin context. Use this for response in
}
}
```

Row answer keys are field slugs. Use search, pagination, and filter query parameters supported by the endpoint when building inbox or table views.
Loading