2
0

Add NocoDB block (#1365)

#970 #997

Fully integrate NocoDB.

Added all API Functions:

- List Table Records
- Create Table Records
- Update Table Records
- Delete Table Records
- Read Table Record
- Count Table Records
- List Linked Records
- Link Records
- Unlink Records

Optional Todo:
- Save responses of non-get requests in a variable (error validation
try-catch is added and logged so i do not think so it is much needed)

You are free to implement any extra validation/function :D

---------

Co-authored-by: Baptiste Arnaud <baptiste.arnaud95@gmail.com>
This commit is contained in:
Abdullah bin Amir
2024-05-27 12:46:42 +04:00
committed by GitHub
parent 3e4e7531f6
commit a17781dfa6
35 changed files with 1158 additions and 38 deletions

View File

@ -6,12 +6,14 @@ import { ZodObjectLayout } from './zodLayouts/ZodObjectLayout'
import { ZodActionDiscriminatedUnion } from './zodLayouts/ZodActionDiscriminatedUnion' import { ZodActionDiscriminatedUnion } from './zodLayouts/ZodActionDiscriminatedUnion'
import { useForgedBlock } from '../hooks/useForgedBlock' import { useForgedBlock } from '../hooks/useForgedBlock'
import { ForgedBlock } from '@typebot.io/forge-repository/types' import { ForgedBlock } from '@typebot.io/forge-repository/types'
import { useState } from 'react'
type Props = { type Props = {
block: ForgedBlock block: ForgedBlock
onOptionsChange: (options: BlockOptions) => void onOptionsChange: (options: BlockOptions) => void
} }
export const ForgedBlockSettings = ({ block, onOptionsChange }: Props) => { export const ForgedBlockSettings = ({ block, onOptionsChange }: Props) => {
const [keySuffix, setKeySuffix] = useState<number>(0)
const { blockDef, blockSchema, actionDef } = useForgedBlock( const { blockDef, blockSchema, actionDef } = useForgedBlock(
block.type, block.type,
block.options?.action block.options?.action
@ -32,7 +34,10 @@ export const ForgedBlockSettings = ({ block, onOptionsChange }: Props) => {
const actionOptions = actionOptionsKeys.reduce( const actionOptions = actionOptionsKeys.reduce(
(acc, key) => ({ (acc, key) => ({
...acc, ...acc,
[key]: undefined, [key]:
block.options[key] && typeof block.options[key] !== 'object'
? block.options[key]
: undefined,
}), }),
{} {}
) )
@ -40,6 +45,7 @@ export const ForgedBlockSettings = ({ block, onOptionsChange }: Props) => {
...updates, ...updates,
...actionOptions, ...actionOptions,
}) })
setKeySuffix((prev) => prev + 1)
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -85,6 +91,7 @@ export const ForgedBlockSettings = ({ block, onOptionsChange }: Props) => {
/> />
)} )}
<ZodActionDiscriminatedUnion <ZodActionDiscriminatedUnion
key={block.id + keySuffix}
schema={blockSchema.shape.options} schema={blockSchema.shape.options}
blockDef={blockDef} blockDef={blockDef}
blockOptions={block.options} blockOptions={block.options}

View File

