Merge branch 'main' into horizontal-nav

This commit is contained in:
Lucas Bordeau
2024-11-19 15:12:28 +01:00
922 changed files with 39794 additions and 37349 deletions

View File

@@ -5,6 +5,7 @@ on:
- main
jobs:
deploy-main:
timeout-minutes: 3
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch

View File

@@ -5,6 +5,7 @@ on:
- 'v*'
jobs:
deploy-tag:
timeout-minutes: 3
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch

View File

@@ -12,6 +12,7 @@ concurrency:
jobs:
chrome-extension-build:
timeout-minutes: 15
runs-on: ubuntu-latest
env:
VITE_SERVER_BASE_URL: http://localhost:3000

View File

@@ -1,34 +1,43 @@
name: Playwright Tests
name: CI E2E Tests
on:
push:
branches: [ main, master ]
branches:
- main
pull_request:
branches: [ main, master ]
branches:
- '**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
timeout-minutes: 60
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Check for changed files
id: changed-files
uses: tj-actions/changed-files@v11
with:
files: |
packages/** # Adjust this to your relevant directories
playwright.config.ts # Include any relevant config files
id: changed-files
uses: tj-actions/changed-files@v11
with:
files: |
packages/**
playwright.config.ts
- name: Skip if no relevant changes
if: steps.changed-files.outputs.any_changed != 'true'
run: echo "No relevant changes detected. Marking as valid."
if: steps.changed-files.outputs.any_changed == 'false'
run: echo "No relevant changes detected. Marking as valid."
- name: Install dependencies
if: steps.changed-files.outputs.any_changed == 'true'
run: npm install -g yarn && yarn
uses: ./.github/workflows/actions/yarn-install
- name: Install Playwright Browsers
if: steps.changed-files.outputs.any_changed == 'true'
run: yarn playwright install --with-deps

View File

@@ -12,6 +12,7 @@ concurrency:
jobs:
front-sb-build:
timeout-minutes: 30
runs-on: ubuntu-latest
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
@@ -58,8 +59,8 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: npx nx storybook:build twenty-front
front-sb-test:
timeout-minutes: 30
runs-on: shipfox-8vcpu-ubuntu-2204
timeout-minutes: 60
needs: front-sb-build
strategy:
matrix:
@@ -101,8 +102,8 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: npx nx storybook:serve-and-test:static twenty-front --configuration=${{ matrix.storybook_scope }}
front-sb-test-performance:
timeout-minutes: 30
runs-on: shipfox-8vcpu-ubuntu-2204
timeout-minutes: 60
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
@@ -135,6 +136,7 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: npx nx run twenty-front:storybook:serve-and-test:static:performance
front-chromatic-deployment:
timeout-minutes: 30
if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push'
needs: front-sb-build
runs-on: ubuntu-latest
@@ -177,6 +179,7 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: npx nx run twenty-front:chromatic:ci
front-task:
timeout-minutes: 30
runs-on: ubuntu-latest
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0

View File

@@ -15,6 +15,7 @@ on:
jobs:
create_pr:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

@@ -6,6 +6,7 @@ on:
jobs:
tag_and_release:
timeout-minutes: 10
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
steps:

View File

@@ -12,17 +12,25 @@ concurrency:
jobs:
server-setup:
timeout-minutes: 30
runs-on: ubuntu-latest
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
services:
postgres:
image: twentycrm/twenty-postgres
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: twenty
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
@@ -62,11 +70,17 @@ jobs:
- name: Server / Write .env
if: steps.changed-files.outputs.any_changed == 'true'
run: npx nx reset:env twenty-server
- name: Server / Create DB
if: steps.changed-files.outputs.any_changed == 'true'
run: |
PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Worker / Run
if: steps.changed-files.outputs.any_changed == 'true'
run: npx nx run twenty-server:worker:ci
server-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: server-setup
env:
@@ -102,16 +116,24 @@ jobs:
tasks: test
server-integration-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: server-setup
services:
postgres:
image: twentycrm/twenty-postgres
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: twenty
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
@@ -146,7 +168,7 @@ jobs:
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: "test:integration"
tasks: "test:integration:with-db-reset"
- name: Server / Upload reset-logs file
if: always()
uses: actions/upload-artifact@v4

View File

@@ -8,6 +8,7 @@ concurrency:
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -35,20 +36,42 @@ jobs:
yq eval 'del(.services.db.image)' -i docker-compose.yml
yq eval '.services.db.build.context = "../../"' -i docker-compose.yml
yq eval '.services.db.build.dockerfile = "./packages/twenty-docker/twenty-postgres/Dockerfile"' -i docker-compose.yml
yq eval '.services.db.build.dockerfile = "./packages/twenty-docker/twenty-postgres-spilo/Dockerfile"' -i docker-compose.yml
echo "Setting up .env file..."
cp .env.example .env
echo "Generating secrets..."
echo "# === Randomly generated secrets ===" >>.env
echo "APP_SECRET=$(openssl rand -base64 32)" >>.env
echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env
echo "PGPASSWORD_SUPERUSER=$(openssl rand -base64 32)" >>.env
echo "Starting server..."
docker compose up -d
echo "Docker compose up..."
docker compose up -d || {
echo "Docker compose failed to start"
docker compose logs
exit 1
}
docker compose logs db server -f &
pid=$!
echo "Waiting for database to start..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ]; do
sleep 1;
count=$((count+1));
if [ $(docker inspect --format='{{.State.Status}}' twenty-db-1) = "exited" ]; then
echo "Database exited"
docker compose logs db
exit 1
fi
if [ $count -gt 300 ]; then
echo "Failed to start database after 5 minutes"
docker compose logs db
exit 1
fi
echo "Still waiting for database... (${count}/60)"
done
echo "Waiting for server to start..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
@@ -56,11 +79,14 @@ jobs:
count=$((count+1));
if [ $(docker inspect --format='{{.State.Status}}' twenty-server-1) = "exited" ]; then
echo "Server exited"
docker compose logs server
exit 1
fi
if [ $count -gt 300 ]; then
echo "Failed to start server"
echo "Failed to start server after 5 minutes"
docker compose logs server
exit 1
fi
echo "Still waiting for server... (${count}/300s)"
done
working-directory: ./packages/twenty-docker/

View File

@@ -3,8 +3,14 @@ on:
push:
branches:
- main
paths:
- 'package.json'
- 'packages/twenty-tinybird/**'
pull_request:
paths:
- 'package.json'
- 'packages/twenty-tinybird/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,23 +18,9 @@ concurrency:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Check for changed files
id: changed-files
uses: tj-actions/changed-files@v11
with:
files: |
package.json
packages/twenty-tinybird/**
- name: Skip if no relevant changes
if: steps.changed-files.outputs.any_changed == 'false'
run: echo "No relevant changes. Skipping CI."
- name: Check twenty-tinybird package
uses: tinybirdco/ci/.github/workflows/ci.yml@main
with:
data_project_dir: packages/twenty-tinybird
tb_admin_token: ${{ secrets.TB_ADMIN_TOKEN }}
tb_host: https://api.eu-central-1.aws.tinybird.co
uses: tinybirdco/ci/.github/workflows/ci.yml@main
with:
data_project_dir: packages/twenty-tinybird
secrets:
tb_admin_token: ${{ secrets.TB_ADMIN_TOKEN }}
tb_host: https://api.eu-central-1.aws.tinybird.co

View File

@@ -15,10 +15,13 @@ permissions:
statuses: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# We don't cancel in-progress because this workflow is triggered on
# pull_request_target, which means the ref can be the same accross two PRs.
# cancel-in-progress: true
jobs:
danger-js:
timeout-minutes: 3
runs-on: ubuntu-latest
if: github.event.action != 'closed'
steps:
@@ -31,6 +34,7 @@ jobs:
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
congratulate:
timeout-minutes: 3
runs-on: ubuntu-latest
if: github.event.action == 'closed' && github.event.pull_request.merged == true
steps:

View File

@@ -13,15 +13,23 @@ concurrency:
jobs:
website-build:
timeout-minutes: 3
runs-on: ubuntu-latest
services:
postgres:
image: twentycrm/twenty-postgres
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_PASSWORD: twenty
POSTGRES_USER: twenty
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: twenty
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
with:
@@ -36,16 +44,20 @@ jobs:
if: steps.changed-files.outputs.changed == 'true'
uses: ./.github/workflows/actions/yarn-install
- name: Server / Create DB
if: steps.changed-files.outputs.any_changed == 'true'
run: PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
- name: Website / Run migrations
if: steps.changed-files.outputs.changed == 'true'
run: npx nx database:migrate twenty-website
env:
DATABASE_PG_URL: postgres://twenty:twenty@localhost:5432/default
DATABASE_PG_URL: postgres://postgres:twenty@localhost:5432/default
- name: Website / Build Website
if: steps.changed-files.outputs.changed == 'true'
run: npx nx build twenty-website
env:
DATABASE_PG_URL: postgres://twenty:twenty@localhost:5432/default
DATABASE_PG_URL: postgres://postgres:twenty@localhost:5432/default
- name: Mark as VALID
if: steps.changed-files.outputs.changed != 'true' # If no changes, mark as valid

View File

@@ -24,10 +24,6 @@
"name": "packages/twenty-emails",
"path": "../packages/twenty-emails"
},
{
"name": "packages/twenty-postgres",
"path": "../packages/twenty-postgres"
},
{
"name": "packages/twenty-server",
"path": "../packages/twenty-server"

View File

@@ -1,12 +1,20 @@
postgres-on-docker:
docker run \
--name twenty_postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=default \
-v twenty_db_data:/var/lib/postgresql/data \
docker run -d \
--name twenty_pg \
-e PGUSER_SUPERUSER=postgres \
-e PGPASSWORD_SUPERUSER=twenty \
-e ALLOW_NOSSL=true \
-v twenty_db_data:/home/postgres/pgdata \
-p 5432:5432 \
twentycrm/twenty-postgres:latest
twentycrm/twenty-postgres-spilo:latest
@echo "Waiting for PostgreSQL to be ready..."
@until PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres \
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
sleep 1; \
done
PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres \
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
redis-on-docker:
docker run -d --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest

View File

@@ -93,7 +93,7 @@ fi
echo "# === Randomly generated secrets ===" >>.env
echo "APP_SECRET=$(openssl rand -base64 32)" >>.env
echo "" >>.env
echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env
echo "PGPASSWORD_SUPERUSER=$(openssl rand -hex 16)" >>.env
echo -e "\t• .env configuration completed"

View File

@@ -23,6 +23,7 @@
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
"@mdx-js/react": "^3.0.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@nestjs/apollo": "^11.0.5",
"@nestjs/axios": "^3.0.1",
"@nestjs/cli": "^9.0.0",
@@ -201,6 +202,7 @@
"@graphql-codegen/typescript": "^3.0.4",
"@graphql-codegen/typescript-operations": "^3.0.4",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@microsoft/microsoft-graph-types": "^2.40.0",
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
@@ -350,7 +352,7 @@
"version": "0.2.1",
"nx": {},
"scripts": {
"start": "npx nx run-many -t start worker -p twenty-server twenty-front"
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
},
"workspaces": {
"packages": [

View File

@@ -3,7 +3,7 @@ import styled from '@emotion/styled';
type H2TitleProps = {
title: string;
description?: string;
addornment?: React.ReactNode;
adornment?: React.ReactNode;
};
const StyledContainer = styled.div`
@@ -33,11 +33,11 @@ const StyledDescription = styled.h3`
margin-top: ${({ theme }) => theme.spacing(3)};
`;
export const H2Title = ({ title, description, addornment }: H2TitleProps) => (
export const H2Title = ({ title, description, adornment }: H2TitleProps) => (
<StyledContainer>
<StyledTitleContainer>
<StyledTitle>{title}</StyledTitle>
{addornment}
{adornment}
</StyledTitleContainer>
{description && <StyledDescription>{description}</StyledDescription>}
</StyledContainer>

View File

@@ -1,6 +1,7 @@
TAG=latest
# POSTGRES_ADMIN_PASSWORD=replace_me_with_a_strong_password
#PGUSER_SUPERUSER=postgres
#PGPASSWORD_SUPERUSER=replace_me_with_a_strong_password
PG_DATABASE_HOST=db:5432
REDIS_URL=redis://redis:6379

View File

@@ -4,9 +4,6 @@ prod-build:
prod-run:
@docker run -d -p 3000:3000 --name twenty twenty
prod-postgres-build:
@cd ../.. && docker build -f ./packages/twenty-docker/twenty-postgres/Dockerfile --tag twenty-postgres . && cd -
prod-postgres-run:
@docker run -d -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres --name twenty-postgres twenty-postgres
@@ -15,11 +12,3 @@ prod-website-build:
prod-website-run:
@docker run -d -p 3000:3000 --name twenty-website twenty-website
release-postgres:
@cd ../.. && docker buildx build \
--push \
--no-cache \
--platform linux/amd64,linux/arm64 \
-f ./packages/twenty-docker/twenty-postgres/Dockerfile -t twentycrm/twenty-postgres:$(version) -t twentycrm/twenty-postgres:latest . \
&& cd -

View File

@@ -22,10 +22,10 @@ services:
- "3000:3000"
environment:
PORT: 3000
PG_DATABASE_URL: postgres://twenty:twenty@${PG_DATABASE_HOST}/default
PG_DATABASE_URL: postgres://${PGUSER_SUPERUSER:-postgres}:${PGPASSWORD_SUPERUSER:-twenty}@${PG_DATABASE_HOST:-db:5432}/default
SERVER_URL: ${SERVER_URL}
FRONT_BASE_URL: ${FRONT_BASE_URL:-$SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://localhost:6379}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ENABLE_DB_MIGRATIONS: "true"
@@ -52,10 +52,10 @@ services:
image: twentycrm/twenty:${TAG}
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgres://twenty:twenty@${PG_DATABASE_HOST}/default
PG_DATABASE_URL: postgres://${PGUSER_SUPERUSER:-postgres}:${PGPASSWORD_SUPERUSER:-twenty}@${PG_DATABASE_HOST:-db:5432}/default
SERVER_URL: ${SERVER_URL}
FRONT_BASE_URL: ${FRONT_BASE_URL:-$SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://localhost:6379}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ENABLE_DB_MIGRATIONS: "false" # it already runs on the server
@@ -73,13 +73,16 @@ services:
restart: always
db:
image: twentycrm/twenty-postgres:${TAG}
image: twentycrm/twenty-postgres-spilo:${TAG}
volumes:
- db-data:/bitnami/postgresql
- db-data:/home/postgres/pgdata
environment:
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
PGUSER_SUPERUSER: ${PGUSER_SUPERUSER:-postgres}
PGPASSWORD_SUPERUSER: ${PGPASSWORD_SUPERUSER:-twenty}
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
healthcheck:
test: pg_isready -U twenty -d default
test: pg_isready -U ${PGUSER_SUPERUSER:-postgres} -h localhost -d postgres
interval: 5s
timeout: 5s
retries: 10

View File

@@ -30,10 +30,10 @@ spec:
image: twentycrm/twenty-postgres:latest
imagePullPolicy: Always
env:
- name: POSTGRES_PASSWORD
- name: PGUSER_SUPERUSER
value: "postgres"
- name: PGPASSWORD_SUPERUSER
value: "twenty"
- name: BITNAMI_DEBUG
value: "true"
ports:
- containerPort: 5432
name: tcp
@@ -48,7 +48,7 @@ spec:
stdin: true
tty: true
volumeMounts:
- mountPath: /bitnami/postgresql
- mountPath: /home/postgres/pgdata
name: twentycrm-db-data
dnsPolicy: ClusterFirst
restartPolicy: Always

View File

@@ -40,7 +40,7 @@ spec:
- name: FRONT_BASE_URL
value: "https://crm.example.com:443"
- name: "PG_DATABASE_URL"
value: "postgres://twenty:twenty@twenty-db.twentycrm.svc.cluster.local/default"
value: "postgres://postgres:twenty@twenty-db.twentycrm.svc.cluster.local/default"
- name: "REDIS_URL"
value: "redis://twentycrm-redis.twentycrm.svc.cluster.local:6379"
- name: ENABLE_DB_MIGRATIONS

View File

@@ -31,7 +31,7 @@ spec:
- name: FRONT_BASE_URL
value: "https://crm.example.com:443"
- name: PG_DATABASE_URL
value: "postgres://twenty:twenty@twenty-db.twentycrm.svc.cluster.local/default"
value: "postgres://postgres:twenty@twenty-db.twentycrm.svc.cluster.local/default"
- name: ENABLE_DB_MIGRATIONS
value: "false" # it already runs on the server
- name: STORAGE_TYPE

View File

@@ -51,7 +51,7 @@ To make configuration changes to how this doc is generated, see `./.terraform-do
| <a name="input_twentycrm_app_hostname"></a> [twentycrm\_app\_hostname](#input\_twentycrm\_app\_hostname) | The protocol, DNS fully qualified hostname, and port used to access TwentyCRM in your environment. Ex: https://crm.example.com:443 | `string` | n/a | yes |
| <a name="input_twentycrm_pgdb_admin_password"></a> [twentycrm\_pgdb\_admin\_password](#input\_twentycrm\_pgdb\_admin\_password) | TwentyCRM password for postgres database. | `string` | n/a | yes |
| <a name="input_twentycrm_app_name"></a> [twentycrm\_app\_name](#input\_twentycrm\_app\_name) | A friendly name prefix to use for every component deployed. | `string` | `"twentycrm"` | no |
| <a name="input_twentycrm_db_image"></a> [twentycrm\_db\_image](#input\_twentycrm\_db\_image) | TwentyCRM image for database deployment. This defaults to latest. | `string` | `"twentycrm/twenty-postgres:latest"` | no |
| <a name="input_twentycrm_db_image"></a> [twentycrm\_db\_image](#input\_twentycrm\_db\_image) | TwentyCRM image for database deployment. This defaults to latest. | `string` | `"twentycrm/twenty-postgres-spilo:latest"` | no |
| <a name="input_twentycrm_db_pv_capacity"></a> [twentycrm\_db\_pv\_capacity](#input\_twentycrm\_db\_pv\_capacity) | Storage capacity provisioned for database persistent volume. | `string` | `"10Gi"` | no |
| <a name="input_twentycrm_db_pv_path"></a> [twentycrm\_db\_pv\_path](#input\_twentycrm\_db\_pv\_path) | Local path to use to store the physical volume if using local storage on nodes. | `string` | `""` | no |
| <a name="input_twentycrm_db_pvc_requests"></a> [twentycrm\_db\_pvc\_requests](#input\_twentycrm\_db\_pvc\_requests) | Storage capacity reservation for database persistent volume claim. | `string` | `"10Gi"` | no |

View File

@@ -29,7 +29,7 @@ variable "twentycrm_server_image" {
variable "twentycrm_db_image" {
type = string
default = "twentycrm/twenty-postgres:latest"
default = "twentycrm/twenty-postgres-spilo:latest"
description = "TwentyCRM image for database deployment. This defaults to latest."
}

View File

@@ -3,10 +3,10 @@ ARG SPILO_VERSION=3.2-p1
ARG WRAPPERS_VERSION=0.2.0
# Build the mysql_fdw extension
FROM debian:bookworm as build-mysql_fdw
FROM debian:bookworm AS build-mysql_fdw
ARG POSTGRES_VERSION
ENV DEBIAN_FRONTEND noninteractive
ENV DEBIAN_FRONTEND=noninteractive
RUN apt update && \
apt install -y \
build-essential \
@@ -17,14 +17,14 @@ RUN apt update && \
# Install mysql_fdw
RUN git clone https://github.com/EnterpriseDB/mysql_fdw.git
WORKDIR mysql_fdw
WORKDIR /mysql_fdw
RUN make USE_PGXS=1
# Build libssl for wrappers
FROM ubuntu:22.04 as build-libssl
FROM ubuntu:22.04 AS build-libssl
ENV DEBIAN_FRONTEND noninteractive
ENV DEBIAN_FRONTEND=noninteractive
RUN apt update && \
apt install -y \
build-essential \

View File

@@ -1,45 +0,0 @@
ARG IMAGE_TAG='15.5.0-debian-11-r15'
FROM bitnami/postgresql:${IMAGE_TAG}
ARG PG_MAIN_VERSION=15
ARG WRAPPERS_VERSION=0.2.0
ARG TARGETARCH
USER root
RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
TARGETARCH='arm64'; \
;; \
amd64|x86_64) \
TARGETARCH='amd64'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
exit 1; \
;; \
esac;
RUN apt update && apt install build-essential git curl default-libmysqlclient-dev -y
# Install precompiled supabase wrappers extensions
RUN curl -L "https://github.com/supabase/wrappers/releases/download/v${WRAPPERS_VERSION}/wrappers-v${WRAPPERS_VERSION}-pg${PG_MAIN_VERSION}-${TARGETARCH}-linux-gnu.deb" -o wrappers.deb
RUN dpkg --install wrappers.deb
RUN cp /usr/share/postgresql/${PG_MAIN_VERSION}/extension/wrappers* /opt/bitnami/postgresql/share/extension/
RUN cp /usr/lib/postgresql/${PG_MAIN_VERSION}/lib/wrappers* /opt/bitnami/postgresql/lib/
RUN export PATH=/usr/local/pgsql/bin/:$PATH
RUN export PATH=/usr/local/mysql/bin/:$PATH
RUN git clone https://github.com/EnterpriseDB/mysql_fdw.git
WORKDIR mysql_fdw
RUN make USE_PGXS=1
RUN make USE_PGXS=1 install
COPY ./packages/twenty-docker/twenty-postgres/init.sql /docker-entrypoint-initdb.d/
USER 1001
ENTRYPOINT ["/opt/bitnami/scripts/postgresql/entrypoint.sh"]
CMD ["/opt/bitnami/scripts/postgresql/run.sh"]

View File

@@ -1,4 +0,0 @@
CREATE DATABASE "default";
CREATE DATABASE "test";
CREATE USER twenty PASSWORD 'twenty';
ALTER ROLE twenty superuser;

View File

@@ -52,9 +52,10 @@ RUN apk add --no-cache curl jq
RUN npm install -g tsx
RUN apk add --no-cache postgresql-client
COPY ./packages/twenty-docker/twenty/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
WORKDIR /app/packages/twenty-server
ARG REACT_APP_SERVER_BASE_URL

View File

@@ -4,6 +4,14 @@
if [ "${ENABLE_DB_MIGRATIONS}" = "true" ] && [ ! -f /app/docker-data/db_status ]; then
echo "Running database setup and migrations..."
# Creating the database if it doesn't exist
PGUSER=$(echo $PG_DATABASE_URL | awk -F '//' '{print $2}' | awk -F ':' '{print $1}')
PGPASS=$(echo $PG_DATABASE_URL | awk -F ':' '{print $3}' | awk -F '@' '{print $1}')
PGHOST=$(echo $PG_DATABASE_URL | awk -F '@' '{print $2}' | awk -F ':' '{print $1}')
PGPORT=$(echo $PG_DATABASE_URL | awk -F ':' '{print $4}' | awk -F '/' '{print $1}')
PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'default'" | grep -q 1 || \
PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -c "CREATE DATABASE \"default\""
# Run setup and migration scripts
NODE_OPTIONS="--max-old-space-size=1500" tsx ./scripts/setup-db.ts
yarn database:migrate:prod

View File

@@ -0,0 +1,22 @@
import * as fs from 'fs';
import path from 'path';
export const envVariables = (variables: string) => {
let payload = `
PG_DATABASE_URL=postgres://postgres:twenty@localhost:5432/default
FRONT_BASE_URL=http://localhost:3001
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh
REDIS_URL=redis://localhost:6379
`;
payload = payload.concat(variables);
fs.writeFile(
path.join(__dirname, '..', '..', 'twenty-server', '.env'),
payload,
(err) => {
throw err;
},
);
};

View File

@@ -0,0 +1,36 @@
import { Locator, Page } from '@playwright/test';
export class ConfirmationModal {
private readonly input: Locator;
private readonly cancelButton: Locator;
private readonly confirmButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.input = page.getByTestId('confirmation-modal-input');
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
this.confirmButton = page.getByTestId('confirmation-modal-confirm-button');
}
async typePlaceholderToInput() {
await this.page
.getByTestId('confirmation-modal-input')
.fill(
await this.page
.getByTestId('confirmation-modal-input')
.getAttribute('placeholder'),
);
}
async typePhraseToInput(value: string) {
await this.page.getByTestId('confirmation-modal-input').fill(value);
}
async clickCancelButton() {
await this.page.getByRole('button', { name: 'Cancel' }).click();
}
async clickConfirmButton() {
await this.page.getByTestId('confirmation-modal-confirm-button').click();
}
}

View File

@@ -0,0 +1,28 @@
const nth = (d: number) => {
if (d > 3 && d < 21) return 'th';
switch (d % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
};
// label looks like this: Choose Wednesday, October 30th, 2024
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
export function formatDate(value: string): string {
const date = new Date(value);
return 'Choose '.concat(
date.toLocaleDateString('en-US', { weekday: 'long' }),
', ',
date.toLocaleDateString('en-US', { month: 'long' }),
' ',
nth(date.getDate()),
', ',
date.toLocaleDateString('en-US', { year: 'numeric' }),
);
}

View File

@@ -0,0 +1,6 @@
import { Locator, Page } from '@playwright/test';
export class GoogleLogin {
// TODO: map all things like inputs and buttons
// (what's the correct way for proceeding with Google interaction? log in each time test is performed?)
}

View File

@@ -0,0 +1,23 @@
import { Locator, Page } from '@playwright/test';
export class IconSelect {
private readonly iconSelectButton: Locator;
private readonly iconSearchInput: Locator;
constructor(public readonly page: Page) {
this.iconSelectButton = page.getByLabel('Click to select icon (');
this.iconSearchInput = page.getByPlaceholder('Search icon');
}
async selectIcon(name: string) {
await this.iconSelectButton.click();
await this.iconSearchInput.fill(name);
await this.page.getByTitle(name).click();
}
async selectRelationIcon(name: string) {
await this.iconSelectButton.nth(1).click();
await this.iconSearchInput.fill(name);
await this.page.getByTitle(name).click();
}
}

View File

@@ -0,0 +1,267 @@
import { Locator, Page } from '@playwright/test';
import { formatDate } from './formatDate.function';
export class InsertFieldData {
private readonly address1Input: Locator;
private readonly address2Input: Locator;
private readonly cityInput: Locator;
private readonly stateInput: Locator;
private readonly postCodeInput: Locator;
private readonly countrySelect: Locator;
private readonly arrayValueInput: Locator;
private readonly arrayAddValueButton: Locator;
// boolean react after click so no need to write special locator
private readonly currencySelect: Locator;
private readonly currencyAmountInput: Locator;
private readonly monthSelect: Locator;
private readonly yearSelect: Locator;
private readonly previousMonthButton: Locator;
private readonly nextMonthButton: Locator;
private readonly clearDateButton: Locator;
private readonly dateInput: Locator;
private readonly firstNameInput: Locator;
private readonly lastNameInput: Locator;
private readonly addURLButton: Locator;
private readonly setAsPrimaryButton: Locator;
private readonly addPhoneButton: Locator;
private readonly addMailButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.address1Input = page.locator(
'//label[contains(., "ADDRESS 1")]/../div[last()]/input',
);
this.address2Input = page.locator(
'//label[contains(., "ADDRESS 2")]/../div[last()]/input',
);
this.cityInput = page.locator(
'//label[contains(., "CITY")]/../div[last()]/input',
);
this.stateInput = page.locator(
'//label[contains(., "STATE")]/../div[last()]/input',
);
this.postCodeInput = page.locator(
'//label[contains(., "POST CODE")]/../div[last()]/input',
);
this.countrySelect = page.locator(
'//span[contains(., "COUNTRY")]/../div[last()]/input',
);
this.arrayValueInput = page.locator("//input[@placeholder='Enter value']");
this.arrayAddValueButton = page.locator(
"//div[@data-testid='tooltip' and contains(.,'Add item')]",
);
this.currencySelect = page.locator(
'//body/div[last()]/div/div/div[first()]/div/div',
);
this.currencyAmountInput = page.locator("//input[@placeholder='Currency']");
this.monthSelect; // TODO: add once some other attributes are added
this.yearSelect;
this.previousMonthButton;
this.nextMonthButton;
this.clearDateButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Clear')]",
);
this.dateInput = page.locator("//input[@placeholder='Type date and time']");
this.firstNameInput = page.locator("//input[@placeholder='First name']"); // may fail if placeholder is `F&zwnj;&zwnj;irst name` instead of `First name`
this.lastNameInput = page.locator("//input[@placeholder='Last name']"); // may fail if placeholder is `L&zwnj;&zwnj;ast name` instead of `Last name`
this.addURLButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Add URL')]",
);
this.setAsPrimaryButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Set as primary')]",
);
this.addPhoneButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Add Phone')]",
);
this.addMailButton = page.locator(
"//div[@data-testid='tooltip' and contains(., 'Add Email')]",
);
}
// address
async typeAddress1(value: string) {
await this.address1Input.fill(value);
}
async typeAddress2(value: string) {
await this.address2Input.fill(value);
}
async typeCity(value: string) {
await this.cityInput.fill(value);
}
async typeState(value: string) {
await this.stateInput.fill(value);
}
async typePostCode(value: string) {
await this.postCodeInput.fill(value);
}
async selectCountry(value: string) {
await this.countrySelect.click();
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
// array
async typeArrayValue(value: string) {
await this.arrayValueInput.fill(value);
}
async clickAddItemButton() {
await this.arrayAddValueButton.click();
}
// currency
async selectCurrency(value: string) {
await this.currencySelect.click();
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
async typeCurrencyAmount(value: string) {
await this.currencyAmountInput.fill(value);
}
// date(-time)
async typeDate(value: string) {
await this.dateInput.fill(value);
}
async selectMonth(value: string) {
await this.monthSelect.click();
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
async selectYear(value: string) {
await this.yearSelect.click();
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
async clickPreviousMonthButton() {
await this.previousMonthButton.click();
}
async clickNextMonthButton() {
await this.nextMonthButton.click();
}
async selectDay(value: string) {
await this.page
.locator(`//div[@aria-label='${formatDate(value)}']`)
.click();
}
async clearDate() {
await this.clearDateButton.click();
}
// email
async typeEmail(value: string) {
await this.page.locator(`//input[@placeholder='Email']`).fill(value);
}
async clickAddMailButton() {
await this.addMailButton.click();
}
// full name
async typeFirstName(name: string) {
await this.firstNameInput.fill(name);
}
async typeLastName(name: string) {
await this.lastNameInput.fill(name);
}
// JSON
// placeholder is dependent on the name of field
async typeJSON(placeholder: string, value: string) {
await this.page
.locator(`//input[@placeholder='${placeholder}']`)
.fill(value);
}
// link
async typeLink(value: string) {
await this.page.locator("//input[@placeholder='URL']").fill(value);
}
async clickAddURL() {
await this.addURLButton.click();
}
// (multi-)select
async selectValue(value: string) {
await this.page
.locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`)
.click();
}
// number
// placeholder is dependent on the name of field
async typeNumber(placeholder: string, value: string) {
await this.page
.locator(`//input[@placeholder='${placeholder}']`)
.fill(value);
}
// phones
async selectCountryPhoneCode(countryCode: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${countryCode}')]`,
)
.click();
}
async typePhoneNumber(value: string) {
await this.page.locator(`//input[@placeholder='Phone']`).fill(value);
}
async clickAddPhoneButton() {
await this.addPhoneButton.click();
}
// rating
// if adding rating for the first time, hover must be used
async selectRating(rating: number) {
await this.page.locator(`//div[@role='slider']/div[${rating}]`).click();
}
// text
// placeholder is dependent on the name of field
async typeText(placeholder: string, value: string) {
await this.page
.locator(`//input[@placeholder='${placeholder}']`)
.fill(value);
}
async clickSetAsPrimaryButton() {
await this.setAsPrimaryButton.click();
}
async searchValue(value: string) {
await this.page.locator(`//div[@placeholder='Search']`).fill(value);
}
async clickEditButton() {
await this.page
.locator("//div[@data-testid='tooltip' and contains(., 'Edit')]")
.click();
}
async clickDeleteButton() {
await this.page
.locator("//div[@data-testid='tooltip' and contains(., 'Delete')]")
.click();
}
}

View File

@@ -0,0 +1,5 @@
import { Locator, Page } from '@playwright/test';
export class StripePage {
// TODO: implement all necessary methods (staging/sandbox page - does it differ anyhow from normal page?)
}

View File

@@ -0,0 +1,25 @@
import { Locator, Page } from '@playwright/test';
export class UploadImage {
private readonly imagePreview: Locator;
private readonly uploadButton: Locator;
private readonly removeButton: Locator;
constructor(public readonly page: Page) {
this.imagePreview = page.locator('.css-6eut39'); //TODO: add attribute to make it independent of theme
this.uploadButton = page.getByRole('button', { name: 'Upload' });
this.removeButton = page.getByRole('button', { name: 'Remove' });
}
async clickImagePreview() {
await this.imagePreview.click();
}
async clickUploadButton() {
await this.uploadButton.click();
}
async clickRemoveButton() {
await this.removeButton.click();
}
}

View File

@@ -0,0 +1,115 @@
import { Locator, Page } from '@playwright/test';
export class LeftMenu {
private readonly workspaceDropdown: Locator;
private readonly leftMenu: Locator;
private readonly searchSubTab: Locator;
private readonly settingsTab: Locator;
private readonly peopleTab: Locator;
private readonly companiesTab: Locator;
private readonly opportunitiesTab: Locator;
private readonly opportunitiesTabAll: Locator;
private readonly opportunitiesTabByStage: Locator;
private readonly tasksTab: Locator;
private readonly tasksTabAll: Locator;
private readonly tasksTabByStatus: Locator;
private readonly notesTab: Locator;
private readonly rocketsTab: Locator;
private readonly workflowsTab: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.workspaceDropdown = page.getByTestId('workspace-dropdown');
this.leftMenu = page.getByRole('button').first();
this.searchSubTab = page.getByText('Search');
this.settingsTab = page.getByRole('link', { name: 'Settings' });
this.peopleTab = page.getByRole('link', { name: 'People' });
this.companiesTab = page.getByRole('link', { name: 'Companies' });
this.opportunitiesTab = page.getByRole('link', { name: 'Opportunities' });
this.opportunitiesTabAll = page.getByRole('link', {
name: 'All',
exact: true,
});
this.opportunitiesTabByStage = page.getByRole('link', { name: 'By Stage' });
this.tasksTab = page.getByRole('link', { name: 'Tasks' });
this.tasksTabAll = page.getByRole('link', { name: 'All tasks' });
this.tasksTabByStatus = page.getByRole('link', { name: 'Notes' });
this.notesTab = page.getByRole('link', { name: 'Notes' });
this.rocketsTab = page.getByRole('link', { name: 'Rockets' });
this.workflowsTab = page.getByRole('link', { name: 'Workflows' });
}
async selectWorkspace(workspaceName: string) {
await this.workspaceDropdown.click();
await this.page
.getByTestId('tooltip')
.filter({ hasText: workspaceName })
.click();
}
async changeLeftMenu() {
await this.leftMenu.click();
}
async openSearchTab() {
await this.searchSubTab.click();
}
async goToSettings() {
await this.settingsTab.click();
}
async goToPeopleTab() {
await this.peopleTab.click();
}
async goToCompaniesTab() {
await this.companiesTab.click();
}
async goToOpportunitiesTab() {
await this.opportunitiesTab.click();
}
async goToOpportunitiesTableView() {
await this.opportunitiesTabAll.click();
}
async goToOpportunitiesKanbanView() {
await this.opportunitiesTabByStage.click();
}
async goToTasksTab() {
await this.tasksTab.click();
}
async goToTasksTableView() {
await this.tasksTabAll.click();
}
async goToTasksKanbanView() {
await this.tasksTabByStatus.click();
}
async goToNotesTab() {
await this.notesTab.click();
}
async goToRocketsTab() {
await this.rocketsTab.click();
}
async goToWorkflowsTab() {
await this.workflowsTab.click();
}
async goToCustomObject(customObjectName: string) {
await this.page.getByRole('link', { name: customObjectName }).click();
}
async goToCustomObjectView(name: string) {
await this.page.getByRole('link', { name: name }).click();
}
}
export default LeftMenu;

View File

@@ -0,0 +1,187 @@
import { Locator, Page } from '@playwright/test';
export class LoginPage {
private readonly loginWithGoogleButton: Locator;
private readonly loginWithEmailButton: Locator;
private readonly termsOfServiceLink: Locator;
private readonly privacyPolicyLink: Locator;
private readonly emailField: Locator;
private readonly continueButton: Locator;
private readonly forgotPasswordButton: Locator;
private readonly passwordField: Locator;
private readonly revealPasswordButton: Locator;
private readonly signInButton: Locator;
private readonly signUpButton: Locator;
private readonly previewImageButton: Locator;
private readonly uploadImageButton: Locator;
private readonly deleteImageButton: Locator;
private readonly workspaceNameField: Locator;
private readonly firstNameField: Locator;
private readonly lastNameField: Locator;
private readonly syncEverythingWithGoogleRadio: Locator;
private readonly syncSubjectAndMetadataWithGoogleRadio: Locator;
private readonly syncMetadataWithGoogleRadio: Locator;
private readonly syncWithGoogleButton: Locator;
private readonly noSyncButton: Locator;
private readonly inviteLinkField1: Locator;
private readonly inviteLinkField2: Locator;
private readonly inviteLinkField3: Locator;
private readonly copyInviteLink: Locator;
private readonly finishButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.loginWithGoogleButton = page.getByRole('button', {
name: 'Continue with Google',
});
this.loginWithEmailButton = page.getByRole('button', {
name: 'Continue With Email',
});
this.termsOfServiceLink = page.getByRole('link', {
name: 'Terms of Service',
});
this.privacyPolicyLink = page.getByRole('link', { name: 'Privacy Policy' });
this.emailField = page.getByPlaceholder('Email');
this.continueButton = page.getByRole('button', {
name: 'Continue',
exact: true,
});
this.forgotPasswordButton = page.getByText('Forgot your password?');
this.passwordField = page.getByPlaceholder('Password');
this.revealPasswordButton = page.getByTestId('reveal-password-button');
this.signInButton = page.getByRole('button', { name: 'Sign in' });
this.signUpButton = page.getByRole('button', { name: 'Sign up' });
this.previewImageButton = page.locator('.css-1qzw107'); // TODO: fix
this.uploadImageButton = page.getByRole('button', { name: 'Upload' });
this.deleteImageButton = page.getByRole('button', { name: 'Remove' });
this.workspaceNameField = page.getByPlaceholder('Apple');
this.firstNameField = page.getByPlaceholder('Tim');
this.lastNameField = page.getByPlaceholder('Cook');
this.syncEverythingWithGoogleRadio = page.locator(
'input[value="SHARE_EVERYTHING"]',
);
this.syncSubjectAndMetadataWithGoogleRadio = page.locator(
'input[value="SUBJECT"]',
);
this.syncMetadataWithGoogleRadio = page.locator('input[value="METADATA"]');
this.syncWithGoogleButton = page.getByRole('button', {
name: 'Sync with Google',
});
this.noSyncButton = page.getByText('Continue without sync');
this.inviteLinkField1 = page.getByPlaceholder('tim@apple.dev');
this.inviteLinkField2 = page.getByPlaceholder('craig@apple.dev');
this.inviteLinkField3 = page.getByPlaceholder('mike@apple.dev');
this.copyInviteLink = page.getByRole('button', {
name: 'Copy invitation link',
});
this.finishButton = page.getByRole('button', { name: 'Finish' });
}
async loginWithGoogle() {
await this.loginWithGoogleButton.click();
}
async clickLoginWithEmail() {
await this.loginWithEmailButton.click();
}
async clickContinueButton() {
await this.continueButton.click();
}
async clickTermsLink() {
await this.termsOfServiceLink.click();
}
async clickPrivacyPolicyLink() {
await this.privacyPolicyLink.click();
}
async typeEmail(email: string) {
await this.emailField.fill(email);
}
async typePassword(email: string) {
await this.passwordField.fill(email);
}
async clickSignInButton() {
await this.signInButton.click();
}
async clickSignUpButton() {
await this.signUpButton.click();
}
async clickForgotPassword() {
await this.forgotPasswordButton.click();
}
async revealPassword() {
await this.revealPasswordButton.click();
}
async previewImage() {
await this.previewImageButton.click();
}
async clickUploadImage() {
await this.uploadImageButton.click();
}
async deleteImage() {
await this.deleteImageButton.click();
}
async typeWorkspaceName(workspaceName: string) {
await this.workspaceNameField.fill(workspaceName);
}
async typeFirstName(firstName: string) {
await this.firstNameField.fill(firstName);
}
async typeLastName(lastName: string) {
await this.lastNameField.fill(lastName);
}
async clickSyncEverythingWithGoogleRadio() {
await this.syncEverythingWithGoogleRadio.click();
}
async clickSyncSubjectAndMetadataWithGoogleRadio() {
await this.syncSubjectAndMetadataWithGoogleRadio.click();
}
async clickSyncMetadataWithGoogleRadio() {
await this.syncMetadataWithGoogleRadio.click();
}
async clickSyncWithGoogleButton() {
await this.syncWithGoogleButton.click();
}
async noSyncWithGoogle() {
await this.noSyncButton.click();
}
async typeInviteLink1(email: string) {
await this.inviteLinkField1.fill(email);
}
async typeInviteLink2(email: string) {
await this.inviteLinkField2.fill(email);
}
async typeInviteLink3(email: string) {
await this.inviteLinkField3.fill(email);
}
async clickCopyInviteLink() {
await this.copyInviteLink.click();
}
async clickFinishButton() {
await this.finishButton.click();
}
}

View File

@@ -0,0 +1,196 @@
import { Locator, Page } from '@playwright/test';
export class MainPage {
// TODO: add missing elements (advanced filters, import/export popups)
private readonly tableViews: Locator;
private readonly addViewButton: Locator;
private readonly viewIconSelect: Locator;
private readonly viewNameInput: Locator;
private readonly viewTypeSelect: Locator;
private readonly createViewButton: Locator;
private readonly deleteViewButton: Locator;
private readonly filterButton: Locator;
private readonly searchFieldInput: Locator;
private readonly advancedFilterButton: Locator;
private readonly addFilterButton: Locator;
private readonly resetFilterButton: Locator;
private readonly saveFilterAsViewButton: Locator;
private readonly sortButton: Locator;
private readonly sortOrderButton: Locator;
private readonly optionsButton: Locator;
private readonly fieldsButton: Locator;
private readonly goBackButton: Locator;
private readonly hiddenFieldsButton: Locator;
private readonly editFieldsButton: Locator;
private readonly importButton: Locator;
private readonly exportButton: Locator;
private readonly deletedRecordsButton: Locator;
private readonly createNewRecordButton: Locator;
private readonly addToFavoritesButton: Locator;
private readonly deleteFromFavoritesButton: Locator;
private readonly exportBottomBarButton: Locator;
private readonly deleteRecordsButton: Locator;
constructor(public readonly page: Page) {
this.tableViews = page.getByText('·');
this.addViewButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Add view$/ });
this.viewIconSelect = page.getByLabel('Click to select icon (');
this.viewNameInput; // can be selected using only actual value
this.viewTypeSelect = page.locator(
"//span[contains(., 'View type')]/../div",
);
this.createViewButton = page.getByRole('button', { name: 'Create' });
this.deleteViewButton = page.getByRole('button', { name: 'Delete' });
this.filterButton = page.getByText('Filter');
this.searchFieldInput = page.getByPlaceholder('Search fields');
this.advancedFilterButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Advanced filter$/ });
this.addFilterButton = page.getByRole('button', { name: 'Add Filter' });
this.resetFilterButton = page.getByTestId('cancel-button');
this.saveFilterAsViewButton = page.getByRole('button', {
name: 'Save as new view',
});
this.sortButton = page.getByText('Sort');
this.sortOrderButton = page.locator('//li');
this.optionsButton = page.getByText('Options');
this.fieldsButton = page.getByText('Fields');
this.goBackButton = page.getByTestId('dropdown-menu-header-end-icon');
this.hiddenFieldsButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Hidden Fields$/ });
this.editFieldsButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Edit Fields$/ });
this.importButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Import$/ });
this.exportButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Export$/ });
this.deletedRecordsButton = page
.getByTestId('tooltip')
.filter({ hasText: /^Deleted */ });
this.createNewRecordButton = page.getByTestId('add-button');
this.addToFavoritesButton = page.getByText('Add to favorites');
this.deleteFromFavoritesButton = page.getByText('Delete from favorites');
this.exportBottomBarButton = page.getByText('Export');
this.deleteRecordsButton = page.getByText('Delete');
}
async clickTableViews() {
await this.tableViews.click();
}
async clickAddViewButton() {
await this.addViewButton.click();
}
async typeViewName(name: string) {
await this.viewNameInput.clear();
await this.viewNameInput.fill(name);
}
// name can be either be 'Table' or 'Kanban'
async selectViewType(name: string) {
await this.viewTypeSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async createView() {
await this.createViewButton.click();
}
async deleteView() {
await this.deleteViewButton.click();
}
async clickFilterButton() {
await this.filterButton.click();
}
async searchFields(name: string) {
await this.searchFieldInput.clear();
await this.searchFieldInput.fill(name);
}
async clickAdvancedFilterButton() {
await this.advancedFilterButton.click();
}
async addFilter() {
await this.addFilterButton.click();
}
async resetFilter() {
await this.resetFilterButton.click();
}
async saveFilterAsView() {
await this.saveFilterAsViewButton.click();
}
async clickSortButton() {
await this.sortButton.click();
}
//can be Ascending or Descending
async setSortOrder(name: string) {
await this.sortOrderButton.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async clickOptionsButton() {
await this.optionsButton.click();
}
async clickFieldsButton() {
await this.fieldsButton.click();
}
async clickBackButton() {
await this.goBackButton.click();
}
async clickHiddenFieldsButton() {
await this.hiddenFieldsButton.click();
}
async clickEditFieldsButton() {
await this.editFieldsButton.click();
}
async clickImportButton() {
await this.importButton.click();
}
async clickExportButton() {
await this.exportButton.click();
}
async clickDeletedRecordsButton() {
await this.deletedRecordsButton.click();
}
async clickCreateNewRecordButton() {
await this.createNewRecordButton.click();
}
async clickAddToFavoritesButton() {
await this.addToFavoritesButton.click();
}
async clickDeleteFromFavoritesButton() {
await this.deleteFromFavoritesButton.click();
}
async clickExportBottomBarButton() {
await this.exportBottomBarButton.click();
}
async clickDeleteRecordsButton() {
await this.deleteRecordsButton.click();
}
}

View File

@@ -0,0 +1,150 @@
import { Locator, Page } from '@playwright/test';
export class RecordDetails {
// TODO: add missing components in tasks, notes, files, emails, calendar tabs
private readonly closeRecordButton: Locator;
private readonly previousRecordButton: Locator;
private readonly nextRecordButton: Locator;
private readonly favoriteRecordButton: Locator;
private readonly addShowPageButton: Locator;
private readonly moreOptionsButton: Locator;
private readonly deleteButton: Locator;
private readonly uploadProfileImageButton: Locator;
private readonly timelineTab: Locator;
private readonly tasksTab: Locator;
private readonly notesTab: Locator;
private readonly filesTab: Locator;
private readonly emailsTab: Locator;
private readonly calendarTab: Locator;
private readonly detachRelationButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
}
async clickCloseRecordButton() {
await this.closeRecordButton.click();
}
async clickPreviousRecordButton() {
await this.previousRecordButton.click();
}
async clickNextRecordButton() {
await this.nextRecordButton.click();
}
async clickFavoriteRecordButton() {
await this.favoriteRecordButton.click();
}
async createRelatedNote() {
await this.addShowPageButton.click();
await this.page
.locator('//div[@data-testid="tooltip" and contains(., "Note")]')
.click();
}
async createRelatedTask() {
await this.addShowPageButton.click();
await this.page
.locator('//div[@data-testid="tooltip" and contains(., "Task")]')
.click();
}
async clickMoreOptionsButton() {
await this.moreOptionsButton.click();
}
async clickDeleteRecordButton() {
await this.deleteButton.click();
}
async clickUploadProfileImageButton() {
await this.uploadProfileImageButton.click();
}
async goToTimelineTab() {
await this.timelineTab.click();
}
async goToTasksTab() {
await this.tasksTab.click();
}
async goToNotesTab() {
await this.notesTab.click();
}
async goToFilesTab() {
await this.filesTab.click();
}
async goToEmailsTab() {
await this.emailsTab.click();
}
async goToCalendarTab() {
await this.calendarTab.click();
}
async clickField(name: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${name}']/../../../div[last()]/div/div`,
)
.click();
}
async clickFieldWithButton(name: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${name}']/../../../div[last()]/div/div`,
)
.hover();
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${name}']/../../../div[last()]/div/div[last()]/div/button`,
)
.click();
}
async clickRelationEditButton(name: string) {
await this.page.getByRole('heading').filter({ hasText: name }).hover();
await this.page
.locator(`//header[contains(., "${name}")]/div[last()]/div/button`)
.click();
}
async detachRelation(name: string) {
await this.page.locator(`//a[contains(., "${name}")]`).hover();
await this.page
.locator(`, //a[contains(., "${name}")]/../div[last()]/div/div/button`)
.hover();
await this.detachRelationButton.click();
}
async deleteRelationRecord(name: string) {
await this.page.locator(`//a[contains(., "${name}")]`).hover();
await this.page
.locator(`, //a[contains(., "${name}")]/../div[last()]/div/div/button`)
.hover();
await this.deleteButton.click();
}
async selectRelationRecord(name: string) {
await this.page
.locator(`//div[@data-testid="tooltip" and contains(., "${name}")]`)
.click();
}
async searchRelationRecord(name: string) {
await this.page.getByPlaceholder('Search').fill(name);
}
async createNewRelationRecord() {
await this.page
.locator('//div[@data-testid="tooltip" and contains(., "Add New")]')
.click();
}
}

