Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea04a9ab0d | ||
|
|
8049394c97 | ||
|
|
31842f7714 | ||
|
|
9988f98577 | ||
|
|
3c458ce4ca | ||
|
|
b869107a22 | ||
|
|
4c4dc4cb21 | ||
|
|
237a943947 | ||
|
|
6e5e7963b5 | ||
|
|
a2acf88a57 | ||
|
|
c002bc52bd | ||
|
|
127fb2a470 | ||
|
|
3d49c17e34 | ||
|
|
b454ad2aea | ||
|
|
fb47e4497a | ||
|
|
00fad657f4 | ||
|
|
c800eccc65 | ||
|
|
a26fe3bb65 | ||
|
|
8d81496625 | ||
|
|
f1b7c21b6c | ||
|
|
f630ce34fe | ||
|
|
7ebcbe8801 | ||
|
|
b821061526 | ||
|
|
f3aadbbb66 | ||
|
|
e494bc7006 | ||
|
|
91e0e07af4 | ||
|
|
7ab6f5719f |
@@ -50,4 +50,4 @@ runs:
|
||||
- name: Deploy
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty deploy --remote target
|
||||
run: yarn twenty app:publish --private --remote target
|
||||
|
||||
@@ -50,4 +50,4 @@ runs:
|
||||
- name: Install
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty install --remote target
|
||||
run: yarn twenty app:install --remote target
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
create-twenty-app --version
|
||||
mkdir -p /tmp/e2e-test-workspace
|
||||
cd /tmp/e2e-test-workspace
|
||||
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --workspace-url http://localhost:3000
|
||||
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --url http://localhost:3000
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
- name: Authenticate with twenty-server
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
npx --no-install twenty remote:add --api-key ${{ env.TWENTY_API_KEY }} --url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
|
||||
run: |
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
steps:
|
||||
- name: Trigger preview environment workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: 'Website Preview Dispatch'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed, labeled]
|
||||
paths:
|
||||
- packages/twenty-website/**
|
||||
- .github/workflows/website-preview-dispatch.yaml
|
||||
|
||||
concurrency:
|
||||
# Keyed on PR number so independent PRs don't cancel each other. `github.ref`
|
||||
# would resolve to the base branch under pull_request and collide.
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-build:
|
||||
# Same fork PRs from outside the org don't have `secrets.*` so the dispatch
|
||||
# call would fail anyway — skip explicitly to avoid noise.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action != 'closed' && (
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'preview-website') ||
|
||||
(
|
||||
(
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR'
|
||||
) && contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action)
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch website-preview-build to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=website-preview-build \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER" \
|
||||
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
|
||||
-f "client_payload[pr_head_ref]=$PR_HEAD_REF"
|
||||
|
||||
trigger-cleanup:
|
||||
# Covers both merge and close-without-merge — pull_request `closed` fires
|
||||
# for both. PRs left open forever are covered by OpenNext's
|
||||
# `maxVersionAgeDays: 14` + `maxNumberOfVersions: 50` auto-pruning in
|
||||
# open-next.config.ts, so nothing leaks even if cleanup never runs.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch website-preview-cleanup to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=website-preview-cleanup \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER"
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": [
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/lib/**",
|
||||
"**/.next/**",
|
||||
"**/coverage/**",
|
||||
"**/generated/**",
|
||||
"**/generated-admin/**",
|
||||
"**/generated-metadata/**",
|
||||
"**/.cache/**",
|
||||
"**/node_modules/**",
|
||||
"**/*.min.js",
|
||||
"**/*.snap",
|
||||
"**/*.md",
|
||||
"**/*.mdx",
|
||||
"**/seed-project/**/*.mjs",
|
||||
"packages/twenty-zapier/build/**"
|
||||
]
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default defineObject({
|
||||
Then ship it to your workspace:
|
||||
|
||||
```bash
|
||||
npx twenty deploy
|
||||
npx twenty app:publish --private
|
||||
```
|
||||
|
||||
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
|
||||
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {},
|
||||
"fix": {
|
||||
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
|
||||
"command": "npx oxlint --fix -c .oxlintrc.json . && npx oxfmt ."
|
||||
}
|
||||
},
|
||||
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
|
||||
@@ -59,12 +59,12 @@
|
||||
"cache": false,
|
||||
"dependsOn": ["twenty-oxlint-rules:build"],
|
||||
"options": {
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
|
||||
"pattern": "\\.(ts|tsx|js|jsx)$"
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && npx oxfmt $FILES)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -73,18 +73,14 @@
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "prettier {args.files} --check --cache {args.cache} --cache-location {args.cacheLocation} --write {args.write} --cache-strategy {args.cacheStrategy}",
|
||||
"cache": true,
|
||||
"cacheLocation": "../../.cache/prettier/{projectRoot}",
|
||||
"cacheStrategy": "metadata",
|
||||
"write": false
|
||||
"command": "npx oxfmt --check {args.files} {args.write}",
|
||||
"files": ".",
|
||||
"write": ""
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"cacheStrategy": "content"
|
||||
},
|
||||
"ci": {},
|
||||
"fix": {
|
||||
"write": true
|
||||
"command": "npx oxfmt {args.files}"
|
||||
}
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"concurrently": "^8.2.2",
|
||||
"http-server": "^14.1.1",
|
||||
"nx": "22.5.4",
|
||||
"oxfmt": "0.50.0",
|
||||
"tsx": "^4.17.0",
|
||||
"verdaccio": "^6.3.1"
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ The scaffolder will:
|
||||
| `--name <name>` | Set the app name |
|
||||
| `--display-name <displayName>` | Set the display name |
|
||||
| `--description <description>` | Set the description |
|
||||
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
|
||||
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
|
||||
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
|
||||
|
||||
## Documentation
|
||||
@@ -48,8 +48,8 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
|
||||
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
|
||||
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
|
||||
- Auth not working: run `yarn twenty remote:add --local` to re-authenticate.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.5.1",
|
||||
"version": "2.7.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -18,11 +18,8 @@ const program = new Command(packageJson.name)
|
||||
.option('-n, --name <name>', 'Application name')
|
||||
.option('-d, --display-name <displayName>', 'Application display name')
|
||||
.option('--description <description>', 'Application description')
|
||||
.option(
|
||||
'--workspace-url <workspaceUrl>',
|
||||
'Twenty workspace URL (default: http://localhost:2020)',
|
||||
)
|
||||
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
|
||||
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
|
||||
.option('--api-url <apiUrl>', '[deprecated: use --url]')
|
||||
.option(
|
||||
'--authentication-method <method>',
|
||||
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
|
||||
@@ -35,7 +32,7 @@ const program = new Command(packageJson.name)
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
workspaceUrl?: string;
|
||||
url?: string;
|
||||
apiUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
},
|
||||
@@ -68,18 +65,18 @@ const program = new Command(packageJson.name)
|
||||
|
||||
if (options?.apiUrl) {
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
'Warning: --api-url is deprecated. Use --workspace-url instead.',
|
||||
),
|
||||
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
|
||||
);
|
||||
}
|
||||
|
||||
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
|
||||
|
||||
await new CreateAppCommand().execute({
|
||||
directory,
|
||||
name: options?.name,
|
||||
displayName: options?.displayName,
|
||||
description: options?.description,
|
||||
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
|
||||
serverUrl,
|
||||
authenticationMethod: options?.authenticationMethod,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -49,19 +49,19 @@
|
||||
|
||||
## Best practice
|
||||
|
||||
It's highly recommended to create new app entities using `yarn twenty add`. These are the options:
|
||||
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
|
||||
|
||||
| Entity type | Command | Generated file |
|
||||
| -------------------- | ------------------------------------ | ------------------------------------- |
|
||||
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
| Entity type | Command | Generated file |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------- |
|
||||
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
|
||||
This helps automatically generate required IDs etc.
|
||||
|
||||
@@ -11,8 +11,8 @@ Run `yarn twenty help` to list all available commands.
|
||||
## Useful Commands
|
||||
|
||||
- `yarn twenty dev` - Start the development server and sync your app
|
||||
- `yarn twenty server status` - Check the local Twenty server status
|
||||
- `yarn twenty server start` - Start the local Twenty server
|
||||
- `yarn twenty docker:status` - Check the local Twenty server status
|
||||
- `yarn twenty docker:start` - Start the local Twenty server
|
||||
- `yarn test` - Run integration tests
|
||||
|
||||
## Learn More
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
+28
-16
@@ -1,4 +1,11 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
Avatar,
|
||||
IconBox,
|
||||
IconHierarchy,
|
||||
IconLayout,
|
||||
IconSettingsAutomation,
|
||||
} from 'twenty-sdk/ui';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -30,7 +37,7 @@ const CATEGORIES = [
|
||||
href: `${DOCS_BASE_URL}/logic/logic-functions`,
|
||||
},
|
||||
{
|
||||
label: 'SERVERLESS FUNCT.',
|
||||
label: 'LOGIC FUNCTION',
|
||||
href: `${DOCS_BASE_URL}/logic/logic-functions`,
|
||||
},
|
||||
{
|
||||
@@ -59,15 +66,6 @@ const CATEGORIES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ItemIcon = ({ color }: { color: string }) => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="2" y="2" width="5" height="5" rx="1" fill={color} />
|
||||
<rect x="9" y="2" width="5" height="5" rx="1" fill={color} opacity="0.6" />
|
||||
<rect x="2" y="9" width="5" height="5" rx="1" fill={color} opacity="0.6" />
|
||||
<rect x="9" y="9" width="5" height="5" rx="1" fill={color} opacity="0.3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path
|
||||
@@ -93,6 +91,18 @@ const CategoryCard = ({
|
||||
}) => {
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
|
||||
const CategoryIcon = () => {
|
||||
if (title === 'Data model') {
|
||||
return <IconHierarchy color={color} size={'20px'} />;
|
||||
}
|
||||
if (title === 'Logic') {
|
||||
return <IconSettingsAutomation color={color} size={'20px'} />;
|
||||
}
|
||||
if (title === 'Layout') {
|
||||
return <IconLayout color={color} size={'20px'} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -111,8 +121,12 @@ const CategoryCard = ({
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: `${color}22`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<CategoryIcon />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
@@ -154,7 +168,7 @@ const CategoryCard = ({
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
<ItemIcon color={color} />
|
||||
<IconBox color={color} size={'20px'} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
@@ -190,13 +204,11 @@ const MainPage = () => {
|
||||
padding: '40px',
|
||||
}}
|
||||
>
|
||||
{/*<Avatar
|
||||
avatarUrl={getPublicAssetUrl('logo.svg')}
|
||||
<Avatar
|
||||
placeholder={APP_DISPLAY_NAME}
|
||||
placeholderColorSeed={APP_DISPLAY_NAME}
|
||||
type="squared"
|
||||
size="xl"
|
||||
/>*/}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
@@ -220,7 +232,7 @@ const MainPage = () => {
|
||||
You can now add content to your app.
|
||||
</span>
|
||||
<a
|
||||
href="/settings/applications"
|
||||
href="/settings/applications#installed"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
DEV_API_URL,
|
||||
serverStart,
|
||||
} from 'twenty-sdk/cli';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
|
||||
import {
|
||||
getDockerInstallInstructions,
|
||||
isDockerInstalled,
|
||||
@@ -33,7 +33,7 @@ type CreateAppOptions = {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
workspaceUrl?: string;
|
||||
serverUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
};
|
||||
|
||||
@@ -45,9 +45,9 @@ export class CreateAppCommand {
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
this.getAppInfos(options);
|
||||
|
||||
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
|
||||
const serverUrl = options.serverUrl ?? DEV_API_URL;
|
||||
|
||||
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
|
||||
const skipLocalInstance = serverUrl !== DEV_API_URL;
|
||||
|
||||
if (!skipLocalInstance && !isDockerInstalled()) {
|
||||
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
|
||||
@@ -118,7 +118,7 @@ export class CreateAppCommand {
|
||||
console.log('');
|
||||
|
||||
let authSucceeded = false;
|
||||
let resolvedWorkspaceUrl = workspaceUrl;
|
||||
let resolvedServerUrl = serverUrl;
|
||||
let serverReady = skipLocalInstance;
|
||||
|
||||
if (!skipLocalInstance) {
|
||||
@@ -126,7 +126,7 @@ export class CreateAppCommand {
|
||||
const serverResult = await this.ensureDockerServer(dockerPullPromise);
|
||||
|
||||
if (isDefined(serverResult.url)) {
|
||||
resolvedWorkspaceUrl = serverResult.url;
|
||||
resolvedServerUrl = serverResult.url;
|
||||
serverReady = true;
|
||||
}
|
||||
}
|
||||
@@ -134,18 +134,16 @@ export class CreateAppCommand {
|
||||
if (serverReady) {
|
||||
this.logNextStep('Authenticating');
|
||||
|
||||
authSucceeded = await this.tryExistingAuth(resolvedWorkspaceUrl);
|
||||
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
|
||||
|
||||
if (authSucceeded) {
|
||||
this.logDetail('Reusing existing credentials');
|
||||
} else if (authenticationMethod === 'oauth') {
|
||||
this.logDetail('Starting OAuth flow');
|
||||
authSucceeded =
|
||||
await this.authenticateWithOAuth(resolvedWorkspaceUrl);
|
||||
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
|
||||
} else {
|
||||
this.logDetail('Using development API key');
|
||||
authSucceeded =
|
||||
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
|
||||
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,10 +163,10 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
if (syncSucceeded) {
|
||||
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
|
||||
await this.openMainPage(appDirectory, resolvedServerUrl);
|
||||
}
|
||||
|
||||
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
|
||||
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('\nCreate application failed:'),
|
||||
@@ -315,7 +313,7 @@ export class CreateAppCommand {
|
||||
|
||||
private async openMainPage(
|
||||
appDirectory: string,
|
||||
workspaceUrl: string,
|
||||
serverUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const configService = new ConfigService();
|
||||
@@ -328,7 +326,7 @@ export class CreateAppCommand {
|
||||
|
||||
const [universalIdentifier, frontUrl] = await Promise.all([
|
||||
this.readMainPageLayoutUniversalIdentifier(appDirectory),
|
||||
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
|
||||
this.resolveWorkspaceFrontUrl(serverUrl, token),
|
||||
]);
|
||||
|
||||
if (!universalIdentifier || !frontUrl) {
|
||||
@@ -336,7 +334,7 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
const pageLayoutId = await this.resolvePageLayoutId(
|
||||
workspaceUrl,
|
||||
serverUrl,
|
||||
universalIdentifier,
|
||||
token,
|
||||
);
|
||||
@@ -355,12 +353,12 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
private async resolveWorkspaceFrontUrl(
|
||||
workspaceUrl: string,
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
|
||||
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
const response = await fetch(`${serverUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -389,7 +387,7 @@ export class CreateAppCommand {
|
||||
|
||||
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
|
||||
|
||||
return frontUrl?.replace(/\/+$/, '') ?? null;
|
||||
return frontUrl ? normalizeUrl(frontUrl) : null;
|
||||
}
|
||||
|
||||
private async readMainPageLayoutUniversalIdentifier(
|
||||
@@ -410,13 +408,13 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
private async resolvePageLayoutId(
|
||||
workspaceUrl: string,
|
||||
serverUrl: string,
|
||||
universalIdentifier: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ getPageLayouts { id universalIdentifier } }`;
|
||||
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
const response = await fetch(`${serverUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -503,7 +501,7 @@ export class CreateAppCommand {
|
||||
});
|
||||
}
|
||||
|
||||
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
|
||||
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const configService = new ConfigService();
|
||||
const remoteNames = await configService.getRemotes();
|
||||
@@ -511,7 +509,7 @@ export class CreateAppCommand {
|
||||
for (const remoteName of remoteNames) {
|
||||
const remoteConfig = await configService.getConfigForRemote(remoteName);
|
||||
|
||||
if (remoteConfig.apiUrl !== workspaceUrl) {
|
||||
if (remoteConfig.apiUrl !== serverUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -521,7 +519,7 @@ export class CreateAppCommand {
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
const response = await fetch(`${serverUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -555,11 +553,11 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
|
||||
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await authLogin({
|
||||
apiKey: DEV_API_KEY,
|
||||
apiUrl: workspaceUrl,
|
||||
apiUrl: serverUrl,
|
||||
remote: 'local',
|
||||
});
|
||||
|
||||
@@ -574,7 +572,7 @@ export class CreateAppCommand {
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -582,7 +580,7 @@ export class CreateAppCommand {
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -598,21 +596,21 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
|
||||
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const remoteName = this.deriveRemoteName(workspaceUrl);
|
||||
const remoteName = this.deriveRemoteName(serverUrl);
|
||||
|
||||
ConfigService.setActiveRemote(remoteName);
|
||||
|
||||
this.logDetail('Opening browser for OAuth...');
|
||||
|
||||
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
|
||||
const result = await authLoginOAuth({ apiUrl: serverUrl });
|
||||
|
||||
if (result.success) {
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setDefaultRemote(remoteName);
|
||||
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
|
||||
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -620,7 +618,7 @@ export class CreateAppCommand {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` OAuth failed: ${result.error.message}\n` +
|
||||
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
|
||||
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -628,7 +626,7 @@ export class CreateAppCommand {
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
|
||||
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -638,7 +636,7 @@ export class CreateAppCommand {
|
||||
|
||||
private logSuccess(
|
||||
appDirectory: string,
|
||||
workspaceUrl: string,
|
||||
serverUrl: string,
|
||||
authSucceeded: boolean,
|
||||
): void {
|
||||
const dirName = basename(appDirectory);
|
||||
@@ -656,9 +654,7 @@ export class CreateAppCommand {
|
||||
if (!authSucceeded) {
|
||||
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
' yarn twenty remote add --api-url <your-instance-url>\n',
|
||||
),
|
||||
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
|
||||
);
|
||||
stepNumber++;
|
||||
}
|
||||
@@ -668,7 +664,7 @@ export class CreateAppCommand {
|
||||
stepNumber++;
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
|
||||
console.log(chalk.cyan(` ${workspaceUrl}\n`));
|
||||
console.log(chalk.cyan(` ${serverUrl}\n`));
|
||||
|
||||
console.log(
|
||||
chalk.gray(
|
||||
|
||||
@@ -17,6 +17,7 @@ const UNIVERSAL_IDENTIFIERS_PATH = join(
|
||||
'constants',
|
||||
'universal-identifiers.ts',
|
||||
);
|
||||
const YARNRC_PATH = 'yarnrc.yml';
|
||||
|
||||
// Template content matching template/src/constants/universal-identifiers.ts
|
||||
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
|
||||
@@ -173,6 +174,27 @@ describe('copyBaseApplicationProject', () => {
|
||||
expect(publicDirectoryContents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
|
||||
await fs.writeFile(
|
||||
join(testAppDirectory, YARNRC_PATH),
|
||||
'nodeLinker: node-modules',
|
||||
);
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty description', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
|
||||
@@ -22,7 +22,7 @@ export const copyBaseApplicationProject = async ({
|
||||
onProgress?.('Copying base template');
|
||||
await fs.copy(join(__dirname, './constants/template'), appDirectory);
|
||||
|
||||
onProgress?.('Configuring dotfiles (.gitignore, .github)');
|
||||
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
|
||||
await renameDotfiles({ appDirectory });
|
||||
|
||||
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
|
||||
@@ -47,6 +47,7 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
|
||||
const renames = [
|
||||
{ from: 'gitignore', to: '.gitignore' },
|
||||
{ from: 'github', to: '.github' },
|
||||
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
|
||||
];
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
|
||||
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
|
||||
' Then run this command again.',
|
||||
'',
|
||||
' Alternatively, connect to an existing Twenty instance:',
|
||||
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
|
||||
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ yarn install
|
||||
# Register your local Twenty server as a remote (interactive prompt).
|
||||
# When asked for the URL use http://localhost:2021 and paste an API key
|
||||
# from Settings -> Developers in the Twenty UI.
|
||||
yarn twenty remote add
|
||||
yarn twenty remote:add
|
||||
|
||||
# Build, install, and watch for changes.
|
||||
yarn twenty dev
|
||||
@@ -107,8 +107,8 @@ watching `src/`. Edit any file and the change is re-synced within seconds.
|
||||
```bash
|
||||
cd packages/twenty-apps/community/github-connector
|
||||
yarn install
|
||||
yarn twenty remote add # same prompts as above
|
||||
yarn twenty install # builds and installs once
|
||||
yarn twenty remote:add # same prompts as above
|
||||
yarn twenty app:install # builds and installs once
|
||||
```
|
||||
|
||||
## Configure authentication
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote:status # Check auth status
|
||||
yarn twenty remote:use # Set default remote
|
||||
yarn twenty remote:list # List all configured remotes
|
||||
yarn twenty remote:remove <name> # Remove a remote
|
||||
|
||||
# Application
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty uninstall # Uninstall app from workspace
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty dev:function:logs # Stream function logs
|
||||
yarn twenty dev:function:exec # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
@@ -13,7 +13,7 @@ beforeAll(async () => {
|
||||
if (!apiUrl || !token) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev": "twenty dev",
|
||||
"exec": "twenty exec",
|
||||
"uninstall": "twenty uninstall",
|
||||
"exec": "twenty dev:fn-exec",
|
||||
"uninstall": "twenty app:uninstall",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote:status # Check auth status
|
||||
yarn twenty remote:use # Set default remote
|
||||
yarn twenty remote:list # List all configured remotes
|
||||
yarn twenty remote:remove <name> # Remove a remote
|
||||
|
||||
# Application
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty uninstall # Uninstall app from workspace
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty dev:function:logs # Stream function logs
|
||||
yarn twenty dev:function:exec # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
@@ -13,7 +13,7 @@ beforeAll(async () => {
|
||||
if (!apiUrl || !token) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,15 +64,15 @@ cd packages/twenty-apps/internal/twenty-linear
|
||||
# For day-to-day development (publish + install + watch in one):
|
||||
yarn twenty dev
|
||||
|
||||
# Manual publish flow (deploy registers the app, install activates it):
|
||||
yarn twenty deploy
|
||||
yarn twenty install
|
||||
# Manual publish flow (publish registers the app, install activates it):
|
||||
yarn twenty app:publish --private
|
||||
yarn twenty app:install
|
||||
```
|
||||
|
||||
`twenty dev` is recommended for iteration — it publishes, installs, and
|
||||
watches for changes in one command. Use `twenty deploy` + `twenty install`
|
||||
when you want to control each step separately (e.g. deploying to a
|
||||
production server without auto-installing).
|
||||
watches for changes in one command. Use `twenty app:publish --private` +
|
||||
`twenty app:install` when you want to control each step separately (e.g.
|
||||
deploying to a production server without auto-installing).
|
||||
|
||||
This serves as the reference implementation for Twenty's
|
||||
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"ignorePatterns": ["node_modules", "dist"],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"typescript/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"typescript/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
# twenty-slack
|
||||
|
||||
Slack tools for **Twenty workflows** and **agents** (the same logic functions
|
||||
are available as workflow steps and as tools where your deployment exposes
|
||||
them). Uses the official
|
||||
[`@slack/web-api`](https://github.com/slackapi/node-slack-sdk) `WebClient`
|
||||
(Slack retries and error types).
|
||||
|
||||
## What you can do
|
||||
|
||||
Once the app is installed and Slack is **connected** (see **Twenty setup**
|
||||
below):
|
||||
|
||||
- **Workflow steps** — post, update, or delete bot messages; send ephemerals;
|
||||
add reactions; list channels. Pick a **workspace shared** or **just for me**
|
||||
connection; steps run with that token.
|
||||
- **Agents / AI** — when your Twenty instance surfaces app tools to the model,
|
||||
these functions can be invoked the same way as other app logic functions.
|
||||
- **Quick-send** — command menu **Send Slack message** opens a side panel to
|
||||
pick a channel and post (same Slack connection as workflows).
|
||||
|
||||
## Tools
|
||||
|
||||
| Name | Slack API |
|
||||
|------|-----------|
|
||||
| `slack-post-message` | `chat.postMessage` |
|
||||
| `slack-post-ephemeral-message` | `chat.postEphemeral` |
|
||||
| `slack-update-message` | `chat.update` |
|
||||
| `slack-delete-message` | `chat.delete` |
|
||||
| `slack-add-reaction` | `reactions.add` |
|
||||
| `slack-list-channels` | `conversations.list` |
|
||||
|
||||
### Workflow field names (for authors)
|
||||
|
||||
Fields use camelCase names in the step UI, for example **`slackChannelId`** (Slack channel or DM: **name** or **ID**), **`messageText`**, and **`messageTimestamp`** (Slack’s per-message id — same value as tool output **`slackTs`** when chaining steps). Optional **`parentMessageTimestamp`** is only for **thread replies**. Post / update / ephemeral steps support optional **`messageFormat`**: **`markdown`** sends the body as Slack **`markdown_text`** (e.g. **`**bold**`**), **`plain`** sends **`text`** with markup disabled, omit uses Slack’s default for **`text`**. Ephemeral steps use **`recipientSlackUserId`**; reactions use **`emojiName`** (Slack shortcode, for example `white_check_mark`). Updating a message uses **`newMessageText`**.
|
||||
|
||||
### Quick-send command menu item
|
||||
|
||||
This app also ships a global command menu item — **Send Slack message** — that opens a side-panel form to pick a channel (from `conversations.list`) and post a message via `chat.postMessage`. The form is backed by two HTTP routes exposed by the app:
|
||||
|
||||
- `GET /slack/channels` — lists channels visible to the bot (mirrors `slack-list-channels`).
|
||||
- `POST /slack/messages` — posts a message (mirrors `slack-post-message`).
|
||||
|
||||
Both routes require an authenticated Twenty user and use the same shared Slack connection as the workflow tools.
|
||||
|
||||
### Prerequisites (Slack workspace)
|
||||
|
||||
- You can **install** the Slack app on a workspace you administer (or get an
|
||||
admin to approve it).
|
||||
- For **posting**: invite the bot to the channel, **or** rely on
|
||||
**`chat:write.public`** (included in OAuth) to post to **public** channels
|
||||
without joining — private channels still require membership.
|
||||
- **`slack-list-channels`** and the quick-send channel picker need
|
||||
**`channels:read`** / **`groups:read`** on the token (requested at connect
|
||||
time; see below).
|
||||
|
||||
## Slack app setup
|
||||
|
||||
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
|
||||
(dedicated to this Twenty app — do not reuse for other Twenty apps).
|
||||
2. **OAuth & Permissions** → **Bot Token Scopes**. Twenty uses Slack’s **bot**
|
||||
OAuth (`oauth/v2/authorize` with `scope=…`). You must add scopes here — not
|
||||
only under **User Token Scopes** — or Slack will refuse install with *“doesn’t
|
||||
have a bot user to install”* until at least one bot scope exists.
|
||||
|
||||
The scopes **requested at connect time** are defined in
|
||||
`src/connection-providers/slack-connection.ts` and must also appear under
|
||||
**Bot Token Scopes** on the Slack app (Slack validates the set). Current
|
||||
list:
|
||||
|
||||
- `channels:read` — `conversations.list` / channel picker (public)
|
||||
- `chat:write` — post, update, delete, ephemeral
|
||||
- `chat:write.public` — post to public channels without the bot joining
|
||||
- `groups:read` — list private channels the bot is in
|
||||
- `reactions:write` — add reactions
|
||||
|
||||
If you **add or remove** scopes in that file or in the Slack app, existing
|
||||
installs must **re-authorize** (disconnect and **Add connection** again, or
|
||||
reinstall the Slack app to the workspace) so the token picks up new scopes.
|
||||
|
||||
3. Set the **Redirect URL** on the Slack app to
|
||||
`<YOUR_TWENTY_SERVER_URL>/apps/oauth/callback` — the same origin your
|
||||
Twenty **server** uses for API routes (the callback is not served by the SPA
|
||||
alone). Local monorepo dev often uses `http://localhost:3000` (confirm the
|
||||
port your `twenty-server` / `SERVER_URL` actually uses).
|
||||
|
||||
**Slack “PKCE” app setting vs `localhost`:** If you turn on **PKCE** for the
|
||||
Slack app under **OAuth & Permissions**, Slack treats `http://localhost…`
|
||||
redirect URLs as **desktop** redirects. **Desktop redirects cannot request
|
||||
bot scopes**, so OAuth will fail for this integration while you use a
|
||||
localhost callback. For local dev you can either **leave Slack’s PKCE
|
||||
opt-in disabled** on that Slack app, or use an **`https://` redirect** (for
|
||||
example a tunnel such as ngrok or Cloudflare Tunnel to your local server),
|
||||
register that URL in the Slack app, and point Twenty’s `SERVER_URL` at the
|
||||
same public base URL. See Slack’s [Using
|
||||
PKCE](https://docs.slack.dev/authentication/using-pkce) docs (this is
|
||||
separate from Twenty sending a PKCE challenge on the authorize request).
|
||||
|
||||
4. Copy the Slack **Client ID** and **Client Secret**.
|
||||
|
||||
## Twenty setup
|
||||
|
||||
1. Register / install this app on your Twenty server (`twenty-slack`).
|
||||
2. In **Settings → Applications → Twenty Slack**, open the **Application registration**
|
||||
tab (admin-only) and set:
|
||||
- `SLACK_CLIENT_ID`
|
||||
- `SLACK_CLIENT_SECRET`
|
||||
3. In the same app, open the **Connections** tab and click **Add connection**.
|
||||
Choose **Just for me** or **Workspace shared**, then complete the Slack sign-in.
|
||||
|
||||
Once connected, workflow steps use the connection access token: a
|
||||
**workspace** connection is preferred when present; otherwise the first
|
||||
connection returned for the Slack provider is used (see
|
||||
`src/logic-functions/utils/get-slack-connection.ts`).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-slack
|
||||
yarn install
|
||||
yarn lint
|
||||
yarn test
|
||||
```
|
||||
|
||||
Use `yarn twenty dev` from this directory to develop against a local Twenty
|
||||
instance (see other internal apps in this monorepo).
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "twenty-slack",
|
||||
"version": "0.1.0",
|
||||
"description": "Slack workflow connector for Twenty",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"twenty-app"
|
||||
],
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@slack/web-api": "^7.8.0",
|
||||
"twenty-sdk": "2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"oxlint": "^0.16.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 127 127"
|
||||
fill="none"
|
||||
role="img"
|
||||
>
|
||||
<title>Slack</title>
|
||||
<path
|
||||
fill="#E01E5A"
|
||||
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
|
||||
/>
|
||||
<path
|
||||
fill="#36C5F0"
|
||||
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
|
||||
/>
|
||||
<path
|
||||
fill="#2EB67D"
|
||||
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#ECB22E"
|
||||
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,37 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Twenty Slack',
|
||||
description:
|
||||
'Connect Slack to Twenty. Each workspace member (or a shared workspace connection) can authenticate Slack; workflow steps then post messages, ephemerals, updates, deletes, and reactions on behalf of that connection.',
|
||||
logoUrl: 'public/twenty-slack.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Communication',
|
||||
aboutDescription:
|
||||
'Official Slack connector for Twenty CRM. Install a Slack app on api.slack.com, add the OAuth client ID and secret as server variables, then connect Slack per member or as a shared workspace connection. Use workflow actions to post, update, or delete messages, send ephemeral notes, and add reactions using the connected account.',
|
||||
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: '[email protected]',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
serverVariables: {
|
||||
SLACK_CLIENT_ID: {
|
||||
description:
|
||||
'OAuth client ID from your Slack app (api.slack.com/apps). Public in OAuth flows; only the client secret must stay confidential.',
|
||||
isSecret: false,
|
||||
isRequired: true,
|
||||
},
|
||||
SLACK_CLIENT_SECRET: {
|
||||
description:
|
||||
'OAuth client secret from your Slack app. Stored encrypted; never exposed in API responses.',
|
||||
isSecret: true,
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Send Slack message',
|
||||
shortLabel: 'Slack message',
|
||||
icon: 'IconBrandSlack',
|
||||
isPinned: false,
|
||||
availabilityType: 'GLOBAL',
|
||||
frontComponentUniversalIdentifier:
|
||||
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
+673
@@ -0,0 +1,673 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type SyntheticEvent,
|
||||
} from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
closeSidePanel,
|
||||
enqueueSnackbar,
|
||||
unmountFrontComponent,
|
||||
} from 'twenty-sdk/front-component';
|
||||
import { themeCssVariables } from 'twenty-sdk/ui';
|
||||
|
||||
import { SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
type SlackChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
isMember: boolean;
|
||||
numMembers: number;
|
||||
topic: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
type ListChannelsResponse = {
|
||||
success: boolean;
|
||||
channels?: SlackChannel[];
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type PostMessageResponse = {
|
||||
success: boolean;
|
||||
slackTs?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type PostedMessage = {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
slackTs?: string;
|
||||
};
|
||||
|
||||
type MessageFormat = 'plain' | 'markdown';
|
||||
|
||||
const FORMAT_OPTIONS: { value: MessageFormat; label: string }[] = [
|
||||
{ value: 'plain', label: 'Plain text' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
];
|
||||
|
||||
const isMessageFormat = (value: string): value is MessageFormat =>
|
||||
value === 'plain' || value === 'markdown';
|
||||
|
||||
const readSerializedValue = (
|
||||
event: SyntheticEvent<HTMLElement>,
|
||||
): string | undefined => {
|
||||
const object = event as {
|
||||
detail?: { value?: string };
|
||||
value?: string;
|
||||
target?: { value?: string };
|
||||
};
|
||||
|
||||
if (typeof object.detail?.value === 'string') {
|
||||
return object.detail.value;
|
||||
}
|
||||
if (typeof object.value === 'string') {
|
||||
return object.value;
|
||||
}
|
||||
if (typeof object.target?.value === 'string') {
|
||||
return object.target.value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const onValueChange =
|
||||
(setValue: (value: string) => void) =>
|
||||
(event: SyntheticEvent<HTMLElement>) => {
|
||||
const value = readSerializedValue(event);
|
||||
|
||||
if (typeof value === 'string') {
|
||||
setValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const callAppRoute = async <TResponse,>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<TResponse> => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token =
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!apiBaseUrl || !token) {
|
||||
throw new Error('App is missing API URL or access token configuration.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/s${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
messages?: string[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
errorMessage = parsed.messages?.[0] ?? parsed.message ?? parsed.error;
|
||||
} catch {
|
||||
// Body is not JSON; fall through to raw text or status message.
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
errorMessage ??
|
||||
(text.length > 0
|
||||
? text.slice(0, 200)
|
||||
: `Request failed with status ${response.status}.`),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResponse>;
|
||||
};
|
||||
|
||||
const sortChannels = (channels: SlackChannel[]): SlackChannel[] =>
|
||||
[...channels].sort((a, b) => {
|
||||
if (a.isMember !== b.isMember) {
|
||||
return a.isMember ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const formatChannelOptionLabel = (channel: SlackChannel): string => {
|
||||
const prefix = channel.isPrivate ? '🔒' : '#';
|
||||
const suffix = channel.isMember ? '' : ' — bot is not a member';
|
||||
|
||||
return `${prefix} ${channel.name}${suffix}`;
|
||||
};
|
||||
|
||||
const getChannelHelperText = (
|
||||
selectedChannel: SlackChannel | undefined,
|
||||
): string => {
|
||||
if (selectedChannel === undefined) {
|
||||
return 'Pick a channel to post to.';
|
||||
}
|
||||
|
||||
if (selectedChannel.isMember) {
|
||||
return selectedChannel.isPrivate
|
||||
? 'Private channel · bot is a member.'
|
||||
: 'Public channel · bot is a member.';
|
||||
}
|
||||
|
||||
return 'Bot is not a member of this channel — it must be invited before it can post.';
|
||||
};
|
||||
|
||||
const getStyles = (): Record<string, CSSProperties> => ({
|
||||
container: {
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
background: themeCssVariables.background.primary,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
header: {
|
||||
padding: themeCssVariables.spacing[4],
|
||||
borderBottom: `1px solid ${themeCssVariables.border.color.light}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
headerTitleBlock: {
|
||||
marginBottom: themeCssVariables.spacing[4],
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: themeCssVariables.font.size.md,
|
||||
fontWeight: themeCssVariables.font.weight.semiBold,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
margin: 0,
|
||||
},
|
||||
pageSubtitle: {
|
||||
fontSize: themeCssVariables.font.size.md,
|
||||
fontWeight: themeCssVariables.font.weight.regular,
|
||||
color: themeCssVariables.font.color.tertiary,
|
||||
margin: 0,
|
||||
marginTop: themeCssVariables.spacing[2],
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
padding: themeCssVariables.spacing[4],
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: themeCssVariables.spacing[4],
|
||||
overflowY: 'auto',
|
||||
},
|
||||
field: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: themeCssVariables.spacing[1],
|
||||
},
|
||||
fieldGrowing: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: themeCssVariables.spacing[1],
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
label: {
|
||||
fontSize: themeCssVariables.font.size.xs,
|
||||
fontWeight: themeCssVariables.font.weight.medium,
|
||||
color: themeCssVariables.font.color.secondary,
|
||||
},
|
||||
helperText: {
|
||||
fontSize: themeCssVariables.font.size.xs,
|
||||
color: themeCssVariables.font.color.tertiary,
|
||||
},
|
||||
select: {
|
||||
appearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
background: themeCssVariables.background.secondary,
|
||||
border: `1px solid ${themeCssVariables.border.color.medium}`,
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
padding: `${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]}`,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
height: themeCssVariables.spacing[8],
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
textarea: {
|
||||
background: themeCssVariables.background.secondary,
|
||||
border: `1px solid ${themeCssVariables.border.color.medium}`,
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
padding: themeCssVariables.spacing[3],
|
||||
color: themeCssVariables.font.color.primary,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
lineHeight: 1.5,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
flex: 1,
|
||||
minHeight: themeCssVariables.spacing[20],
|
||||
},
|
||||
footer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: themeCssVariables.spacing[2],
|
||||
padding: themeCssVariables.spacing[3],
|
||||
borderTop: `1px solid ${themeCssVariables.border.color.light}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
buttonBase: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: themeCssVariables.spacing[1],
|
||||
height: themeCssVariables.spacing[8],
|
||||
padding: `0 ${themeCssVariables.spacing[3]}`,
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
fontWeight: themeCssVariables.font.weight.medium,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
secondaryButton: {
|
||||
background: themeCssVariables.background.secondary,
|
||||
color: themeCssVariables.font.color.secondary,
|
||||
border: `1px solid ${themeCssVariables.border.color.medium}`,
|
||||
},
|
||||
primaryButton: {
|
||||
background: themeCssVariables.color.blue,
|
||||
color: themeCssVariables.font.color.inverted,
|
||||
},
|
||||
primaryButtonDisabled: {
|
||||
background: themeCssVariables.accent.accent4060,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
centeredState: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: themeCssVariables.spacing[4],
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
stateBlock: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: themeCssVariables.spacing[3],
|
||||
maxWidth: '320px',
|
||||
textAlign: 'center',
|
||||
},
|
||||
stateTitle: {
|
||||
fontSize: themeCssVariables.font.size.md,
|
||||
fontWeight: themeCssVariables.font.weight.medium,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
margin: 0,
|
||||
},
|
||||
stateDescription: {
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
color: themeCssVariables.font.color.tertiary,
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
stateError: {
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
color: themeCssVariables.font.color.danger,
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
const SendMessageForm = () => {
|
||||
const [channels, setChannels] = useState<SlackChannel[]>([]);
|
||||
const [channelsLoading, setChannelsLoading] = useState(true);
|
||||
const [channelsError, setChannelsError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [postedMessage, setPostedMessage] = useState<PostedMessage | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const [messageFormat, setMessageFormat] = useState<MessageFormat>('markdown');
|
||||
|
||||
const styles = getStyles();
|
||||
|
||||
const fetchChannels = useCallback(async () => {
|
||||
try {
|
||||
setChannelsError(null);
|
||||
setChannelsLoading(true);
|
||||
|
||||
const result = await callAppRoute<ListChannelsResponse>(
|
||||
'/slack/channels?limit=200',
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setChannelsError(
|
||||
result.error ?? result.message ?? 'Failed to load Slack channels.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = sortChannels(result.channels ?? []);
|
||||
|
||||
setChannels(sorted);
|
||||
|
||||
const firstMemberChannel = sorted.find((channel) => channel.isMember);
|
||||
|
||||
if (firstMemberChannel !== undefined) {
|
||||
setChannelId(firstMemberChannel.id);
|
||||
} else if (sorted.length === 1) {
|
||||
setChannelId(sorted[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
setChannelsError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load Slack channels.',
|
||||
);
|
||||
} finally {
|
||||
setChannelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
}, [fetchChannels]);
|
||||
|
||||
const handleClose = () => {
|
||||
unmountFrontComponent();
|
||||
closeSidePanel();
|
||||
};
|
||||
|
||||
const handleSendAnother = () => {
|
||||
setMessageText('');
|
||||
setPostedMessage(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedMessage = messageText.trim();
|
||||
|
||||
if (channelId === '' || trimmedMessage === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await callAppRoute<PostMessageResponse>(
|
||||
'/slack/messages',
|
||||
'POST',
|
||||
{
|
||||
slackChannelId: channelId,
|
||||
messageText: trimmedMessage,
|
||||
messageFormat,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
await enqueueSnackbar({
|
||||
message:
|
||||
result.error ?? result.message ?? 'Failed to send Slack message.',
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = channels.find(
|
||||
(channelItem) => channelItem.id === channelId,
|
||||
);
|
||||
|
||||
setPostedMessage({
|
||||
channelId,
|
||||
channelName: channel?.name ?? channelId,
|
||||
slackTs: result.slackTs,
|
||||
});
|
||||
|
||||
await enqueueSnackbar({
|
||||
message: `Message sent to #${channel?.name ?? channelId}`,
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
await enqueueSnackbar({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to send Slack message.',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (channelsLoading) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>Loading channels…</h2>
|
||||
<p style={styles.stateDescription}>
|
||||
Fetching the list of Slack channels your bot can post to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channelsError !== null) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>Couldn't load channels</h2>
|
||||
<p style={styles.stateError}>{channelsError}</p>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={fetchChannels}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>No channels available</h2>
|
||||
<p style={styles.stateDescription}>
|
||||
The bot doesn't have access to any Slack channels yet. Invite it to
|
||||
a channel and try again.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={fetchChannels}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (postedMessage !== null) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitleBlock}>
|
||||
<h2 style={styles.pageTitle}>Message sent</h2>
|
||||
<p style={styles.pageSubtitle}>
|
||||
{`Posted to #${postedMessage.channelName}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.body}>
|
||||
<p style={styles.stateDescription}>
|
||||
Your message is now visible in Slack.
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.primaryButton }}
|
||||
onClick={handleSendAnother}
|
||||
>
|
||||
Send another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedMessage = messageText.trim();
|
||||
const canSubmit = channelId !== '' && trimmedMessage !== '' && !submitting;
|
||||
const selectedChannel = channels.find(
|
||||
(channelItem) => channelItem.id === channelId,
|
||||
);
|
||||
const channelHelperText = getChannelHelperText(selectedChannel);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitleBlock}>
|
||||
<h2 style={styles.pageTitle}>Send Slack message</h2>
|
||||
<p style={styles.pageSubtitle}>
|
||||
Post a message to any channel your bot has access to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.body}>
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="slack-channel-select" style={styles.label}>
|
||||
Channel
|
||||
</label>
|
||||
<select
|
||||
id="slack-channel-select"
|
||||
value={channelId}
|
||||
onChange={onValueChange(setChannelId)}
|
||||
style={styles.select}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a channel…
|
||||
</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{formatChannelOptionLabel(channel)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.helperText}>{channelHelperText}</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="slack-message-format-select" style={styles.label}>
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
id="slack-message-format-select"
|
||||
value={messageFormat}
|
||||
onChange={onValueChange((value) => {
|
||||
if (isMessageFormat(value)) {
|
||||
setMessageFormat(value);
|
||||
}
|
||||
})}
|
||||
style={styles.select}
|
||||
disabled={submitting}
|
||||
>
|
||||
{FORMAT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.helperText}>
|
||||
{messageFormat === 'markdown'
|
||||
? 'Markdown is rendered using Slack mrkdwn (bold, italics, code, links).'
|
||||
: 'The message will be sent exactly as written, with no formatting.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.fieldGrowing}>
|
||||
<label htmlFor="slack-message-textarea" style={styles.label}>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="slack-message-textarea"
|
||||
value={messageText}
|
||||
onInput={onValueChange(setMessageText)}
|
||||
onChange={onValueChange(setMessageText)}
|
||||
placeholder="Write your message…"
|
||||
style={styles.textarea}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...styles.buttonBase,
|
||||
...styles.primaryButton,
|
||||
...(canSubmit ? {} : styles.primaryButtonDisabled),
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'send-slack-message-form',
|
||||
description:
|
||||
'Form to send a Slack message to any channel the bot can post to, with plain-text or markdown formatting.',
|
||||
component: SendMessageForm,
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { defineConnectionProvider } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineConnectionProvider({
|
||||
universalIdentifier: SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack',
|
||||
displayName: 'Slack',
|
||||
type: 'oauth',
|
||||
oauth: {
|
||||
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
|
||||
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
|
||||
revokeEndpoint: 'https://slack.com/api/auth.revoke',
|
||||
scopes: [
|
||||
'channels:read',
|
||||
'chat:write',
|
||||
'chat:write.public',
|
||||
'groups:read',
|
||||
'reactions:write',
|
||||
],
|
||||
clientIdVariable: 'SLACK_CLIENT_ID',
|
||||
clientSecretVariable: 'SLACK_CLIENT_SECRET',
|
||||
tokenRequestContentType: 'form-urlencoded',
|
||||
usePkce: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'a8c47f21-3b9e-4d2a-8f61-9c0e7d4a2b51';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'b7d36e10-2a8d-4c1b-9e50-8bfd6c3a1940';
|
||||
|
||||
export const SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
|
||||
'8b6c6fd9-8d61-4b6f-9f25-3d92a0f2cc5b';
|
||||
|
||||
export const SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'c6f25d09-1b7c-4e3f-ad42-7aec5b29830f';
|
||||
|
||||
export const SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'd5e14c98-0a6b-4e2e-ac31-69db4a18720e';
|
||||
|
||||
export const SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'e4d03b87-9a5b-4f1d-8b20-58ca3917620d';
|
||||
|
||||
export const SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'f3c92a76-8b4a-4a09-ba19-47b9280651c9';
|
||||
|
||||
export const SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER =
|
||||
'2a8c7f91-4d3e-5b6f-a7c8-9d0e1f2a3b4c';
|
||||
|
||||
export const SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER =
|
||||
'3b7d8a92-5e4f-4c7a-b8d9-0e1f2a3b4c5d';
|
||||
|
||||
export const SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'6e0c3d5f-9a4f-4f62-bc0e-3d5a7f9b4c6e';
|
||||
|
||||
export const SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'7f1d4e60-ab50-4273-9d1f-4e6b8a0c5d7f';
|
||||
|
||||
export const SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'4c8a1b3f-7e2d-4f50-9a8c-1b3e5d7f2a4c';
|
||||
|
||||
export const SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER =
|
||||
'5d9b2c4e-8f3e-4f50-ab9d-2c4f6e8a3b5d';
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { type SlackAddReactionInput } from 'src/logic-functions/types/slack-add-reaction-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackAddReactionHandler = async (
|
||||
parameters: SlackAddReactionInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
await client.reactions.add({
|
||||
channel: parameters.slackChannelId,
|
||||
timestamp: parameters.messageTimestamp,
|
||||
name: parameters.emojiName.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Reaction "${parameters.emojiName.trim()}" added to the message.`,
|
||||
slackTs: parameters.messageTimestamp,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to add Slack reaction', error);
|
||||
}
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { type SlackDeleteMessageInput } from 'src/logic-functions/types/slack-delete-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackDeleteMessageHandler = async (
|
||||
parameters: SlackDeleteMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
await client.chat.delete({
|
||||
channel: parameters.slackChannelId,
|
||||
ts: parameters.messageTimestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Slack message deleted.',
|
||||
slackTs: parameters.messageTimestamp,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to delete Slack message', error);
|
||||
}
|
||||
};
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
type SlackChannelType,
|
||||
type SlackListChannelsInput,
|
||||
} from 'src/logic-functions/types/slack-list-channels-input.type';
|
||||
import {
|
||||
type SlackListChannelsResult,
|
||||
type SlackListChannelsResultChannel,
|
||||
} from 'src/logic-functions/types/slack-list-channels-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 1000;
|
||||
const SLACK_PAGE_SIZE = 200;
|
||||
|
||||
const SLACK_TYPES_BY_CHANNEL_TYPE = {
|
||||
Public: 'public_channel',
|
||||
Private: 'private_channel',
|
||||
All: 'public_channel,private_channel',
|
||||
} as const satisfies Record<SlackChannelType, string>;
|
||||
|
||||
export const slackListChannelsHandler = async (
|
||||
parameters: SlackListChannelsInput,
|
||||
): Promise<SlackListChannelsResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
channels: [],
|
||||
count: 0,
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
const limit = Math.min(
|
||||
Math.max(parameters.limit ?? DEFAULT_LIMIT, 1),
|
||||
MAX_LIMIT,
|
||||
);
|
||||
const excludeArchived = parameters.excludeArchived ?? true;
|
||||
const types = SLACK_TYPES_BY_CHANNEL_TYPE[parameters.channelType ?? 'All'];
|
||||
|
||||
const channels: SlackListChannelsResultChannel[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
try {
|
||||
while (channels.length < limit) {
|
||||
const remaining = limit - channels.length;
|
||||
const response = await client.conversations.list({
|
||||
types,
|
||||
exclude_archived: excludeArchived,
|
||||
limit: Math.min(SLACK_PAGE_SIZE, remaining),
|
||||
cursor,
|
||||
});
|
||||
|
||||
const page = response.channels ?? [];
|
||||
|
||||
for (const channel of page) {
|
||||
if (channels.length >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isDefined(channel.id) || !isDefined(channel.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
channels.push({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
isPrivate: channel.is_private === true,
|
||||
isArchived: channel.is_archived === true,
|
||||
isMember: channel.is_member === true,
|
||||
numMembers: channel.num_members ?? 0,
|
||||
topic: channel.topic?.value ?? '',
|
||||
purpose: channel.purpose?.value ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
const nextCursor = response.response_metadata?.next_cursor;
|
||||
|
||||
if (!isDefined(nextCursor) || nextCursor.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channels,
|
||||
count: channels.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
channels: [],
|
||||
count: 0,
|
||||
error: error instanceof Error ? error.message : 'Slack request failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { type SlackPostEphemeralMessageInput } from 'src/logic-functions/types/slack-post-ephemeral-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackPostEphemeralMessageHandler = async (
|
||||
parameters: SlackPostEphemeralMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.messageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const postEphemeralPayload = {
|
||||
channel: parameters.slackChannelId,
|
||||
user: parameters.recipientSlackUserId,
|
||||
...bodyFields,
|
||||
};
|
||||
|
||||
await client.chat.postEphemeral(postEphemeralPayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Ephemeral message sent to the user in the channel.',
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to post Slack ephemeral message', error);
|
||||
}
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type SlackPostMessageInput } from 'src/logic-functions/types/slack-post-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackPostMessageHandler = async (
|
||||
parameters: SlackPostMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
const parentTimestamp = parameters.parentMessageTimestamp;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.messageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const data = await client.chat.postMessage({
|
||||
channel: parameters.slackChannelId,
|
||||
thread_ts:
|
||||
isDefined(parentTimestamp) && parentTimestamp.trim().length > 0
|
||||
? parentTimestamp.trim()
|
||||
: undefined,
|
||||
...bodyFields,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: data.ts
|
||||
? `Message posted to Slack (ts=${data.ts}).`
|
||||
: 'Message posted to Slack.',
|
||||
slackTs: data.ts,
|
||||
channel: data.channel,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to post Slack message', error);
|
||||
}
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { type SlackUpdateMessageInput } from 'src/logic-functions/types/slack-update-message-input.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackUpdateMessageHandler = async (
|
||||
parameters: SlackUpdateMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.newMessageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const updatePayload = {
|
||||
channel: parameters.slackChannelId,
|
||||
ts: parameters.messageTimestamp,
|
||||
...bodyFields,
|
||||
};
|
||||
|
||||
const data = await client.chat.update(updatePayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Slack message updated.',
|
||||
slackTs: data.ts,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to update Slack message', error);
|
||||
}
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackAddReactionInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel where the message lives (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'The message to react to: use the **timestamp** from Slack (same value as `slackTs` after posting, or visible in “Copy link” URLs as the last number).',
|
||||
},
|
||||
emojiName: {
|
||||
type: 'string',
|
||||
label: 'Emoji name',
|
||||
description:
|
||||
'Emoji to add, **without** colons — for example `thumbsup`, `eyes`, or `white_check_mark`. Do not paste `:thumbsup:` style names.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp', 'emojiName'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackDeleteMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel that contains the message (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'Which message to remove: its **timestamp** from Slack (same value as `slackTs` when the bot posted it). Only messages sent by this bot can be deleted.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackListChannelsInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelType: {
|
||||
type: 'string',
|
||||
label: 'Channel type',
|
||||
enum: ['Public', 'Private', 'All'],
|
||||
description:
|
||||
'Optional. Which kinds of channels to include. `All` returns both public and private channels. Default: `All`.',
|
||||
},
|
||||
excludeArchived: {
|
||||
type: 'boolean',
|
||||
label: 'Exclude archived',
|
||||
description:
|
||||
'Optional. Hide archived channels from the list. Default: `true`.',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
label: 'Max results',
|
||||
minimum: 1,
|
||||
maximum: 1000,
|
||||
description:
|
||||
'Optional. Cap the total number of channels returned across pagination. Default: `200`. Max: `1000`. Larger workspaces may not be fully covered within the function timeout.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackPostEphemeralMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel where the note appears (Slack channel ID like C…). The person you choose must already be in this channel, or they will not see the message.',
|
||||
},
|
||||
recipientSlackUserId: {
|
||||
type: 'string',
|
||||
label: 'Recipient (Slack user ID)',
|
||||
description:
|
||||
'Who can see this message: their Slack **member ID** (starts with U…). Only that person sees it; everyone else in the channel does not. In Slack: click their profile → three dots → Copy member ID (wording may vary by client).',
|
||||
},
|
||||
messageText: {
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
multiline: true,
|
||||
description:
|
||||
'Short note shown only to the recipient above — for example a private hint, validation result, or next step.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. Same as **Send Slack Message**: `markdown` / `plain` / omit — see that action’s `messageFormat` description.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'recipientSlackUserId', 'messageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackPostMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Where to post: the Slack channel or DM ID (looks like C0123…, G… for private channels, or D… for DMs). In Slack: open the channel → channel name → scroll down → copy Channel ID. Using the ID is more reliable than a channel name.',
|
||||
},
|
||||
messageText: {
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
multiline: true,
|
||||
description:
|
||||
'The message people will read in Slack. Keep it concise; very long messages may be rejected by Slack.',
|
||||
},
|
||||
parentMessageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Parent message timestamp',
|
||||
description:
|
||||
'Optional. Only when you want a **thread reply**: paste the **Message timestamp** from an earlier Slack step (the value returned as `slackTs` after posting). Leave empty for a normal new message at the bottom of the channel.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. `markdown`: send as Slack `markdown_text` so usual Markdown in `messageText` works (**bold**, lists, fenced code). `plain`: send as `text` with `mrkdwn: false` (no markup). Omit: send as `text` and let Slack use its default (legacy mrkdwn may still apply in `text`).',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackUpdateMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel that contains the message (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'Which message to edit: its **timestamp** from Slack (same value as `slackTs` returned when the bot posted the message). You cannot edit other people’s messages—only ones this bot sent.',
|
||||
},
|
||||
newMessageText: {
|
||||
type: 'string',
|
||||
label: 'New message',
|
||||
multiline: true,
|
||||
description:
|
||||
'Replacement text shown in Slack instead of the old content.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. Same as **Send Slack Message**: `markdown` → `markdown_text`, `plain` → plain `text` without markup, omit → Slack default on `text`.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp', 'newMessageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackAddReactionHandler } from 'src/logic-functions/handlers/slack-add-reaction-handler';
|
||||
import { slackAddReactionInputSchema } from './schemas/slack-add-reaction-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-add-reaction',
|
||||
description:
|
||||
'Add an emoji reaction to a message (for example a checkmark as `white_check_mark`) so the channel can see status at a glance.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackAddReactionInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Add Slack Reaction',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackAddReactionInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackAddReactionHandler,
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackDeleteMessageHandler } from 'src/logic-functions/handlers/slack-delete-message-handler';
|
||||
import { slackDeleteMessageInputSchema } from './schemas/slack-delete-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-delete-message',
|
||||
description:
|
||||
'Remove a message this bot sent (for example after a mistake or when a workflow is cancelled).',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackDeleteMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Delete Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackDeleteMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackDeleteMessageHandler,
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
|
||||
import { type SlackChannelType } from 'src/logic-functions/types/slack-list-channels-input.type';
|
||||
|
||||
const VALID_CHANNEL_TYPES: SlackChannelType[] = ['Public', 'Private', 'All'];
|
||||
|
||||
const isSlackChannelType = (value: unknown): value is SlackChannelType =>
|
||||
typeof value === 'string' &&
|
||||
VALID_CHANNEL_TYPES.includes(value as SlackChannelType);
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const params = event.queryStringParameters ?? {};
|
||||
|
||||
const rawLimit = params.limit;
|
||||
const parsedLimit =
|
||||
typeof rawLimit === 'string' && rawLimit.length > 0
|
||||
? Number(rawLimit)
|
||||
: undefined;
|
||||
|
||||
return slackListChannelsHandler({
|
||||
channelType: isSlackChannelType(params.channelType)
|
||||
? params.channelType
|
||||
: undefined,
|
||||
excludeArchived: params.excludeArchived === 'false' ? false : undefined,
|
||||
limit:
|
||||
typeof parsedLimit === 'number' && Number.isFinite(parsedLimit)
|
||||
? parsedLimit
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-list-channels-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/slack/channels',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
|
||||
import { slackListChannelsInputSchema } from './schemas/slack-list-channels-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-list-channels',
|
||||
description:
|
||||
'List Slack channels visible to the bot, with basic metadata (id, name, privacy, archive state, membership, member count, topic, purpose). Useful for picking a channel by name or branching a workflow on whether the bot is a member.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackListChannelsInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'List Slack Channels',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackListChannelsInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
channels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
isPrivate: { type: 'boolean' },
|
||||
isArchived: { type: 'boolean' },
|
||||
isMember: { type: 'boolean' },
|
||||
numMembers: { type: 'number' },
|
||||
topic: { type: 'string' },
|
||||
purpose: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackListChannelsHandler,
|
||||
});
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostEphemeralMessageHandler } from 'src/logic-functions/handlers/slack-post-ephemeral-message-handler';
|
||||
import { slackPostEphemeralMessageInputSchema } from './schemas/slack-post-ephemeral-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-ephemeral-message',
|
||||
description:
|
||||
'Send a private-on-channel note: only the chosen teammate sees it in that channel (not a DM broadcast to everyone).',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackPostEphemeralMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Send Slack Ephemeral Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackPostEphemeralMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackPostEphemeralMessageHandler,
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
const VALID_MESSAGE_FORMATS: SlackMessageBodyFormat[] = ['plain', 'markdown'];
|
||||
|
||||
const isSlackMessageBodyFormat = (
|
||||
value: unknown,
|
||||
): value is SlackMessageBodyFormat =>
|
||||
typeof value === 'string' &&
|
||||
VALID_MESSAGE_FORMATS.includes(value as SlackMessageBodyFormat);
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
return slackPostMessageHandler({
|
||||
slackChannelId: (body?.slackChannelId as string | undefined) ?? '',
|
||||
messageText: (body?.messageText as string | undefined) ?? '',
|
||||
parentMessageTimestamp: body?.parentMessageTimestamp as string | undefined,
|
||||
messageFormat: isSlackMessageBodyFormat(body?.messageFormat)
|
||||
? body.messageFormat
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-message-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/slack/messages',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
|
||||
import { slackPostMessageInputSchema } from './schemas/slack-post-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-message',
|
||||
description:
|
||||
'Send a message to a Slack channel or DM. Optionally reply inside an existing thread using the parent message’s timestamp from a previous step.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackPostMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Send Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackPostMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackPostMessageHandler,
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackUpdateMessageHandler } from 'src/logic-functions/handlers/slack-update-message-handler';
|
||||
import { slackUpdateMessageInputSchema } from './schemas/slack-update-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-update-message',
|
||||
description:
|
||||
'Change the text of a message this bot already sent. You need the channel ID and the message’s timestamp from when it was posted.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackUpdateMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Update Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackUpdateMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackUpdateMessageHandler,
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type SlackAddReactionInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
emojiName: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type SlackDeleteMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type SlackChannelType = 'Public' | 'Private' | 'All';
|
||||
|
||||
export type SlackListChannelsInput = {
|
||||
channelType?: SlackChannelType;
|
||||
excludeArchived?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
export type SlackListChannelsResultChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
isMember: boolean;
|
||||
numMembers: number;
|
||||
topic: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
export type SlackListChannelsResult = {
|
||||
success: boolean;
|
||||
channels: SlackListChannelsResultChannel[];
|
||||
count: number;
|
||||
error?: string;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type SlackMessageBodyFormat = 'plain' | 'markdown';
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackPostEphemeralMessageInput = {
|
||||
slackChannelId: string;
|
||||
recipientSlackUserId: string;
|
||||
messageText: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackPostMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageText: string;
|
||||
parentMessageTimestamp?: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type SlackToolResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
slackTs?: string;
|
||||
channel?: string;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackUpdateMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
newMessageText: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
type SlackChatMessageBodyFields =
|
||||
| { markdown_text: string; text?: never; mrkdwn?: never }
|
||||
| { text: string; markdown_text?: never; mrkdwn?: boolean };
|
||||
|
||||
export const getSlackChatMessageBodyFields = (
|
||||
messageText: string,
|
||||
messageFormat: SlackMessageBodyFormat | undefined,
|
||||
): SlackChatMessageBodyFields => {
|
||||
switch (messageFormat) {
|
||||
case 'markdown':
|
||||
return { markdown_text: messageText };
|
||||
case 'plain':
|
||||
return { text: messageText, mrkdwn: false };
|
||||
default:
|
||||
return { text: messageText };
|
||||
}
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { WebClient } from '@slack/web-api';
|
||||
|
||||
import { getSlackConnection } from 'src/logic-functions/utils/get-slack-connection';
|
||||
|
||||
export const getSlackClient = async (): Promise<
|
||||
{ success: true; client: WebClient } | { success: false; error: string }
|
||||
> => {
|
||||
const connectionResult = await getSlackConnection();
|
||||
|
||||
if (!connectionResult.success) {
|
||||
return connectionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
client: new WebClient(connectionResult.accessToken),
|
||||
};
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { listConnections } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const getSlackConnection = async (): Promise<
|
||||
{ success: true; accessToken: string } | { success: false; error: string }
|
||||
> => {
|
||||
try {
|
||||
const connections = await listConnections({ providerName: 'slack' });
|
||||
const connection =
|
||||
connections.find((item) => item.visibility === 'workspace') ??
|
||||
connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Slack is not connected. Open the Slack app settings and click "Add connection" first.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, accessToken: connection.accessToken };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load Slack connection.';
|
||||
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
|
||||
export const slackToolFailure = (
|
||||
message: string,
|
||||
error: unknown,
|
||||
): SlackToolResult => ({
|
||||
success: false,
|
||||
message,
|
||||
error: error instanceof Error ? error.message : 'Slack request failed',
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Twenty Slack tools role',
|
||||
description:
|
||||
'No CRM data access — tools only forward requests to Slack using the configured connected account.',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
objectPermissions: [],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: [],
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.5.1",
|
||||
"version": "2.7.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Stub — overwritten by `twenty build` or `twenty dev`
|
||||
// Stub — overwritten by `twenty dev:build` or `twenty dev`
|
||||
export type CoreSchema = {};
|
||||
|
||||
@@ -2998,8 +2998,6 @@ type Query {
|
||||
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
|
||||
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
|
||||
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
connectedAccountById(id: UUID!): ConnectedAccountPublicDTO
|
||||
connectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
|
||||
@@ -2598,8 +2598,6 @@ export interface Query {
|
||||
myMessageFolders: MessageFolder[]
|
||||
myMessageChannels: MessageChannel[]
|
||||
myConnectedAccounts: ConnectedAccountPublicDTO[]
|
||||
connectedAccountById?: ConnectedAccountPublicDTO
|
||||
connectedAccounts: ConnectedAccountPublicDTO[]
|
||||
myCalendarChannels: CalendarChannel[]
|
||||
webhooks: Webhook[]
|
||||
webhook?: Webhook
|
||||
@@ -5631,8 +5629,6 @@ export interface QueryGenqlSelection{
|
||||
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
|
||||
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
connectedAccountById?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
connectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
webhooks?: WebhookGenqlSelection
|
||||
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
|
||||
@@ -6176,18 +6176,6 @@ export default {
|
||||
"myConnectedAccounts": [
|
||||
269
|
||||
],
|
||||
"connectedAccountById": [
|
||||
269,
|
||||
{
|
||||
"id": [
|
||||
3,
|
||||
"UUID!"
|
||||
]
|
||||
}
|
||||
],
|
||||
"connectedAccounts": [
|
||||
269
|
||||
],
|
||||
"myCalendarChannels": [
|
||||
300,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxfmt/configuration_schema.json",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": [
|
||||
"dist/**",
|
||||
"build/**",
|
||||
".next/**",
|
||||
"coverage/**",
|
||||
"generated/**",
|
||||
".cache/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
"ignorePatterns": ["node_modules", ".next", "storybook-static"],
|
||||
"rules": {
|
||||
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
|
||||
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
|
||||
"no-console": [
|
||||
"warn",
|
||||
{ "allow": ["group", "groupCollapsed", "groupEnd"] }
|
||||
],
|
||||
"no-control-regex": "off",
|
||||
"no-debugger": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
@@ -13,22 +16,31 @@
|
||||
"no-redeclare": "off",
|
||||
"import/no-duplicates": "error",
|
||||
"typescript/no-redeclare": "error",
|
||||
"typescript/consistent-type-imports": ["error", {
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}],
|
||||
"typescript/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"typescript/explicit-function-return-type": "off",
|
||||
"typescript/explicit-module-boundary-types": "off",
|
||||
"typescript/no-empty-object-type": ["error", {
|
||||
"allowInterfaces": "with-single-extends"
|
||||
}],
|
||||
"typescript/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
"allowInterfaces": "with-single-extends"
|
||||
}
|
||||
],
|
||||
"typescript/no-empty-function": "off",
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/no-unused-vars": ["warn", {
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_"
|
||||
}]
|
||||
"typescript/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,21 @@
|
||||
## What Was Migrated
|
||||
|
||||
### Documentation Files
|
||||
|
||||
- **69 MDX files** copied from twenty-website to twenty-docs
|
||||
- **45 User Guide articles**
|
||||
- **22 Developer documentation articles**
|
||||
- **2 Getting Started guides** (existing)
|
||||
|
||||
### Images & Assets
|
||||
|
||||
- **81 images** copied to `public/images/`
|
||||
- User guide screenshots
|
||||
- Developer documentation images
|
||||
- Logo and branding assets
|
||||
|
||||
### Navigation Structure
|
||||
|
||||
- Complete `mint.json` configuration with tabs and nested navigation
|
||||
- User Guide tab with 11 sections
|
||||
- Developers tab with 6 sections
|
||||
@@ -22,12 +25,15 @@
|
||||
## Components Converted
|
||||
|
||||
### Custom Components → Mintlify Equivalents
|
||||
|
||||
- `<ArticleWarning>` → `<Warning>`
|
||||
- `<ArticleLink href="...">text</ArticleLink>` → `[text](...)`
|
||||
- `<ArticleEditContent>` → Removed (not needed)
|
||||
|
||||
### Still Need Manual Review
|
||||
|
||||
Some components may need additional conversion:
|
||||
|
||||
- `<ArticleTabs>` - Mintlify uses `<Tabs>` component
|
||||
- Embedded iframes/videos - May need adjustment
|
||||
- Custom styled elements - Review for Mintlify compatibility
|
||||
@@ -63,6 +69,7 @@ packages/twenty-docs/
|
||||
## Testing
|
||||
|
||||
Start the local Mintlify dev server:
|
||||
|
||||
```bash
|
||||
npx nx run twenty-docs:dev
|
||||
```
|
||||
@@ -72,6 +79,7 @@ Open http://localhost:3000 to preview all migrated documentation.
|
||||
## Deployment
|
||||
|
||||
To deploy to Mintlify:
|
||||
|
||||
1. Push changes to GitHub
|
||||
2. Connect the repo in Mintlify dashboard
|
||||
3. Set subdirectory to `packages/twenty-docs`
|
||||
|
||||
@@ -9,6 +9,7 @@ Visit the documentation at [docs.twenty.com](https://docs.twenty.com)
|
||||
## 📚 Content
|
||||
|
||||
This repository contains:
|
||||
|
||||
- **User Guide** (46 pages) - Complete guide for Twenty users
|
||||
- **Developers** (24 pages) - Technical documentation for developers
|
||||
- **Twenty UI** (25 pages) - UI component library documentation
|
||||
@@ -57,6 +58,7 @@ Your content here...
|
||||
1. Place images in the `/images/` directory
|
||||
2. Reference them in MDX: ``
|
||||
3. Or use Mintlify Frame component:
|
||||
|
||||
```mdx
|
||||
<Frame>
|
||||
<img src="/images/your-image.png" alt="Description" />
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
opacity: 0.85 !important;
|
||||
}
|
||||
|
||||
:is(.dark, [data-theme="dark"]) #topbar-cta-button a,
|
||||
:is(.dark, [data-theme="dark"]) #topbar-cta-button a span {
|
||||
:is(.dark, [data-theme='dark']) #topbar-cta-button a,
|
||||
:is(.dark, [data-theme='dark']) #topbar-cta-button a span {
|
||||
background-color: #ffffff !important;
|
||||
color: #141414 !important;
|
||||
}
|
||||
@@ -35,14 +35,14 @@
|
||||
* We use :has() to scope the rule to only the sidebar group that contains
|
||||
* the developers/introduction link, so other tabs are unaffected.
|
||||
*/
|
||||
div:has(> .sidebar-group a[href="/developers/introduction"]),
|
||||
div:has(> .sidebar-group a[href="/user-guide/introduction"]) {
|
||||
div:has(> .sidebar-group a[href='/developers/introduction']),
|
||||
div:has(> .sidebar-group a[href='/user-guide/introduction']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Remove the top margin on the group that follows the hidden overview group,
|
||||
so it sits flush at the top of the sidebar without a gap. */
|
||||
div:has(> .sidebar-group a[href="/developers/introduction"]) + div,
|
||||
div:has(> .sidebar-group a[href="/user-guide/introduction"]) + div {
|
||||
div:has(> .sidebar-group a[href='/developers/introduction']) + div,
|
||||
div:has(> .sidebar-group a[href='/user-guide/introduction']) + div {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
-2
@@ -2,7 +2,6 @@
|
||||
title: Best Practices
|
||||
---
|
||||
|
||||
|
||||
This document outlines the best practices you should follow when working on the backend.
|
||||
|
||||
## Follow a modular approach
|
||||
@@ -21,4 +20,3 @@ You should also expose services that you want to use in other modules. Exposing
|
||||
When you declare a variable as `any`, TypeScript's type checker doesn't perform any type checking, making it possible to assign any type of values to the variable. TypeScript uses type inference to determine the type of variable based on the value. By declaring it as `any`, TypeScript can no longer infer the type. This makes it hard to catch type-related errors during development, leading to runtime errors and makes the code less maintainable, less reliable, and harder to understand for others.
|
||||
|
||||
This is why everything should have a type. So if you create a new object with a first name and last name, you should create an interface or type that contains a first name and last name that defines the shape of the object you are manipulating.
|
||||
|
||||
|
||||
+19
-12
@@ -2,21 +2,22 @@
|
||||
title: Custom Objects
|
||||
---
|
||||
|
||||
|
||||
Objects are structures that allow you to store data (records, attributes, and values) specific to an organization. Twenty provides both standard and custom objects.
|
||||
|
||||
Standard objects are in-built objects with a set of attributes available for all users. Examples of standard objects in Twenty include Company and Person. Standard objects have standard fields that are also available for all Twenty users, like Company.displayName.
|
||||
|
||||
Custom objects are objects that you can create to store information that is unique to your organization. They are not built-in; members of your workspace can create and customize custom objects to hold information that standard objects aren't suitable for.
|
||||
|
||||
|
||||
## High-level schema
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/server/custom-object-schema.png" alt="High level schema" />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src="/images/docs/server/custom-object-schema.png"
|
||||
alt="High level schema"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br />
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -27,15 +28,21 @@ Custom objects come from metadata tables that determine the shape, name, and typ
|
||||
- **Field**: Outlines an Object's fields and connects to the Object.
|
||||
|
||||
To add a custom object, the workspaceMember will query the /metadata API. This updates the metadata accordingly and computes a GraphQL schema based on the metadata, storing it in a GQL cache for later use.
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/server/add-custom-objects.jpeg" alt="Query the /metadata API to add custom objects" />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src="/images/docs/server/add-custom-objects.jpeg"
|
||||
alt="Query the /metadata API to add custom objects"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br />
|
||||
|
||||
To fetch data, the process involves making queries through the /graphql endpoint and passing them through the Query Resolver.
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/server/custom-object-schema.png" alt="Query the /graphql endpoint to fetch data" />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src="/images/docs/server/custom-object-schema.png"
|
||||
alt="Query the /graphql endpoint to fetch data"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
+3
-8
@@ -2,7 +2,6 @@
|
||||
title: Feature Flags
|
||||
---
|
||||
|
||||
|
||||
Feature flags are used to hide experimental features. For Twenty, they are set on workspace level and not on a user level.
|
||||
|
||||
## Adding a new feature flag
|
||||
@@ -24,8 +23,6 @@ enum FeatureFlagKeys {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
To apply a feature flag on a **backend** feature use:
|
||||
|
||||
```ts
|
||||
@@ -40,12 +37,10 @@ To apply a feature flag on a **frontend** feature use:
|
||||
const isFeatureNameEnabled = useIsFeatureEnabled('IS_FEATURENAME_ENABLED');
|
||||
```
|
||||
|
||||
|
||||
## Configure feature flags for the deployment
|
||||
|
||||
Change the corresponding record in the Table `core.featureFlag`:
|
||||
|
||||
| id | key | workspaceId | value |
|
||||
|----------|--------------------------|---------------|--------|
|
||||
| Random | `IS_FEATURENAME_ENABLED` | WorkspaceID | `true` |
|
||||
|
||||
| id | key | workspaceId | value |
|
||||
| ------ | ------------------------ | ----------- | ------ |
|
||||
| Random | `IS_FEATURENAME_ENABLED` | WorkspaceID | `true` |
|
||||
|
||||
+1
-3
@@ -3,7 +3,6 @@ title: Folder Architecture
|
||||
info: A detailed look into our server folder architecture
|
||||
---
|
||||
|
||||
|
||||
The backend directory structure is as follows:
|
||||
|
||||
```
|
||||
@@ -84,7 +83,6 @@ workspace
|
||||
└───workspace.factory.ts
|
||||
```
|
||||
|
||||
|
||||
The root of the workspace directory includes the `workspace.factory.ts`, a file containing the `createGraphQLSchema` function. This function generates workspace-specific schema by using the metadata to tailor a schema for individual workspaces. By separating the schema and resolver construction, we use the `makeExecutableSchema` function, which combines these discrete elements.
|
||||
|
||||
This strategy is not just about organization, but also helps with optimization, such as caching generated type definitions to enhance performance and scalability.
|
||||
@@ -96,6 +94,7 @@ Generates the GraphQL schema, and includes:
|
||||
#### Factories:
|
||||
|
||||
Specialised constructors to generate GraphQL-related constructs.
|
||||
|
||||
- The type.factory translates field metadata into GraphQL types using `TypeMapperService`.
|
||||
- The type-definition.factory creates GraphQL input or output objects derived from `objectMetadata`.
|
||||
|
||||
@@ -124,4 +123,3 @@ Each factory in this directory is responsible for producing a distinct resolver
|
||||
### Workspace Query Runner
|
||||
|
||||
Runs the generated queries on the database and parses the result.
|
||||
|
||||
|
||||
+7
-5
@@ -2,7 +2,6 @@
|
||||
title: Message Queue
|
||||
---
|
||||
|
||||
|
||||
Queues facilitate async operations to be performed. They can be used for performing background tasks such as sending a welcome email on register.
|
||||
Each use case will have its own queue class extended from `MessageQueueServiceBase`.
|
||||
|
||||
@@ -16,9 +15,12 @@ Currently, we only support `bull-mq`[bull-mq](https://bullmq.io/) as the queue d
|
||||
4. Add worker class with token based injection just like producer.
|
||||
|
||||
### Example usage
|
||||
|
||||
```ts
|
||||
class Resolver {
|
||||
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {}
|
||||
constructor(
|
||||
@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService,
|
||||
) {}
|
||||
|
||||
async onSomeAction() {
|
||||
//business logic
|
||||
@@ -28,7 +30,9 @@ class Resolver {
|
||||
|
||||
//async worker
|
||||
class CustomWorker {
|
||||
constructor(@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService) {
|
||||
constructor(
|
||||
@Inject(MESSAGE_QUEUES.custom) private queue: MessageQueueService,
|
||||
) {
|
||||
this.initWorker();
|
||||
}
|
||||
|
||||
@@ -39,5 +43,3 @@ class CustomWorker {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
+8
-5
@@ -1,9 +1,8 @@
|
||||
---
|
||||
title: Backend Commands
|
||||
icon: "terminal"
|
||||
icon: 'terminal'
|
||||
---
|
||||
|
||||
|
||||
## Useful commands
|
||||
|
||||
These commands should be executed from packages/twenty-server folder.
|
||||
@@ -33,6 +32,7 @@ npx nx run twenty-server:lint # pass --fix to fix lint errors
|
||||
npx nx run twenty-server:test:unit # run unit tests
|
||||
npx nx run twenty-server:test:integration # run integration tests
|
||||
```
|
||||
|
||||
Note: you can run `npx nx run twenty-server:test:integration:with-db-reset` in case you need to reset the database before running the integration tests.
|
||||
|
||||
### Resetting the database
|
||||
@@ -59,26 +59,29 @@ Prisma was the first ORM we used. But in order to allow users to create custom f
|
||||
|
||||
Here's what the tech stack now looks like.
|
||||
|
||||
|
||||
**Core**
|
||||
|
||||
- [NestJS](https://nestjs.com/)
|
||||
- [TypeORM](https://typeorm.io/)
|
||||
- [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
|
||||
|
||||
**Database**
|
||||
|
||||
- [Postgres](https://www.postgresql.org/)
|
||||
|
||||
**Third-party integrations**
|
||||
|
||||
- [Sentry](https://sentry.io/welcome/) for tracking bugs
|
||||
|
||||
**Testing**
|
||||
|
||||
- [Jest](https://jestjs.io/)
|
||||
|
||||
**Tooling**
|
||||
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
- [Oxlint](https://oxc.rs/docs/guide/usage/linter.html)
|
||||
|
||||
**Development**
|
||||
|
||||
- [AWS EKS](https://aws.amazon.com/eks/)
|
||||
|
||||
|
||||
|
||||
+12
-3
@@ -2,7 +2,6 @@
|
||||
title: Zapier App
|
||||
---
|
||||
|
||||
|
||||
Effortlessly sync Twenty with 3000+ apps using [Zapier](https://zapier.com/). Automate tasks, boost productivity, and supercharge your customer relationships!
|
||||
|
||||
## About Zapier
|
||||
@@ -36,6 +35,7 @@ From the `packages/twenty-zapier` folder, run:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Run the application locally, go to [http://localhost:3000/settings/api-webhooks](http://localhost:3000/settings/api-webhooks), and generate an API key.
|
||||
|
||||
Replace the **YOUR_API_KEY** value in the `.env` file with the API key you just generated.
|
||||
@@ -49,28 +49,37 @@ Make sure to run `yarn build` before any `zapier` command.
|
||||
</Warning>
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
yarn test
|
||||
```
|
||||
|
||||
### Lint
|
||||
|
||||
```bash
|
||||
yarn format
|
||||
```
|
||||
|
||||
### Watch and compile as you edit code
|
||||
|
||||
```bash
|
||||
yarn watch
|
||||
```
|
||||
|
||||
### Validate your Zapier app
|
||||
|
||||
```bash
|
||||
yarn validate
|
||||
```
|
||||
|
||||
### Deploy your Zapier app
|
||||
|
||||
```bash
|
||||
yarn deploy
|
||||
```
|
||||
|
||||
### List all Zapier CLI commands
|
||||
|
||||
```bash
|
||||
zapier
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
title: Bugs, Requests & Pull Requests
|
||||
icon: "bug"
|
||||
icon: 'bug'
|
||||
info: Report issues, request features, and contribute code
|
||||
---
|
||||
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
To report a bug, please [create an issue on GitHub](https://github.com/twentyhq/twenty/issues/new).
|
||||
@@ -29,12 +28,14 @@ Contributing code to Twenty starts with a pull request (PR).
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/twenty.git
|
||||
cd twenty
|
||||
```
|
||||
|
||||
3. Add upstream remote:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/twentyhq/twenty.git
|
||||
```
|
||||
@@ -46,6 +47,7 @@ git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
Use descriptive branch names:
|
||||
|
||||
- `feature/add-export-button`
|
||||
- `fix/login-redirect-issue`
|
||||
- `docs/update-api-guide`
|
||||
@@ -60,6 +62,7 @@ Use descriptive branch names:
|
||||
### Submit Your PR
|
||||
|
||||
1. Push your branch:
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
@@ -74,4 +77,3 @@ git push origin feature/your-feature-name
|
||||
- [ ] Tests pass locally
|
||||
- [ ] Documentation is updated
|
||||
- [ ] PR description explains the changes
|
||||
|
||||
|
||||
+11
-22
@@ -1,9 +1,8 @@
|
||||
---
|
||||
title: Best Practices
|
||||
icon: "star"
|
||||
icon: 'star'
|
||||
---
|
||||
|
||||
|
||||
This document outlines the best practices you should follow when working on the frontend.
|
||||
|
||||
## State management
|
||||
@@ -34,13 +33,10 @@ export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={myAtom}
|
||||
onChange={(e) => setMyAtom(e.target.value)}
|
||||
/>
|
||||
<input value={myAtom} onChange={(e) => setMyAtom(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Do not use `useRef` to store state
|
||||
@@ -91,7 +87,7 @@ export const PageComponent = () => {
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
if (someDependency !== data) {
|
||||
setData(someDependency);
|
||||
}
|
||||
}, [someDependency]);
|
||||
@@ -99,9 +95,7 @@ export const PageComponent = () => {
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<PageComponent />
|
||||
);
|
||||
export const App = () => <PageComponent />;
|
||||
```
|
||||
|
||||
```tsx
|
||||
@@ -118,7 +112,7 @@ export const PageData = () => {
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
if (someDependency !== data) {
|
||||
setData(someDependency);
|
||||
}
|
||||
}, [someDependency]);
|
||||
@@ -169,6 +163,7 @@ Make sure you remove all `console.logs` before pushing the code to production.
|
||||
Variable names ought to precisely depict the purpose or function of the variable.
|
||||
|
||||
#### The issue with generic names
|
||||
|
||||
Generic names in programming are not ideal because they lack specificity, leading to ambiguity and reduced code readability. Such names fail to convey the variable or function's purpose, making it challenging for developers to understand the code's intent without deeper investigation. This can result in increased debugging time, higher susceptibility to errors, and difficulties in maintenance and collaboration. Meanwhile, descriptive naming makes the code self-explanatory and easier to navigate, enhancing code quality and developer productivity.
|
||||
|
||||
```tsx
|
||||
@@ -184,7 +179,7 @@ const [email, setEmail] = useState('');
|
||||
|
||||
#### Some words to avoid in variable names
|
||||
|
||||
- dummy
|
||||
- dummy
|
||||
|
||||
### Event handlers
|
||||
|
||||
@@ -287,14 +282,11 @@ When importing, opt for the designated aliases rather than specifying complete o
|
||||
```
|
||||
|
||||
**Usage**
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, specifies the entire relative path
|
||||
import {
|
||||
CatalogDecorator
|
||||
} from '../../../../../testing/decorators/CatalogDecorator';
|
||||
import {
|
||||
ComponentDecorator
|
||||
} from '../../../../../testing/decorators/ComponentDecorator';
|
||||
import { CatalogDecorator } from '../../../../../testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '../../../../../testing/decorators/ComponentDecorator';
|
||||
```
|
||||
|
||||
```tsx
|
||||
@@ -323,9 +315,6 @@ const validationSchema = z
|
||||
type Form = z.infer<typeof validationSchema>;
|
||||
```
|
||||
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Always perform thorough manual testing before proceeding to guarantee that modifications haven’t caused disruptions elsewhere, given that tests have not yet been extensively integrated.
|
||||
|
||||
|
||||
|
||||
+1
-4
@@ -1,10 +1,9 @@
|
||||
---
|
||||
title: Folder Architecture
|
||||
icon: "folder-tree"
|
||||
icon: 'folder-tree'
|
||||
info: A detailed look into our folder architecture
|
||||
---
|
||||
|
||||
|
||||
In this guide, you will explore the details of the project directory structure and how it contributes to the organization and maintainability of Twenty.
|
||||
|
||||
By following this folder architecture convention, it's easier to find the files related to specific features and ensure that the application is scalable and maintainable.
|
||||
@@ -94,7 +93,6 @@ React's built-in state management still handles state within a component.
|
||||
|
||||
Should just contain reusable pure functions. Otherwise, create custom hooks in the `hooks` folder.
|
||||
|
||||
|
||||
## UI
|
||||
|
||||
Contains all the reusable UI components used in the application.
|
||||
@@ -110,4 +108,3 @@ You can import other module code from any module except for the `ui` folder. Thi
|
||||
### Internal
|
||||
|
||||
Each part (hooks, states, ...) of a module can have an `internal` folder, which contains parts that are just used within the module.
|
||||
|
||||
|
||||
+3
-4
@@ -1,9 +1,8 @@
|
||||
---
|
||||
title: Frontend Commands
|
||||
icon: "terminal"
|
||||
icon: 'terminal'
|
||||
---
|
||||
|
||||
|
||||
## Useful commands
|
||||
|
||||
### Starting the app
|
||||
@@ -17,7 +16,9 @@ npx nx start twenty-front
|
||||
```bash
|
||||
npx nx run twenty-front:graphql:generate --configuration=metadata
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```bash
|
||||
npx nx run twenty-front:graphql:generate
|
||||
```
|
||||
@@ -88,5 +89,3 @@ See [best practices](/developers/contribute/capabilities/frontend-development/be
|
||||
Jest is mainly for testing utility functions, and not components themselves.
|
||||
|
||||
Storybook is for testing the behavior of isolated components, as well as displaying the design system.
|
||||
|
||||
|
||||
|
||||
+11
-10
@@ -2,7 +2,6 @@
|
||||
title: Hotkeys
|
||||
---
|
||||
|
||||
|
||||
## Introduction
|
||||
|
||||
When you need to listen to a hotkey, you would normally use the `onKeyDown` event listener.
|
||||
@@ -20,6 +19,7 @@ You place it in a component, and it will listen to the hotkeys only when the com
|
||||
## How to listen for hotkeys in practice?
|
||||
|
||||
There are two steps involved in setting up hotkey listening :
|
||||
|
||||
1. Set the [hotkey scope](#what-is-a-hotkey-scope-) that will listen to hotkeys
|
||||
2. Use the `useScopedHotkeys` hook to listen to hotkeys
|
||||
|
||||
@@ -28,6 +28,7 @@ Setting up hotkey scopes is required even in simple pages, because other UI elem
|
||||
## Use cases for hotkeys
|
||||
|
||||
In general, you'll have two use cases that require hotkeys :
|
||||
|
||||
1. In a page or a component mounted in a page
|
||||
2. In a modal-type component that takes the focus due to a user action
|
||||
|
||||
@@ -88,9 +89,7 @@ const ExamplePageWithModal = () => {
|
||||
const handleOpenModalClick = () => {
|
||||
// 1. Set the hotkey scope when user opens the modal
|
||||
setShowModal(true);
|
||||
setHotkeyScopeAndMemorizePreviousScope(
|
||||
ExampleHotkeyScopes.ExampleModal,
|
||||
);
|
||||
setHotkeyScopeAndMemorizePreviousScope(ExampleHotkeyScopes.ExampleModal);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
@@ -99,11 +98,13 @@ const ExamplePageWithModal = () => {
|
||||
goBackToPreviousHotkeyScope();
|
||||
};
|
||||
|
||||
return <div>
|
||||
<h1>My page with a modal</h1>
|
||||
<button onClick={handleOpenModalClick}>Open modal</button>
|
||||
{showModal && <MyModalComponent onClose={handleModalClose} />}
|
||||
</div>;
|
||||
return (
|
||||
<div>
|
||||
<h1>My page with a modal</h1>
|
||||
<button onClick={handleOpenModalClick}>Open modal</button>
|
||||
{showModal && <MyModalComponent onClose={handleModalClose} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -117,7 +118,7 @@ const MyDropdownComponent = ({ onClose }: { onClose: () => void }) => {
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
() => {
|
||||
onClose()
|
||||
onClose();
|
||||
},
|
||||
ExampleHotkeyScopes.ExampleModal,
|
||||
);
|
||||
|
||||
-1
@@ -6,4 +6,3 @@ description: Browse Twenty's UI component library
|
||||
View our complete component library and documentation in Storybook.
|
||||
|
||||
[Open Storybook →](https://storybook.twenty.com)
|
||||
|
||||
|
||||
+13
-11
@@ -1,9 +1,8 @@
|
||||
---
|
||||
title: Style Guide
|
||||
icon: "paintbrush"
|
||||
icon: 'paintbrush'
|
||||
---
|
||||
|
||||
|
||||
This document includes the rules to follow when writing code.
|
||||
|
||||
The goal here is to have a consistent codebase, which is easy to read and easy to maintain.
|
||||
@@ -33,7 +32,7 @@ export default MyComponent;
|
||||
// ✅ Good, easy to read, easy to import with code completion
|
||||
export function MyComponent() {
|
||||
return <div>Hello World</div>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
@@ -51,7 +50,9 @@ type MyComponentProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const MyComponent = ({ name }: MyComponentProps) => <div>Hello {name}</div>;
|
||||
export const MyComponent = ({ name }: MyComponentProps) => (
|
||||
<div>Hello {name}</div>
|
||||
);
|
||||
```
|
||||
|
||||
#### Refrain from using `React.FC` or `React.FunctionComponent` to define prop types
|
||||
@@ -92,7 +93,7 @@ Avoid using single variable prop spreading in JSX elements, like `{...props}`. T
|
||||
*/
|
||||
const MyComponent = (props: OwnProps) => {
|
||||
return <OtherComponent {...props} />;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```tsx
|
||||
@@ -105,6 +106,7 @@ const MyComponent = ({ prop1, prop2, prop3 }: MyComponentProps) => {
|
||||
```
|
||||
|
||||
Rationale:
|
||||
|
||||
- At a glance, it's clearer which props the code passes down, making it easier to understand and maintain.
|
||||
- It helps to prevent tight coupling between components via their props.
|
||||
- Linting tools make it easier to identify misspelled or unused props when you list props explicitly.
|
||||
@@ -158,9 +160,9 @@ You can see why TypeScript recommends avoiding enums [here](https://www.typescri
|
||||
```tsx
|
||||
// ❌ Bad, utilizes an enum
|
||||
enum Color {
|
||||
Red = "red",
|
||||
Green = "green",
|
||||
Blue = "blue",
|
||||
Red = 'red',
|
||||
Green = 'green',
|
||||
Blue = 'blue',
|
||||
}
|
||||
|
||||
let color = Color.Red;
|
||||
@@ -169,7 +171,7 @@ let color = Color.Red;
|
||||
```tsx
|
||||
// ✅ Good, utilizes a string literal
|
||||
|
||||
let color: "red" | "green" | "blue" = "red";
|
||||
let color: 'red' | 'green' | 'blue' = 'red';
|
||||
```
|
||||
|
||||
#### GraphQL and internal libraries
|
||||
@@ -237,7 +239,6 @@ Avoid using `px` or `rem` values directly within the styled components. The nece
|
||||
|
||||
Refrain from introducing new colors; instead, use the existing palette from the theme. Should there be a situation where the palette does not align, please leave a comment so that the team can rectify it.
|
||||
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, directly specifies style values without utilizing the theme
|
||||
const StyledButton = styled.button`
|
||||
@@ -256,9 +257,10 @@ const StyledButton = styled.button`
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
border-radius: ${({ theme }) => theme.border.rounded};
|
||||
border-radius: ${({ theme }) => theme.border.rounded};
|
||||
`;
|
||||
```
|
||||
|
||||
## Enforcing No-Type Imports
|
||||
|
||||
Avoid type imports. To enforce this standard, an Oxlint rule checks for and reports any type imports. This helps maintain consistency and readability in the TypeScript code.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user