@ -1,6 +1,7 @@
import { NumberInput, TextInput, Textarea } from '@/components/inputs' import { NumberInput, TextInput, Textarea } from '@/components/inputs'
import { z } from '@typebot.io/forge/zod' import { z } from '@typebot.io/forge/zod'
import { ZodLayoutMetadata } from '@typebot.io/forge/zod' import { ZodLayoutMetadata } from '@typebot.io/forge/zod'
import { evaluateIsHidden } from '@typebot.io/forge/zod/helpers/evaluateIsHidden'
import Markdown, { Components } from 'react-markdown' import Markdown, { Components } from 'react-markdown'
import { ZodTypeAny } from 'zod' import { ZodTypeAny } from 'zod'
import { ForgeSelectInput } from '../ForgeSelectInput' import { ForgeSelectInput } from '../ForgeSelectInput'
@ -64,7 +65,7 @@ export const ZodFieldLayout = ({
const innerSchema = getZodInnerSchema(schema) const innerSchema = getZodInnerSchema(schema)
const layout = innerSchema._def.layout const layout = innerSchema._def.layout
if (layout?.isHidden) return null if (evaluateIsHidden(layout?.isHidden, blockOptions)) return null
switch (innerSchema._def.typeName) { switch (innerSchema._def.typeName) {
case 'ZodObject': case 'ZodObject':

View File

@ -19,6 +19,7 @@ import {
ForgedBlock, ForgedBlock,
} from '@typebot.io/forge-repository/types' } from '@typebot.io/forge-repository/types'
import { getZodInnerSchema } from '../../helpers/getZodInnerSchema' import { getZodInnerSchema } from '../../helpers/getZodInnerSchema'
import { evaluateIsHidden } from '@typebot.io/forge/zod/helpers/evaluateIsHidden'
export const ZodObjectLayout = ({ export const ZodObjectLayout = ({
schema, schema,
@ -37,20 +38,23 @@ export const ZodObjectLayout = ({
blockOptions?: ForgedBlock['options'] blockOptions?: ForgedBlock['options']
onDataChange: (value: any) => void onDataChange: (value: any) => void
}): ReactNode[] => { }): ReactNode[] => {
const layout = getZodInnerSchema(schema)._def.layout const innerSchema = getZodInnerSchema(schema)
if (layout?.isHidden) return [] const shape =
return Object.keys(schema.shape).reduce<{ 'shape' in innerSchema ? innerSchema.shape : innerSchema._def.shape()
const layout = innerSchema._def.layout
if (evaluateIsHidden(layout?.isHidden, blockOptions)) return []
return Object.keys(shape).reduce<{
nodes: ReactNode[] nodes: ReactNode[]
accordionsCreated: string[] accordionsCreated: string[]
}>( }>(
(nodes, key, index) => { (nodes, key, index) => {
if (ignoreKeys?.includes(key)) return nodes if (ignoreKeys?.includes(key)) return nodes
const keySchema = getZodInnerSchema(schema.shape[key]) const keySchema = getZodInnerSchema(shape[key])
const layout = keySchema._def.layout as const layout = keySchema._def.layout as
| ZodLayoutMetadata<ZodTypeAny> | ZodLayoutMetadata<ZodTypeAny>
| undefined | undefined
if (layout?.isHidden) return nodes if (evaluateIsHidden(layout?.isHidden, blockOptions)) return nodes
if ( if (
layout && layout &&
layout.accordion && layout.accordion &&
@ -60,7 +64,7 @@ export const ZodObjectLayout = ({
if (nodes.accordionsCreated.includes(layout.accordion)) return nodes if (nodes.accordionsCreated.includes(layout.accordion)) return nodes
const accordionKeys = getObjectKeysWithSameAccordionAttr( const accordionKeys = getObjectKeysWithSameAccordionAttr(
layout.accordion, layout.accordion,
schema shape
) )
return { return {
nodes: [ nodes: [
@ -77,7 +81,7 @@ export const ZodObjectLayout = ({
{accordionKeys.map((accordionKey, idx) => ( {accordionKeys.map((accordionKey, idx) => (
<ZodFieldLayout <ZodFieldLayout
key={accordionKey + idx} key={accordionKey + idx}
schema={schema.shape[accordionKey]} schema={shape[accordionKey]}
data={data?.[accordionKey]} data={data?.[accordionKey]}
onDataChange={(val) => onDataChange={(val) =>
onDataChange({ ...data, [accordionKey]: val }) onDataChange({ ...data, [accordionKey]: val })
@ -118,12 +122,9 @@ export const ZodObjectLayout = ({
).nodes ).nodes
} }
const getObjectKeysWithSameAccordionAttr = ( const getObjectKeysWithSameAccordionAttr = (accordion: string, shape: any) =>
accordion: string, Object.keys(shape).reduce<string[]>((keys, currentKey) => {
schema: z.ZodObject<any> const l = shape[currentKey]._def.layout as
) =>
Object.keys(schema.shape).reduce<string[]>((keys, currentKey) => {
const l = schema.shape[currentKey]._def.layout as
| ZodLayoutMetadata<ZodTypeAny> | ZodLayoutMetadata<ZodTypeAny>
| undefined | undefined
return !l?.accordion || l.accordion !== accordion return !l?.accordion || l.accordion !== accordion

View File

@ -2,11 +2,6 @@
title: Anthropic title: Anthropic
--- ---
<Warning>
There is an ongoing issue with Anthropic block streaming capabilities. We are
working on a fix and will update this page once the issue is resolved.
</Warning>
## Create Message ## Create Message
With the Anthropic block, you can create chat messages based on your user queries and display the answer back to your typebot using Claude AI. With the Anthropic block, you can create chat messages based on your user queries and display the answer back to your typebot using Claude AI.

View File

@ -0,0 +1,31 @@
---
title: NocoDB
---
With the NocoDB block, you can create, update or get data from your NocoDB tables.
## How to find my `Table ID`?
To find your `Table ID`, you need to go to your NocoDB dashboard and click on the 3 dots button next to your table name.
<Frame>
<img
src="/images/blocks/integrations/nocodb-table-id.jpg"
alt="NocoDB table ID"
/>
</Frame>
## Search Records
This action allows you to search for existing records in a table. It requires your `Table ID` and can optionally take a `View ID` to search in a specific view.
<Frame>
<img
src="/images/blocks/integrations/nocodb.jpg"
alt="NocoDB block example"
/>
</Frame>
You can configure the filter to return `All`, `First`, `Last` or `Random` found records.
Then all you need to do is to map the found fields to variables that you can re-use on your bot.

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@ -126,7 +126,8 @@
"editor/blocks/integrations/mistral", "editor/blocks/integrations/mistral",
"editor/blocks/integrations/elevenlabs", "editor/blocks/integrations/elevenlabs",
"editor/blocks/integrations/anthropic", "editor/blocks/integrations/anthropic",
"editor/blocks/integrations/dify-ai" "editor/blocks/integrations/dify-ai",
"editor/blocks/integrations/nocodb"
] ]
} }
] ]

View File

@ -20532,6 +20532,242 @@
"id", "id",
"type" "type"
] ]
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"outgoingEdgeId": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"nocodb"
]
},
"options": {
"oneOf": [
{
"type": "object",
"properties": {
"credentialsId": {
"type": "string"
}
}
},
{
"type": "object",
"properties": {
"credentialsId": {
"type": "string"
},
"action": {
"type": "string",
"enum": [
"Search Records"
]
},
"tableId": {
"type": "string"
},
"viewId": {
"type": "string"
},
"returnType": {
"type": "string",
"enum": [
"All",
"First",
"Last",
"Random"
]
},
"filter": {
"type": "object",
"properties": {
"comparisons": {
"type": "array",
"items": {
"type": "object",
"properties": {
"input": {
"type": "string"
},
"operator": {
"type": "string",
"enum": [
"Equal to",
"Not equal",
"Contains",
"Greater than",
"Less than",
"Is set",
"Is empty",
"Starts with",
"Ends with"
]
},
"value": {
"type": "string"
}
}
}
},
"joiner": {
"type": "string",
"enum": [
"AND",
"OR"
]
}
},
"required": [
"comparisons"
]
},
"responseMapping": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fieldName": {
"type": "string"
},
"variableId": {
"type": "string"
}
}
}
}
},
"required": [
"action"
]
},
{
"type": "object",
"properties": {
"credentialsId": {
"type": "string"
},
"action": {
"type": "string",
"enum": [
"Create Record"
]
},
"tableId": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
}
},
"required": [
"action"
]
},
{
"type": "object",
"properties": {
"credentialsId": {
"type": "string"
},
"action": {
"type": "string",
"enum": [
"Update Existing Record"
]
},
"tableId": {
"type": "string"
},
"viewId": {
"type": "string"
},
"filter": {
"type": "object",
"properties": {
"comparisons": {
"type": "array",
"items": {
"type": "object",
"properties": {
"input": {
"type": "string"
},
"operator": {
"type": "string",
"enum": [
"Equal to",
"Not equal",
"Contains",
"Greater than",
"Less than",
"Is set",
"Is empty",
"Starts with",
"Ends with"
]
},
"value": {
"type": "string"
}
}
}
},
"joiner": {
"type": "string",
"enum": [
"AND",
"OR"
]
}
},
"required": [
"comparisons"
]
},
"updates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fieldName": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
}
},
"required": [
"action"
]
}
]
}
},
"required": [
"id",
"type"
]
} }
], ],
"title": "Block" "title": "Block"

View File

@ -11513,6 +11513,242 @@
"id", "id",
"type" "type"
] ]
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"outgoingEdgeId": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"nocodb"
]
},
"options": {
"oneOf": [
{
"type": "object",
"properties": {
"credentialsId": {
"type": "string"
}
}
},
{
"type": "object",
"properties": {
"credentialsId": {
"type": "string"
},
"action": {
"type": "string",
"enum": [
"Search Records"
]
},
"tableId": {
"type": "string"
},
"viewId": {
"type": "string"
},
"returnType": {
"type": "string",
"enum": [
"All",
"First",
"Last",
"Random"
]
},
"filter": {
"type": "object",
"properties": {
"comparisons": {
"type": "array",
"items": {
"type": "object",
"properties": {
"input": {
"type": "string"
},
"operator": {
"type": "string",
"enum": [
"Equal to",
"Not equal",
"Contains",
"Greater than",
"Less than",
"Is set",
"Is empty",
"Starts with",
"Ends with"
]
},
"value": {
"type": "string"
}
}
}
},
"joiner": {
"type": "string",
"enum": [
"AND",
"OR"
]
}
},
"required": [
"comparisons"
]
},
"responseMapping": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fieldName": {
"type": "string"
},
"variableId": {
"type": "string"
}
}
}
}
},
"required": [
"action"
]
},
{
"type": "object",
"properties": {
"credentialsId": {
"type": "string"
},
"action": {
"type": "string",
"enum": [
"Create Record"
]
},
"tableId": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
}
},
"required": [
"action"
]
},
{
"type": "object",
"properties": {
"credentialsId": {
"type": "string"
},
"action": {
"type": "string",
"enum": [
"Update Existing Record"
]
},
"tableId": {
"type": "string"
},
"viewId": {
"type": "string"
},
"filter": {
"type": "object",
"properties": {
"comparisons": {
"type": "array",
"items": {
"type": "object",
"properties": {
"input": {
"type": "string"
},
"operator": {
"type": "string",
"enum": [
"Equal to",
"Not equal",
"Contains",
"Greater than",
"Less than",
"Is set",
"Is empty",
"Starts with",
"Ends with"
]
},
"value": {
"type": "string"
}
}
}
},
"joiner": {
"type": "string",
"enum": [
"AND",
"OR"
]
}
},
"required": [
"comparisons"
]
},
"updates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fieldName": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
}
},
"required": [
"action"
]
}
]
}
},
"required": [
"id",
"type"
]
} }
], ],
"title": "Block" "title": "Block"