View File

@@ -0,0 +1,54 @@
import { Locator, Page } from '@playwright/test';
export class AccountsSection {
private readonly addAccountButton: Locator;
private readonly deleteAccountButton: Locator;
private readonly addBlocklistField: Locator;
private readonly addBlocklistButton: Locator;
private readonly connectWithGoogleButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.addAccountButton = page.getByRole('button', { name: 'Add account' });
this.deleteAccountButton = page
.getByTestId('tooltip')
.getByText('Remove account');
this.addBlocklistField = page.getByPlaceholder(
'eddy@gmail.com, @apple.com',
);
this.addBlocklistButton = page.getByRole('button', {
name: 'Add to blocklist',
});
this.connectWithGoogleButton = page.getByRole('button', {
name: 'Connect with Google',
});
}
async clickAddAccount() {
await this.addAccountButton.click();
}
async deleteAccount(email: string) {
await this.page
.locator(`//span[contains(., "${email}")]/../div/div/div/button`)
.click();
await this.deleteAccountButton.click();
}
async addToBlockList(domain: string) {
await this.addBlocklistField.fill(domain);
await this.addBlocklistButton.click();
}
async removeFromBlocklist(domain: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${domain}')]/../../div[last()]/button`,
)
.click();
}
async linkGoogleAccount() {
await this.connectWithGoogleButton.click();
}
}

