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

View File

@ -1,6 +1,7 @@
import { NumberInput, TextInput, Textarea } from '@/components/inputs'
import { z } 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 { ZodTypeAny } from 'zod'
import { ForgeSelectInput } from '../ForgeSelectInput'
@ -64,7 +65,7 @@ export const ZodFieldLayout = ({
const innerSchema = getZodInnerSchema(schema)
const layout = innerSchema._def.layout
if (layout?.isHidden) return null
if (evaluateIsHidden(layout?.isHidden, blockOptions)) return null
switch (innerSchema._def.typeName) {
case 'ZodObject':

View File

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

View File

@ -2,11 +2,6 @@
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
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/elevenlabs",
"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",
"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"

View File

@ -11513,6 +11513,242 @@
"id",
"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"

View File

@ -29,7 +29,18 @@ export const createChatMessage = createAction({
moreInfoTooltip:
'The user identifier, defined by the developer, must ensure uniqueness within the app.',
}),
inputs: option.keyValueList.layout({
inputs: option
.array(
option.object({
key: option.string.layout({
label: 'Key',
}),
value: option.string.layout({
label: 'Value',
}),
})
)
.layout({
accordion: 'Inputs',
}),
responseMapping: option

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(),
number: z.number().or(variableStringSchema).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: <
T extends string,
J extends [
@ -165,6 +153,49 @@ export const option = {
})
)
.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'

View File

@ -20,7 +20,7 @@ export interface ZodLayoutMetadata<
itemLabel?: T extends OptionableZodType<ZodArray<any>> ? string : never
isOrdered?: T extends OptionableZodType<ZodArray<any>> ? boolean : never
moreInfoTooltip?: string
isHidden?: boolean
isHidden?: boolean | ((currentObj: Record<string, any>) => boolean)
isDebounceDisabled?: boolean
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',
'together-ai',
'open-router',
'nocodb',
] 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 { zemanticAiBlock } from '@typebot.io/zemantic-ai-block'
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 = {
[openAIBlock.id]: openAICredentialsSchema,
@ -33,4 +35,5 @@ export const forgedCredentialsSchemas = {
[anthropicBlock.id]: anthropicCredentialsSchema,
[togetherAiBlock.id]: togetherAiCredentialsSchema,
[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 { zemanticAiBlock } from '@typebot.io/zemantic-ai-block'
import { openAIBlock } from '@typebot.io/openai-block'
import { nocodbBlock } from '@typebot.io/nocodb-block'
export const forgedBlocks = {
[openAIBlock.id]: openAIBlock,
@ -23,4 +24,5 @@ export const forgedBlocks = {
[anthropicBlock.id]: anthropicBlock,
[togetherAiBlock.id]: togetherAiBlock,
[openRouterBlock.id]: openRouterBlock,
[nocodbBlock.id]: nocodbBlock,
}

View File

@ -17,6 +17,7 @@
"@typebot.io/elevenlabs-block": "workspace:*",
"@typebot.io/anthropic-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 { zemanticAiBlock } from '@typebot.io/zemantic-ai-block'
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 = {
[openAIBlock.id]: openAIBlockSchema,
@ -34,4 +36,5 @@ export const forgedBlockSchemas = {
[anthropicBlock.id]: anthropicBlockSchema,
[togetherAiBlock.id]: togetherAiBlockSchema,
[openRouterBlock.id]: openRouterBlockSchema,
[nocodbBlock.id]: nocodbBlockSchema,
}

31
pnpm-lock.yaml generated
View File

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