View File

@ -29,9 +29,20 @@ export const createChatMessage = createAction({
moreInfoTooltip: moreInfoTooltip:
'The user identifier, defined by the developer, must ensure uniqueness within the app.', 'The user identifier, defined by the developer, must ensure uniqueness within the app.',
}), }),
inputs: option.keyValueList.layout({ inputs: option
accordion: 'Inputs', .array(
}), option.object({
key: option.string.layout({
label: 'Key',
}),
value: option.string.layout({
label: 'Value',
}),
})
)
.layout({
accordion: 'Inputs',
}),
responseMapping: option responseMapping: option
.saveResponseArray( .saveResponseArray(
['Answer', 'Conversation ID', 'Total Tokens'] as const, ['Answer', 'Conversation ID', 'Total Tokens'] as const,

View File

@ -0,0 +1,61 @@
import { createAction, option } from '@typebot.io/forge'
import { auth } from '../auth'
import ky, { HTTPError } from 'ky'
import { defaultBaseUrl } from '../constants'
import { parseRecordsCreateBody } from '../helpers/parseRecordCreateBody'
export const createRecord = createAction({
auth,
name: 'Create Record',
options: option.object({
tableId: option.string.layout({
label: 'Table ID',
isRequired: true,
helperText: 'Identifier of the table to create records in.',
}),
fields: option
.array(
option.object({
key: option.string.layout({
label: 'Field',
isRequired: true,
}),
value: option.string.layout({
label: 'Value',
isRequired: true,
}),
})
)
.layout({
itemLabel: 'field',
}),
}),
run: {
server: async ({
credentials: { baseUrl, apiKey },
options: { tableId, fields },
logs,
}) => {
try {
if (!fields || fields.length === 0) return
await ky.post(
`${baseUrl ?? defaultBaseUrl}/api/v2/tables/${tableId}/records`,
{
headers: {
'xc-token': apiKey,
},
json: parseRecordsCreateBody(fields),
}
)
} catch (error) {
if (error instanceof HTTPError)
return logs.add({
status: 'error',
description: error.message,
details: await error.response.text(),
})
console.error(error)
}
},
},
})

View File

@ -0,0 +1,136 @@
import { createAction, option } from '@typebot.io/forge'
import { auth } from '../auth'
import ky, { HTTPError } from 'ky'
import { ListTableRecordsResponse } from '../types'
import { isDefined } from '@typebot.io/lib'
import { parseSearchParams } from '../helpers/parseSearchParams'
import { convertFilterToWhereClause } from '../helpers/convertFilterToWhereClause'
import {
defaultBaseUrl,
defaultLimitForSearch,
filterOperators,
} from '../constants'
export const searchRecords = createAction({
auth,
name: 'Search Records',
options: option.object({
tableId: option.string.layout({
label: 'Table ID',
moreInfoTooltip:
'Can be found by clicking on the 3 dots next to the table name.',
isRequired: true,
}),
viewId: option.string.layout({
label: 'View ID',
moreInfoTooltip:
'Can be found by clicking on the 3 dots next to the view name.',
}),
returnType: option.enum(['All', 'First', 'Last', 'Random']).layout({
accordion: 'Filter',
defaultValue: 'All',
}),
filter: option
.filter({
operators: filterOperators,
isJoinerHidden: ({ filter }) =>
!filter?.comparisons || filter.comparisons.length < 2,
})
.layout({
accordion: 'Filter',
}),
responseMapping: option
.array(
option.object({
fieldName: option.string.layout({
label: 'Enter a field name',
}),
variableId: option.string.layout({
inputType: 'variableDropdown',
}),
})
)
.layout({
accordion: 'Response Mapping',
}),
}),
getSetVariableIds: ({ responseMapping }) =>
responseMapping?.map((r) => r.variableId).filter(isDefined) ?? [],
run: {
server: async ({
credentials: { baseUrl, apiKey },
options: { tableId, responseMapping, filter, returnType, viewId },
variables,
logs,
}) => {
if (!apiKey) return logs.add('API key is required')
try {
const data = await ky
.get(
`${baseUrl ?? defaultBaseUrl}/api/v2/tables/${tableId}/records`,
{
headers: {
'xc-token': apiKey,
},
searchParams: parseSearchParams({
where: convertFilterToWhereClause(filter),
viewId,
limit: defaultLimitForSearch,
}),
}
)
.json<ListTableRecordsResponse>()
let filterIndex: number | undefined = undefined
if (returnType && returnType !== 'All') {
const total = data.pageInfo.totalRows
if (returnType === 'First') {
filterIndex = 0
} else if (returnType === 'Last') {
filterIndex = total - 1
} else if (returnType === 'Random') {
filterIndex = Math.floor(Math.random() * total)
}
}
const filteredList =
isDefined(filterIndex) && data.list[filterIndex]
? [data.list[filterIndex]]
: data.list
if (filteredList.length === 0)
return logs.add({
status: 'info',
description: `Couldn't find any rows matching the filter`,
details: JSON.stringify(filter, null, 2),
})
responseMapping?.forEach((mapping) => {
if (!mapping.fieldName || !mapping.variableId) return
if (!filteredList[0][mapping.fieldName]) {
logs.add(`Field ${mapping.fieldName} does not exist in the table`)
return
}
const items = filteredList.map(
(item) => item[mapping.fieldName as string]
)
variables.set(
mapping.variableId,
items.length === 1 ? items[0] : items
)
})
} catch (error) {
if (error instanceof HTTPError)
return logs.add({
status: 'error',
description: error.message,
details: await error.response.text(),
})
console.error(error)
}
},
},
})

View File

@ -0,0 +1,103 @@
import { createAction, option } from '@typebot.io/forge'
import { auth } from '../auth'
import ky, { HTTPError } from 'ky'
import { parseRecordsUpdateBody } from '../helpers/parseRecordsUpdateBody'
import {
defaultBaseUrl,
defaultLimitForSearch,
filterOperators,
} from '../constants'
import { parseSearchParams } from '../helpers/parseSearchParams'
import { convertFilterToWhereClause } from '../helpers/convertFilterToWhereClause'
import { ListTableRecordsResponse } from '../types'
export const updateExistingRecord = createAction({
auth,
name: 'Update Existing Record',
options: option.object({
tableId: option.string.layout({
label: 'Table ID',
isRequired: true,
moreInfoTooltip:
'Can be found by clicking on the 3 dots next to the table name.',
}),
viewId: option.string.layout({
label: 'View ID',
moreInfoTooltip:
'Can be found by clicking on the 3 dots next to the view name.',
}),
filter: option
.filter({
operators: filterOperators,
isJoinerHidden: ({ filter }) =>
!filter?.comparisons || filter.comparisons.length < 2,
})
.layout({
accordion: 'Select Records',
}),
updates: option
.array(
option.object({
fieldName: option.string.layout({
label: 'Enter a field name',
}),
value: option.string.layout({
placeholder: 'Enter a value',
}),
})
)
.layout({
accordion: 'Updates',
}),
}),
run: {
server: async ({
credentials: { baseUrl, apiKey },
options: { tableId, filter, viewId, updates },
logs,
}) => {
if (!apiKey) return logs.add('API key is required')
if (!updates) return logs.add('At least one update is required')
if (!filter?.comparisons || filter.comparisons.length === 0)
return logs.add('At least one filter is required')
try {
const listData = await ky
.get(
`${baseUrl ?? defaultBaseUrl}/api/v2/tables/${tableId}/records`,
{
headers: {
'xc-token': apiKey,
},
searchParams: parseSearchParams({
where: convertFilterToWhereClause(filter),
viewId,
limit: defaultLimitForSearch,
}),
}
)
.json<ListTableRecordsResponse>()
await ky.patch(
`${baseUrl ?? defaultBaseUrl}/api/v2/tables/${tableId}/records`,
{
headers: {
'xc-token': apiKey,
},
json: parseRecordsUpdateBody(
listData.list.map((item) => item.Id),
updates
),
}
)
} catch (error) {
if (error instanceof HTTPError)
return logs.add({
status: 'error',
description: error.message,
details: await error.response.text(),
})
console.error(error)
}
},
},
})

View File

@ -0,0 +1,24 @@
import { option, AuthDefinition } from '@typebot.io/forge'
import { defaultBaseUrl } from './constants'
export const auth = {
type: 'encryptedCredentials',
name: 'NocoDB account',
schema: option.object({
baseUrl: option.string.layout({
label: 'Base URL',
isRequired: true,
helperText: 'Change it only if you are self-hosting NocoDB.',
withVariableButton: false,
defaultValue: defaultBaseUrl,
}),
apiKey: option.string.layout({
label: 'API Token',
isRequired: true,
helperText:
'You can generate an API token [here](https://app.nocodb.com/#/account/tokens)',
inputType: 'password',
withVariableButton: false,
}),
}),
} satisfies AuthDefinition

View File

@ -0,0 +1,15 @@
export const defaultBaseUrl = 'https://app.nocodb.com'
export const filterOperators = [
'Equal to',
'Not equal',
'Contains',
'Greater than',
'Less than',
'Is set',
'Is empty',
'Starts with',
'Ends with',
] as const
export const defaultLimitForSearch = 1000

View File

@ -0,0 +1,50 @@
// See `where`: https://docs.nocodb.com/0.109.7/developer-resources/rest-apis/#query-params
// Example: (colName,eq,colValue)~or(colName2,gt,colValue2)
import { isEmpty } from '@typebot.io/lib'
export const convertFilterToWhereClause = (
filter:
| {
comparisons: {
input?: string
operator?: string
value?: string
}[]
joiner?: 'AND' | 'OR'
}
| undefined
): string | undefined => {
if (!filter || !filter.comparisons || filter.comparisons.length === 0) return
const where = filter.comparisons
.map((comparison) => {
if (!comparison.value) return ''
switch (comparison.operator) {
case 'Not equal':
return `(${comparison.input},ne,${comparison.value})`
case 'Contains':
return `(${comparison.input},like,%${comparison.value}%)`
case 'Greater than':
return `(${comparison.input},gt,${comparison.value})`
case 'Less than':
return `(${comparison.input},lt,${comparison.value})`
case 'Is set':
return `(${comparison.input},isnot,null)`
case 'Is empty':
return `(${comparison.input},is,null)`
case 'Starts with':
return `(${comparison.input},like,${comparison.value}%)`
case 'Ends with':
return `(${comparison.input},like,%${comparison.value})`
default:
return `(${comparison.input},eq,${comparison.value})`
}
})
.filter(Boolean)
.join('~' + (filter.joiner === 'OR' ? 'or' : 'and'))
if (isEmpty(where)) return
return where
}

View File

@ -0,0 +1,12 @@
export const parseRecordsCreateBody = (
fields: { key?: string; value?: string }[]
): Record<string, any> => {
const record: Record<string, any> = {}
fields.forEach(({ key, value }) => {
if (!key || !value) return
record[key] = value
})
return record
}

View File

@ -0,0 +1,16 @@
export const parseRecordsUpdateBody = (
ids: string[],
updates: { fieldName?: string; value?: string }[]
): Record<string, any>[] =>
ids.map((id) => {
const record: Record<string, any> = {
Id: id,
}
updates.forEach(({ fieldName, value }) => {
if (!fieldName) return
record[fieldName] = value ?? null
})
return record
})

View File

@ -0,0 +1,8 @@
export const parseSearchParams = (
records: Record<string, any>
): Record<string, string> => {
return Object.entries(records).reduce((acc, [key, value]) => {
if (value === null || value === undefined) return acc
return { ...acc, [key]: value.toString() }
}, {})
}

View File

@ -0,0 +1,16 @@
import { createBlock } from '@typebot.io/forge'
import { NocodbLogo } from './logo'
import { auth } from './auth'
import { searchRecords } from './actions/searchRecords'
import { createRecord } from './actions/createRecord'
import { updateExistingRecord } from './actions/updateExistingRecord'
export const nocodbBlock = createBlock({
id: 'nocodb',
name: 'NocoDB',
docsUrl: 'https://docs.typebot.io/forge/blocks/nocodb',
tags: ['database'],
LightLogo: NocodbLogo,
auth,
actions: [searchRecords, createRecord, updateExistingRecord],
})

View File

@ -0,0 +1,34 @@
/** @jsxImportSource react */
export const NocodbLogo = (props: React.SVGProps<SVGSVGElement>) => (
<svg
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
width="32"
height="32"
rx="4"
fill="url(#paint0_linear_1871_109536)"
></rect>
<path
d="M8.3335 15.1562L12.0047 18.8297V24.6454H8.3335V15.1562ZM23.7533 7.34649V24.0464C23.7533 24.3894 23.4738 24.6665 23.1309 24.6665C22.9665 24.6665 22.8092 24.6031 22.6917 24.4857L8.3335 11.5367V7.8726C8.3335 7.52968 8.61066 7.25253 8.95359 7.25253H8.98648C9.1509 7.25253 9.3106 7.31831 9.42569 7.4334L20.0798 16.6783V7.34649H23.7533Z"
fill="white"
></path>
<defs>
<linearGradient
id="paint0_linear_1871_109536"
x1="15.9976"
y1="42.7731"
x2="15.9976"
y2="-8.9707"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#4351E8"></stop>
<stop offset="1" stopColor="#2A1EA5"></stop>
</linearGradient>
</defs>
</svg>
)

View File

@ -0,0 +1,18 @@
{
"name": "@typebot.io/nocodb-block",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"keywords": [],
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@typebot.io/forge": "workspace:*",
"@typebot.io/lib": "workspace:*",
"@typebot.io/tsconfig": "workspace:*",
"@types/react": "18.2.15",
"typescript": "5.3.2"
},
"dependencies": {
"ky": "1.2.3"
}
}

View File

@ -0,0 +1,6 @@
// Do not edit this file manually
import { parseBlockCredentials, parseBlockSchema } from '@typebot.io/forge'
import { nocodbBlock } from '.'
export const nocodbBlockSchema = parseBlockSchema(nocodbBlock)
export const nocodbCredentialsSchema = parseBlockCredentials(nocodbBlock)

View File

@ -0,0 +1,11 @@
{
"extends": "@typebot.io/tsconfig/base.json",
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"],
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "react"
}
}