View File

@@ -0,0 +1,30 @@
import { Locator, Page } from '@playwright/test';
export class CalendarSection {
private readonly eventVisibilityEverythingOption: Locator;
private readonly eventVisibilityMetadataOption: Locator;
private readonly contactAutoCreation: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.eventVisibilityEverythingOption = page.locator(
'input[value="SHARE_EVERYTHING"]',
);
this.eventVisibilityMetadataOption = page.locator(
'input[value="METADATA"]',
);
this.contactAutoCreation = page.getByRole('checkbox').nth(1);
}
async changeVisibilityToEverything() {
await this.eventVisibilityEverythingOption.click();
}
async changeVisibilityToMetadata() {
await this.eventVisibilityMetadataOption.click();
}
async toggleAutoCreation() {
await this.contactAutoCreation.click();
}
}

View File

@@ -0,0 +1,189 @@
import { Locator, Page } from '@playwright/test';
export class DataModelSection {
private readonly searchObjectInput: Locator;
private readonly addObjectButton: Locator;
private readonly objectSingularNameInput: Locator;
private readonly objectPluralNameInput: Locator;
private readonly objectDescription: Locator;
private readonly synchronizeLabelAPIToggle: Locator;
private readonly objectAPISingularNameInput: Locator;
private readonly objectAPIPluralNameInput: Locator;
private readonly objectMoreOptionsButton: Locator;
private readonly editObjectButton: Locator;
private readonly deleteObjectButton: Locator;
private readonly activeSection: Locator;
private readonly inactiveSection: Locator;
private readonly searchFieldInput: Locator;
private readonly addFieldButton: Locator;
private readonly viewFieldDetailsMoreOptionsButton: Locator;
private readonly nameFieldInput: Locator;
private readonly descriptionFieldInput: Locator;
private readonly deactivateMoreOptionsButton: Locator;
private readonly activateMoreOptionsButton: Locator;
private readonly deactivateButton: Locator; // TODO: add attribute to make it one button
private readonly activateButton: Locator;
private readonly cancelButton: Locator;
private readonly saveButton: Locator;
constructor(public readonly page: Page) {
this.searchObjectInput = page.getByPlaceholder('Search an object...');
this.addObjectButton = page.getByRole('button', { name: 'Add object' });
this.objectSingularNameInput = page.getByPlaceholder('Listing', {
exact: true,
});
this.objectPluralNameInput = page.getByPlaceholder('Listings', {
exact: true,
});
this.objectDescription = page.getByPlaceholder('Write a description');
this.synchronizeLabelAPIToggle = page.getByRole('checkbox').nth(1);
this.objectAPISingularNameInput = page.getByPlaceholder('listing', {
exact: true,
});
this.objectAPIPluralNameInput = page.getByPlaceholder('listings', {
exact: true,
});
this.objectMoreOptionsButton = page.getByLabel('Object Options');
this.editObjectButton = page.getByTestId('tooltip').getByText('Edit');
this.deactivateMoreOptionsButton = page
.getByTestId('tooltip')
.getByText('Deactivate');
this.activateMoreOptionsButton = page
.getByTestId('tooltip')
.getByText('Activate');
this.deleteObjectButton = page.getByTestId('tooltip').getByText('Delete');
this.activeSection = page.getByText('Active', { exact: true });
this.inactiveSection = page.getByText('Inactive');
this.searchFieldInput = page.getByPlaceholder('Search a field...');
this.addFieldButton = page.getByRole('button', { name: 'Add field' });
this.viewFieldDetailsMoreOptionsButton = page
.getByTestId('tooltip')
.getByText('View');
this.nameFieldInput = page.getByPlaceholder('Employees');
this.descriptionFieldInput = page.getByPlaceholder('Write a description');
this.deactivateButton = page.getByRole('button', { name: 'Deactivate' });
this.activateButton = page.getByRole('button', { name: 'Activate' });
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
this.saveButton = page.getByRole('button', { name: 'Save' });
}
async searchObject(name: string) {
await this.searchObjectInput.fill(name);
}
async clickAddObjectButton() {
await this.addObjectButton.click();
}
async typeObjectSingularName(name: string) {
await this.objectSingularNameInput.fill(name);
}
async typeObjectPluralName(name: string) {
await this.objectPluralNameInput.fill(name);
}
async typeObjectDescription(name: string) {
await this.objectDescription.fill(name);
}
async toggleSynchronizeLabelAPI() {
await this.synchronizeLabelAPIToggle.click();
}
async typeObjectSingularAPIName(name: string) {
await this.objectAPISingularNameInput.fill(name);
}
async typeObjectPluralAPIName(name: string) {
await this.objectAPIPluralNameInput.fill(name);
}
async checkObjectDetails(name: string) {
await this.page.getByRole('link').filter({ hasText: name }).click();
}
async activateInactiveObject(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.activateButton.click();
}
// object can be deleted only if is custom and inactive
async deleteInactiveObject(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.deleteObjectButton.click();
}
async editObjectDetails() {
await this.objectMoreOptionsButton.click();
await this.editObjectButton.click();
}
async deactivateObjectWithMoreOptions() {
await this.objectMoreOptionsButton.click();
await this.deactivateButton.click();
}
async searchField(name: string) {
await this.searchFieldInput.fill(name);
}
async checkFieldDetails(name: string) {
await this.page.locator(`//div[@title="${name}"]`).click();
}
async checkFieldDetailsWithButton(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.viewFieldDetailsMoreOptionsButton.click();
}
async deactivateFieldWithButton(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.deactivateMoreOptionsButton.click();
}
async activateFieldWithButton(name: string) {
await this.page
.locator(`//div[@title="${name}"]/../../div[last()]`)
.click();
await this.activateMoreOptionsButton.click();
}
async clickAddFieldButton() {
await this.addFieldButton.click();
}
async typeFieldName(name: string) {
await this.nameFieldInput.clear();
await this.nameFieldInput.fill(name);
}
async typeFieldDescription(description: string) {
await this.descriptionFieldInput.clear();
await this.descriptionFieldInput.fill(description);
}
async clickInactiveSection() {
await this.inactiveSection.click();
}
async clickActiveSection() {
await this.activeSection.click();
}
async clickCancelButton() {
await this.cancelButton.click();
}
async clickSaveButton() {
await this.saveButton.click();
}
}

View File

@@ -0,0 +1,123 @@
import { Locator, Page } from '@playwright/test';
export class DevelopersSection {
private readonly readDocumentationButton: Locator;
private readonly createAPIKeyButton: Locator;
private readonly regenerateAPIKeyButton: Locator;
private readonly nameOfAPIKeyInput: Locator;
private readonly expirationDateAPIKeySelect: Locator;
private readonly createWebhookButton: Locator;
private readonly webhookURLInput: Locator;
private readonly webhookDescription: Locator;
private readonly webhookFilterObjectSelect: Locator;
private readonly webhookFilterActionSelect: Locator;
private readonly cancelButton: Locator;
private readonly saveButton: Locator;
private readonly deleteButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.readDocumentationButton = page.getByRole('link', {
name: 'Read documentation',
});
this.createAPIKeyButton = page.getByRole('link', {
name: 'Create API Key',
});
this.createWebhookButton = page.getByRole('link', {
name: 'Create Webhook',
});
this.nameOfAPIKeyInput = page
.getByPlaceholder('E.g. backoffice integration')
.first();
this.expirationDateAPIKeySelect = page
.locator('div')
.filter({ hasText: /^Never$/ })
.nth(3); // good enough if expiration date will change only 1 time
this.regenerateAPIKeyButton = page.getByRole('button', {
name: 'Regenerate Key',
});
this.webhookURLInput = page.getByPlaceholder('URL');
this.webhookDescription = page.getByPlaceholder('Write a description');
this.webhookFilterObjectSelect = page
.locator('div')
.filter({ hasText: /^All Objects$/ })
.nth(3); // works only for first filter
this.webhookFilterActionSelect = page
.locator('div')
.filter({ hasText: /^All Actions$/ })
.nth(3); // works only for first filter
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
this.saveButton = page.getByRole('button', { name: 'Save' });
this.deleteButton = page.getByRole('button', { name: 'Delete' });
}
async openDocumentation() {
await this.readDocumentationButton.click();
}
async createAPIKey() {
await this.createAPIKeyButton.click();
}
async typeAPIKeyName(name: string) {
await this.nameOfAPIKeyInput.clear();
await this.nameOfAPIKeyInput.fill(name);
}
async selectAPIExpirationDate(date: string) {
await this.expirationDateAPIKeySelect.click();
await this.page.getByText(date, { exact: true }).click();
}
async regenerateAPIKey() {
await this.regenerateAPIKeyButton.click();
}
async deleteAPIKey() {
await this.deleteButton.click();
}
async deleteWebhook() {
await this.deleteButton.click();
}
async createWebhook() {
await this.createWebhookButton.click();
}
async typeWebhookURL(url: string) {
await this.webhookURLInput.fill(url);
}
async typeWebhookDescription(description: string) {
await this.webhookDescription.fill(description);
}
async selectWebhookObject(object: string) {
// TODO: finish
}
async selectWebhookAction(action: string) {
// TODO: finish
}
async deleteWebhookFilter() {
// TODO: finish
}
async clickCancelButton() {
await this.cancelButton.click();
}
async clickSaveButton() {
await this.saveButton.click();
}
async checkAPIKeyDetails(name: string) {
await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click();
}
async checkWebhookDetails(name: string) {
await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click();
}
}