View File

@ -0,0 +1,10 @@
export type ListTableRecordsResponse = {
list: Array<Record<string, any>>
pageInfo: {
totalRows: number
page: number
pageSize: number
isFirstPage: boolean
isLastPage: boolean
}
}

View File

@ -110,18 +110,6 @@ export const option = {
z.enum(values).optional(), z.enum(values).optional(),
number: z.number().or(variableStringSchema).optional(), number: z.number().or(variableStringSchema).optional(),
array: <T extends z.ZodTypeAny>(schema: T) => z.array(schema).optional(), array: <T extends z.ZodTypeAny>(schema: T) => z.array(schema).optional(),
keyValueList: z
.array(
z.object({
key: z.string().optional().layout({
label: 'Key',
}),
value: z.string().optional().layout({
label: 'Value',
}),
})
)
.optional(),
discriminatedUnion: < discriminatedUnion: <
T extends string, T extends string,
J extends [ J extends [
@ -165,6 +153,49 @@ export const option = {
}) })
) )
.optional(), .optional(),
filter: ({
operators = defaultFilterOperators,
isJoinerHidden,
}: {
operators?: readonly [string, ...string[]]
isJoinerHidden: (currentObj: Record<string, any>) => boolean
}) =>
z
.object({
comparisons: z.array(
z.object({
input: z.string().optional().layout({ label: 'Enter a field ' }),
operator: z
.enum(operators)
.optional()
.layout({ defaultValue: 'Equal to' }),
value: z
.string()
.optional()
.layout({ placeholder: 'Enter a value' }),
})
),
joiner: z.enum(['AND', 'OR']).optional().layout({
placeholder: 'Select joiner',
isHidden: isJoinerHidden,
}),
})
.optional(),
} }
const defaultFilterOperators = [
'Equal to',
'Not equal',
'Contains',
'Does not contain',
'Greater than',
'Less than',
'Is set',
'Is empty',
'Starts with',
'Ends with',
'Matches regex',
'Does not match regex',
] as const
export type * from './types' export type * from './types'

View File

@ -20,7 +20,7 @@ export interface ZodLayoutMetadata<
itemLabel?: T extends OptionableZodType<ZodArray<any>> ? string : never itemLabel?: T extends OptionableZodType<ZodArray<any>> ? string : never
isOrdered?: T extends OptionableZodType<ZodArray<any>> ? boolean : never isOrdered?: T extends OptionableZodType<ZodArray<any>> ? boolean : never
moreInfoTooltip?: string moreInfoTooltip?: string
isHidden?: boolean isHidden?: boolean | ((currentObj: Record<string, any>) => boolean)
isDebounceDisabled?: boolean isDebounceDisabled?: boolean
hiddenItems?: string[] hiddenItems?: string[]
} }

View File

@ -0,0 +1,9 @@
export const evaluateIsHidden = (
isHidden: boolean | ((obj: any) => boolean) | undefined,
obj: any
): boolean => {
if (typeof isHidden === 'function') {
return isHidden(obj)
}
return isHidden ?? false
}

View File

@ -13,4 +13,5 @@ export const forgedBlockIds = [
'anthropic', 'anthropic',
'together-ai', 'together-ai',
'open-router', 'open-router',
'nocodb',
] as const satisfies ForgedBlock['type'][] ] as const satisfies ForgedBlock['type'][]