View File

@@ -0,0 +1,61 @@
import { Locator, Page } from '@playwright/test';
export class EmailsSection {
private readonly visibilityEverythingRadio: Locator;
private readonly visibilitySubjectRadio: Locator;
private readonly visibilityMetadataRadio: Locator;
private readonly autoCreationReceivedRadio: Locator;
private readonly autoCreationSentRadio: Locator;
private readonly autoCreationNoneRadio: Locator;
private readonly excludeNonProfessionalToggle: Locator;
private readonly excludeGroupToggle: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.visibilityEverythingRadio = page.locator(
'input[value="SHARE_EVERYTHING"]',
);
this.visibilitySubjectRadio = page.locator('input[value="SUBJECT"]');
this.visibilityMetadataRadio = page.locator('input[value="METADATA"]');
this.autoCreationReceivedRadio = page.locator(
'input[value="SENT_AND_RECEIVED"]',
);
this.autoCreationSentRadio = page.locator('input[value="SENT"]');
this.autoCreationNoneRadio = page.locator('input[value="NONE"]');
// first checkbox is advanced settings toggle
this.excludeNonProfessionalToggle = page.getByRole('checkbox').nth(1);
this.excludeGroupToggle = page.getByRole('checkbox').nth(2);
}
async changeVisibilityToEverything() {
await this.visibilityEverythingRadio.click();
}
async changeVisibilityToSubject() {
await this.visibilitySubjectRadio.click();
}
async changeVisibilityToMetadata() {
await this.visibilityMetadataRadio.click();
}
async changeAutoCreationToAll() {
await this.autoCreationReceivedRadio.click();
}
async changeAutoCreationToSent() {
await this.autoCreationSentRadio.click();
}
async changeAutoCreationToNone() {
await this.autoCreationNoneRadio.click();
}
async toggleExcludeNonProfessional() {
await this.excludeNonProfessionalToggle.click();
}
async toggleExcludeGroup() {
await this.excludeGroupToggle.click();
}
}

View File

@@ -0,0 +1,55 @@
import { Locator, Page } from '@playwright/test';
export class ExperienceSection {
private readonly lightThemeButton: Locator;
private readonly darkThemeButton: Locator;
private readonly timezoneDropdown: Locator;
private readonly dateFormatDropdown: Locator;
private readonly timeFormatDropdown: Locator;
private readonly searchInput: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.lightThemeButton = page.getByText('AaLight'); // it works
this.darkThemeButton = page.getByText('AaDark');
this.timezoneDropdown = page.locator(
'//span[contains(., "Time zone")]/../div/div/div',
);
this.dateFormatDropdown = page.locator(
'//span[contains(., "Date format")]/../div/div/div',
);
this.timeFormatDropdown = page.locator(
'//span[contains(., "Time format")]/../div/div/div',
);
this.searchInput = page.getByPlaceholder('Search');
}
async changeThemeToLight() {
await this.lightThemeButton.click();
}
async changeThemeToDark() {
await this.darkThemeButton.click();
}
async selectTimeZone(timezone: string) {
await this.timezoneDropdown.click();
await this.page.getByText(timezone, { exact: true }).click();
}
async selectTimeZoneWithSearch(timezone: string) {
await this.timezoneDropdown.click();
await this.searchInput.fill(timezone);
await this.page.getByText(timezone, { exact: true }).click();
}
async selectDateFormat(dateFormat: string) {
await this.dateFormatDropdown.click();
await this.page.getByText(dateFormat, { exact: true }).click();
}
async selectTimeFormat(timeFormat: string) {
await this.timeFormatDropdown.click();
await this.page.getByText(timeFormat, { exact: true }).click();
}
}

View File

@@ -0,0 +1,159 @@
import { Locator, Page } from '@playwright/test';
export class FunctionsSection {
private readonly newFunctionButton: Locator;
private readonly functionNameInput: Locator;
private readonly functionDescriptionInput: Locator;
private readonly editorTab: Locator;
private readonly codeEditorField: Locator;
private readonly resetButton: Locator;
private readonly publishButton: Locator;
private readonly testButton: Locator;
private readonly testTab: Locator;
private readonly runFunctionButton: Locator;
private readonly inputField: Locator;
private readonly settingsTab: Locator;
private readonly searchVariableInput: Locator;
private readonly addVariableButton: Locator;
private readonly nameVariableInput: Locator;
private readonly valueVariableInput: Locator;
private readonly cancelVariableButton: Locator;
private readonly saveVariableButton: Locator;
private readonly editVariableButton: Locator;
private readonly deleteVariableButton: Locator;
private readonly cancelButton: Locator;
private readonly saveButton: Locator;
private readonly deleteButton: Locator;
constructor(public readonly page: Page) {
this.newFunctionButton = page.getByRole('button', { name: 'New Function' });
this.functionNameInput = page.getByPlaceholder('Name');
this.functionDescriptionInput = page.getByPlaceholder('Description');
this.editorTab = page.getByTestId('tab-editor');
this.codeEditorField = page.getByTestId('dummyInput'); // TODO: fix
this.resetButton = page.getByRole('button', { name: 'Reset' });
this.publishButton = page.getByRole('button', { name: 'Publish' });
this.testButton = page.getByRole('button', { name: 'Test' });
this.testTab = page.getByTestId('tab-test');
this.runFunctionButton = page.getByRole('button', { name: 'Run Function' });
this.inputField = page.getByTestId('dummyInput'); // TODO: fix
this.settingsTab = page.getByTestId('tab-settings');
this.searchVariableInput = page.getByPlaceholder('Search a variable');
this.addVariableButton = page.getByRole('button', { name: 'Add Variable' });
this.nameVariableInput = page.getByPlaceholder('Name').nth(1);
this.valueVariableInput = page.getByPlaceholder('Value');
this.cancelVariableButton = page.locator('.css-uwqduk').first(); // TODO: fix
this.saveVariableButton = page.locator('.css-uwqduk').nth(1); // TODO: fix
this.editVariableButton = page.getByText('Edit', { exact: true });
this.deleteVariableButton = page.getByText('Delete', { exact: true });
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
this.saveButton = page.getByRole('button', { name: 'Save' });
this.deleteButton = page.getByRole('button', { name: 'Delete function' });
}
async clickNewFunction() {
await this.newFunctionButton.click();
}
async typeFunctionName(name: string) {
await this.functionNameInput.fill(name);
}
async typeFunctionDescription(description: string) {
await this.functionDescriptionInput.fill(description);
}
async checkFunctionDetails(name: string) {
await this.page.getByRole('link', { name: `${name} nodejs18.x` }).click();
}
async clickEditorTab() {
await this.editorTab.click();
}
async clickResetButton() {
await this.resetButton.click();
}
async clickPublishButton() {
await this.publishButton.click();
}
async clickTestButton() {
await this.testButton.click();
}
async typeFunctionCode() {
// TODO: finish once utils are merged
}
async clickTestTab() {
await this.testTab.click();
}
async runFunction() {
await this.runFunctionButton.click();
}
async typeFunctionInput() {
// TODO: finish once utils are merged
}
async clickSettingsTab() {
await this.settingsTab.click();
}
async searchVariable(name: string) {
await this.searchVariableInput.fill(name);
}
async addVariable() {
await this.addVariableButton.click();
}
async typeVariableName(name: string) {
await this.nameVariableInput.fill(name);
}
async typeVariableValue(value: string) {
await this.valueVariableInput.fill(value);
}
async editVariable(name: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${name}')]/../../div[last()]/div/div/button`,
)
.click();
await this.editVariableButton.click();
}
async deleteVariable(name: string) {
await this.page
.locator(
`//div[@data-testid='tooltip' and contains(., '${name}')]/../../div[last()]/div/div/button`,
)
.click();
await this.deleteVariableButton.click();
}
async cancelVariable() {
await this.cancelVariableButton.click();
}
async saveVariable() {
await this.saveVariableButton.click();
}
async clickCancelButton() {
await this.cancelButton.click();
}
async clickSaveButton() {
await this.saveButton.click();
}
async clickDeleteButton() {
await this.deleteButton.click();
}
}

View File

@@ -0,0 +1,29 @@
import { Locator, Page } from '@playwright/test';
export class GeneralSection {
private readonly workspaceNameField: Locator;
private readonly supportSwitch: Locator;
private readonly deleteWorkspaceButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.workspaceNameField = page.getByPlaceholder('Apple');
this.supportSwitch = page.getByRole('checkbox').nth(1);
this.deleteWorkspaceButton = page.getByRole('button', {
name: 'Delete workspace',
});
}
async changeWorkspaceName(workspaceName: string) {
await this.workspaceNameField.clear();
await this.workspaceNameField.fill(workspaceName);
}
async changeSupportSwitchState() {
await this.supportSwitch.click();
}
async clickDeleteWorkSpaceButton() {
await this.deleteWorkspaceButton.click();
}
}

View File

@@ -0,0 +1,48 @@
import { Locator, Page } from '@playwright/test';
export class MembersSection {
private readonly inviteMembersField: Locator;
private readonly inviteMembersButton: Locator;
private readonly inviteLinkButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.inviteMembersField = page.getByPlaceholder(
'tim@apple.com, jony.ive@apple',
);
this.inviteMembersButton = page.getByRole('button', { name: 'Invite' });
this.inviteLinkButton = page.getByRole('button', { name: 'Copy link' });
}
async copyInviteLink() {
await this.inviteLinkButton.click();
}
async sendInviteEmail(email: string) {
await this.inviteMembersField.click();
await this.inviteMembersField.fill(email);
await this.inviteMembersButton.click();
}
async deleteMember(email: string) {
await this.page
.locator(`//div[contains(., '${email}')]/../../div[last()]/div/button`)
.click();
}
async deleteInviteEmail(email: string) {
await this.page
.locator(
`//div[contains(., '${email}')]/../../div[last()]/div/button[first()]`,
)
.click();
}
async refreshInviteEmail(email: string) {
await this.page
.locator(
`//div[contains(., '${email}')]/../../div[last()]/div/button[last()]`,
)
.click();
}
}

View File

@@ -0,0 +1,250 @@
import { Locator, Page } from '@playwright/test';
export class NewFieldSection {
private readonly searchTypeFieldInput: Locator;
private readonly currencyFieldLink: Locator;
private readonly currencyDefaultUnitSelect: Locator;
private readonly emailsFieldLink: Locator;
private readonly linksFieldLink: Locator;
private readonly phonesFieldLink: Locator;
private readonly addressFieldLink: Locator;
private readonly textFieldLink: Locator;
private readonly numberFieldLink: Locator;
private readonly decreaseDecimalsButton: Locator;
private readonly decimalsNumberInput: Locator;
private readonly increaseDecimalsButton: Locator;
private readonly booleanFieldLink: Locator;
private readonly defaultBooleanSelect: Locator;
private readonly dateTimeFieldLink: Locator;
private readonly dateFieldLink: Locator;
private readonly relativeDateToggle: Locator;
private readonly selectFieldLink: Locator;
private readonly multiSelectFieldLink: Locator;
private readonly setAsDefaultOptionButton: Locator;
private readonly removeOptionButton: Locator;
private readonly addOptionButton: Locator;
private readonly ratingFieldLink: Locator;
private readonly JSONFieldLink: Locator;
private readonly arrayFieldLink: Locator;
private readonly relationFieldLink: Locator;
private readonly relationTypeSelect: Locator;
private readonly objectDestinationSelect: Locator;
private readonly relationFieldNameInput: Locator;
private readonly fullNameFieldLink: Locator;
private readonly UUIDFieldLink: Locator;
private readonly nameFieldInput: Locator;
private readonly descriptionFieldInput: Locator;
constructor(public readonly page: Page) {
this.searchTypeFieldInput = page.getByPlaceholder('Search a type');
this.currencyFieldLink = page.getByRole('link', { name: 'Currency' });
this.currencyDefaultUnitSelect = page.locator(
"//span[contains(., 'Default Unit')]/../div",
);
this.emailsFieldLink = page.getByRole('link', { name: 'Emails' }).nth(1);
this.linksFieldLink = page.getByRole('link', { name: 'Links' });
this.phonesFieldLink = page.getByRole('link', { name: 'Phones' });
this.addressFieldLink = page.getByRole('link', { name: 'Address' });
this.textFieldLink = page.getByRole('link', { name: 'Text' });
this.numberFieldLink = page.getByRole('link', { name: 'Number' });
this.decreaseDecimalsButton = page.locator(
"//div[contains(., 'Number of decimals')]/../div[last()]/div/div/button[2]",
);
this.decimalsNumberInput = page.locator(
// would be better if first div was span tag
"//div[contains(., 'Number of decimals')]/../div[last()]/div/div/div/div/input[2]",
);
this.increaseDecimalsButton = page.locator(
"//div[contains(., 'Number of decimals')]/../div[last()]/div/div/button[3]",
);
this.booleanFieldLink = page.getByRole('link', { name: 'True/False' });
this.defaultBooleanSelect = page.locator(
"//span[contains(., 'Default Value')]/../div",
);
this.dateTimeFieldLink = page.getByRole('link', { name: 'Date and Time' });
this.dateFieldLink = page.getByRole('link', { name: 'Date' });
this.relativeDateToggle = page.getByRole('checkbox').nth(1);
this.selectFieldLink = page.getByRole('link', { name: 'Select' });
this.multiSelectFieldLink = page.getByRole('link', {
name: 'Multi-select',
});
this.setAsDefaultOptionButton = page
.getByTestId('tooltip')
.getByText('Set as default');
this.removeOptionButton = page
.getByTestId('tooltip')
.getByText('Remove option');
this.addOptionButton = page.getByRole('button', { name: 'Add option' });
this.ratingFieldLink = page.getByRole('link', { name: 'Rating' });
this.JSONFieldLink = page.getByRole('link', { name: 'JSON' });
this.arrayFieldLink = page.getByRole('link', { name: 'Array' });
this.relationFieldLink = page.getByRole('link', { name: 'Relation' });
this.relationTypeSelect = page.locator(
"//span[contains(., 'Relation type')]/../div",
);
this.objectDestinationSelect = page.locator(
"//span[contains(., 'Object destination')]/../div",
);
this.relationIconSelect = page.getByLabel('Click to select icon (').nth(1);
this.relationFieldNameInput = page.getByPlaceholder('Field name');
this.fullNameFieldLink = page.getByRole('link', { name: 'Full Name' });
this.UUIDFieldLink = page.getByRole('link', { name: 'Unique ID' });
this.nameFieldInput = page.getByPlaceholder('Employees');
this.descriptionFieldInput = page.getByPlaceholder('Write a description');
}
async searchTypeField(name: string) {
await this.searchTypeFieldInput.fill(name);
}
async clickCurrencyType() {
await this.currencyFieldLink.click();
}
async selectDefaultUnit(name: string) {
await this.currencyDefaultUnitSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async clickEmailsType() {
await this.emailsFieldLink.click();
}
async clickLinksType() {
await this.linksFieldLink.click();
}
async clickPhonesType() {
await this.phonesFieldLink.click();
}
async clickAddressType() {
await this.addressFieldLink.click();
}
async clickTextType() {
await this.textFieldLink.click();
}
async clickNumberType() {
await this.numberFieldLink.click();
}
async decreaseDecimals() {
await this.decreaseDecimalsButton.click();
}
async typeNumberOfDecimals(amount: number) {
await this.decimalsNumberInput.fill(String(amount));
}
async increaseDecimals() {
await this.increaseDecimalsButton.click();
}
async clickBooleanType() {
await this.booleanFieldLink.click();
}
// either True of False
async selectDefaultBooleanValue(value: string) {
await this.defaultBooleanSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: value }).click();
}
async clickDateTimeType() {
await this.dateTimeFieldLink.click();
}
async clickDateType() {
await this.dateFieldLink.click();
}
async toggleRelativeDate() {
await this.relativeDateToggle.click();
}
async clickSelectType() {
await this.selectFieldLink.click();
}
async clickMultiSelectType() {
await this.multiSelectFieldLink.click();
}
async addSelectOption() {
await this.addOptionButton.click();
}
async setOptionAsDefault() {
// TODO: finish
await this.setAsDefaultOptionButton.click();
}
async deleteSelectOption() {
// TODO: finish
await this.removeOptionButton.click();
}
async changeOptionAPIName() {
// TODO: finish
}
async changeOptionColor() {
// TODO: finish
}
async changeOptionName() {
// TODO: finish
}
async clickRatingType() {
await this.ratingFieldLink.click();
}
async clickJSONType() {
await this.JSONFieldLink.click();
}
async clickArrayType() {
await this.arrayFieldLink.click();
}
async clickRelationType() {
await this.relationFieldLink.click();
}
// either 'Has many' or 'Belongs to one'
async selectRelationType(name: string) {
await this.relationTypeSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async selectObjectDestination(name: string) {
await this.objectDestinationSelect.click();
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
}
async typeRelationName(name: string) {
await this.relationFieldNameInput.clear();
await this.relationFieldNameInput.fill(name);
}
async clickFullNameType() {
await this.fullNameFieldLink.click();
}
async clickUUIDType() {
await this.UUIDFieldLink.click();
}
async typeFieldName(name: string) {
await this.nameFieldInput.clear();
await this.nameFieldInput.fill(name);
}
async typeFieldDescription(description: string) {
await this.descriptionFieldInput.clear();
await this.descriptionFieldInput.fill(description);
}
}

View File

@@ -0,0 +1,44 @@
import { Locator, Page } from '@playwright/test';
export class ProfileSection {
private readonly firstNameField: Locator;
private readonly lastNameField: Locator;
private readonly emailField: Locator;
private readonly changePasswordButton: Locator;
private readonly deleteAccountButton: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.firstNameField = page.getByPlaceholder('Tim');
this.lastNameField = page.getByPlaceholder('Cook');
this.emailField = page.getByRole('textbox').nth(2);
this.changePasswordButton = page.getByRole('button', {
name: 'Change Password',
});
this.deleteAccountButton = page.getByRole('button', {
name: 'Delete account',
});
}
async changeFirstName(firstName: string) {
await this.firstNameField.clear();
await this.firstNameField.fill(firstName);
}
async changeLastName(lastName: string) {
await this.lastNameField.clear();
await this.lastNameField.fill(lastName);
}
async getEmail() {
await this.emailField.textContent();
}
async sendChangePasswordEmail() {
await this.changePasswordButton.click();
}
async deleteAccount() {
await this.deleteAccountButton.click();
}
}

View File

@@ -0,0 +1,13 @@
import { Locator, Page } from '@playwright/test';
export class SecuritySection {
private readonly inviteByLinkToggle: Locator;
constructor(public readonly page: Page) {
this.inviteByLinkToggle = page.locator('input[type="checkbox"]').nth(1);
}
async toggleInviteByLink() {
await this.inviteByLinkToggle.click();
}
}

View File

@@ -0,0 +1,104 @@
import { Locator, Page } from '@playwright/test';
export class SettingsPage {
private readonly exitSettingsLink: Locator;
private readonly profileLink: Locator;
private readonly experienceLink: Locator;
private readonly accountsLink: Locator;
private readonly emailsLink: Locator;
private readonly calendarsLink: Locator;
private readonly generalLink: Locator;
private readonly membersLink: Locator;
private readonly dataModelLink: Locator;
private readonly developersLink: Locator;
private readonly functionsLink: Locator;
private readonly securityLink: Locator;
private readonly integrationsLink: Locator;
private readonly releasesLink: Locator;
private readonly logoutLink: Locator;
private readonly advancedToggle: Locator;
constructor(public readonly page: Page) {
this.page = page;
this.exitSettingsLink = page.getByRole('button', { name: 'Exit Settings' });
this.profileLink = page.getByRole('link', { name: 'Profile' });
this.experienceLink = page.getByRole('link', { name: 'Experience' });
this.accountsLink = page.getByRole('link', { name: 'Accounts' });
this.emailsLink = page.getByRole('link', { name: 'Emails', exact: true });
this.calendarsLink = page.getByRole('link', { name: 'Calendars' });
this.generalLink = page.getByRole('link', { name: 'General' });
this.membersLink = page.getByRole('link', { name: 'Members' });
this.dataModelLink = page.getByRole('link', { name: 'Data model' });
this.developersLink = page.getByRole('link', { name: 'Developers' });
this.functionsLink = page.getByRole('link', { name: 'Functions' });
this.integrationsLink = page.getByRole('link', { name: 'Integrations' });
this.securityLink = page.getByRole('link', { name: 'Security' });
this.releasesLink = page.getByRole('link', { name: 'Releases' });
this.logoutLink = page.getByText('Logout');
this.advancedToggle = page.locator('input[type="checkbox"]').first();
}
async leaveSettingsPage() {
await this.exitSettingsLink.click();
}
async goToProfileSection() {
await this.profileLink.click();
}
async goToExperienceSection() {
await this.experienceLink.click();
}
async goToAccountsSection() {
await this.accountsLink.click();
}
async goToEmailsSection() {
await this.emailsLink.click();
}
async goToCalendarsSection() {
await this.calendarsLink.click();
}
async goToGeneralSection() {
await this.generalLink.click();
}
async goToMembersSection() {
await this.membersLink.click();
}
async goToDataModelSection() {
await this.dataModelLink.click();
}
async goToDevelopersSection() {
await this.developersLink.click();
}
async goToFunctionsSection() {
await this.functionsLink.click();
}
async goToSecuritySection() {
await this.securityLink.click();
}
async goToIntegrationsSection() {
await this.integrationsLink.click();
}
async goToReleasesIntegration() {
await this.releasesLink.click();
}
async logout() {
await this.logoutLink.click();
}
async toggleAdvancedSettings() {
await this.advancedToggle.click();
}
}

View File

@@ -0,0 +1,94 @@
import { Page } from '@playwright/test';
const MAC = process.platform === 'darwin';
async function keyDownCtrlOrMeta(page: Page) {
if (MAC) {
await page.keyboard.down('Meta');
} else {
await page.keyboard.down('Control');
}
}
async function keyUpCtrlOrMeta(page: Page) {
if (MAC) {
await page.keyboard.up('Meta');
} else {
await page.keyboard.up('Control');
}
}
export async function withCtrlOrMeta(page: Page, key: () => Promise<void>) {
await keyDownCtrlOrMeta(page);
await key();
await keyUpCtrlOrMeta(page);
}
export async function selectAllByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('a', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function copyByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('c', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function cutByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('x', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function pasteByKeyboard(page: Page) {
await keyDownCtrlOrMeta(page);
await page.keyboard.press('v', { delay: 50 });
await keyUpCtrlOrMeta(page);
}
export async function companiesShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('c');
}
export async function notesShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('n');
}
export async function opportunitiesShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('o');
}
export async function peopleShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('p');
}
export async function rocketsShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('r');
}
export async function tasksShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('t');
}
export async function workflowsShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('w');
}
export async function settingsShortcut(page: Page) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press('s');
}
export async function customShortcut(page: Page, shortcut: string) {
await page.keyboard.press('g', { delay: 50 });
await page.keyboard.press(shortcut);
}

View File

@@ -0,0 +1,14 @@
import { Locator, Page } from '@playwright/test';
import { selectAllByKeyboard } from './keyboardShortcuts';
// https://github.com/microsoft/playwright/issues/14126
// code must have \n at the end of lines otherwise everything will be in one line
export const pasteCodeToCodeEditor = async (
page: Page,
locator: Locator,
code: string,
) => {
await locator.click();
await selectAllByKeyboard(page);
await page.keyboard.type(code);
};

View File

@@ -0,0 +1,15 @@
import { Page } from '@playwright/test';
import path from 'path';
export const fileUploader = async (
page: Page,
trigger: () => Promise<void>,
filename: string,
) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await trigger();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, '..', 'test_files', filename),
);
};

View File