View File

@ -20,6 +20,8 @@ import { togetherAiBlock } from '@typebot.io/together-ai-block'
import { togetherAiCredentialsSchema } from '@typebot.io/together-ai-block/schemas' import { togetherAiCredentialsSchema } from '@typebot.io/together-ai-block/schemas'
import { zemanticAiBlock } from '@typebot.io/zemantic-ai-block' import { zemanticAiBlock } from '@typebot.io/zemantic-ai-block'
import { zemanticAiCredentialsSchema } from '@typebot.io/zemantic-ai-block/schemas' import { zemanticAiCredentialsSchema } from '@typebot.io/zemantic-ai-block/schemas'
import { nocodbBlock } from '@typebot.io/nocodb-block'
import { nocodbCredentialsSchema } from '@typebot.io/nocodb-block/schemas'
export const forgedCredentialsSchemas = { export const forgedCredentialsSchemas = {
[openAIBlock.id]: openAICredentialsSchema, [openAIBlock.id]: openAICredentialsSchema,
@ -33,4 +35,5 @@ export const forgedCredentialsSchemas = {
[anthropicBlock.id]: anthropicCredentialsSchema, [anthropicBlock.id]: anthropicCredentialsSchema,
[togetherAiBlock.id]: togetherAiCredentialsSchema, [togetherAiBlock.id]: togetherAiCredentialsSchema,
[openRouterBlock.id]: openRouterCredentialsSchema, [openRouterBlock.id]: openRouterCredentialsSchema,
[nocodbBlock.id]: nocodbCredentialsSchema,
} }

View File

@ -10,6 +10,7 @@ import { chatNodeBlock } from '@typebot.io/chat-node-block'
import { calComBlock } from '@typebot.io/cal-com-block' import { calComBlock } from '@typebot.io/cal-com-block'
import { zemanticAiBlock } from '@typebot.io/zemantic-ai-block' import { zemanticAiBlock } from '@typebot.io/zemantic-ai-block'
import { openAIBlock } from '@typebot.io/openai-block' import { openAIBlock } from '@typebot.io/openai-block'
import { nocodbBlock } from '@typebot.io/nocodb-block'
export const forgedBlocks = { export const forgedBlocks = {
[openAIBlock.id]: openAIBlock, [openAIBlock.id]: openAIBlock,
@ -23,4 +24,5 @@ export const forgedBlocks = {
[anthropicBlock.id]: anthropicBlock, [anthropicBlock.id]: anthropicBlock,
[togetherAiBlock.id]: togetherAiBlock, [togetherAiBlock.id]: togetherAiBlock,
[openRouterBlock.id]: openRouterBlock, [openRouterBlock.id]: openRouterBlock,
[nocodbBlock.id]: nocodbBlock,
} }

View File

@ -17,6 +17,7 @@
"@typebot.io/elevenlabs-block": "workspace:*", "@typebot.io/elevenlabs-block": "workspace:*",
"@typebot.io/anthropic-block": "workspace:*", "@typebot.io/anthropic-block": "workspace:*",
"@typebot.io/together-ai-block": "workspace:*", "@typebot.io/together-ai-block": "workspace:*",
"@typebot.io/open-router-block": "workspace:*" "@typebot.io/open-router-block": "workspace:*",
"@typebot.io/nocodb-block": "workspace:*"
} }
} }

View File

@ -21,6 +21,8 @@ import { togetherAiBlock } from '@typebot.io/together-ai-block'
import { togetherAiBlockSchema } from '@typebot.io/together-ai-block/schemas' import { togetherAiBlockSchema } from '@typebot.io/together-ai-block/schemas'
import { zemanticAiBlock } from '@typebot.io/zemantic-ai-block' import { zemanticAiBlock } from '@typebot.io/zemantic-ai-block'
import { zemanticAiBlockSchema } from '@typebot.io/zemantic-ai-block/schemas' import { zemanticAiBlockSchema } from '@typebot.io/zemantic-ai-block/schemas'
import { nocodbBlock } from '@typebot.io/nocodb-block'
import { nocodbBlockSchema } from '@typebot.io/nocodb-block/schemas'
export const forgedBlockSchemas = { export const forgedBlockSchemas = {
[openAIBlock.id]: openAIBlockSchema, [openAIBlock.id]: openAIBlockSchema,
@ -34,4 +36,5 @@ export const forgedBlockSchemas = {
[anthropicBlock.id]: anthropicBlockSchema, [anthropicBlock.id]: anthropicBlockSchema,
[togetherAiBlock.id]: togetherAiBlockSchema, [togetherAiBlock.id]: togetherAiBlockSchema,
[openRouterBlock.id]: openRouterBlockSchema, [openRouterBlock.id]: openRouterBlockSchema,
[nocodbBlock.id]: nocodbBlockSchema,
} }

31
pnpm-lock.yaml generated
View File

@ -1420,6 +1420,28 @@ importers:
specifier: 5.4.5 specifier: 5.4.5
version: 5.4.5 version: 5.4.5
packages/forge/blocks/nocodb:
dependencies:
ky:
specifier: 1.2.3
version: 1.2.3
devDependencies:
'@typebot.io/forge':
specifier: workspace:*
version: link:../../core
'@typebot.io/lib':
specifier: workspace:*
version: link:../../../lib
'@typebot.io/tsconfig':
specifier: workspace:*
version: link:../../../tsconfig
'@types/react':
specifier: 18.2.15
version: 18.2.15
typescript:
specifier: 5.3.2
version: 5.3.2
packages/forge/blocks/openRouter: packages/forge/blocks/openRouter:
devDependencies: devDependencies:
'@typebot.io/forge': '@typebot.io/forge':
@ -1596,6 +1618,9 @@ importers:
'@typebot.io/mistral-block': '@typebot.io/mistral-block':
specifier: workspace:* specifier: workspace:*
version: link:../blocks/mistral version: link:../blocks/mistral
'@typebot.io/nocodb-block':
specifier: workspace:*
version: link:../blocks/nocodb
'@typebot.io/open-router-block': '@typebot.io/open-router-block':
specifier: workspace:* specifier: workspace:*
version: link:../blocks/openRouter version: link:../blocks/openRouter
@ -22392,6 +22417,12 @@ packages:
hasBin: true hasBin: true
dev: false dev: false
/typescript@5.3.2:
resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/typescript@5.4.5: /typescript@5.4.5:
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}