@@ -1,7 +1,4 @@
import { test, expect } from '../lib/fixtures/screenshot';
import { config } from 'dotenv';
import path = require('path');
config({ path: path.resolve(__dirname, '..', '.env') });
test.describe('Basic check', () => {
test('Checking if table in Companies is visible', async ({ page }) => {

View File

@@ -1,6 +1,6 @@
{
"name": "twenty-emails",
"version": "0.32.0",
"version": "0.33.0-canary",
"description": "",
"author": "",
"private": true,

View File

@@ -2,6 +2,7 @@ REACT_APP_SERVER_BASE_URL=http://localhost:3000
GENERATE_SOURCEMAP=false
# ———————— Optional ————————
# REACT_APP_PORT=3001
# CHROMATIC_PROJECT_TOKEN=
# VITE_DISABLE_TYPESCRIPT_CHECKER=true
# VITE_DISABLE_ESLINT_CHECKER=true

View File

@@ -6,7 +6,6 @@
<link rel="icon" href="/icons/android/android-launchericon-48-48.png" />
<link rel="apple-touch-icon" href="/icons/ios/192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="A modern open-source CRM" />
<meta
@@ -30,6 +29,22 @@
<title>Twenty</title>
<script src="/env-config.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module">
const disableInputAutoZoom = () => {
const viewportMetadata = document.querySelector('meta[name=viewport]');
if (viewportMetadata !== null) {
viewportMetadata.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0');
}
}
const isIOS = /iPad|iPhone/.test(navigator.userAgent);
if (isIOS) {
disableInputAutoZoom();
}
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,6 +1,6 @@
{
"name": "twenty-front",
"version": "0.32.0",
"version": "0.33.0-canary",
"private": true,
"type": "module",
"scripts": {

View File

@@ -33,7 +33,7 @@ const documents = {
"\n mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n deleteOneField(input: { id: $idToDelete }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n }\n }\n": types.DeleteOneFieldMetadataItemDocument,
"\n mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n": types.DeleteOneRelationMetadataItemDocument,
"\n query ObjectMetadataItems(\n $objectFilter: objectFilter\n $fieldFilter: fieldFilter\n ) {\n objects(paging: { first: 1000 }, filter: $objectFilter) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fields(paging: { first: 1000 }, filter: $fieldFilter) {\n edges {\n node {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument,
"\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema {\n name\n type\n }\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc,
"\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc,
"\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument,
"\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument,
"\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n duration\n status\n error\n }\n }\n": types.ExecuteOneServerlessFunctionDocument,
@@ -142,7 +142,7 @@ export function graphql(source: "\n query ObjectMetadataItems(\n $objectFilt
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema {\n name\n type\n }\n publishedVersions\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema {\n name\n type\n }\n publishedVersions\n createdAt\n updatedAt\n }\n"];
export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@@ -31,6 +31,16 @@ export type Analytics = {
success: Scalars['Boolean'];
};
export type AnalyticsTinybirdJwtMap = {
__typename?: 'AnalyticsTinybirdJwtMap';
getPageviewsAnalytics: Scalars['String'];
getServerlessFunctionDuration: Scalars['String'];
getServerlessFunctionErrorCount: Scalars['String'];
getServerlessFunctionSuccessRate: Scalars['String'];
getUsersAnalytics: Scalars['String'];
getWebhookAnalytics: Scalars['String'];
};
export type ApiConfig = {
__typename?: 'ApiConfig';
mutationMaximumAffectedRecords: Scalars['Float'];
@@ -321,12 +331,6 @@ export type FullName = {
lastName: Scalars['String'];
};
export type FunctionParameter = {
__typename?: 'FunctionParameter';
name: Scalars['String'];
type: Scalars['String'];
};
export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth;
export type GenerateJwtOutputWithAuthTokens = {
@@ -973,7 +977,7 @@ export type ServerlessFunction = {
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
latestVersion?: Maybe<Scalars['String']>;
latestVersionInputSchema?: Maybe<Array<FunctionParameter>>;
latestVersionInputSchema?: Maybe<Scalars['JSON']>;
name: Scalars['String'];
publishedVersions: Array<Scalars['String']>;
runtime: Scalars['String'];
@@ -1207,7 +1211,7 @@ export type UpdateWorkspaceInput = {
export type User = {
__typename?: 'User';
analyticsTinybirdJwt?: Maybe<Scalars['String']>;
analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>;
canImpersonate: Scalars['Boolean'];
createdAt: Scalars['DateTime'];
defaultAvatarUrl?: Maybe<Scalars['String']>;
@@ -1696,7 +1700,7 @@ export type ImpersonateMutationVariables = Exact<{
}>;
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type RenewTokenMutationVariables = Exact<{
appToken: Scalars['String'];
@@ -1729,7 +1733,7 @@ export type VerifyMutationVariables = Exact<{
}>;
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type CheckUserExistsQueryVariables = Exact<{
email: Scalars['String'];
@@ -1816,7 +1820,7 @@ export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key:
export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdpType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@@ -1833,7 +1837,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, analyticsTinybirdJwt?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } };
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String'];
@@ -2060,7 +2064,14 @@ export const UserQueryFragmentFragmentDoc = gql`
email
canImpersonate
supportUserHash
analyticsTinybirdJwt
analyticsTinybirdJwts {
getWebhookAnalytics
getPageviewsAnalytics
getUsersAnalytics
getServerlessFunctionDuration
getServerlessFunctionSuccessRate
getServerlessFunctionErrorCount
}
onboardingStatus
workspaceMember {
...WorkspaceMemberQueryFragment

View File

@@ -35,9 +35,7 @@ export const Default: Story = {
await canvas.findByText('Search');
await canvas.findByText('Settings');
await canvas.findByText('Tasks');
await canvas.findByText('People');
await canvas.findByText('Opportunities');
await canvas.findByText('Rockets');
await canvas.findByText('Linkedin');
await canvas.findByText('All companies (v2)');
},
};

View File

@@ -34,7 +34,9 @@ export const WorkflowRunActionEffect = () => {
position: index,
Icon: IconSettingsAutomation,
onClick: async () => {
await runWorkflowVersion(activeWorkflowVersion.id);
await runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id,
});
enqueueSnackBar('', {
variant: SnackBarVariant.Success,

View File

@@ -1,8 +1,10 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
@@ -35,7 +37,8 @@ export const DeleteRecordsActionEffect = ({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { favorites, deleteFavorite } = useFavorites();
const favorites = useFavorites();
const deleteFavorite = useDeleteFavorite();
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
@@ -45,8 +48,13 @@ export const DeleteRecordsActionEffect = ({
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreFilters = useRecoilComponentValueV2(
contextStoreFiltersComponentState,
);
const graphqlFilter = computeContextStoreFilters(
contextStoreTargetedRecordsRule,
contextStoreFilters,
objectMetadataItem,
);

View File

@@ -1,13 +1,13 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
displayedExportProgress,
useExportRecordData,
} from '@/action-menu/hooks/useExportRecordData';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { IconDatabaseExport } from 'twenty-ui';
import {
displayedExportProgress,
useExportRecords,
} from '@/object-record/record-index/export/hooks/useExportRecords';
import { useEffect } from 'react';
export const ExportRecordsActionEffect = ({
@@ -22,7 +22,7 @@ export const ExportRecordsActionEffect = ({
contextStoreNumberOfSelectedRecordsComponentState,
);
const { progress, download } = useExportRecordData({
const { progress, download } = useExportRecords({
delayMs: 100,
objectMetadataItem,
recordIndexId: objectMetadataItem.namePlural,

View File

@@ -1,5 +1,7 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
@@ -21,7 +23,11 @@ export const ManageFavoritesActionEffect = ({
contextStoreTargetedRecordsRuleComponentState,
);
const { favorites, createFavorite, deleteFavorite } = useFavorites();
const favorites = useFavorites();
const createFavorite = useCreateFavorite();
const deleteFavorite = useDeleteFavorite();
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'

View File

@@ -8,14 +8,17 @@ import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMeta
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
const globalRecordActionEffects = [ExportRecordsActionEffect];
const noSelectionRecordActionEffects = [ExportRecordsActionEffect];
const singleRecordActionEffects = [
ManageFavoritesActionEffect,
DeleteRecordsActionEffect,
];
const multipleRecordActionEffects = [DeleteRecordsActionEffect];
const multipleRecordActionEffects = [
ExportRecordsActionEffect,
DeleteRecordsActionEffect,
];
export const RecordActionMenuEntriesSetter = () => {
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
@@ -39,23 +42,18 @@ export const RecordActionMenuEntriesSetter = () => {
}
const actions =
contextStoreNumberOfSelectedRecords === 1
? singleRecordActionEffects
: multipleRecordActionEffects;
contextStoreNumberOfSelectedRecords === 0
? noSelectionRecordActionEffects
: contextStoreNumberOfSelectedRecords === 1
? singleRecordActionEffects
: multipleRecordActionEffects;
return (
<>
{globalRecordActionEffects.map((ActionEffect, index) => (
<ActionEffect
key={index}
position={index}
objectMetadataItem={objectMetadataItem}
/>
))}
{actions.map((ActionEffect, index) => (
<ActionEffect
key={index}
position={globalRecordActionEffects.length + index}
position={index}
objectMetadataItem={objectMetadataItem}
/>
))}

View File

@@ -65,7 +65,10 @@ export const WorkflowRunRecordActionEffect = ({
return;
}
await runWorkflowVersion(activeWorkflowVersion.id, selectedRecord);
await runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id,
payload: selectedRecord,
});
enqueueSnackBar('', {
variant: SnackBarVariant.Success,

View File

@@ -10,10 +10,10 @@ import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDro
import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { MenuItem } from 'twenty-ui';
type StyledContainerProps = {
position: PositionType;

View File

@@ -11,16 +11,16 @@ import { RecordShowPageBaseHeader } from '~/pages/object-record/RecordShowPageBa
export const RecordShowActionMenu = ({
isFavorite,
handleFavoriteButtonClick,
record,
objectMetadataItem,
objectNameSingular,
handleFavoriteButtonClick,
}: {
isFavorite: boolean;
handleFavoriteButtonClick: () => void;
record: ObjectRecord | undefined;
objectMetadataItem: ObjectMetadataItem;
objectNameSingular: string;
handleFavoriteButtonClick: () => void;
}) => {
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState,
@@ -40,10 +40,10 @@ export const RecordShowActionMenu = ({
<RecordShowPageBaseHeader
{...{
isFavorite,
handleFavoriteButtonClick,
record,
objectMetadataItem,
objectNameSingular,
handleFavoriteButtonClick,
}}
/>
<ActionMenuConfirmationModals />

View File

@@ -1,57 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
type RecordShowActionMenuBarEntryProps = {
entry: ActionMenuEntry;
};
const StyledButton = styled.div<{ accent: MenuItemAccent }>`
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${(props) =>
props.accent === 'danger'
? props.theme.color.red
: props.theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
transition: background 0.1s ease;
user-select: none;
&:hover {
background: ${({ theme, accent }) =>
accent === 'danger'
? theme.background.danger
: theme.background.transparent.light};
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding: ${({ theme }) => theme.spacing(1)};
}
`;
const StyledButtonLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
// For now, this component is the same as RecordIndexActionMenuBarEntry but they
// will probably diverge in the future
export const RecordShowActionMenuBarEntry = ({
entry,
}: RecordShowActionMenuBarEntryProps) => {
const theme = useTheme();
return (
<StyledButton
accent={entry.accent ?? 'default'}
onClick={() => entry.onClick?.()}
>
{entry.Icon && <entry.Icon size={theme.icon.size.md} />}
<StyledButtonLabel>{entry.label}</StyledButtonLabel>
</StyledButton>
);
};

View File

@@ -1,7 +1,7 @@
import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter';
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { RecordShowRightDrawerActionMenuBar } from '@/action-menu/components/RecordShowRightDrawerActionMenuBar';
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
@@ -21,7 +21,7 @@ export const RecordShowRightDrawerActionMenu = () => {
onActionExecutedCallback: () => {},
}}
>
<RecordShowRightDrawerActionMenuBar />
<RightDrawerActionMenuDropdown />
<ActionMenuConfirmationModals />
<RecordActionMenuEntriesSetter />
<GlobalActionMenuEntriesSetter />

View File

@@ -1,21 +0,0 @@
import { RecordShowActionMenuBarEntry } from '@/action-menu/components/RecordShowActionMenuBarEntry';
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const RecordShowRightDrawerActionMenuBar = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const standardActionMenuEntries = actionMenuEntries.filter(
(actionMenuEntry) => actionMenuEntry.type === 'standard',
);
return (
<>
{standardActionMenuEntries.map((actionMenuEntry) => (
<RecordShowActionMenuBarEntry entry={actionMenuEntry} />
))}
</>
);
};

View File

@@ -0,0 +1,87 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { RightDrawerActionMenuDropdownHotkeyScope } from '@/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope';
import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useTheme } from '@emotion/react';
import { Key } from 'ts-key-enum';
import { Button, MenuItem } from 'twenty-ui';
export const RightDrawerActionMenuDropdown = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
);
const { closeDropdown, openDropdown } = useDropdownV2();
const theme = useTheme();
useScopedHotkeys(
[Key.Escape, 'ctrl+o,meta+o'],
() => {
closeDropdown(
getRightDrawerActionMenuDropdownIdFromActionMenuId(actionMenuId),
);
},
RightDrawerActionMenuDropdownHotkeyScope.RightDrawerActionMenuDropdown,
[closeDropdown],
);
useScopedHotkeys(
['ctrl+o,meta+o'],
() => {
openDropdown(
getRightDrawerActionMenuDropdownIdFromActionMenuId(actionMenuId),
);
},
RightDrawerHotkeyScope.RightDrawer,
[openDropdown],
);
return (
<Dropdown
dropdownId={getRightDrawerActionMenuDropdownIdFromActionMenuId(
actionMenuId,
)}
dropdownHotkeyScope={{
scope:
RightDrawerActionMenuDropdownHotkeyScope.RightDrawerActionMenuDropdown,
}}
data-select-disable
clickableComponent={<Button title="Actions" shortcut="⌘O" />}
dropdownPlacement="top-end"
dropdownOffset={{
y: parseInt(theme.spacing(2)),
}}
dropdownComponents={
<DropdownMenuItemsContainer>
{actionMenuEntries.map((item, index) => (
<MenuItem
key={index}
LeftIcon={item.Icon}
onClick={() => {
closeDropdown(
getRightDrawerActionMenuDropdownIdFromActionMenuId(
actionMenuId,
),
);
item.onClick?.();
}}
text={item.label}
/>
))}
</DropdownMenuItemsContainer>
}
/>
);
};

View File

@@ -2,28 +2,28 @@ import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { RecordShowRightDrawerActionMenuBar } from '@/action-menu/components/RecordShowRightDrawerActionMenuBar';
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
import { userEvent, waitFor, within } from '@storybook/test';
import {
ComponentDecorator,
IconFileExport,
IconHeart,
IconTrash,
MenuItemAccent,
} from 'twenty-ui';
const deleteMock = jest.fn();
const addToFavoritesMock = jest.fn();
const exportMock = jest.fn();
const meta: Meta<typeof RecordShowRightDrawerActionMenuBar> = {
title: 'Modules/ActionMenu/RecordShowRightDrawerActionMenuBar',
component: RecordShowRightDrawerActionMenuBar,
const meta: Meta<typeof RightDrawerActionMenuDropdown> = {
title: 'Modules/ActionMenu/RightDrawerActionMenuDropdown',
component: RightDrawerActionMenuDropdown,
decorators: [
(Story) => (
<RecoilRoot
@@ -98,7 +98,7 @@ const meta: Meta<typeof RecordShowRightDrawerActionMenuBar> = {
export default meta;
type Story = StoryObj<typeof RecordShowRightDrawerActionMenuBar>;
type Story = StoryObj<typeof RightDrawerActionMenuDropdown>;
export const Default: Story = {
args: {
@@ -113,12 +113,21 @@ export const WithButtonClicks: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
let actionButton = await canvas.findByText('Actions');
await userEvent.click(actionButton);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
actionButton = await canvas.findByText('Actions');
await userEvent.click(actionButton);
const addToFavoritesButton = await canvas.findByText('Add to favorites');
await userEvent.click(addToFavoritesButton);
actionButton = await canvas.findByText('Actions');
await userEvent.click(actionButton);
const exportButton = await canvas.findByText('Export');
await userEvent.click(exportButton);

View File

@@ -1,7 +1,5 @@
import { MouseEvent, ReactNode } from 'react';
import { IconComponent } from 'twenty-ui';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
import { IconComponent, MenuItemAccent } from 'twenty-ui';
export type ActionMenuEntry = {
type: 'standard' | 'workflow-run';

View File

@@ -0,0 +1,3 @@
export enum RightDrawerActionMenuDropdownHotkeyScope {
RightDrawerActionMenuDropdown = 'right-drawer-action-menu-dropdown',
}

View File

@@ -0,0 +1,9 @@
import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '../getRightDrawerActionMenuDropdownIdFromActionMenuId';
describe('getRightDrawerActionMenuDropdownIdFromActionMenuId', () => {
it('should return the right drawer action menu dropdown id', () => {
expect(
getRightDrawerActionMenuDropdownIdFromActionMenuId('action-menu-id'),
).toBe('right-drawer-action-menu-dropdown-action-menu-id');
});
});

View File

@@ -0,0 +1,5 @@
export const getRightDrawerActionMenuDropdownIdFromActionMenuId = (
actionMenuId: string,
) => {
return `right-drawer-action-menu-dropdown-${actionMenuId}`;
};

View File

@@ -350,6 +350,10 @@ export const RichTextEditor = ({
editor.focus();
},
RightDrawerHotkeyScope.RightDrawer,
[],
{
preventDefault: false,
},
);
const handleBlockEditorFocus = () => {

View File

@@ -1,9 +1,8 @@
import { MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { MenuItemAvatar } from '@/ui/navigation/menu-item/components/MenuItemAvatar';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { IconPlus } from 'twenty-ui';
import { IconPlus, MenuItemAvatar } from 'twenty-ui';
export const MessageThreadSubscriberDropdownAddSubscriberMenuItem = ({
workspaceMember,

View File

@@ -1,5 +1,5 @@
import { offset } from '@floating-ui/react';
import { IconMinus, IconPlus } from 'twenty-ui';
import { IconMinus, IconPlus, MenuItem, MenuItemAvatar } from 'twenty-ui';
import { MessageThreadSubscriberDropdownAddSubscriber } from '@/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriber';
import { MessageThreadSubscribersChip } from '@/activities/emails/components/MessageThreadSubscribersChip';
@@ -10,8 +10,6 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemAvatar } from '@/ui/navigation/menu-item/components/MenuItemAvatar';
import { useState } from 'react';
export const MESSAGE_THREAD_SUBSCRIBER_DROPDOWN_ID =

View File

@@ -4,13 +4,12 @@ import {
IconPencil,
IconTrash,
LightIconButton,
MenuItem,
} from 'twenty-ui';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
type AttachmentDropdownProps = {
onDownload: () => void;
@@ -50,27 +49,26 @@ export const AttachmentDropdown = ({
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownMenuWidth={160}
dropdownComponents={
<DropdownMenu width="160px">
<DropdownMenuItemsContainer>
<MenuItem
text="Download"
LeftIcon={IconDownload}
onClick={handleDownload}
/>
<MenuItem
text="Rename"
LeftIcon={IconPencil}
onClick={handleRename}
/>
<MenuItem
text="Delete"
accent="danger"
LeftIcon={IconTrash}
onClick={handleDelete}
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
<DropdownMenuItemsContainer>
<MenuItem
text="Download"
LeftIcon={IconDownload}
onClick={handleDownload}
/>
<MenuItem
text="Rename"
LeftIcon={IconPencil}
onClick={handleRename}
/>
<MenuItem
text="Delete"
accent="danger"
LeftIcon={IconTrash}
onClick={handleDelete}
/>
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{
scope: dropdownId,

View File

@@ -27,6 +27,7 @@ import {
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ActivityTargetInlineCellEditModeMultiRecordsEffect } from '@/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsEffect';
import { ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect } from '@/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect';
import { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect';
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
import { prefillRecord } from '@/object-record/utils/prefillRecord';
@@ -287,6 +288,7 @@ export const ActivityTargetInlineCellEditMode = ({
<ActivityTargetInlineCellEditModeMultiRecordsEffect
selectedObjectRecordIds={selectedTargetObjectIds}
/>
<ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect />
<MultiRecordSelect onSubmit={handleSubmit} onChange={handleChange} />
</RelationPickerScope>
</StyledSelectContainer>

View File

@@ -84,6 +84,7 @@ const mocks: MockedResponse[] = [
companyId
createdAt
deletedAt
favoriteFolderId
id
noteId
opportunityId

View File

@@ -26,8 +26,6 @@ const StyledTimelineContainer = styled.div`
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-start;
width: calc(100% - ${({ theme }) => theme.spacing(8)});
`;
export const EventList = ({ events, targetableObject }: EventListProps) => {

View File

@@ -11,6 +11,7 @@ import { TimelineActivity } from '@/activities/timeline-activities/types/Timelin
import { getTimelineActivityAuthorFullName } from '@/activities/timeline-activities/utils/getTimelineActivityAuthorFullName';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@@ -62,6 +63,7 @@ const StyledSummary = styled.summary`
flex: 1;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
const StyledItemContainer = styled.div<{ isMarginBottom?: boolean }>`
@@ -77,6 +79,9 @@ const StyledItemContainer = styled.div<{ isMarginBottom?: boolean }>`
`;
const StyledItemTitleDate = styled.div`
@media (max-width: ${MOBILE_VIEWPORT}px) {
display: none;
}
align-items: flex-start;
padding-top: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.tertiary};

View File

@@ -14,6 +14,7 @@ import {
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
MOBILE_VIEWPORT,
} from 'twenty-ui';
const StyledMainContainer = styled.div`
@@ -31,6 +32,11 @@ const StyledMainContainer = styled.div`
padding-right: ${({ theme }) => theme.spacing(6)};
padding-left: ${({ theme }) => theme.spacing(6)};
gap: ${({ theme }) => theme.spacing(4)};
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-right: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(1)};
}
`;
export const TimelineActivities = ({

View File

@@ -16,6 +16,10 @@ const StyledLinkedActivity = styled.span`
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
text-decoration: underline;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const StyledEventRowItemText = styled.span`

View File

@@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { Card } from 'twenty-ui';
import { Card, MOBILE_VIEWPORT } from 'twenty-ui';
type EventCardProps = {
children: React.ReactNode;
@@ -16,6 +16,10 @@ const StyledCardContainer = styled.div`
width: 400px;
padding: ${({ theme }) => theme.spacing(2)} 0px
${({ theme }) => theme.spacing(1)} 0px;
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 300px;
}
`;
const StyledCard = styled(Card)`

View File

@@ -20,7 +20,10 @@ const StyledEventFieldDiffContainer = styled.div`
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
height: 24px;
width: 380px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledEmptyValue = styled.div`

View File

@@ -1,23 +1,19 @@
import { SettingsDevelopersWebhookTooltip } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookTooltip';
import { useGraphData } from '@/settings/developers/webhook/hooks/useGraphData';
import { webhookGraphDataState } from '@/settings/developers/webhook/states/webhookGraphDataState';
import { WebhookAnalyticsTooltip } from '@/analytics/components/WebhookAnalyticsTooltip';
import { ANALYTICS_GRAPH_DESCRIPTION_MAP } from '@/analytics/constants/AnalyticsGraphDescriptionMap';
import { ANALYTICS_GRAPH_TITLE_MAP } from '@/analytics/constants/AnalyticsGraphTitleMap';
import { useGraphData } from '@/analytics/hooks/useGraphData';
import { analyticsGraphDataComponentState } from '@/analytics/states/analyticsGraphDataComponentState';
import { AnalyticsComponentProps as AnalyticsActivityGraphProps } from '@/analytics/types/AnalyticsComponentProps';
import { computeAnalyticsGraphDataFunction } from '@/analytics/utils/computeAnalyticsGraphDataFunction';
import { Select } from '@/ui/input/components/Select';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ResponsiveLine } from '@nivo/line';
import { Section } from '@react-email/components';
import { useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useId, useState } from 'react';
import { H2Title } from 'twenty-ui';
export type NivoLineInput = {
id: string | number;
color?: string;
data: Array<{
x: number | string | Date;
y: number | string | Date;
}>;
};
const StyledGraphContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@@ -33,34 +29,38 @@ const StyledTitleContainer = styled.div`
justify-content: space-between;
`;
type SettingsDevelopersWebhookUsageGraphProps = {
webhookId: string;
};
export const SettingsDevelopersWebhookUsageGraph = ({
webhookId,
}: SettingsDevelopersWebhookUsageGraphProps) => {
const webhookGraphData = useRecoilValue(webhookGraphDataState);
const setWebhookGraphData = useSetRecoilState(webhookGraphDataState);
export const AnalyticsActivityGraph = ({
recordId,
endpointName,
}: AnalyticsActivityGraphProps) => {
const [analyticsGraphData, setAnalyticsGraphData] = useRecoilComponentStateV2(
analyticsGraphDataComponentState,
);
const theme = useTheme();
const [windowLengthGraphOption, setWindowLengthGraphOption] = useState<
'7D' | '1D' | '12H' | '4H'
>('7D');
const { fetchGraphData } = useGraphData(webhookId);
const { fetchGraphData } = useGraphData({
recordId,
endpointName,
});
const transformDataFunction = computeAnalyticsGraphDataFunction(endpointName);
const dropdownId = useId();
return (
<>
{webhookGraphData.length ? (
{analyticsGraphData.length ? (
<Section>
<StyledTitleContainer>
<H2Title
title="Activity"
description="See your webhook activity over time"
title={`${ANALYTICS_GRAPH_TITLE_MAP[endpointName]}`}
description={`${ANALYTICS_GRAPH_DESCRIPTION_MAP[endpointName]}`}
/>
<Select
dropdownId="test-id-webhook-graph"
dropdownId={dropdownId}
value={windowLengthGraphOption}
options={[
{ value: '7D', label: 'This week' },
@@ -71,7 +71,7 @@ export const SettingsDevelopersWebhookUsageGraph = ({
onChange={(windowLengthGraphOption) => {
setWindowLengthGraphOption(windowLengthGraphOption);
fetchGraphData(windowLengthGraphOption).then((graphInput) => {
setWebhookGraphData(graphInput);
setAnalyticsGraphData(transformDataFunction(graphInput));
});
}}
/>
@@ -79,10 +79,12 @@ export const SettingsDevelopersWebhookUsageGraph = ({
<StyledGraphContainer>
<ResponsiveLine
data={webhookGraphData}
data={analyticsGraphData}
curve={'monotoneX'}
enableArea={true}
colors={(d) => d.color}
colors={{ scheme: 'set1' }}
//it "addapts" to the color scheme of the graph without hardcoding them
//is there a color scheme for graph Data in twenty? Do we always want the gradient?
theme={{
text: {
fill: theme.font.color.light,
@@ -149,7 +151,7 @@ export const SettingsDevelopersWebhookUsageGraph = ({
type: 'linear',
}}
axisBottom={{
format: '%b %d, %I:%M %p',
format: '%b %d, %I:%M %p', //TODO: add the user prefered time format for the graph
tickValues: 2,
tickPadding: 5,
tickSize: 6,
@@ -167,9 +169,7 @@ export const SettingsDevelopersWebhookUsageGraph = ({
useMesh={true}
enableSlices={false}
enableCrosshair={false}
tooltip={({ point }) => (
<SettingsDevelopersWebhookTooltip point={point} />
)}
tooltip={({ point }) => <WebhookAnalyticsTooltip point={point} />} // later add a condition to get different tooltips
/>
</StyledGraphContainer>
</Section>

View File

@@ -0,0 +1,32 @@
import { useGraphData } from '@/analytics/hooks/useGraphData';
import { analyticsGraphDataComponentState } from '@/analytics/states/analyticsGraphDataComponentState';
import { AnalyticsComponentProps as AnalyticsGraphEffectProps } from '@/analytics/types/AnalyticsComponentProps';
import { computeAnalyticsGraphDataFunction } from '@/analytics/utils/computeAnalyticsGraphDataFunction';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useState } from 'react';
export const AnalyticsGraphEffect = ({
recordId,
endpointName,
}: AnalyticsGraphEffectProps) => {
const setAnalyticsGraphData = useSetRecoilComponentStateV2(
analyticsGraphDataComponentState,
);
const transformDataFunction = computeAnalyticsGraphDataFunction(endpointName);
const [isLoaded, setIsLoaded] = useState(false);
const { fetchGraphData } = useGraphData({
recordId,
endpointName,
});
if (!isLoaded) {
fetchGraphData('7D').then((graphInput) => {
setAnalyticsGraphData(transformDataFunction(graphInput));
});
setIsLoaded(true);
}
return <></>;
};

View File

@@ -58,12 +58,12 @@ const StyledDataDefinition = styled.div`
const StyledSpan = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
type SettingsDevelopersWebhookTooltipProps = {
type WebhookAnalyticsTooltipProps = {
point: Point;
};
export const SettingsDevelopersWebhookTooltip = ({
export const WebhookAnalyticsTooltip = ({
point,
}: SettingsDevelopersWebhookTooltipProps): ReactElement => {
}: WebhookAnalyticsTooltipProps): ReactElement => {
const { timeFormat, timeZone } = useContext(UserContext);
const windowInterval = new Date(point.data.x);
const windowIntervalDate = formatDateISOStringToDateTimeSimplified(

View File

@@ -0,0 +1,10 @@
import { AnalyticsTinybirdJwtMap } from '~/generated-metadata/graphql';
export const ANALYTICS_ENDPOINT_TYPE_MAP: AnalyticsTinybirdJwtMap = {
getWebhookAnalytics: 'webhook',
getPageviewsAnalytics: 'pageviews',
getUsersAnalytics: 'users',
getServerlessFunctionDuration: 'function',
getServerlessFunctionSuccessRate: 'function',
getServerlessFunctionErrorCount: 'function',
};

Some files were not shown because too many files have changed in this diff Show More