Compare commits
48 Commits
migrate-fl
...
v0.9.3
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8156b8b356 | ||
![]() |
3a2cbae0a0 | ||
![]() |
0ad8da097b | ||
![]() |
e2dcdd2811 | ||
![]() |
8074f9146b | ||
![]() |
df24bac913 | ||
![]() |
4d4091adcc | ||
![]() |
cac54c41a1 | ||
![]() |
130931d7af | ||
![]() |
d35b08b35e | ||
![]() |
82031da6a6 | ||
![]() |
9df5ee7b11 | ||
![]() |
2ed1a57cd9 | ||
![]() |
101450cba6 | ||
![]() |
6bab5b3f7c | ||
![]() |
ca3c0e00a7 | ||
![]() |
6d64daf324 | ||
![]() |
9ae4578e19 | ||
![]() |
e06b7ab87a | ||
![]() |
1e2adedcbf | ||
![]() |
adf763c1b0 | ||
![]() |
36ee0df256 | ||
![]() |
823d85b24a | ||
![]() |
a3b3038709 | ||
![]() |
ddeb18f626 | ||
![]() |
90cd11bd38 | ||
![]() |
e9ba37b8de | ||
![]() |
d5e4a1b1ad | ||
![]() |
129e6d60e5 | ||
![]() |
4b77f2f590 | ||
![]() |
a909966562 | ||
![]() |
fd184239d6 | ||
![]() |
52bc49dc6a | ||
![]() |
b9352ccc06 | ||
![]() |
525b2baf06 | ||
![]() |
a8edeb2459 | ||
![]() |
e3830d64e0 | ||
![]() |
91f3e2c2b4 | ||
![]() |
77b4408416 | ||
![]() |
cede96f018 | ||
![]() |
8e0a28d238 | ||
![]() |
da5d594428 | ||
![]() |
9f9ee0bb58 | ||
![]() |
163aca6179 | ||
![]() |
cb06d3b0ae | ||
![]() |
dbe18dd100 | ||
![]() |
217970667a | ||
![]() |
dace794167 |
@@ -29,7 +29,6 @@ rm -rf .env
|
|||||||
echo "
|
echo "
|
||||||
PORT=$WEB_PORT
|
PORT=$WEB_PORT
|
||||||
REACT_APP_GRAPHQL_URL=http://localhost:$BACKEND_PORT/graphql
|
REACT_APP_GRAPHQL_URL=http://localhost:$BACKEND_PORT/graphql
|
||||||
REACT_APP_NOTIFICATIONS_URL=https://notifications.automatisch.io
|
|
||||||
" >> .env
|
" >> .env
|
||||||
cd $CURRENT_DIR
|
cd $CURRENT_DIR
|
||||||
|
|
||||||
|
70
.github/workflows/playwright.yml
vendored
70
.github/workflows/playwright.yml
vendored
@@ -1,25 +1,87 @@
|
|||||||
name: Automatisch UI Test
|
name: Automatisch UI Tests
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 12 * * *'
|
- cron: '0 12 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
ENCRYPTION_KEY: sample_encryption_key
|
||||||
|
WEBHOOK_SECRET_KEY: sample_webhook_secret_key
|
||||||
|
APP_SECRET_KEY: sample_app_secret_key
|
||||||
|
POSTGRES_HOST: localhost
|
||||||
|
POSTGRES_DATABASE: automatisch
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
POSTGRES_USERNAME: automatisch_user
|
||||||
|
POSTGRES_PASSWORD: automatisch_password
|
||||||
|
REDIS_HOST: localhost
|
||||||
|
APP_ENV: production
|
||||||
|
LICENSE_KEY: ${{ secrets.E2E_LICENSE_KEY }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:14.5-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: automatisch
|
||||||
|
POSTGRES_USER: automatisch_user
|
||||||
|
POSTGRES_PASSWORD: automatisch_password
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U automatisch_user -d automatisch"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
redis:
|
||||||
|
image: redis:7.0.4-alpine
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn && yarn lerna bootstrap
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: yarn playwright install --with-deps
|
run: yarn playwright install --with-deps
|
||||||
|
- name: Build Automatisch
|
||||||
|
run: yarn lerna run --scope=@*/{web,backend,cli} build
|
||||||
|
env:
|
||||||
|
# Keep this until we clean up warnings in build processes
|
||||||
|
CI: false
|
||||||
|
- name: Migrate database
|
||||||
|
working-directory: ./packages/backend
|
||||||
|
run: yarn db:migrate --migrations-directory ./dist/src/db/migrations
|
||||||
|
- name: Seed user
|
||||||
|
working-directory: ./packages/backend
|
||||||
|
run: yarn db:seed:user &
|
||||||
|
- name: Run Automatisch
|
||||||
|
run: yarn start &
|
||||||
|
working-directory: ./packages/backend
|
||||||
|
- name: Run Automatisch worker
|
||||||
|
run: node dist/src/worker.js &
|
||||||
|
working-directory: ./packages/backend
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: yarn playwright test
|
working-directory: ./packages/e2e-tests
|
||||||
|
env:
|
||||||
|
LOGIN_EMAIL: ${{ secrets.LOGIN_EMAIL }}
|
||||||
|
LOGIN_PASSWORD: ${{ secrets.LOGIN_PASSWORD }}
|
||||||
|
BASE_URL: ${{ vars.E2E_BASE_URL }}
|
||||||
|
run: yarn test
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: playwright-report/
|
path: ./packages/e2e-tests/test-results/**/*
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,7 @@ WORKDIR /automatisch
|
|||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
apk --no-cache add --virtual build-dependencies python3 build-base && \
|
apk --no-cache add --virtual build-dependencies python3 build-base && \
|
||||||
yarn global add @automatisch/cli@0.8.0 --network-timeout 1000000 && \
|
yarn global add @automatisch/cli@0.9.3 --network-timeout 1000000 && \
|
||||||
rm -rf /usr/local/share/.cache/ && \
|
rm -rf /usr/local/share/.cache/ && \
|
||||||
apk del build-dependencies
|
apk del build-dependencies
|
||||||
|
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM automatischio/automatisch:0.8.0
|
FROM automatischio/automatisch:0.9.3
|
||||||
WORKDIR /automatisch
|
WORKDIR /automatisch
|
||||||
|
|
||||||
RUN apk add --no-cache openssl dos2unix
|
RUN apk add --no-cache openssl dos2unix
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "0.8.0",
|
"version": "0.9.3",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"useWorkspaces": true,
|
"useWorkspaces": true,
|
||||||
"command": {
|
"command": {
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automatisch/backend",
|
"name": "@automatisch/backend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.3",
|
||||||
"license": "See LICENSE file",
|
"license": "See LICENSE file",
|
||||||
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"prebuild": "rm -rf ./dist"
|
"prebuild": "rm -rf ./dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@automatisch/web": "^0.8.0",
|
"@automatisch/web": "^0.9.3",
|
||||||
"@bull-board/express": "^3.10.1",
|
"@bull-board/express": "^3.10.1",
|
||||||
"@casl/ability": "^6.5.0",
|
"@casl/ability": "^6.5.0",
|
||||||
"@graphql-tools/graphql-file-loader": "^7.3.4",
|
"@graphql-tools/graphql-file-loader": "^7.3.4",
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
"url": "https://github.com/automatisch/automatisch/issues"
|
"url": "https://github.com/automatisch/automatisch/issues"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@automatisch/types": "^0.8.0",
|
"@automatisch/types": "^0.9.3",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/bull": "^3.15.8",
|
"@types/bull": "^3.15.8",
|
||||||
"@types/cors": "^2.8.12",
|
"@types/cors": "^2.8.12",
|
||||||
|
@@ -4,6 +4,7 @@ import htmlToMarkdown from './transformers/html-to-markdown';
|
|||||||
import markdownToHtml from './transformers/markdown-to-html';
|
import markdownToHtml from './transformers/markdown-to-html';
|
||||||
import useDefaultValue from './transformers/use-default-value';
|
import useDefaultValue from './transformers/use-default-value';
|
||||||
import extractEmailAddress from './transformers/extract-email-address';
|
import extractEmailAddress from './transformers/extract-email-address';
|
||||||
|
import extractNumber from './transformers/extract-number';
|
||||||
|
|
||||||
const transformers = {
|
const transformers = {
|
||||||
capitalize,
|
capitalize,
|
||||||
@@ -11,6 +12,7 @@ const transformers = {
|
|||||||
markdownToHtml,
|
markdownToHtml,
|
||||||
useDefaultValue,
|
useDefaultValue,
|
||||||
extractEmailAddress,
|
extractEmailAddress,
|
||||||
|
extractNumber,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineAction({
|
export default defineAction({
|
||||||
@@ -32,6 +34,7 @@ export default defineAction({
|
|||||||
{ label: 'Convert Markdown to HTML', value: 'markdownToHtml' },
|
{ label: 'Convert Markdown to HTML', value: 'markdownToHtml' },
|
||||||
{ label: 'Use Default Value', value: 'useDefaultValue' },
|
{ label: 'Use Default Value', value: 'useDefaultValue' },
|
||||||
{ label: 'Extract Email Address', value: 'extractEmailAddress' },
|
{ label: 'Extract Email Address', value: 'extractEmailAddress' },
|
||||||
|
{ label: 'Extract Number', value: 'extractNumber' },
|
||||||
],
|
],
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
type: 'query',
|
type: 'query',
|
||||||
|
@@ -0,0 +1,26 @@
|
|||||||
|
import { IGlobalVariable } from '@automatisch/types';
|
||||||
|
|
||||||
|
const extractNumber = ($: IGlobalVariable) => {
|
||||||
|
const input = $.step.parameters.input as string;
|
||||||
|
|
||||||
|
// Example numbers that's supported:
|
||||||
|
// 123
|
||||||
|
// -123
|
||||||
|
// 123456
|
||||||
|
// -123456
|
||||||
|
// 121,234
|
||||||
|
// -121,234
|
||||||
|
// 121.234
|
||||||
|
// -121.234
|
||||||
|
// 1,234,567.89
|
||||||
|
// -1,234,567.89
|
||||||
|
// 1.234.567,89
|
||||||
|
// -1.234.567,89
|
||||||
|
|
||||||
|
const numberRegexp = /-?((\d{1,3})+\.?,?)+/g;
|
||||||
|
|
||||||
|
const numbers = input.match(numberRegexp);
|
||||||
|
return numbers ? numbers[0] : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default extractNumber;
|
@@ -4,6 +4,7 @@ import htmlToMarkdown from './options/html-to-markdown';
|
|||||||
import markdownToHtml from './options/markdown-to-html';
|
import markdownToHtml from './options/markdown-to-html';
|
||||||
import useDefaultValue from './options/use-default-value';
|
import useDefaultValue from './options/use-default-value';
|
||||||
import extractEmailAddress from './options/extract-email-address';
|
import extractEmailAddress from './options/extract-email-address';
|
||||||
|
import extractNumber from './options/extract-number';
|
||||||
|
|
||||||
const options: IJSONObject = {
|
const options: IJSONObject = {
|
||||||
capitalize,
|
capitalize,
|
||||||
@@ -11,6 +12,7 @@ const options: IJSONObject = {
|
|||||||
markdownToHtml,
|
markdownToHtml,
|
||||||
useDefaultValue,
|
useDefaultValue,
|
||||||
extractEmailAddress,
|
extractEmailAddress,
|
||||||
|
extractNumber,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@@ -0,0 +1,12 @@
|
|||||||
|
const extractNumber = [
|
||||||
|
{
|
||||||
|
label: 'Input',
|
||||||
|
key: 'input',
|
||||||
|
type: 'string' as const,
|
||||||
|
required: true,
|
||||||
|
description: 'Text that will be searched for a number.',
|
||||||
|
variables: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default extractNumber;
|
@@ -4,9 +4,13 @@ import bcrypt from 'bcrypt';
|
|||||||
|
|
||||||
const getInternalId = async (item: IJSONObject): Promise<string> => {
|
const getInternalId = async (item: IJSONObject): Promise<string> => {
|
||||||
if (item.guid) {
|
if (item.guid) {
|
||||||
return item.guid.toString();
|
return typeof item.guid === 'object'
|
||||||
|
? (item.guid as IJSONObject)['#text'].toString()
|
||||||
|
: item.guid.toString();
|
||||||
} else if (item.id) {
|
} else if (item.id) {
|
||||||
return item.id.toString();
|
return typeof item.id === 'object'
|
||||||
|
? (item.id as IJSONObject)['#text'].toString()
|
||||||
|
: item.id.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
return await hashItem(JSON.stringify(item));
|
return await hashItem(JSON.stringify(item));
|
||||||
|
@@ -0,0 +1,25 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
const role = await knex('roles')
|
||||||
|
.select('id')
|
||||||
|
.whereIn('key', ['user', 'admin'])
|
||||||
|
.orderBy('key', 'desc')
|
||||||
|
.limit(1)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
// backfill nulls
|
||||||
|
await knex('users').whereNull('role_id').update({ role_id: role.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await knex.schema.alterTable('users', (table) => {
|
||||||
|
table.uuid('role_id').notNullable().alter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
return await knex.schema.alterTable('users', (table) => {
|
||||||
|
table.uuid('role_id').nullable().alter();
|
||||||
|
});
|
||||||
|
}
|
@@ -0,0 +1,35 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
const users = await knex('users').whereNotNull('deleted_at');
|
||||||
|
const userIds = users.map((user) => user.id);
|
||||||
|
|
||||||
|
const flows = await knex('flows').whereIn('user_id', userIds);
|
||||||
|
const flowIds = flows.map((flow) => flow.id);
|
||||||
|
const executions = await knex('executions').whereIn('flow_id', flowIds);
|
||||||
|
const executionIds = executions.map((execution) => execution.id);
|
||||||
|
|
||||||
|
await knex('execution_steps').whereIn('execution_id', executionIds).update({
|
||||||
|
deleted_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('executions').whereIn('id', executionIds).update({
|
||||||
|
deleted_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('steps').whereIn('flow_id', flowIds).update({
|
||||||
|
deleted_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('flows').whereIn('id', flowIds).update({
|
||||||
|
deleted_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('connections').whereIn('user_id', userIds).update({
|
||||||
|
deleted_at: knex.fn.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(): Promise<void> {
|
||||||
|
// void
|
||||||
|
}
|
@@ -0,0 +1,11 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex('permissions')
|
||||||
|
.where(knex.raw('conditions::text'), '=', knex.raw("'{}'::text"))
|
||||||
|
.update('conditions', JSON.stringify([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(): Promise<void> {
|
||||||
|
// void
|
||||||
|
}
|
@@ -1,17 +1,59 @@
|
|||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
import Context from '../../types/express/context';
|
import Context from '../../types/express/context';
|
||||||
import deleteUserQueue from '../../queues/delete-user.ee';
|
import deleteUserQueue from '../../queues/delete-user.ee';
|
||||||
|
import flowQueue from '../../queues/flow';
|
||||||
|
import Flow from '../../models/flow';
|
||||||
|
import Execution from '../../models/execution';
|
||||||
|
import ExecutionStep from '../../models/execution-step';
|
||||||
|
import appConfig from '../../config/app';
|
||||||
|
|
||||||
const deleteCurrentUser = async (_parent: unknown, params: never, context: Context) => {
|
const deleteCurrentUser = async (
|
||||||
|
_parent: unknown,
|
||||||
|
params: never,
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
const id = context.currentUser.id;
|
const id = context.currentUser.id;
|
||||||
|
|
||||||
|
const flows = await context.currentUser.$relatedQuery('flows').where({
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeatableJobs = await flowQueue.getRepeatableJobs();
|
||||||
|
|
||||||
|
for (const flow of flows) {
|
||||||
|
const job = repeatableJobs.find((job) => job.id === flow.id);
|
||||||
|
|
||||||
|
if (job) {
|
||||||
|
await flowQueue.removeRepeatableByKey(job.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionIds = (
|
||||||
|
await context.currentUser
|
||||||
|
.$relatedQuery('executions')
|
||||||
|
.select('executions.id')
|
||||||
|
).map((execution: Execution) => execution.id);
|
||||||
|
const flowIds = flows.map((flow) => flow.id);
|
||||||
|
|
||||||
|
await ExecutionStep.query().delete().whereIn('execution_id', executionIds);
|
||||||
|
await context.currentUser.$relatedQuery('executions').delete();
|
||||||
|
await context.currentUser.$relatedQuery('steps').delete();
|
||||||
|
await Flow.query().whereIn('id', flowIds).delete();
|
||||||
|
await context.currentUser.$relatedQuery('connections').delete();
|
||||||
|
await context.currentUser.$relatedQuery('identities').delete();
|
||||||
|
|
||||||
|
if (appConfig.isCloud) {
|
||||||
|
await context.currentUser.$relatedQuery('subscriptions').delete();
|
||||||
|
await context.currentUser.$relatedQuery('usageData').delete();
|
||||||
|
}
|
||||||
|
|
||||||
await context.currentUser.$query().delete();
|
await context.currentUser.$query().delete();
|
||||||
|
|
||||||
const jobName = `Delete user - ${id}`;
|
const jobName = `Delete user - ${id}`;
|
||||||
const jobPayload = { id };
|
const jobPayload = { id };
|
||||||
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
|
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
|
||||||
const jobOptions = {
|
const jobOptions = {
|
||||||
delay: millisecondsFor30Days
|
delay: millisecondsFor30Days,
|
||||||
};
|
};
|
||||||
|
|
||||||
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
|
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import Context from '../../types/express/context';
|
import Context from '../../types/express/context';
|
||||||
import testRun from '../../services/test-run';
|
import testRun from '../../services/test-run';
|
||||||
|
import Step from '../../models/step';
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
input: {
|
input: {
|
||||||
@@ -12,12 +13,16 @@ const executeFlow = async (
|
|||||||
params: Params,
|
params: Params,
|
||||||
context: Context
|
context: Context
|
||||||
) => {
|
) => {
|
||||||
context.currentUser.can('update', 'Flow');
|
const conditions = context.currentUser.can('update', 'Flow');
|
||||||
|
const isCreator = conditions.isCreator;
|
||||||
|
const allSteps = Step.query();
|
||||||
|
const userSteps = context.currentUser.$relatedQuery('steps');
|
||||||
|
const baseQuery = isCreator ? userSteps : allSteps;
|
||||||
|
|
||||||
const { stepId } = params.input;
|
const { stepId } = params.input;
|
||||||
|
|
||||||
const untilStep = await context.currentUser
|
const untilStep = await baseQuery
|
||||||
.$relatedQuery('steps')
|
.clone()
|
||||||
.findById(stepId)
|
.findById(stepId)
|
||||||
.throwIfNotFound();
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
@@ -8,7 +8,11 @@ type Params = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateConfig = async (_parent: unknown, params: Params, context: Context) => {
|
const updateConfig = async (
|
||||||
|
_parent: unknown,
|
||||||
|
params: Params,
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
context.currentUser.can('update', 'Config');
|
context.currentUser.can('update', 'Config');
|
||||||
|
|
||||||
const config = params.input;
|
const config = params.input;
|
||||||
@@ -18,22 +22,26 @@ const updateConfig = async (_parent: unknown, params: Params, context: Context)
|
|||||||
for (const key of configKeys) {
|
for (const key of configKeys) {
|
||||||
const newValue = config[key];
|
const newValue = config[key];
|
||||||
|
|
||||||
const entryUpdate = Config
|
if (newValue) {
|
||||||
.query()
|
const entryUpdate = Config.query()
|
||||||
.insert({
|
.insert({
|
||||||
key,
|
key,
|
||||||
value: {
|
value: {
|
||||||
data: newValue
|
data: newValue,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
.onConflict('key')
|
.onConflict('key')
|
||||||
.merge({
|
.merge({
|
||||||
value: {
|
value: {
|
||||||
data: newValue
|
data: newValue,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
updates.push(entryUpdate);
|
updates.push(entryUpdate);
|
||||||
|
} else {
|
||||||
|
const entryUpdate = Config.query().findOne({ key }).delete();
|
||||||
|
updates.push(entryUpdate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(updates);
|
await Promise.all(updates);
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import Flow from '../../models/flow';
|
||||||
import Context from '../../types/express/context';
|
import Context from '../../types/express/context';
|
||||||
import flowQueue from '../../queues/flow';
|
import flowQueue from '../../queues/flow';
|
||||||
import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, REMOVE_AFTER_7_DAYS_OR_50_JOBS } from '../../helpers/remove-job-configuration';
|
import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, REMOVE_AFTER_7_DAYS_OR_50_JOBS } from '../../helpers/remove-job-configuration';
|
||||||
@@ -18,10 +19,14 @@ const updateFlowStatus = async (
|
|||||||
params: Params,
|
params: Params,
|
||||||
context: Context
|
context: Context
|
||||||
) => {
|
) => {
|
||||||
context.currentUser.can('publish', 'Flow');
|
const conditions = context.currentUser.can('publish', 'Flow');
|
||||||
|
const isCreator = conditions.isCreator;
|
||||||
|
const allFlows = Flow.query();
|
||||||
|
const userFlows = context.currentUser.$relatedQuery('flows');
|
||||||
|
const baseQuery = isCreator ? userFlows : allFlows;
|
||||||
|
|
||||||
let flow = await context.currentUser
|
let flow = await baseQuery
|
||||||
.$relatedQuery('flows')
|
.clone()
|
||||||
.findOne({
|
.findOne({
|
||||||
id: params.input.id,
|
id: params.input.id,
|
||||||
})
|
})
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { IJSONObject } from '@automatisch/types';
|
import { IJSONObject } from '@automatisch/types';
|
||||||
import App from '../../models/app';
|
import App from '../../models/app';
|
||||||
import Step from '../../models/step';
|
import Step from '../../models/step';
|
||||||
|
import Connection from '../../models/connection';
|
||||||
import Context from '../../types/express/context';
|
import Context from '../../types/express/context';
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -23,12 +24,14 @@ const updateStep = async (
|
|||||||
params: Params,
|
params: Params,
|
||||||
context: Context
|
context: Context
|
||||||
) => {
|
) => {
|
||||||
context.currentUser.can('update', 'Flow');
|
const { isCreator } = context.currentUser.can('update', 'Flow');
|
||||||
|
const userSteps = context.currentUser.$relatedQuery('steps');
|
||||||
|
const allSteps = Step.query();
|
||||||
|
const baseQuery = isCreator ? userSteps : allSteps;
|
||||||
|
|
||||||
const { input } = params;
|
const { input } = params;
|
||||||
|
|
||||||
let step = await context.currentUser
|
let step = await baseQuery
|
||||||
.$relatedQuery('steps')
|
|
||||||
.findOne({
|
.findOne({
|
||||||
'steps.id': input.id,
|
'steps.id': input.id,
|
||||||
flow_id: input.flow.id,
|
flow_id: input.flow.id,
|
||||||
@@ -36,11 +39,24 @@ const updateStep = async (
|
|||||||
.throwIfNotFound();
|
.throwIfNotFound();
|
||||||
|
|
||||||
if (input.connection.id) {
|
if (input.connection.id) {
|
||||||
const hasConnection = await context.currentUser
|
let canSeeAllConnections = false;
|
||||||
.$relatedQuery('connections')
|
try {
|
||||||
.findById(input.connection?.id);
|
const conditions = context.currentUser.can('read', 'Connection');
|
||||||
|
|
||||||
if (!hasConnection) {
|
canSeeAllConnections = !conditions.isCreator;
|
||||||
|
} catch {
|
||||||
|
// void
|
||||||
|
}
|
||||||
|
|
||||||
|
const userConnections = context.currentUser.$relatedQuery('connections');
|
||||||
|
const allConnections = Connection.query();
|
||||||
|
const baseConnectionsQuery = canSeeAllConnections ? allConnections : userConnections;
|
||||||
|
|
||||||
|
const connection = await baseConnectionsQuery
|
||||||
|
.clone()
|
||||||
|
.findById(input.connection?.id)
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
throw new Error('The connection does not exist!');
|
throw new Error('The connection does not exist!');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
15
packages/backend/src/graphql/queries/get-notifications.ts
Normal file
15
packages/backend/src/graphql/queries/get-notifications.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import axios from '../../helpers/axios-with-proxy';
|
||||||
|
|
||||||
|
const NOTIFICATIONS_URL = 'https://notifications.automatisch.io/notifications.json';
|
||||||
|
|
||||||
|
const getNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const { data: notifications = [] } = await axios.get(NOTIFICATIONS_URL);
|
||||||
|
|
||||||
|
return notifications;
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getNotifications;
|
@@ -0,0 +1,23 @@
|
|||||||
|
import Context from '../../types/express/context';
|
||||||
|
import SamlAuthProvider from '../../models/saml-auth-provider.ee';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSamlAuthProviderRoleMappings = async (_parent: unknown, params: Params, context: Context) => {
|
||||||
|
context.currentUser.can('read', 'SamlAuthProvider');
|
||||||
|
|
||||||
|
const samlAuthProvider = await SamlAuthProvider
|
||||||
|
.query()
|
||||||
|
.findById(params.id)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const roleMappings = await samlAuthProvider
|
||||||
|
.$relatedQuery('samlAuthProvidersRoleMappings')
|
||||||
|
.orderBy('remote_role_name', 'asc')
|
||||||
|
|
||||||
|
return roleMappings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getSamlAuthProviderRoleMappings;
|
@@ -10,15 +10,14 @@ type Params = {
|
|||||||
const getUsers = async (_parent: unknown, params: Params, context: Context) => {
|
const getUsers = async (_parent: unknown, params: Params, context: Context) => {
|
||||||
context.currentUser.can('read', 'User');
|
context.currentUser.can('read', 'User');
|
||||||
|
|
||||||
const usersQuery = User
|
const usersQuery = User.query()
|
||||||
.query()
|
|
||||||
.leftJoinRelated({
|
.leftJoinRelated({
|
||||||
role: true
|
role: true,
|
||||||
})
|
})
|
||||||
.withGraphFetched({
|
.withGraphFetched({
|
||||||
role: true
|
role: true,
|
||||||
})
|
})
|
||||||
.orderBy('full_name', 'desc');
|
.orderBy('full_name', 'asc');
|
||||||
|
|
||||||
return paginate(usersQuery, params.limit, params.offset);
|
return paginate(usersQuery, params.limit, params.offset);
|
||||||
};
|
};
|
||||||
|
@@ -16,11 +16,13 @@ import getExecutions from './queries/get-executions';
|
|||||||
import getFlow from './queries/get-flow';
|
import getFlow from './queries/get-flow';
|
||||||
import getFlows from './queries/get-flows';
|
import getFlows from './queries/get-flows';
|
||||||
import getInvoices from './queries/get-invoices.ee';
|
import getInvoices from './queries/get-invoices.ee';
|
||||||
|
import getNotifications from './queries/get-notifications';
|
||||||
import getPaddleInfo from './queries/get-paddle-info.ee';
|
import getPaddleInfo from './queries/get-paddle-info.ee';
|
||||||
import getPaymentPlans from './queries/get-payment-plans.ee';
|
import getPaymentPlans from './queries/get-payment-plans.ee';
|
||||||
import getPermissionCatalog from './queries/get-permission-catalog.ee';
|
import getPermissionCatalog from './queries/get-permission-catalog.ee';
|
||||||
import getRole from './queries/get-role.ee';
|
import getRole from './queries/get-role.ee';
|
||||||
import getRoles from './queries/get-roles.ee';
|
import getRoles from './queries/get-roles.ee';
|
||||||
|
import getSamlAuthProviderRoleMappings from './queries/get-saml-auth-provider-role-mappings.ee';
|
||||||
import getSamlAuthProvider from './queries/get-saml-auth-provider.ee';
|
import getSamlAuthProvider from './queries/get-saml-auth-provider.ee';
|
||||||
import getStepWithTestExecutions from './queries/get-step-with-test-executions';
|
import getStepWithTestExecutions from './queries/get-step-with-test-executions';
|
||||||
import getSubscriptionStatus from './queries/get-subscription-status.ee';
|
import getSubscriptionStatus from './queries/get-subscription-status.ee';
|
||||||
@@ -50,12 +52,14 @@ const queryResolvers = {
|
|||||||
getFlow,
|
getFlow,
|
||||||
getFlows,
|
getFlows,
|
||||||
getInvoices,
|
getInvoices,
|
||||||
|
getNotifications,
|
||||||
getPaddleInfo,
|
getPaddleInfo,
|
||||||
getPaymentPlans,
|
getPaymentPlans,
|
||||||
getPermissionCatalog,
|
getPermissionCatalog,
|
||||||
getRole,
|
getRole,
|
||||||
getRoles,
|
getRoles,
|
||||||
getSamlAuthProvider,
|
getSamlAuthProvider,
|
||||||
|
getSamlAuthProviderRoleMappings,
|
||||||
getStepWithTestExecutions,
|
getStepWithTestExecutions,
|
||||||
getSubscriptionStatus,
|
getSubscriptionStatus,
|
||||||
getTrialStatus,
|
getTrialStatus,
|
||||||
|
@@ -46,7 +46,9 @@ type Query {
|
|||||||
getPermissionCatalog: PermissionCatalog
|
getPermissionCatalog: PermissionCatalog
|
||||||
getRole(id: String!): Role
|
getRole(id: String!): Role
|
||||||
getRoles: [Role]
|
getRoles: [Role]
|
||||||
|
getNotifications: [Notification]
|
||||||
getSamlAuthProvider: SamlAuthProvider
|
getSamlAuthProvider: SamlAuthProvider
|
||||||
|
getSamlAuthProviderRoleMappings(id: String!): [SamlAuthProvidersRoleMapping]
|
||||||
getSubscriptionStatus: GetSubscriptionStatus
|
getSubscriptionStatus: GetSubscriptionStatus
|
||||||
getTrialStatus: GetTrialStatus
|
getTrialStatus: GetTrialStatus
|
||||||
getUser(id: String!): User
|
getUser(id: String!): User
|
||||||
@@ -329,6 +331,7 @@ type SamlAuthProvider {
|
|||||||
emailAttributeName: String
|
emailAttributeName: String
|
||||||
roleAttributeName: String
|
roleAttributeName: String
|
||||||
active: Boolean
|
active: Boolean
|
||||||
|
defaultRoleId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type SamlAuthProvidersRoleMapping {
|
type SamlAuthProvidersRoleMapping {
|
||||||
@@ -341,6 +344,7 @@ type SamlAuthProvidersRoleMapping {
|
|||||||
type UserConnection {
|
type UserConnection {
|
||||||
edges: [UserEdge]
|
edges: [UserEdge]
|
||||||
pageInfo: PageInfo
|
pageInfo: PageInfo
|
||||||
|
totalCount: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserEdge {
|
type UserEdge {
|
||||||
@@ -717,6 +721,7 @@ type ListSamlAuthProvider {
|
|||||||
id: String
|
id: String
|
||||||
name: String
|
name: String
|
||||||
issuer: String
|
issuer: String
|
||||||
|
loginUrl: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type Permission {
|
type Permission {
|
||||||
@@ -783,6 +788,13 @@ input UpdateAppAuthClientInput {
|
|||||||
active: Boolean
|
active: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Notification {
|
||||||
|
name: String
|
||||||
|
createdAt: String
|
||||||
|
documentationUrl: String
|
||||||
|
description: String
|
||||||
|
}
|
||||||
|
|
||||||
schema {
|
schema {
|
||||||
query: Query
|
query: Query
|
||||||
mutation: Mutation
|
mutation: Mutation
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { rule, shield, allow } from 'graphql-shield';
|
import { allow, rule, shield } from 'graphql-shield';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import User from '../models/user';
|
|
||||||
import appConfig from '../config/app';
|
import appConfig from '../config/app';
|
||||||
|
import User from '../models/user';
|
||||||
|
|
||||||
const isAuthenticated = rule()(async (_parent, _args, req) => {
|
const isAuthenticated = rule()(async (_parent, _args, req) => {
|
||||||
const token = req.headers['authorization'];
|
const token = req.headers['authorization'];
|
||||||
@@ -34,15 +34,16 @@ const authentication = shield(
|
|||||||
Query: {
|
Query: {
|
||||||
'*': isAuthenticated,
|
'*': isAuthenticated,
|
||||||
getAutomatischInfo: allow,
|
getAutomatischInfo: allow,
|
||||||
listSamlAuthProviders: allow,
|
|
||||||
healthcheck: allow,
|
|
||||||
getConfig: allow,
|
getConfig: allow,
|
||||||
|
getNotifications: allow,
|
||||||
|
healthcheck: allow,
|
||||||
|
listSamlAuthProviders: allow,
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
'*': isAuthenticated,
|
'*': isAuthenticated,
|
||||||
registerUser: allow,
|
|
||||||
forgotPassword: allow,
|
forgotPassword: allow,
|
||||||
login: allow,
|
login: allow,
|
||||||
|
registerUser: allow,
|
||||||
resetPassword: allow,
|
resetPassword: allow,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -2,8 +2,7 @@ import Step from '../models/step';
|
|||||||
import ExecutionStep from '../models/execution-step';
|
import ExecutionStep from '../models/execution-step';
|
||||||
import get from 'lodash.get';
|
import get from 'lodash.get';
|
||||||
|
|
||||||
// INFO: don't remove space in allowed character group!
|
const variableRegExp = /({{step\.[\da-zA-Z-]+(?:\.[^.}{]+)+}})/g;
|
||||||
const variableRegExp = /({{step\.[\da-zA-Z-]+(?:\.[\da-zA-Z-_ :]+)+}})/g;
|
|
||||||
|
|
||||||
export default function computeParameters(
|
export default function computeParameters(
|
||||||
parameters: Step['parameters'],
|
parameters: Step['parameters'],
|
||||||
|
@@ -48,7 +48,7 @@ const findOrCreateUserBySamlIdentity = async (
|
|||||||
.join(' '),
|
.join(' '),
|
||||||
email: mappedUser.email as string,
|
email: mappedUser.email as string,
|
||||||
roleId:
|
roleId:
|
||||||
samlAuthProviderRoleMapping.roleId || samlAuthProvider.defaultRoleId,
|
samlAuthProviderRoleMapping?.roleId || samlAuthProvider.defaultRoleId,
|
||||||
identities: [
|
identities: [
|
||||||
{
|
{
|
||||||
remoteId: mappedUser.id as string,
|
remoteId: mappedUser.id as string,
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
// TODO: replace with axios-with-proxy
|
|
||||||
import axios from 'axios';
|
|
||||||
import appConfig from '../config/app';
|
|
||||||
import memoryCache from 'memory-cache';
|
import memoryCache from 'memory-cache';
|
||||||
|
import appConfig from '../config/app';
|
||||||
|
import axios from './axios-with-proxy';
|
||||||
|
|
||||||
const CACHE_DURATION = 1000 * 60 * 60 * 24; // 24 hours in milliseconds
|
const CACHE_DURATION = 1000 * 60 * 60 * 24; // 24 hours in milliseconds
|
||||||
|
|
||||||
|
@@ -21,6 +21,7 @@ const paginate = async (
|
|||||||
currentPage: Math.ceil(offset / limit + 1),
|
currentPage: Math.ceil(offset / limit + 1),
|
||||||
totalPages: Math.ceil(count / limit),
|
totalPages: Math.ceil(count / limit),
|
||||||
},
|
},
|
||||||
|
totalCount: count,
|
||||||
edges: records.map((record: Base) => ({
|
edges: records.map((record: Base) => ({
|
||||||
node: record,
|
node: record,
|
||||||
})),
|
})),
|
||||||
|
@@ -75,6 +75,14 @@ class SamlAuthProvider extends Base {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static get virtualAttributes() {
|
||||||
|
return ['loginUrl'];
|
||||||
|
}
|
||||||
|
|
||||||
|
get loginUrl() {
|
||||||
|
return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
get config(): SamlConfig {
|
get config(): SamlConfig {
|
||||||
const callbackUrl = new URL(
|
const callbackUrl = new URL(
|
||||||
`/login/saml/${this.issuer}/callback`,
|
`/login/saml/${this.issuer}/callback`,
|
||||||
|
@@ -3,6 +3,7 @@ import { Worker } from 'bullmq';
|
|||||||
import * as Sentry from '../helpers/sentry.ee';
|
import * as Sentry from '../helpers/sentry.ee';
|
||||||
import redisConfig from '../config/redis';
|
import redisConfig from '../config/redis';
|
||||||
import logger from '../helpers/logger';
|
import logger from '../helpers/logger';
|
||||||
|
import appConfig from '../config/app';
|
||||||
import User from '../models/user';
|
import User from '../models/user';
|
||||||
import Execution from '../models/execution';
|
import Execution from '../models/execution';
|
||||||
import ExecutionStep from '../models/execution-step';
|
import ExecutionStep from '../models/execution-step';
|
||||||
@@ -12,21 +13,34 @@ export const worker = new Worker(
|
|||||||
async (job) => {
|
async (job) => {
|
||||||
const { id } = job.data;
|
const { id } = job.data;
|
||||||
|
|
||||||
const user = await User.query().findById(id).throwIfNotFound();
|
const user = await User.query()
|
||||||
|
.withSoftDeleted()
|
||||||
|
.findById(id)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
const executionIds = (
|
const executionIds = (
|
||||||
await user.$relatedQuery('executions').select('executions.id')
|
await user
|
||||||
|
.$relatedQuery('executions')
|
||||||
|
.withSoftDeleted()
|
||||||
|
.select('executions.id')
|
||||||
).map((execution: Execution) => execution.id);
|
).map((execution: Execution) => execution.id);
|
||||||
|
|
||||||
await ExecutionStep.query()
|
await ExecutionStep.query()
|
||||||
.hardDelete()
|
.withSoftDeleted()
|
||||||
.whereIn('execution_id', executionIds);
|
.whereIn('execution_id', executionIds)
|
||||||
await user.$relatedQuery('executions').hardDelete();
|
.hardDelete();
|
||||||
await user.$relatedQuery('steps').hardDelete();
|
await user.$relatedQuery('executions').withSoftDeleted().hardDelete();
|
||||||
await user.$relatedQuery('flows').hardDelete();
|
await user.$relatedQuery('steps').withSoftDeleted().hardDelete();
|
||||||
await user.$relatedQuery('connections').hardDelete();
|
await user.$relatedQuery('flows').withSoftDeleted().hardDelete();
|
||||||
|
await user.$relatedQuery('connections').withSoftDeleted().hardDelete();
|
||||||
|
await user.$relatedQuery('identities').withSoftDeleted().hardDelete();
|
||||||
|
|
||||||
await user.$query().hardDelete();
|
if (appConfig.isCloud) {
|
||||||
|
await user.$relatedQuery('subscriptions').withSoftDeleted().hardDelete();
|
||||||
|
await user.$relatedQuery('usageData').withSoftDeleted().hardDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.$query().withSoftDeleted().hardDelete();
|
||||||
},
|
},
|
||||||
{ connection: redisConfig }
|
{ connection: redisConfig }
|
||||||
);
|
);
|
||||||
|
@@ -3,6 +3,7 @@ import { Worker } from 'bullmq';
|
|||||||
import * as Sentry from '../helpers/sentry.ee';
|
import * as Sentry from '../helpers/sentry.ee';
|
||||||
import redisConfig from '../config/redis';
|
import redisConfig from '../config/redis';
|
||||||
import logger from '../helpers/logger';
|
import logger from '../helpers/logger';
|
||||||
|
import flowQueue from '../queues/flow';
|
||||||
import triggerQueue from '../queues/trigger';
|
import triggerQueue from '../queues/trigger';
|
||||||
import { processFlow } from '../services/flow';
|
import { processFlow } from '../services/flow';
|
||||||
import Flow from '../models/flow';
|
import Flow from '../models/flow';
|
||||||
@@ -66,7 +67,7 @@ worker.on('completed', (job) => {
|
|||||||
logger.info(`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has started!`);
|
logger.info(`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has started!`);
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.on('failed', (job, err) => {
|
worker.on('failed', async (job, err) => {
|
||||||
const errorMessage = `
|
const errorMessage = `
|
||||||
JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}
|
JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}
|
||||||
\n ${err.stack}
|
\n ${err.stack}
|
||||||
@@ -74,6 +75,18 @@ worker.on('failed', (job, err) => {
|
|||||||
|
|
||||||
logger.error(errorMessage);
|
logger.error(errorMessage);
|
||||||
|
|
||||||
|
const flow = await Flow.query().findById(job.data.flowId);
|
||||||
|
|
||||||
|
if (!flow) {
|
||||||
|
await flowQueue.removeRepeatableByKey(job.repeatJobKey);
|
||||||
|
|
||||||
|
const flowNotFoundErrorMessage = `
|
||||||
|
JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has been deleted from Redis because flow was not found!
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.error(flowNotFoundErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
Sentry.captureException(err, {
|
Sentry.captureException(err, {
|
||||||
extra: {
|
extra: {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automatisch/cli",
|
"name": "@automatisch/cli",
|
||||||
"version": "0.8.0",
|
"version": "0.9.3",
|
||||||
"license": "See LICENSE file",
|
"license": "See LICENSE file",
|
||||||
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"version": "oclif readme && git add README.md"
|
"version": "oclif readme && git add README.md"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@automatisch/backend": "^0.8.0",
|
"@automatisch/backend": "^0.9.3",
|
||||||
"@oclif/core": "^1",
|
"@oclif/core": "^1",
|
||||||
"@oclif/plugin-help": "^5",
|
"@oclif/plugin-help": "^5",
|
||||||
"@oclif/plugin-plugins": "^2.0.1",
|
"@oclif/plugin-plugins": "^2.0.1",
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automatisch/docs",
|
"name": "@automatisch/docs",
|
||||||
"version": "0.8.0",
|
"version": "0.9.3",
|
||||||
"license": "See LICENSE file",
|
"license": "See LICENSE file",
|
||||||
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
@@ -1,17 +0,0 @@
|
|||||||
const { defineConfig } = require('cypress');
|
|
||||||
|
|
||||||
const TO_BE_PROVIDED = 'HAS_TO_BE_PROVIDED_IN_cypress.env.json';
|
|
||||||
|
|
||||||
module.exports = defineConfig({
|
|
||||||
e2e: {
|
|
||||||
baseUrl: 'http://localhost:3001',
|
|
||||||
env: {
|
|
||||||
login_email: 'user@automatisch.io',
|
|
||||||
login_password: 'sample',
|
|
||||||
deepl_auth_key: TO_BE_PROVIDED,
|
|
||||||
},
|
|
||||||
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
|
|
||||||
viewportWidth: 1280,
|
|
||||||
viewportHeight: 768,
|
|
||||||
},
|
|
||||||
});
|
|
@@ -1,52 +0,0 @@
|
|||||||
/// <reference types="cypress" />
|
|
||||||
|
|
||||||
describe('Apps page', () => {
|
|
||||||
before(() => {
|
|
||||||
cy.login();
|
|
||||||
|
|
||||||
cy.og('apps-page-drawer-link').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
cy.logout();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays applications', () => {
|
|
||||||
cy.og('apps-loader').should('not.exist');
|
|
||||||
cy.og('app-row').should('have.length.least', 1);
|
|
||||||
|
|
||||||
cy.ss('Applications');
|
|
||||||
});
|
|
||||||
|
|
||||||
context('can add connection', () => {
|
|
||||||
before(() => {
|
|
||||||
cy
|
|
||||||
.og('add-connection-button')
|
|
||||||
.click({ force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists applications', () => {
|
|
||||||
cy.og('app-list-item').should('have.length.above', 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('searches an application', () => {
|
|
||||||
cy.og('search-for-app-text-field').type('DeepL');
|
|
||||||
cy.og('app-list-item').should('have.length', 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('goes to app page to create a connection', () => {
|
|
||||||
cy.og('app-list-item').first().click();
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/app/deepl/connections/add');
|
|
||||||
|
|
||||||
cy.og('add-app-connection-dialog').should('be.visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes the dialog on backdrop click', () => {
|
|
||||||
cy.clickOutside();
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/app/deepl/connections');
|
|
||||||
cy.og('add-app-connection-dialog').should('not.exist');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,48 +0,0 @@
|
|||||||
/// <reference types="cypress" />
|
|
||||||
|
|
||||||
describe('Connections page', () => {
|
|
||||||
before(() => {
|
|
||||||
cy.login();
|
|
||||||
|
|
||||||
cy.og('apps-page-drawer-link').click();
|
|
||||||
|
|
||||||
cy.visit('/app/deepl/connections');
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
cy.logout();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows connections if any', () => {
|
|
||||||
cy.og('apps-loader').should('not.exist');
|
|
||||||
|
|
||||||
cy.ss('DeepL connections before creating a connection');
|
|
||||||
});
|
|
||||||
|
|
||||||
context('can add connection', () => {
|
|
||||||
it('has a button to open add connection dialog', () => {
|
|
||||||
cy.scrollTo('top', { ensureScrollable: false });
|
|
||||||
|
|
||||||
cy
|
|
||||||
.og('add-connection-button')
|
|
||||||
.should('be.visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('add connection button takes user to add connection page', () => {
|
|
||||||
cy.og('add-connection-button').click();
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/app/deepl/connections/add');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows add connection dialog to create a new connection', () => {
|
|
||||||
cy.get('input[name="screenName"]').type('e2e-test connection!');
|
|
||||||
cy.get('input[name="authenticationKey"]').type(Cypress.env('deepl_auth_key'));
|
|
||||||
|
|
||||||
cy.og('create-connection-button').click();
|
|
||||||
|
|
||||||
cy.og('create-connection-button').should('not.exist');
|
|
||||||
|
|
||||||
cy.ss('DeepL connections after creating a connection');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,32 +0,0 @@
|
|||||||
/// <reference types="cypress" />
|
|
||||||
|
|
||||||
describe('Execution page', () => {
|
|
||||||
before(() => {
|
|
||||||
cy.login();
|
|
||||||
|
|
||||||
cy.og('executions-page-drawer-link').click();
|
|
||||||
cy.og('execution-row').first().click({ force: true });
|
|
||||||
|
|
||||||
cy.location('pathname').should('match', /^\/executions\//);
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
cy.logout();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays data in by default', () => {
|
|
||||||
cy.og('execution-step').should('have.length', 2);
|
|
||||||
|
|
||||||
cy.ss('Execution - data in');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays data out', () => {
|
|
||||||
cy.og('data-out-tab').click({ multiple: true });
|
|
||||||
|
|
||||||
cy.ss('Execution - data out');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not display error', () => {
|
|
||||||
cy.og('error-tab').should('not.exist');
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,20 +0,0 @@
|
|||||||
/// <reference types="cypress" />
|
|
||||||
|
|
||||||
describe('Executions page', () => {
|
|
||||||
before(() => {
|
|
||||||
cy.login();
|
|
||||||
|
|
||||||
cy.og('executions-page-drawer-link').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
cy.logout();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays executions', () => {
|
|
||||||
cy.og('executions-loader').should('not.exist');
|
|
||||||
cy.og('execution-row').should('exist');
|
|
||||||
|
|
||||||
cy.ss('Executions');
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,217 +0,0 @@
|
|||||||
/// <reference types="cypress" />
|
|
||||||
|
|
||||||
describe('Flow editor page', () => {
|
|
||||||
before(() => {
|
|
||||||
cy.login();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
cy.logout();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('create flow', () => {
|
|
||||||
cy.og('create-flow-button').click({ force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has two steps by default', () => {
|
|
||||||
cy.og('flow-step').should('have.length', 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
context('edit flow', () => {
|
|
||||||
context('arrange Scheduler trigger', () => {
|
|
||||||
context('choose app and event substep', () => {
|
|
||||||
it('choose application', () => {
|
|
||||||
cy.og('choose-app-autocomplete').click();
|
|
||||||
|
|
||||||
cy.get('li[role="option"]:contains("Scheduler")').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('choose an event', () => {
|
|
||||||
cy.og('choose-event-autocomplete').should('be.visible').click();
|
|
||||||
|
|
||||||
cy.get('li[role="option"]:contains("Every hour")').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('continue to next step', () => {
|
|
||||||
cy.og('flow-substep-continue-button').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collapses the substep', () => {
|
|
||||||
cy.og('choose-app-autocomplete').should('not.be.visible');
|
|
||||||
cy.og('choose-event-autocomplete').should('not.be.visible');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
context('set up a trigger', () => {
|
|
||||||
it('choose "yes" in "trigger on weekends?"', () => {
|
|
||||||
cy.og('parameters.triggersOnWeekend-autocomplete')
|
|
||||||
.should('be.visible')
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.get('li[role="option"]:contains("Yes")').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('continue to next step', () => {
|
|
||||||
cy.og('flow-substep-continue-button').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collapses the substep', () => {
|
|
||||||
cy.og('parameters.triggersOnWeekend-autocomplete').should(
|
|
||||||
'not.exist'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
context('test trigger', () => {
|
|
||||||
it('show sample output', () => {
|
|
||||||
cy.og('flow-test-substep-output').should('not.exist');
|
|
||||||
|
|
||||||
cy.og('flow-substep-continue-button').click();
|
|
||||||
|
|
||||||
cy.og('flow-test-substep-output').should('be.visible');
|
|
||||||
|
|
||||||
cy.ss('Scheduler trigger test output');
|
|
||||||
|
|
||||||
cy.og('flow-substep-continue-button').click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
context('arrange DeepL action', () => {
|
|
||||||
context('choose app and event substep', () => {
|
|
||||||
it('choose application', () => {
|
|
||||||
cy.og('choose-app-autocomplete').click();
|
|
||||||
|
|
||||||
cy.get('li[role="option"]:contains("DeepL")').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('choose an event', () => {
|
|
||||||
cy.og('choose-event-autocomplete').should('be.visible').click();
|
|
||||||
|
|
||||||
cy.get(
|
|
||||||
'li[role="option"]:contains("Translate Text")'
|
|
||||||
).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('continue to next step', () => {
|
|
||||||
cy.og('flow-substep-continue-button').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collapses the substep', () => {
|
|
||||||
cy.og('choose-app-autocomplete').should('not.be.visible');
|
|
||||||
cy.og('choose-event-autocomplete').should('not.be.visible');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
context('choose connection', () => {
|
|
||||||
it('choose connection', () => {
|
|
||||||
cy.og('choose-connection-autocomplete').click();
|
|
||||||
|
|
||||||
cy.get('li[role="option"]').first().click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('continue to next step', () => {
|
|
||||||
cy.og('flow-substep-continue-button').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collapses the substep', () => {
|
|
||||||
cy.og('choose-connection-autocomplete').should('not.be.visible');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
context('set up action', () => {
|
|
||||||
it('arrange text', () => {
|
|
||||||
cy.og('power-input', ' [contenteditable]')
|
|
||||||
.click()
|
|
||||||
.type(
|
|
||||||
`Hello from e2e tests! Here is the first suggested variable's value; `
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.og('power-input-suggestion-group')
|
|
||||||
.first()
|
|
||||||
.og('power-input-suggestion-item')
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.clickOutside();
|
|
||||||
|
|
||||||
cy.ss('DeepL action text');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('set target language', () => {
|
|
||||||
cy.og('parameters.targetLanguage-autocomplete').click();
|
|
||||||
|
|
||||||
cy.get(
|
|
||||||
'li[role="option"]:contains("Turkish")'
|
|
||||||
).first().click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('continue to next step', () => {
|
|
||||||
cy.og('flow-substep-continue-button').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collapses the substep', () => {
|
|
||||||
cy.og('power-input', ' [contenteditable]').should('not.exist');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
context('test trigger', () => {
|
|
||||||
it('show sample output', () => {
|
|
||||||
cy.og('flow-test-substep-output').should('not.exist');
|
|
||||||
|
|
||||||
cy.og('flow-substep-continue-button').click();
|
|
||||||
|
|
||||||
cy.og('flow-test-substep-output').should('be.visible');
|
|
||||||
|
|
||||||
cy.ss('DeepL action test output');
|
|
||||||
|
|
||||||
cy.og('flow-substep-continue-button').click();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
context('publish and unpublish', () => {
|
|
||||||
it('publish flow', () => {
|
|
||||||
cy.og('unpublish-flow-button').should('not.exist');
|
|
||||||
|
|
||||||
cy.og('publish-flow-button').should('be.visible').click();
|
|
||||||
|
|
||||||
cy.og('publish-flow-button').should('not.exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows read-only sticky snackbar', () => {
|
|
||||||
cy.og('flow-cannot-edit-info-snackbar').should('be.visible');
|
|
||||||
|
|
||||||
cy.ss('Published flow');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unpublish from snackbar', () => {
|
|
||||||
cy.og('unpublish-flow-from-snackbar').click();
|
|
||||||
|
|
||||||
cy.og('flow-cannot-edit-info-snackbar').should('not.exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('publish once again', () => {
|
|
||||||
cy.og('publish-flow-button').should('be.visible').click();
|
|
||||||
|
|
||||||
cy.og('publish-flow-button').should('not.exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unpublish from layout top bar', () => {
|
|
||||||
cy.og('unpublish-flow-button').should('be.visible').click();
|
|
||||||
|
|
||||||
cy.og('unpublish-flow-button').should('not.exist');
|
|
||||||
|
|
||||||
cy.ss('Unpublished flow');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
context('in layout', () => {
|
|
||||||
it('can go back to flows page', () => {
|
|
||||||
cy.og('editor-go-back-button').click();
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/flows');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,44 +0,0 @@
|
|||||||
Cypress.Commands.add(
|
|
||||||
'og',
|
|
||||||
{ prevSubject: 'optional' },
|
|
||||||
(subject, selector, suffix = '') => {
|
|
||||||
if (subject) {
|
|
||||||
return cy.wrap(subject).get(`[data-test="${selector}"]${suffix}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cy.get(`[data-test="${selector}"]${suffix}`);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Cypress.Commands.add('login', () => {
|
|
||||||
cy.visit('/login');
|
|
||||||
|
|
||||||
cy.og('email-text-field').type(Cypress.env('login_email'));
|
|
||||||
cy.og('password-text-field').type(Cypress.env('login_password'));
|
|
||||||
|
|
||||||
cy.intercept('/graphql').as('graphqlCalls');
|
|
||||||
cy.intercept('https://notifications.automatisch.io/notifications.json').as(
|
|
||||||
'notificationsCall'
|
|
||||||
);
|
|
||||||
cy.og('login-button').click();
|
|
||||||
|
|
||||||
cy.wait(['@graphqlCalls', '@notificationsCall']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Cypress.Commands.add('logout', () => {
|
|
||||||
cy.og('profile-menu-button').click();
|
|
||||||
|
|
||||||
cy.og('logout-item').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
Cypress.Commands.add('ss', (name, opts = {}) => {
|
|
||||||
return cy.screenshot(name, {
|
|
||||||
overwrite: true,
|
|
||||||
capture: 'viewport',
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Cypress.Commands.add('clickOutside', () => {
|
|
||||||
return cy.get('body').click(0, 0);
|
|
||||||
});
|
|
@@ -1,20 +0,0 @@
|
|||||||
// ***********************************************************
|
|
||||||
// This example support/e2e.js is processed and
|
|
||||||
// loaded automatically before your test files.
|
|
||||||
//
|
|
||||||
// This is a great place to put global configuration and
|
|
||||||
// behavior that modifies Cypress.
|
|
||||||
//
|
|
||||||
// You can change the location of this file or turn off
|
|
||||||
// automatically serving support files with the
|
|
||||||
// 'supportFile' configuration option.
|
|
||||||
//
|
|
||||||
// You can read more here:
|
|
||||||
// https://on.cypress.io/configuration
|
|
||||||
// ***********************************************************
|
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
|
||||||
import './commands';
|
|
||||||
|
|
||||||
// Alternatively you can use CommonJS syntax:
|
|
||||||
// require('./commands')
|
|
@@ -1,12 +1,16 @@
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { BasePage } = require('./base-page');
|
const { AuthenticatedPage } = require('./authenticated-page');
|
||||||
|
|
||||||
export class ApplicationsPage extends BasePage {
|
export class ApplicationsPage extends AuthenticatedPage {
|
||||||
async screenshot(options = {}) {
|
screenshotPath = '/applications';
|
||||||
const { path: plainPath, ...restOptions } = options;
|
|
||||||
|
|
||||||
const computedPath = path.join('applications', plainPath);
|
/**
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
*/
|
||||||
|
constructor(page) {
|
||||||
|
super(page);
|
||||||
|
|
||||||
return await super.screenshot({ path: computedPath, ...restOptions });
|
this.drawerLink = this.page.getByTestId('apps-page-drawer-link');
|
||||||
|
this.addConnectionButton = this.page.getByTestId('add-connection-button');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
21
packages/e2e-tests/fixtures/authenticated-page.js
Normal file
21
packages/e2e-tests/fixtures/authenticated-page.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const path = require('node:path');
|
||||||
|
const { expect } = require('@playwright/test');
|
||||||
|
const { BasePage } = require('./base-page');
|
||||||
|
const { LoginPage } = require('./login-page');
|
||||||
|
|
||||||
|
export class AuthenticatedPage extends BasePage {
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
*/
|
||||||
|
constructor(page) {
|
||||||
|
super(page);
|
||||||
|
|
||||||
|
this.profileMenuButton = this.page.getByTestId('profile-menu-button');
|
||||||
|
this.adminMenuItem = this.page.getByRole('menuitem', { name: 'Admin' });
|
||||||
|
this.userInterfaceDrawerItem = this.page.getByTestId('user-interface-drawer-link');
|
||||||
|
this.appBar = this.page.getByTestId('app-bar');
|
||||||
|
this.goToDashboardButton = this.page.getByTestId('go-back-drawer-link');
|
||||||
|
this.typographyLogo = this.page.getByTestId('typography-logo');
|
||||||
|
this.customLogo = this.page.getByTestId('custom-logo');
|
||||||
|
}
|
||||||
|
}
|
@@ -1,11 +1,14 @@
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
export class BasePage {
|
export class BasePage {
|
||||||
|
screenshotPath = '/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('@playwright/test').Page} page
|
* @param {import('@playwright/test').Page} page
|
||||||
*/
|
*/
|
||||||
constructor(page) {
|
constructor(page) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
|
this.snackbar = this.page.locator('#notistack-snackbar');
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickAway() {
|
async clickAway() {
|
||||||
@@ -15,20 +18,12 @@ export class BasePage {
|
|||||||
async screenshot(options = {}) {
|
async screenshot(options = {}) {
|
||||||
const { path: plainPath, ...restOptions } = options;
|
const { path: plainPath, ...restOptions } = options;
|
||||||
|
|
||||||
const computedPath = path.join('output/screenshots', plainPath);
|
const computedPath = path.join(
|
||||||
|
'output/screenshots',
|
||||||
|
this.screenshotPath,
|
||||||
|
plainPath
|
||||||
|
);
|
||||||
|
|
||||||
return await this.page.screenshot({ path: computedPath, ...restOptions });
|
return await this.page.screenshot({ path: computedPath, ...restOptions });
|
||||||
}
|
}
|
||||||
|
|
||||||
async login() {
|
|
||||||
await this.page.goto('/login');
|
|
||||||
await this.page
|
|
||||||
.getByTestId('email-text-field')
|
|
||||||
.fill(process.env.LOGIN_EMAIL);
|
|
||||||
await this.page
|
|
||||||
.getByTestId('password-text-field')
|
|
||||||
.fill(process.env.LOGIN_PASSWORD);
|
|
||||||
|
|
||||||
await this.page.getByTestId('login-button').click();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,14 +1,8 @@
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { BasePage } = require('./base-page');
|
const { AuthenticatedPage } = require('./authenticated-page');
|
||||||
|
|
||||||
export class ConnectionsPage extends BasePage {
|
export class ConnectionsPage extends AuthenticatedPage {
|
||||||
async screenshot(options = {}) {
|
screenshotPath = '/connections';
|
||||||
const { path: plainPath, ...restOptions } = options;
|
|
||||||
|
|
||||||
const computedPath = path.join('connections', plainPath);
|
|
||||||
|
|
||||||
return await super.screenshot({ path: computedPath, ...restOptions });
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickAddConnectionButton() {
|
async clickAddConnectionButton() {
|
||||||
await this.page.getByTestId('add-connection-button').click();
|
await this.page.getByTestId('add-connection-button').click();
|
||||||
|
@@ -1,12 +1,6 @@
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { BasePage } = require('./base-page');
|
const { AuthenticatedPage } = require('./authenticated-page');
|
||||||
|
|
||||||
export class ExecutionsPage extends BasePage {
|
export class ExecutionsPage extends AuthenticatedPage {
|
||||||
async screenshot(options = {}) {
|
screenshotPath = '/executions';
|
||||||
const { path: plainPath, ...restOptions } = options;
|
|
||||||
|
|
||||||
const computedPath = path.join('executions', plainPath);
|
|
||||||
|
|
||||||
return await super.screenshot({ path: computedPath, ...restOptions });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,15 @@
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { BasePage } = require('./base-page');
|
const { AuthenticatedPage } = require('./authenticated-page');
|
||||||
|
|
||||||
export class FlowEditorPage extends BasePage {
|
export class FlowEditorPage extends AuthenticatedPage {
|
||||||
|
screenshotPath = '/flow-editor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
*/
|
||||||
constructor(page) {
|
constructor(page) {
|
||||||
super(page);
|
super(page);
|
||||||
|
|
||||||
this.appAutocomplete = this.page.getByTestId('choose-app-autocomplete');
|
this.appAutocomplete = this.page.getByTestId('choose-app-autocomplete');
|
||||||
this.eventAutocomplete = this.page.getByTestId('choose-event-autocomplete');
|
this.eventAutocomplete = this.page.getByTestId('choose-event-autocomplete');
|
||||||
this.continueButton = this.page.getByTestId('flow-substep-continue-button');
|
this.continueButton = this.page.getByTestId('flow-substep-continue-button');
|
||||||
@@ -15,13 +21,6 @@ export class FlowEditorPage extends BasePage {
|
|||||||
this.publishFlowButton = this.page.getByTestId('publish-flow-button');
|
this.publishFlowButton = this.page.getByTestId('publish-flow-button');
|
||||||
this.infoSnackbar = this.page.getByTestId('flow-cannot-edit-info-snackbar');
|
this.infoSnackbar = this.page.getByTestId('flow-cannot-edit-info-snackbar');
|
||||||
this.trigger = this.page.getByLabel('Trigger on weekends?');
|
this.trigger = this.page.getByLabel('Trigger on weekends?');
|
||||||
}
|
this.stepCircularLoader = this.page.getByTestId('step-circular-loader');
|
||||||
|
|
||||||
async screenshot(options = {}) {
|
|
||||||
const { path: plainPath, ...restOptions } = options;
|
|
||||||
|
|
||||||
const computedPath = path.join('flow-editor', plainPath);
|
|
||||||
|
|
||||||
return await super.screenshot({ path: computedPath, ...restOptions });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,21 @@
|
|||||||
const base = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { ApplicationsPage } = require('./applications-page');
|
const { ApplicationsPage } = require('./applications-page');
|
||||||
const { ConnectionsPage } = require('./connections-page');
|
const { ConnectionsPage } = require('./connections-page');
|
||||||
const { ExecutionsPage } = require('./executions-page');
|
const { ExecutionsPage } = require('./executions-page');
|
||||||
const { FlowEditorPage } = require('./flow-editor-page');
|
const { FlowEditorPage } = require('./flow-editor-page');
|
||||||
|
const { UserInterfacePage } = require('./user-interface-page');
|
||||||
|
const { LoginPage } = require('./login-page');
|
||||||
|
|
||||||
exports.test = base.test.extend({
|
exports.test = test.extend({
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
await loginPage.login();
|
||||||
|
|
||||||
|
await expect(loginPage.loginButton).not.toBeVisible();
|
||||||
|
await expect(page).toHaveURL('/flows');
|
||||||
|
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
applicationsPage: async ({ page }, use) => {
|
applicationsPage: async ({ page }, use) => {
|
||||||
await use(new ApplicationsPage(page));
|
await use(new ApplicationsPage(page));
|
||||||
},
|
},
|
||||||
@@ -17,5 +28,30 @@ exports.test = base.test.extend({
|
|||||||
flowEditorPage: async ({ page }, use) => {
|
flowEditorPage: async ({ page }, use) => {
|
||||||
await use(new FlowEditorPage(page));
|
await use(new FlowEditorPage(page));
|
||||||
},
|
},
|
||||||
|
userInterfacePage: async ({ page }, use) => {
|
||||||
|
await use(new UserInterfacePage(page));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
exports.expect = base.expect;
|
|
||||||
|
exports.publicTest = test.extend({
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
loginPage: async ({ page }, use) => {
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
|
||||||
|
await loginPage.open();
|
||||||
|
|
||||||
|
await use(loginPage);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect.extend({
|
||||||
|
toBeClickableLink: async (locator) => {
|
||||||
|
await expect(locator).not.toHaveAttribute('aria-disabled', 'true');
|
||||||
|
|
||||||
|
return { pass: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.expect = expect;
|
||||||
|
34
packages/e2e-tests/fixtures/login-page.js
Normal file
34
packages/e2e-tests/fixtures/login-page.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const path = require('node:path');
|
||||||
|
const { expect } = require('@playwright/test');
|
||||||
|
const { BasePage } = require('./base-page');
|
||||||
|
|
||||||
|
export class LoginPage extends BasePage {
|
||||||
|
path = '/login';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
*/
|
||||||
|
constructor(page) {
|
||||||
|
super(page);
|
||||||
|
|
||||||
|
this.page = page;
|
||||||
|
this.emailTextField = this.page.getByTestId('email-text-field');
|
||||||
|
this.passwordTextField = this.page.getByTestId('password-text-field');
|
||||||
|
this.loginButton = this.page.getByTestId('login-button');
|
||||||
|
}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
return await this.page.goto(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(
|
||||||
|
email = process.env.LOGIN_EMAIL,
|
||||||
|
password = process.env.LOGIN_PASSWORD
|
||||||
|
) {
|
||||||
|
await this.page.goto(this.path);
|
||||||
|
await this.emailTextField.fill(email);
|
||||||
|
await this.passwordTextField.fill(password);
|
||||||
|
|
||||||
|
await this.loginButton.click();
|
||||||
|
}
|
||||||
|
}
|
53
packages/e2e-tests/fixtures/user-interface-page.js
Normal file
53
packages/e2e-tests/fixtures/user-interface-page.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const path = require('node:path');
|
||||||
|
const { AuthenticatedPage } = require('./authenticated-page');
|
||||||
|
|
||||||
|
export class UserInterfacePage extends AuthenticatedPage {
|
||||||
|
screenshotPath = '/user-interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
*/
|
||||||
|
constructor(page) {
|
||||||
|
super(page);
|
||||||
|
|
||||||
|
this.flowRowCardActionArea = this.page
|
||||||
|
.getByTestId('flow-row')
|
||||||
|
.first()
|
||||||
|
.getByTestId('card-action-area');
|
||||||
|
this.updateButton = this.page.getByTestId('update-button');
|
||||||
|
this.primaryMainColorInput = this.page
|
||||||
|
.getByTestId('primary-main-color-input')
|
||||||
|
.getByTestId('color-text-field');
|
||||||
|
this.primaryDarkColorInput = this.page
|
||||||
|
.getByTestId('primary-dark-color-input')
|
||||||
|
.getByTestId('color-text-field');
|
||||||
|
this.primaryLightColorInput = this.page
|
||||||
|
.getByTestId('primary-light-color-input')
|
||||||
|
.getByTestId('color-text-field');
|
||||||
|
this.logoSvgCodeInput = this.page.getByTestId('logo-svg-data-text-field');
|
||||||
|
this.primaryMainColorButton = this.page
|
||||||
|
.getByTestId('primary-main-color-input')
|
||||||
|
.getByTestId('color-button');
|
||||||
|
this.primaryDarkColorButton = this.page
|
||||||
|
.getByTestId('primary-dark-color-input')
|
||||||
|
.getByTestId('color-button');
|
||||||
|
this.primaryLightColorButton = this.page
|
||||||
|
.getByTestId('primary-light-color-input')
|
||||||
|
.getByTestId('color-button');
|
||||||
|
}
|
||||||
|
|
||||||
|
hexToRgb(hexColor) {
|
||||||
|
hexColor = hexColor.replace('#', '');
|
||||||
|
const r = parseInt(hexColor.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hexColor.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hexColor.substring(4, 6), 16);
|
||||||
|
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
encodeSVG(svgCode) {
|
||||||
|
const encoded = encodeURIComponent(svgCode);
|
||||||
|
|
||||||
|
return `data:image/svg+xml;utf8,${encoded}`;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@automatisch/e2e-tests",
|
"name": "@automatisch/e2e-tests",
|
||||||
"version": "0.8.0",
|
"version": "0.9.3",
|
||||||
"license": "See LICENSE file",
|
"license": "See LICENSE file",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"open": "cypress open",
|
"test": "playwright test",
|
||||||
"playwright": "playwright test"
|
"test:fast": "yarn test -j 90% --quiet --reporter null --ignore-snapshots -x"
|
||||||
},
|
},
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
@@ -23,8 +23,7 @@
|
|||||||
"url": "https://github.com/automatisch/automatisch/issues"
|
"url": "https://github.com/automatisch/automatisch/issues"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.36.2",
|
"@playwright/test": "^1.36.2"
|
||||||
"cypress": "^10.9.0"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.3.1"
|
"dotenv": "^16.3.1"
|
||||||
|
@@ -16,18 +16,18 @@ module.exports = defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
retries: 0,
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Timeout threshold for each test */
|
||||||
|
timeout: 30000,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: process.env.CI ? 'github' : 'html',
|
reporter: process.env.CI ? 'github' : 'html',
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
baseURL: process.env.CI
|
baseURL: process.env.BASE_URL
|
||||||
? 'https://sandbox.automatisch.io'
|
|| 'http://localhost:3001',
|
||||||
: 'http://localhost:3001',
|
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
@@ -35,6 +35,11 @@ module.exports = defineConfig({
|
|||||||
viewport: { width: 1280, height: 720 },
|
viewport: { width: 1280, height: 720 },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
expect: {
|
||||||
|
/* Timeout threshold for each assertion */
|
||||||
|
timeout: 10000,
|
||||||
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
@@ -2,16 +2,16 @@
|
|||||||
const { test, expect } = require('../../fixtures/index');
|
const { test, expect } = require('../../fixtures/index');
|
||||||
|
|
||||||
test.describe('Apps page', () => {
|
test.describe('Apps page', () => {
|
||||||
test.beforeEach(async ({ page, applicationsPage }) => {
|
test.beforeEach(async ({ applicationsPage }) => {
|
||||||
await applicationsPage.login();
|
await applicationsPage.drawerLink.click();
|
||||||
await page.getByTestId('apps-page-drawer-link').click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('displays applications', async ({ page, applicationsPage }) => {
|
// no connected application exists in an empty account
|
||||||
await page.getByTestId('apps-loader').waitFor({
|
test.skip('displays no applications', async ({ applicationsPage }) => {
|
||||||
|
await applicationsPage.page.getByTestId('apps-loader').waitFor({
|
||||||
state: 'detached',
|
state: 'detached',
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId('app-row')).not.toHaveCount(0);
|
await expect(applicationsPage.page.getByTestId('app-row')).not.toHaveCount(0);
|
||||||
|
|
||||||
await applicationsPage.screenshot({
|
await applicationsPage.screenshot({
|
||||||
path: 'Applications.png',
|
path: 'Applications.png',
|
||||||
@@ -19,49 +19,56 @@ test.describe('Apps page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('can add connection', () => {
|
test.describe('can add connection', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ applicationsPage }) => {
|
||||||
await expect(page.getByTestId('add-connection-button')).toBeVisible();
|
await expect(applicationsPage.addConnectionButton).toBeClickableLink();
|
||||||
await page.getByTestId('add-connection-button').click();
|
await applicationsPage.addConnectionButton.click();
|
||||||
await page
|
await applicationsPage
|
||||||
|
.page
|
||||||
.getByTestId('search-for-app-loader')
|
.getByTestId('search-for-app-loader')
|
||||||
.waitFor({ state: 'detached' });
|
.waitFor({ state: 'detached' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lists applications', async ({ page, applicationsPage }) => {
|
test('lists applications', async ({ applicationsPage }) => {
|
||||||
const appListItemCount = await page.getByTestId('app-list-item').count();
|
const appListItemCount = await applicationsPage.page.getByTestId('app-list-item').count();
|
||||||
expect(appListItemCount).toBeGreaterThan(10);
|
expect(appListItemCount).toBeGreaterThan(10);
|
||||||
|
|
||||||
await applicationsPage.clickAway();
|
await applicationsPage.clickAway();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('searches an application', async ({ page, applicationsPage }) => {
|
test('searches an application', async ({ applicationsPage }) => {
|
||||||
await page.getByTestId('search-for-app-text-field').fill('DeepL');
|
await applicationsPage.page.getByTestId('search-for-app-text-field').fill('DeepL');
|
||||||
await expect(page.getByTestId('app-list-item')).toHaveCount(1);
|
await applicationsPage
|
||||||
|
.page
|
||||||
|
.getByTestId('search-for-app-loader')
|
||||||
|
.waitFor({ state: 'detached' });
|
||||||
|
|
||||||
|
await expect(applicationsPage.page.getByTestId('app-list-item')).toHaveCount(1);
|
||||||
|
|
||||||
await applicationsPage.clickAway();
|
await applicationsPage.clickAway();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('goes to app page to create a connection', async ({
|
test('goes to app page to create a connection', async ({
|
||||||
page,
|
|
||||||
applicationsPage,
|
applicationsPage,
|
||||||
}) => {
|
}) => {
|
||||||
await page.getByTestId('app-list-item').first().click();
|
// loading app, app config, app auth clients take time
|
||||||
await expect(page).toHaveURL('/app/deepl/connections/add');
|
test.setTimeout(60000);
|
||||||
await expect(page.getByTestId('add-app-connection-dialog')).toBeVisible();
|
|
||||||
|
await applicationsPage.page.getByTestId('app-list-item').first().click();
|
||||||
|
await expect(applicationsPage.page).toHaveURL('/app/deepl/connections/add?shared=false');
|
||||||
|
await expect(applicationsPage.page.getByTestId('add-app-connection-dialog')).toBeVisible();
|
||||||
|
|
||||||
await applicationsPage.clickAway();
|
await applicationsPage.clickAway();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closes the dialog on backdrop click', async ({
|
test('closes the dialog on backdrop click', async ({
|
||||||
page,
|
|
||||||
applicationsPage,
|
applicationsPage,
|
||||||
}) => {
|
}) => {
|
||||||
await page.getByTestId('app-list-item').first().click();
|
await applicationsPage.page.getByTestId('app-list-item').first().click();
|
||||||
await expect(page).toHaveURL('/app/deepl/connections/add');
|
await expect(applicationsPage.page).toHaveURL('/app/deepl/connections/add?shared=false');
|
||||||
await expect(page.getByTestId('add-app-connection-dialog')).toBeVisible();
|
await expect(applicationsPage.page.getByTestId('add-app-connection-dialog')).toBeVisible();
|
||||||
await applicationsPage.clickAway();
|
await applicationsPage.clickAway();
|
||||||
await expect(page).toHaveURL('/app/deepl/connections');
|
await expect(applicationsPage.page).toHaveURL('/app/deepl/connections');
|
||||||
await expect(page.getByTestId('add-app-connection-dialog')).toBeHidden();
|
await expect(applicationsPage.page.getByTestId('add-app-connection-dialog')).toBeHidden();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
22
packages/e2e-tests/tests/authentication/login.spec.js
Normal file
22
packages/e2e-tests/tests/authentication/login.spec.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// @ts-check
|
||||||
|
const { publicTest, test, expect } = require('../../fixtures/index');
|
||||||
|
|
||||||
|
publicTest.describe('Login page', () => {
|
||||||
|
publicTest('shows login form', async ({ loginPage }) => {
|
||||||
|
await loginPage.emailTextField.waitFor({ state: 'attached' });
|
||||||
|
await loginPage.passwordTextField.waitFor({ state: 'attached' });
|
||||||
|
await loginPage.loginButton.waitFor({ state: 'attached' });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicTest('lets user login', async ({ loginPage }) => {
|
||||||
|
await loginPage.login();
|
||||||
|
|
||||||
|
await expect(loginPage.page).toHaveURL('/flows');
|
||||||
|
});
|
||||||
|
|
||||||
|
publicTest(`doesn't let un-existing user login`, async ({ loginPage }) => {
|
||||||
|
await loginPage.login('nonexisting@automatisch.io', 'sample');
|
||||||
|
|
||||||
|
await expect(loginPage.page).toHaveURL('/login');
|
||||||
|
});
|
||||||
|
});
|
@@ -3,7 +3,6 @@ const { test, expect } = require('../../fixtures/index');
|
|||||||
|
|
||||||
test.describe('Connections page', () => {
|
test.describe('Connections page', () => {
|
||||||
test.beforeEach(async ({ page, connectionsPage }) => {
|
test.beforeEach(async ({ page, connectionsPage }) => {
|
||||||
await connectionsPage.login();
|
|
||||||
await page.getByTestId('apps-page-drawer-link').click();
|
await page.getByTestId('apps-page-drawer-link').click();
|
||||||
await page.goto('/app/ntfy/connections');
|
await page.goto('/app/ntfy/connections');
|
||||||
});
|
});
|
||||||
@@ -20,7 +19,7 @@ test.describe('Connections page', () => {
|
|||||||
|
|
||||||
test.describe('can add connection', () => {
|
test.describe('can add connection', () => {
|
||||||
test('has a button to open add connection dialog', async ({ page }) => {
|
test('has a button to open add connection dialog', async ({ page }) => {
|
||||||
await expect(page.getByTestId('add-connection-button')).toBeVisible();
|
await expect(page.getByTestId('add-connection-button')).toBeClickableLink();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add connection button takes user to add connection page', async ({
|
test('add connection button takes user to add connection page', async ({
|
||||||
@@ -28,7 +27,7 @@ test.describe('Connections page', () => {
|
|||||||
connectionsPage,
|
connectionsPage,
|
||||||
}) => {
|
}) => {
|
||||||
await connectionsPage.clickAddConnectionButton();
|
await connectionsPage.clickAddConnectionButton();
|
||||||
await expect(page).toHaveURL('/app/ntfy/connections/add');
|
await expect(page).toHaveURL('/app/ntfy/connections/add?shared=false');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows add connection dialog to create a new connection', async ({
|
test('shows add connection dialog to create a new connection', async ({
|
||||||
@@ -36,7 +35,7 @@ test.describe('Connections page', () => {
|
|||||||
connectionsPage,
|
connectionsPage,
|
||||||
}) => {
|
}) => {
|
||||||
await connectionsPage.clickAddConnectionButton();
|
await connectionsPage.clickAddConnectionButton();
|
||||||
await expect(page).toHaveURL('/app/ntfy/connections/add');
|
await expect(page).toHaveURL('/app/ntfy/connections/add?shared=false');
|
||||||
await page.getByTestId('create-connection-button').click();
|
await page.getByTestId('create-connection-button').click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId('create-connection-button')
|
page.getByTestId('create-connection-button')
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const { test, expect } = require('../../fixtures/index');
|
const { test, expect } = require('../../fixtures/index');
|
||||||
|
|
||||||
test.describe('Executions page', () => {
|
// no execution data exists in an empty account
|
||||||
|
test.describe.skip('Executions page', () => {
|
||||||
test.beforeEach(async ({ page, executionsPage }) => {
|
test.beforeEach(async ({ page, executionsPage }) => {
|
||||||
await executionsPage.login();
|
|
||||||
|
|
||||||
await page.getByTestId('executions-page-drawer-link').click();
|
await page.getByTestId('executions-page-drawer-link').click();
|
||||||
await page.getByTestId('execution-row').first().click();
|
await page.getByTestId('execution-row').first().click();
|
||||||
|
|
||||||
|
@@ -3,12 +3,11 @@ const { test, expect } = require('../../fixtures/index');
|
|||||||
|
|
||||||
test.describe('Executions page', () => {
|
test.describe('Executions page', () => {
|
||||||
test.beforeEach(async ({ page, executionsPage }) => {
|
test.beforeEach(async ({ page, executionsPage }) => {
|
||||||
await executionsPage.login();
|
|
||||||
|
|
||||||
await page.getByTestId('executions-page-drawer-link').click();
|
await page.getByTestId('executions-page-drawer-link').click();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('displays executions', async ({ page, executionsPage }) => {
|
// no executions exist in an empty account
|
||||||
|
test.skip('displays executions', async ({ page, executionsPage }) => {
|
||||||
await page.getByTestId('executions-loader').waitFor({
|
await page.getByTestId('executions-loader').waitFor({
|
||||||
state: 'detached',
|
state: 'detached',
|
||||||
});
|
});
|
||||||
|
@@ -1,205 +1,206 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const { FlowEditorPage } = require('../../fixtures/flow-editor-page');
|
|
||||||
const { test, expect } = require('../../fixtures/index');
|
const { test, expect } = require('../../fixtures/index');
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' });
|
test('Ensure creating a new flow works', async ({ page }) => {
|
||||||
|
await page.getByTestId('create-flow-button').click();
|
||||||
let page;
|
await expect(page).toHaveURL(/\/editor\/create/);
|
||||||
let flowEditorPage;
|
await expect(page).toHaveURL(
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
flowEditorPage = new FlowEditorPage(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('create flow', async ({}) => {
|
|
||||||
await flowEditorPage.login();
|
|
||||||
|
|
||||||
await flowEditorPage.page.getByTestId('create-flow-button').click();
|
|
||||||
await expect(flowEditorPage.page).toHaveURL(/\/editor\/create/);
|
|
||||||
await expect(flowEditorPage.page).toHaveURL(
|
|
||||||
/\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/
|
/\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/
|
||||||
);
|
);
|
||||||
});
|
})
|
||||||
|
|
||||||
test('has two steps by default', async ({}) => {
|
test(
|
||||||
await expect(flowEditorPage.page.getByTestId('flow-step')).toHaveCount(2);
|
'Create a new flow with a Scheduler step then an Ntfy step',
|
||||||
});
|
async ({ flowEditorPage, page }) => {
|
||||||
|
await test.step('create flow', async () => {
|
||||||
test.describe('arrange Scheduler trigger', () => {
|
await test.step('navigate to new flow page', async () => {
|
||||||
test.describe('choose app and event substep', () => {
|
await page.getByTestId('create-flow-button').click();
|
||||||
test('choose application', async ({}) => {
|
await page.waitForURL(
|
||||||
await flowEditorPage.appAutocomplete.click();
|
/\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/
|
||||||
await flowEditorPage.page
|
);
|
||||||
.getByRole('option', { name: 'Scheduler' })
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('choose an event', async ({}) => {
|
|
||||||
await expect(flowEditorPage.eventAutocomplete).toBeVisible();
|
|
||||||
await flowEditorPage.eventAutocomplete.click();
|
|
||||||
await flowEditorPage.page
|
|
||||||
.getByRole('option', { name: 'Every hour' })
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('continue to next step', async ({}) => {
|
|
||||||
await flowEditorPage.continueButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('collapses the substep', async ({}) => {
|
|
||||||
await expect(flowEditorPage.appAutocomplete).not.toBeVisible();
|
|
||||||
await expect(flowEditorPage.eventAutocomplete).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('set up a trigger', () => {
|
|
||||||
test('choose "yes" in "trigger on weekends?"', async ({}) => {
|
|
||||||
await expect(flowEditorPage.trigger).toBeVisible();
|
|
||||||
await flowEditorPage.trigger.click();
|
|
||||||
await flowEditorPage.page.getByRole('option', { name: 'Yes' }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('continue to next step', async ({}) => {
|
|
||||||
await flowEditorPage.continueButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('collapses the substep', async ({}) => {
|
|
||||||
await expect(flowEditorPage.trigger).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('test trigger', () => {
|
|
||||||
test('show sample output', async ({}) => {
|
|
||||||
await expect(flowEditorPage.testOuput).not.toBeVisible();
|
|
||||||
await flowEditorPage.continueButton.click();
|
|
||||||
await expect(flowEditorPage.testOuput).toBeVisible();
|
|
||||||
await flowEditorPage.screenshot({
|
|
||||||
path: 'Scheduler trigger test output.png',
|
|
||||||
});
|
});
|
||||||
await flowEditorPage.continueButton.click();
|
|
||||||
});
|
await test.step('has two steps by default', async () => {
|
||||||
});
|
await expect(page.getByTestId('flow-step')).toHaveCount(2);
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('arrange Ntfy action', () => {
|
|
||||||
test.describe('choose app and event substep', () => {
|
|
||||||
test('choose application', async ({}) => {
|
|
||||||
await flowEditorPage.appAutocomplete.click();
|
|
||||||
await flowEditorPage.page.getByRole('option', { name: 'Ntfy' }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('choose an event', async ({}) => {
|
|
||||||
await expect(flowEditorPage.eventAutocomplete).toBeVisible();
|
|
||||||
await flowEditorPage.eventAutocomplete.click();
|
|
||||||
await flowEditorPage.page
|
|
||||||
.getByRole('option', { name: 'Send message' })
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('continue to next step', async ({}) => {
|
|
||||||
await flowEditorPage.continueButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('collapses the substep', async ({}) => {
|
|
||||||
await expect(flowEditorPage.appAutocomplete).not.toBeVisible();
|
|
||||||
await expect(flowEditorPage.eventAutocomplete).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('choose connection', () => {
|
|
||||||
test('choose connection list item', async ({}) => {
|
|
||||||
await flowEditorPage.connectionAutocomplete.click();
|
|
||||||
await flowEditorPage.page.getByRole('listitem').first().click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('continue to next step', async ({}) => {
|
|
||||||
await flowEditorPage.continueButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('collapses the substep', async ({}) => {
|
|
||||||
await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('set up action', () => {
|
|
||||||
test('fill topic and message body', async ({}) => {
|
|
||||||
await flowEditorPage.page
|
|
||||||
.getByTestId('parameters.topic-power-input')
|
|
||||||
.locator('[contenteditable]')
|
|
||||||
.fill('Topic');
|
|
||||||
await flowEditorPage.page
|
|
||||||
.getByTestId('parameters.message-power-input')
|
|
||||||
.locator('[contenteditable]')
|
|
||||||
.fill('Message body');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('continue to next step', async ({}) => {
|
|
||||||
await flowEditorPage.continueButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('collapses the substep', async ({}) => {
|
|
||||||
await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('test trigger', () => {
|
|
||||||
test('show sample output', async ({}) => {
|
|
||||||
await expect(flowEditorPage.testOuput).not.toBeVisible();
|
|
||||||
await flowEditorPage.page
|
|
||||||
.getByTestId('flow-substep-continue-button')
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await expect(flowEditorPage.testOuput).toBeVisible();
|
|
||||||
await flowEditorPage.screenshot({
|
|
||||||
path: 'Ntfy action test output.png',
|
|
||||||
});
|
});
|
||||||
await flowEditorPage.continueButton.click();
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('publish and unpublish', () => {
|
await test.step('setup Scheduler trigger', async () => {
|
||||||
test('publish flow', async ({}) => {
|
await test.step('choose app and event substep', async () => {
|
||||||
await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible();
|
await test.step('choose application', async () => {
|
||||||
await expect(flowEditorPage.publishFlowButton).toBeVisible();
|
await flowEditorPage.appAutocomplete.click();
|
||||||
await flowEditorPage.publishFlowButton.click();
|
await page
|
||||||
await expect(flowEditorPage.publishFlowButton).not.toBeVisible();
|
.getByRole('option', { name: 'Scheduler' })
|
||||||
});
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
test('shows read-only sticky snackbar', async ({}) => {
|
await test.step('choose and event', async () => {
|
||||||
await expect(flowEditorPage.infoSnackbar).toBeVisible();
|
await expect(flowEditorPage.eventAutocomplete).toBeVisible();
|
||||||
await flowEditorPage.screenshot({
|
await flowEditorPage.eventAutocomplete.click();
|
||||||
path: 'Published flow.png',
|
await page
|
||||||
|
.getByRole('option', { name: 'Every hour' })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('continue to next step', async () => {
|
||||||
|
await flowEditorPage.continueButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('collapses the substep', async () => {
|
||||||
|
await expect(flowEditorPage.appAutocomplete).not.toBeVisible();
|
||||||
|
await expect(flowEditorPage.eventAutocomplete).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('set up a trigger', async () => {
|
||||||
|
await test.step('choose "yes" in "trigger on weekends?"', async () => {
|
||||||
|
await expect(flowEditorPage.trigger).toBeVisible();
|
||||||
|
await flowEditorPage.trigger.click();
|
||||||
|
await page.getByRole('option', { name: 'Yes' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('continue to next step', async () => {
|
||||||
|
await flowEditorPage.continueButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('collapses the substep', async () => {
|
||||||
|
await expect(flowEditorPage.trigger).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('test trigger', async () => {
|
||||||
|
await test.step('show sample output', async () => {
|
||||||
|
await expect(flowEditorPage.testOuput).not.toBeVisible();
|
||||||
|
await flowEditorPage.continueButton.click();
|
||||||
|
await expect(flowEditorPage.testOuput).toBeVisible();
|
||||||
|
await flowEditorPage.screenshot({
|
||||||
|
path: 'Scheduler trigger test output.png',
|
||||||
|
});
|
||||||
|
await flowEditorPage.continueButton.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test('unpublish from snackbar', async ({}) => {
|
await test.step('arrange Ntfy action', async () => {
|
||||||
await flowEditorPage.page
|
await test.step('choose app and event substep', async () => {
|
||||||
.getByTestId('unpublish-flow-from-snackbar')
|
await test.step('choose application', async () => {
|
||||||
.click();
|
await flowEditorPage.appAutocomplete.click();
|
||||||
await expect(flowEditorPage.infoSnackbar).not.toBeVisible();
|
await page.getByRole('option', { name: 'Ntfy' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('publish once again', async ({}) => {
|
await test.step('choose an event', async () => {
|
||||||
await expect(flowEditorPage.publishFlowButton).toBeVisible();
|
await expect(flowEditorPage.eventAutocomplete).toBeVisible();
|
||||||
await flowEditorPage.publishFlowButton.click();
|
await flowEditorPage.eventAutocomplete.click();
|
||||||
await expect(flowEditorPage.publishFlowButton).not.toBeVisible();
|
await page
|
||||||
});
|
.getByRole('option', { name: 'Send message' })
|
||||||
|
.click();
|
||||||
test('unpublish from layout top bar', async ({}) => {
|
});
|
||||||
await expect(flowEditorPage.unpublishFlowButton).toBeVisible();
|
|
||||||
await flowEditorPage.unpublishFlowButton.click();
|
await test.step('continue to next step', async () => {
|
||||||
await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible();
|
await flowEditorPage.continueButton.click();
|
||||||
await flowEditorPage.screenshot({
|
});
|
||||||
path: 'Unpublished flow.png',
|
|
||||||
|
await test.step('collapses the substep', async () => {
|
||||||
|
await expect(flowEditorPage.appAutocomplete).not.toBeVisible();
|
||||||
|
await expect(flowEditorPage.eventAutocomplete).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('choose connection substep', async () => {
|
||||||
|
await test.step('choose connection list item', async () => {
|
||||||
|
await flowEditorPage.connectionAutocomplete.click();
|
||||||
|
await page.getByRole('option').first().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('continue to next step', async () => {
|
||||||
|
await flowEditorPage.continueButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('collapses the substep', async () => {
|
||||||
|
await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('set up action substep', async () => {
|
||||||
|
await test.step('fill topic and message body', async () => {
|
||||||
|
await page
|
||||||
|
.getByTestId('parameters.topic-power-input')
|
||||||
|
.locator('[contenteditable]')
|
||||||
|
.fill('Topic');
|
||||||
|
await page
|
||||||
|
.getByTestId('parameters.message-power-input')
|
||||||
|
.locator('[contenteditable]')
|
||||||
|
.fill('Message body');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('continue to next step', async () => {
|
||||||
|
await flowEditorPage.continueButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('collapses the substep', async () => {
|
||||||
|
await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('test trigger substep', async () => {
|
||||||
|
await test.step('show sample output', async () => {
|
||||||
|
await expect(flowEditorPage.testOuput).not.toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByTestId('flow-substep-continue-button')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(flowEditorPage.testOuput).toBeVisible();
|
||||||
|
await flowEditorPage.screenshot({
|
||||||
|
path: 'Ntfy action test output.png',
|
||||||
|
});
|
||||||
|
await flowEditorPage.continueButton.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('in layout', () => {
|
await test.step('publish and unpublish', async () => {
|
||||||
test('can go back to flows page', async ({}) => {
|
await test.step('publish flow', async () => {
|
||||||
await flowEditorPage.page.getByTestId('editor-go-back-button').click();
|
await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible();
|
||||||
await expect(flowEditorPage.page).toHaveURL('/flows');
|
await expect(flowEditorPage.publishFlowButton).toBeVisible();
|
||||||
});
|
await flowEditorPage.publishFlowButton.click();
|
||||||
});
|
await expect(flowEditorPage.publishFlowButton).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('shows read-only sticky snackbar', async () => {
|
||||||
|
await expect(flowEditorPage.infoSnackbar).toBeVisible();
|
||||||
|
await flowEditorPage.screenshot({
|
||||||
|
path: 'Published flow.png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('unpublish from snackbar', async () => {
|
||||||
|
await page
|
||||||
|
.getByTestId('unpublish-flow-from-snackbar')
|
||||||
|
.click();
|
||||||
|
await expect(flowEditorPage.infoSnackbar).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('publish once again', async () => {
|
||||||
|
await expect(flowEditorPage.publishFlowButton).toBeVisible();
|
||||||
|
await flowEditorPage.publishFlowButton.click();
|
||||||
|
await expect(flowEditorPage.publishFlowButton).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('unpublish from layout top bar', async () => {
|
||||||
|
await expect(flowEditorPage.unpublishFlowButton).toBeVisible();
|
||||||
|
await flowEditorPage.unpublishFlowButton.click();
|
||||||
|
await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible();
|
||||||
|
await flowEditorPage.screenshot({
|
||||||
|
path: 'Unpublished flow.png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('in layout', async () => {
|
||||||
|
await test.step('can go back to flows page', async () => {
|
||||||
|
await page.getByTestId('editor-go-back-button').click();
|
||||||
|
await expect(page).toHaveURL('/flows');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@@ -0,0 +1,176 @@
|
|||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('../../fixtures/index');
|
||||||
|
|
||||||
|
test.describe.skip('User interface page', () => {
|
||||||
|
test.beforeEach(async ({ userInterfacePage }) => {
|
||||||
|
await userInterfacePage.profileMenuButton.click();
|
||||||
|
await userInterfacePage.adminMenuItem.click();
|
||||||
|
await expect(userInterfacePage.page).toHaveURL(/\/admin-settings\/users/);
|
||||||
|
await userInterfacePage.userInterfaceDrawerItem.click();
|
||||||
|
await expect(userInterfacePage.page).toHaveURL(
|
||||||
|
/\/admin-settings\/user-interface/
|
||||||
|
);
|
||||||
|
await userInterfacePage.page.waitForURL(/\/admin-settings\/user-interface/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('checks if the shown values are used', async () => {
|
||||||
|
test('checks primary main color', async ({ userInterfacePage }) => {
|
||||||
|
await userInterfacePage.primaryMainColorInput.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const initialPrimaryMainColor =
|
||||||
|
await userInterfacePage.primaryMainColorInput.inputValue();
|
||||||
|
const initialRgbColor = userInterfacePage.hexToRgb(
|
||||||
|
initialPrimaryMainColor
|
||||||
|
);
|
||||||
|
await expect(userInterfacePage.updateButton).toHaveCSS(
|
||||||
|
'background-color',
|
||||||
|
initialRgbColor
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checks primary dark color', async ({ userInterfacePage }) => {
|
||||||
|
await userInterfacePage.primaryDarkColorInput.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
});
|
||||||
|
const initialPrimaryDarkColor =
|
||||||
|
await userInterfacePage.primaryDarkColorInput.inputValue();
|
||||||
|
const initialRgbColor = userInterfacePage.hexToRgb(
|
||||||
|
initialPrimaryDarkColor
|
||||||
|
);
|
||||||
|
await expect(userInterfacePage.appBar).toHaveCSS(
|
||||||
|
'background-color',
|
||||||
|
initialRgbColor
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checks custom logo', async ({ userInterfacePage }) => {
|
||||||
|
const initialLogoSvgCode =
|
||||||
|
await userInterfacePage.logoSvgCodeInput.inputValue();
|
||||||
|
const logoSrcAttribute = await userInterfacePage.customLogo.getAttribute(
|
||||||
|
'src'
|
||||||
|
);
|
||||||
|
const svgCode = userInterfacePage.encodeSVG(initialLogoSvgCode);
|
||||||
|
expect(logoSrcAttribute).toMatch(svgCode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe(
|
||||||
|
'fill fields and check if the inputs reflect them properly',
|
||||||
|
async () => {
|
||||||
|
test('fill primary main color and check the color input', async ({
|
||||||
|
userInterfacePage,
|
||||||
|
}) => {
|
||||||
|
await userInterfacePage.primaryMainColorInput.fill('#FF5733');
|
||||||
|
const rgbColor = userInterfacePage.hexToRgb('#FF5733');
|
||||||
|
const button = await userInterfacePage.primaryMainColorButton;
|
||||||
|
const styleAttribute = await button.getAttribute('style');
|
||||||
|
expect(styleAttribute).toEqual(`background-color: ${rgbColor};`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fill primary dark color and check the color input', async ({
|
||||||
|
userInterfacePage,
|
||||||
|
}) => {
|
||||||
|
await userInterfacePage.primaryDarkColorInput.fill('#12F63F');
|
||||||
|
const rgbColor = userInterfacePage.hexToRgb('#12F63F');
|
||||||
|
const button = await userInterfacePage.primaryDarkColorButton;
|
||||||
|
const styleAttribute = await button.getAttribute('style');
|
||||||
|
expect(styleAttribute).toEqual(`background-color: ${rgbColor};`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fill primary light color and check the color input', async ({
|
||||||
|
userInterfacePage,
|
||||||
|
}) => {
|
||||||
|
await userInterfacePage.primaryLightColorInput.fill('#1D0BF5');
|
||||||
|
const rgbColor = userInterfacePage.hexToRgb('#1D0BF5');
|
||||||
|
const button = await userInterfacePage.primaryLightColorButton;
|
||||||
|
const styleAttribute = await button.getAttribute('style');
|
||||||
|
expect(styleAttribute).toEqual(`background-color: ${rgbColor};`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test.describe(
|
||||||
|
'update form based on input values and check if the inputs still reflect them',
|
||||||
|
async () => {
|
||||||
|
test('update primary main color and check color input', async ({
|
||||||
|
userInterfacePage,
|
||||||
|
}) => {
|
||||||
|
await userInterfacePage.primaryMainColorInput.fill('#00adef');
|
||||||
|
await userInterfacePage.updateButton.click();
|
||||||
|
const rgbColor = userInterfacePage.hexToRgb('#00adef');
|
||||||
|
const button = await userInterfacePage.primaryMainColorButton;
|
||||||
|
const styleAttribute = await button.getAttribute('style');
|
||||||
|
expect(styleAttribute).toBe(`background-color: ${rgbColor};`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update primary dark color and check color input', async ({
|
||||||
|
userInterfacePage,
|
||||||
|
}) => {
|
||||||
|
await userInterfacePage.primaryDarkColorInput.fill('#222222');
|
||||||
|
await userInterfacePage.updateButton.click();
|
||||||
|
const rgbColor = userInterfacePage.hexToRgb('#222222');
|
||||||
|
const button = await userInterfacePage.primaryDarkColorButton;
|
||||||
|
const styleAttribute = await button.getAttribute('style');
|
||||||
|
expect(styleAttribute).toBe(`background-color: ${rgbColor};`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update primary light color and check color input', async ({
|
||||||
|
userInterfacePage,
|
||||||
|
}) => {
|
||||||
|
await userInterfacePage.primaryLightColorInput.fill('#f90707');
|
||||||
|
await userInterfacePage.updateButton.click();
|
||||||
|
const rgbColor = userInterfacePage.hexToRgb('#f90707');
|
||||||
|
const button = await userInterfacePage.primaryLightColorButton;
|
||||||
|
const styleAttribute = await button.getAttribute('style');
|
||||||
|
expect(styleAttribute).toBe(`background-color: ${rgbColor};`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test.describe('update form based on input values', async () => {
|
||||||
|
test('fill primary main color', async ({ userInterfacePage }) => {
|
||||||
|
await userInterfacePage.primaryMainColorInput.fill('#00adef');
|
||||||
|
await userInterfacePage.updateButton.click();
|
||||||
|
await userInterfacePage.snackbar.waitFor({ state: 'visible' });
|
||||||
|
await userInterfacePage.screenshot({
|
||||||
|
path: 'updated primary main color.png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fill primary dark color', async ({ userInterfacePage }) => {
|
||||||
|
await userInterfacePage.primaryDarkColorInput.fill('#222222');
|
||||||
|
await userInterfacePage.updateButton.click();
|
||||||
|
await userInterfacePage.snackbar.waitFor({ state: 'visible' });
|
||||||
|
await userInterfacePage.screenshot({
|
||||||
|
path: 'updated primary dark color.png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fill primary light color', async ({ userInterfacePage }) => {
|
||||||
|
await userInterfacePage.primaryLightColorInput.fill('#f90707');
|
||||||
|
await userInterfacePage.updateButton.click();
|
||||||
|
await userInterfacePage.goToDashboardButton.click();
|
||||||
|
await expect(userInterfacePage.page).toHaveURL('/flows');
|
||||||
|
const span = await userInterfacePage.flowRowCardActionArea;
|
||||||
|
await span.waitFor({ state: 'visible' });
|
||||||
|
await span.hover();
|
||||||
|
await userInterfacePage.screenshot({
|
||||||
|
path: 'updated primary light color.png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fill logo svg code', async ({ userInterfacePage }) => {
|
||||||
|
await userInterfacePage.logoSvgCodeInput
|
||||||
|
.fill(`<svg width="25" height="25" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 100 100">
|
||||||
|
<rect width="100%" height="100%" fill="white" />
|
||||||
|
<text x="10" y="40" font-family="Arial" font-size="40" fill="black">A</text>
|
||||||
|
</svg>`);
|
||||||
|
await userInterfacePage.updateButton.click();
|
||||||
|
await userInterfacePage.snackbar.waitFor({ state: 'visible' });
|
||||||
|
await userInterfacePage.screenshot({
|
||||||
|
path: 'updated svg code.png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
19
packages/types/index.d.ts
vendored
19
packages/types/index.d.ts
vendored
@@ -119,8 +119,8 @@ export interface IPermission {
|
|||||||
|
|
||||||
export interface IPermissionCatalog {
|
export interface IPermissionCatalog {
|
||||||
actions: { label: string; key: string; subjects: string[] }[];
|
actions: { label: string; key: string; subjects: string[] }[];
|
||||||
subjects: { label: string; key: string; }[];
|
subjects: { label: string; key: string }[];
|
||||||
conditions: { label: string; key: string; }[];
|
conditions: { label: string; key: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFieldDropdown {
|
export interface IFieldDropdown {
|
||||||
@@ -418,7 +418,7 @@ type TSamlAuthProvider = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
certificate: string;
|
certificate: string;
|
||||||
signatureAlgorithm: "sha1" | "sha256" | "sha512";
|
signatureAlgorithm: 'sha1' | 'sha256' | 'sha512';
|
||||||
issuer: string;
|
issuer: string;
|
||||||
entryPoint: string;
|
entryPoint: string;
|
||||||
firstnameAttributeName: string;
|
firstnameAttributeName: string;
|
||||||
@@ -426,7 +426,9 @@ type TSamlAuthProvider = {
|
|||||||
emailAttributeName: string;
|
emailAttributeName: string;
|
||||||
roleAttributeName: string;
|
roleAttributeName: string;
|
||||||
defaultRoleId: string;
|
defaultRoleId: string;
|
||||||
}
|
active: boolean;
|
||||||
|
loginUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AppConfig = {
|
type AppConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -436,7 +438,7 @@ type AppConfig = {
|
|||||||
canCustomConnect: boolean;
|
canCustomConnect: boolean;
|
||||||
shared: boolean;
|
shared: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
type AppAuthClient = {
|
type AppAuthClient = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -444,6 +446,13 @@ type AppAuthClient = {
|
|||||||
appConfigId: string;
|
appConfigId: string;
|
||||||
authDefaults: string;
|
authDefaults: string;
|
||||||
formattedAuthDefaults: IJSONObject;
|
formattedAuthDefaults: IJSONObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Notification = {
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
documentationUrl: string;
|
||||||
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'axios' {
|
declare module 'axios' {
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automatisch/types",
|
"name": "@automatisch/types",
|
||||||
"version": "0.8.0",
|
"version": "0.9.3",
|
||||||
"license": "See LICENSE file",
|
"license": "See LICENSE file",
|
||||||
"description": "Type definitions for automatisch",
|
"description": "Type definitions for automatisch",
|
||||||
"homepage": "https://github.com/automatisch/automatisch",
|
"homepage": "https://github.com/automatisch/automatisch",
|
||||||
|
@@ -1,6 +1,4 @@
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
REACT_APP_API_URL=http://localhost:3000
|
|
||||||
REACT_APP_GRAPHQL_URL=http://localhost:3000/graphql
|
REACT_APP_GRAPHQL_URL=http://localhost:3000/graphql
|
||||||
# HTTPS=true
|
# HTTPS=true
|
||||||
REACT_APP_BASE_URL=http://localhost:3001
|
REACT_APP_BASE_URL=http://localhost:3001
|
||||||
REACT_APP_NOTIFICATIONS_URL=https://notifications.automatisch.io
|
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@automatisch/web",
|
"name": "@automatisch/web",
|
||||||
"version": "0.8.0",
|
"version": "0.9.3",
|
||||||
"license": "See LICENSE file",
|
"license": "See LICENSE file",
|
||||||
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.6.9",
|
"@apollo/client": "^3.6.9",
|
||||||
"@automatisch/types": "^0.8.0",
|
"@automatisch/types": "^0.9.3",
|
||||||
"@casl/ability": "^6.5.0",
|
"@casl/ability": "^6.5.0",
|
||||||
"@casl/react": "^3.1.0",
|
"@casl/react": "^3.1.0",
|
||||||
"@emotion/react": "^11.4.1",
|
"@emotion/react": "^11.4.1",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"graphql": "^15.6.0",
|
"graphql": "^15.6.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^2.3.1",
|
"luxon": "^2.3.1",
|
||||||
|
"mui-color-input": "^2.0.0",
|
||||||
"notistack": "^2.0.2",
|
"notistack": "^2.0.2",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
@@ -6,6 +6,8 @@ import CreateUser from 'pages/CreateUser';
|
|||||||
import Roles from 'pages/Roles/index.ee';
|
import Roles from 'pages/Roles/index.ee';
|
||||||
import CreateRole from 'pages/CreateRole/index.ee';
|
import CreateRole from 'pages/CreateRole/index.ee';
|
||||||
import EditRole from 'pages/EditRole/index.ee';
|
import EditRole from 'pages/EditRole/index.ee';
|
||||||
|
import Authentication from 'pages/Authentication';
|
||||||
|
import UserInterface from 'pages/UserInterface';
|
||||||
|
|
||||||
import * as URLS from 'config/urls';
|
import * as URLS from 'config/urls';
|
||||||
import Can from 'components/Can';
|
import Can from 'components/Can';
|
||||||
@@ -79,6 +81,32 @@ export default (
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path={URLS.USER_INTERFACE}
|
||||||
|
element={
|
||||||
|
<Can I="update" a="Config">
|
||||||
|
<AdminSettingsLayout>
|
||||||
|
<UserInterface />
|
||||||
|
</AdminSettingsLayout>
|
||||||
|
</Can>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path={URLS.AUTHENTICATION}
|
||||||
|
element={
|
||||||
|
<Can I="read" a="SamlAuthProvider">
|
||||||
|
<Can I="update" a="SamlAuthProvider">
|
||||||
|
<Can I="create" a="SamlAuthProvider">
|
||||||
|
<AdminSettingsLayout>
|
||||||
|
<Authentication />
|
||||||
|
</AdminSettingsLayout>
|
||||||
|
</Can>
|
||||||
|
</Can>
|
||||||
|
</Can>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={URLS.ADMIN_SETTINGS}
|
path={URLS.ADMIN_SETTINGS}
|
||||||
element={<Navigate to={URLS.USERS} replace />}
|
element={<Navigate to={URLS.USERS} replace />}
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||||
import GroupIcon from '@mui/icons-material/Group';
|
import GroupIcon from '@mui/icons-material/Group';
|
||||||
import GroupsIcon from '@mui/icons-material/Groups';
|
import GroupsIcon from '@mui/icons-material/Groups';
|
||||||
|
import LockIcon from '@mui/icons-material/LockPerson';
|
||||||
|
import BrushIcon from '@mui/icons-material/Brush';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Toolbar from '@mui/material/Toolbar';
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
@@ -18,25 +20,56 @@ type SettingsLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type DrawerLink = {
|
type DrawerLink = {
|
||||||
Icon: SvgIconComponent,
|
Icon: SvgIconComponent;
|
||||||
primary: string,
|
primary: string;
|
||||||
to: string,
|
to: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
function createDrawerLinks({ canReadRole, canReadUser }: { canReadRole: boolean; canReadUser: boolean; }) {
|
function createDrawerLinks({
|
||||||
|
canReadRole,
|
||||||
|
canReadUser,
|
||||||
|
canUpdateConfig,
|
||||||
|
canManageSamlAuthProvider,
|
||||||
|
}: {
|
||||||
|
canReadRole: boolean;
|
||||||
|
canReadUser: boolean;
|
||||||
|
canUpdateConfig: boolean;
|
||||||
|
canManageSamlAuthProvider: boolean;
|
||||||
|
}) {
|
||||||
const items = [
|
const items = [
|
||||||
canReadUser ? {
|
canReadUser
|
||||||
Icon: GroupIcon,
|
? {
|
||||||
primary: 'adminSettingsDrawer.users',
|
Icon: GroupIcon,
|
||||||
to: URLS.USERS,
|
primary: 'adminSettingsDrawer.users',
|
||||||
} : null,
|
to: URLS.USERS,
|
||||||
canReadRole ? {
|
dataTest: 'users-drawer-link',
|
||||||
Icon: GroupsIcon,
|
}
|
||||||
primary: 'adminSettingsDrawer.roles',
|
: null,
|
||||||
to: URLS.ROLES,
|
canReadRole
|
||||||
} : null
|
? {
|
||||||
]
|
Icon: GroupsIcon,
|
||||||
.filter(Boolean) as DrawerLink[];
|
primary: 'adminSettingsDrawer.roles',
|
||||||
|
to: URLS.ROLES,
|
||||||
|
dataTest: 'roles-drawer-link',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
canUpdateConfig
|
||||||
|
? {
|
||||||
|
Icon: BrushIcon,
|
||||||
|
primary: 'adminSettingsDrawer.userInterface',
|
||||||
|
to: URLS.USER_INTERFACE,
|
||||||
|
dataTest: 'user-interface-drawer-link',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
canManageSamlAuthProvider
|
||||||
|
? {
|
||||||
|
Icon: LockIcon,
|
||||||
|
primary: 'adminSettingsDrawer.authentication',
|
||||||
|
to: URLS.AUTHENTICATION,
|
||||||
|
dataTest: 'authentication-drawer-link',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
].filter(Boolean) as DrawerLink[];
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -46,6 +79,7 @@ const drawerBottomLinks = [
|
|||||||
Icon: ArrowBackIosNewIcon,
|
Icon: ArrowBackIosNewIcon,
|
||||||
primary: 'adminSettingsDrawer.goBack',
|
primary: 'adminSettingsDrawer.goBack',
|
||||||
to: '/',
|
to: '/',
|
||||||
|
dataTest: 'go-back-drawer-link',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -62,6 +96,11 @@ export default function SettingsLayout({
|
|||||||
const drawerLinks = createDrawerLinks({
|
const drawerLinks = createDrawerLinks({
|
||||||
canReadUser: currentUserAbility.can('read', 'User'),
|
canReadUser: currentUserAbility.can('read', 'User'),
|
||||||
canReadRole: currentUserAbility.can('read', 'Role'),
|
canReadRole: currentUserAbility.can('read', 'Role'),
|
||||||
|
canUpdateConfig: currentUserAbility.can('update', 'Config'),
|
||||||
|
canManageSamlAuthProvider:
|
||||||
|
currentUserAbility.can('read', 'SamlAuthProvider') &&
|
||||||
|
currentUserAbility.can('update', 'SamlAuthProvider') &&
|
||||||
|
currentUserAbility.can('create', 'SamlAuthProvider'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -46,7 +46,7 @@ export default function AppBar(props: AppBarProps): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MuiAppBar>
|
<MuiAppBar data-test="app-bar">
|
||||||
<Container maxWidth={maxWidth} disableGutters>
|
<Container maxWidth={maxWidth} disableGutters>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
40
packages/web/src/components/ColorInput/ColorButton/index.tsx
Normal file
40
packages/web/src/components/ColorInput/ColorButton/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ButtonProps } from '@mui/material/Button';
|
||||||
|
import { Button } from './style';
|
||||||
|
|
||||||
|
const BG_IMAGE_FALLBACK =
|
||||||
|
'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(135deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(135deg, transparent 75%, #ccc 75%) /*! @noflip */';
|
||||||
|
|
||||||
|
export type ColorButtonProps = Omit<ButtonProps, 'children'> & {
|
||||||
|
bgColor: string;
|
||||||
|
isBgColorValid: boolean;
|
||||||
|
disablePopover: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorButtonElement = (props: ColorButtonProps) => JSX.Element;
|
||||||
|
|
||||||
|
const ColorButton = (props: ColorButtonProps) => {
|
||||||
|
const {
|
||||||
|
bgColor,
|
||||||
|
className,
|
||||||
|
disablePopover,
|
||||||
|
isBgColorValid,
|
||||||
|
...restButtonProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-test="color-button"
|
||||||
|
disableTouchRipple
|
||||||
|
style={{
|
||||||
|
backgroundColor: isBgColorValid ? bgColor : undefined,
|
||||||
|
backgroundImage: isBgColorValid ? undefined : BG_IMAGE_FALLBACK,
|
||||||
|
cursor: disablePopover ? 'default' : undefined,
|
||||||
|
}}
|
||||||
|
className={`MuiColorInput-Button ${className || ''}`}
|
||||||
|
{...restButtonProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColorButton;
|
15
packages/web/src/components/ColorInput/ColorButton/style.tsx
Normal file
15
packages/web/src/components/ColorInput/ColorButton/style.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import MuiButton from '@mui/material/Button';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
|
||||||
|
export const Button = styled(MuiButton)(() => ({
|
||||||
|
backgroundSize: '8px 8px',
|
||||||
|
backgroundPosition: '0 0, 4px 0, 4px -4px, 0px 4px',
|
||||||
|
transition: 'none',
|
||||||
|
boxShadow: '0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||||
|
border: 0,
|
||||||
|
borderRadius: 4,
|
||||||
|
width: '24px',
|
||||||
|
aspectRatio: '1 / 1',
|
||||||
|
height: '24px',
|
||||||
|
minWidth: 0,
|
||||||
|
})) as typeof MuiButton;
|
42
packages/web/src/components/ColorInput/index.tsx
Normal file
42
packages/web/src/components/ColorInput/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Controller, useFormContext } from 'react-hook-form';
|
||||||
|
import { MuiColorInput, MuiColorInputProps } from 'mui-color-input';
|
||||||
|
import ColorButton from './ColorButton';
|
||||||
|
|
||||||
|
type ColorInputProps = {
|
||||||
|
shouldUnregister?: boolean;
|
||||||
|
name: string;
|
||||||
|
'data-test'?: string;
|
||||||
|
} & Partial<MuiColorInputProps>;
|
||||||
|
|
||||||
|
export default function ColorInput(props: ColorInputProps): React.ReactElement {
|
||||||
|
const { control } = useFormContext();
|
||||||
|
const {
|
||||||
|
required,
|
||||||
|
name,
|
||||||
|
shouldUnregister = false,
|
||||||
|
disabled = false,
|
||||||
|
...textFieldProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
rules={{ required }}
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
shouldUnregister={shouldUnregister}
|
||||||
|
render={({ field }) => (
|
||||||
|
<MuiColorInput
|
||||||
|
Adornment={ColorButton}
|
||||||
|
format="hex"
|
||||||
|
{...textFieldProps}
|
||||||
|
{...field}
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={{
|
||||||
|
'data-test': 'color-text-field',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@@ -20,7 +20,7 @@ interface CustomOptionsProps {
|
|||||||
onTabChange: (tabIndex: 0 | 1) => void;
|
onTabChange: (tabIndex: 0 | 1) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
initialTabIndex?: 0 | 1;
|
initialTabIndex?: 0 | 1;
|
||||||
};
|
}
|
||||||
|
|
||||||
const CustomOptions = (props: CustomOptionsProps) => {
|
const CustomOptions = (props: CustomOptionsProps) => {
|
||||||
const {
|
const {
|
||||||
@@ -34,17 +34,23 @@ const CustomOptions = (props: CustomOptionsProps) => {
|
|||||||
label,
|
label,
|
||||||
initialTabIndex,
|
initialTabIndex,
|
||||||
} = props;
|
} = props;
|
||||||
const [activeTabIndex, setActiveTabIndex] = React.useState<number | undefined>(undefined);
|
|
||||||
|
|
||||||
React.useEffect(function applyInitialActiveTabIndex() {
|
const [activeTabIndex, setActiveTabIndex] = React.useState<
|
||||||
setActiveTabIndex((currentActiveTabIndex) => {
|
number | undefined
|
||||||
if (currentActiveTabIndex === undefined) {
|
>(undefined);
|
||||||
return initialTabIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentActiveTabIndex;
|
React.useEffect(
|
||||||
});
|
function applyInitialActiveTabIndex() {
|
||||||
}, [initialTabIndex]);
|
setActiveTabIndex((currentActiveTabIndex) => {
|
||||||
|
if (currentActiveTabIndex === undefined) {
|
||||||
|
return initialTabIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentActiveTabIndex;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[initialTabIndex]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popper
|
<Popper
|
||||||
@@ -75,22 +81,15 @@ const CustomOptions = (props: CustomOptionsProps) => {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<TabPanel value={activeTabIndex ?? 0} index={0}>
|
<TabPanel value={activeTabIndex ?? 0} index={0}>
|
||||||
<Options
|
<Options data={options} onOptionClick={onOptionClick} />
|
||||||
data={options}
|
|
||||||
onOptionClick={onOptionClick}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={activeTabIndex ?? 0} index={1}>
|
<TabPanel value={activeTabIndex ?? 0} index={1}>
|
||||||
<Suggestions
|
<Suggestions data={data} onSuggestionClick={onSuggestionClick} />
|
||||||
data={data}
|
|
||||||
onSuggestionClick={onSuggestionClick}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Popper>
|
</Popper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default CustomOptions;
|
export default CustomOptions;
|
||||||
|
@@ -1,15 +1,17 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useController, useFormContext } from 'react-hook-form';
|
import { useController, useFormContext } from 'react-hook-form';
|
||||||
|
import { IconButton } from '@mui/material';
|
||||||
import FormHelperText from '@mui/material/FormHelperText';
|
import FormHelperText from '@mui/material/FormHelperText';
|
||||||
import { AutocompleteProps } from '@mui/material/Autocomplete';
|
import { AutocompleteProps } from '@mui/material/Autocomplete';
|
||||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
import type { IFieldDropdownOption } from '@automatisch/types';
|
import type { IFieldDropdownOption } from '@automatisch/types';
|
||||||
import { FakeDropdownButton } from './style';
|
import { ActionButtonsWrapper } from './style';
|
||||||
|
|
||||||
import ClickAwayListener from '@mui/base/ClickAwayListener';
|
import ClickAwayListener from '@mui/base/ClickAwayListener';
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
import { createEditor } from 'slate';
|
import { createEditor } from 'slate';
|
||||||
import { Editable, ReactEditor,} from 'slate-react';
|
import { Editable, ReactEditor } from 'slate-react';
|
||||||
|
|
||||||
import Slate from 'components/Slate';
|
import Slate from 'components/Slate';
|
||||||
import Element from 'components/Slate/Element';
|
import Element from 'components/Slate/Element';
|
||||||
@@ -23,7 +25,11 @@ import {
|
|||||||
overrideEditorValue,
|
overrideEditorValue,
|
||||||
focusEditor,
|
focusEditor,
|
||||||
} from 'components/Slate/utils';
|
} from 'components/Slate/utils';
|
||||||
import { FakeInput, InputLabelWrapper, ChildrenWrapper, } from 'components/PowerInput/style';
|
import {
|
||||||
|
FakeInput,
|
||||||
|
InputLabelWrapper,
|
||||||
|
ChildrenWrapper,
|
||||||
|
} from 'components/PowerInput/style';
|
||||||
import { VariableElement } from 'components/Slate/types';
|
import { VariableElement } from 'components/Slate/types';
|
||||||
import CustomOptions from './CustomOptions';
|
import CustomOptions from './CustomOptions';
|
||||||
import { processStepWithExecutions } from 'components/PowerInput/data';
|
import { processStepWithExecutions } from 'components/PowerInput/data';
|
||||||
@@ -75,9 +81,11 @@ function ControlledCustomAutocomplete(
|
|||||||
onChange: controllerOnChange,
|
onChange: controllerOnChange,
|
||||||
onBlur: controllerOnBlur,
|
onBlur: controllerOnBlur,
|
||||||
} = field;
|
} = field;
|
||||||
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||||
const [isInitialValueSet, setInitialValue] = React.useState(false);
|
const [isInitialValueSet, setInitialValue] = React.useState(false);
|
||||||
const [isSingleChoice, setSingleChoice] = React.useState<boolean | undefined>(undefined);
|
const [isSingleChoice, setSingleChoice] = React.useState<boolean | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
|
const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
|
||||||
const editorRef = React.useRef<HTMLDivElement | null>(null);
|
const editorRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const renderElement = React.useCallback(
|
const renderElement = React.useCallback(
|
||||||
@@ -104,12 +112,12 @@ function ControlledCustomAutocomplete(
|
|||||||
const promoteValue = () => {
|
const promoteValue = () => {
|
||||||
const serializedValue = serialize(editor.children);
|
const serializedValue = serialize(editor.children);
|
||||||
controllerOnChange(serializedValue);
|
controllerOnChange(serializedValue);
|
||||||
}
|
};
|
||||||
|
|
||||||
const resizeObserver = React.useMemo(function syncCustomOptionsPosition() {
|
const resizeObserver = React.useMemo(function syncCustomOptionsPosition() {
|
||||||
return new ResizeObserver(() => {
|
return new ResizeObserver(() => {
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
})
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -121,24 +129,37 @@ function ControlledCustomAutocomplete(
|
|||||||
}
|
}
|
||||||
}, dependsOnValues);
|
}, dependsOnValues);
|
||||||
|
|
||||||
React.useEffect(function updateInitialValue() {
|
React.useEffect(
|
||||||
const hasOptions = options.length;
|
function updateInitialValue() {
|
||||||
const isOptionsLoaded = loading === false;
|
const hasOptions = options.length;
|
||||||
if (!isInitialValueSet && hasOptions && isOptionsLoaded) {
|
const isOptionsLoaded = loading === false;
|
||||||
setInitialValue(true);
|
if (!isInitialValueSet && hasOptions && isOptionsLoaded) {
|
||||||
|
setInitialValue(true);
|
||||||
|
|
||||||
const option: IFieldDropdownOption | undefined = options.find((option) => option.value === value);
|
const option: IFieldDropdownOption | undefined = options.find(
|
||||||
|
(option) => option.value === value
|
||||||
|
);
|
||||||
|
|
||||||
if (option) {
|
if (option) {
|
||||||
overrideEditorValue(editor, { option, focus: false });
|
overrideEditorValue(editor, { option, focus: false });
|
||||||
setSingleChoice(true);
|
setSingleChoice(true);
|
||||||
} else if (value) {
|
} else if (value) {
|
||||||
setSingleChoice(false);
|
setSingleChoice(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, [isInitialValueSet, options, loading]);
|
[isInitialValueSet, options, loading]
|
||||||
|
);
|
||||||
|
|
||||||
const hideSuggestionsOnShift = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
React.useEffect(() => {
|
||||||
|
if (!showVariableSuggestions && value !== serialize(editor.children)) {
|
||||||
|
promoteValue();
|
||||||
|
}
|
||||||
|
}, [showVariableSuggestions]);
|
||||||
|
|
||||||
|
const hideSuggestionsOnShift = (
|
||||||
|
event: React.KeyboardEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
if (event.code === 'Tab') {
|
if (event.code === 'Tab') {
|
||||||
setShowVariableSuggestions(false);
|
setShowVariableSuggestions(false);
|
||||||
}
|
}
|
||||||
@@ -170,21 +191,26 @@ function ControlledCustomAutocomplete(
|
|||||||
(event: React.MouseEvent, option: IFieldDropdownOption) => {
|
(event: React.MouseEvent, option: IFieldDropdownOption) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
overrideEditorValue(editor, { option, focus: false });
|
overrideEditorValue(editor, { option, focus: false });
|
||||||
|
|
||||||
setShowVariableSuggestions(false);
|
setShowVariableSuggestions(false);
|
||||||
|
setSingleChoice(true);
|
||||||
promoteValue();
|
|
||||||
},
|
},
|
||||||
[stepsWithVariables]
|
[stepsWithVariables]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClearButtonClick = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
resetEditor(editor);
|
||||||
|
promoteValue();
|
||||||
|
setSingleChoice(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
const reset = (tabIndex: 0 | 1) => {
|
const reset = (tabIndex: 0 | 1) => {
|
||||||
const isOptions = tabIndex === 0;
|
const isOptions = tabIndex === 0;
|
||||||
|
|
||||||
setSingleChoice(isOptions);
|
setSingleChoice(isOptions);
|
||||||
|
|
||||||
resetEditor(editor, { focus: true });
|
resetEditor(editor, { focus: true });
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slate
|
<Slate
|
||||||
@@ -193,11 +219,7 @@ function ControlledCustomAutocomplete(
|
|||||||
>
|
>
|
||||||
<ClickAwayListener
|
<ClickAwayListener
|
||||||
mouseEvent="onMouseDown"
|
mouseEvent="onMouseDown"
|
||||||
onClickAway={() => {
|
onClickAway={() => setShowVariableSuggestions(false)}
|
||||||
promoteValue();
|
|
||||||
|
|
||||||
setShowVariableSuggestions(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* ref-able single child for ClickAwayListener */}
|
{/* ref-able single child for ClickAwayListener */}
|
||||||
<ChildrenWrapper style={{ width: '100%' }} data-test="power-input">
|
<ChildrenWrapper style={{ width: '100%' }} data-test="power-input">
|
||||||
@@ -232,14 +254,27 @@ function ControlledCustomAutocomplete(
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FakeDropdownButton
|
<ActionButtonsWrapper direction="row" mr={1.5}>
|
||||||
disabled={disabled}
|
{isSingleChoice && serialize(editor.children) && (
|
||||||
edge="end"
|
<IconButton
|
||||||
size="small"
|
disabled={disabled}
|
||||||
tabIndex={-1}
|
edge="end"
|
||||||
>
|
size="small"
|
||||||
<ArrowDropDownIcon />
|
tabIndex={-1}
|
||||||
</FakeDropdownButton>
|
onClick={handleClearButtonClick}
|
||||||
|
>
|
||||||
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<ArrowDropDownIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ActionButtonsWrapper>
|
||||||
</FakeInput>
|
</FakeInput>
|
||||||
{/* ghost placer for the variables popover */}
|
{/* ghost placer for the variables popover */}
|
||||||
<div
|
<div
|
||||||
@@ -247,14 +282,16 @@ function ControlledCustomAutocomplete(
|
|||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 16,
|
right: 16,
|
||||||
left: 16
|
left: 16,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CustomOptions
|
<CustomOptions
|
||||||
label={label}
|
label={label}
|
||||||
open={showVariableSuggestions}
|
open={showVariableSuggestions}
|
||||||
initialTabIndex={isSingleChoice === undefined ? undefined : (isSingleChoice ? 0 : 1)}
|
initialTabIndex={
|
||||||
|
isSingleChoice === undefined ? undefined : isSingleChoice ? 0 : 1
|
||||||
|
}
|
||||||
anchorEl={editorRef.current}
|
anchorEl={editorRef.current}
|
||||||
data={stepsWithVariables}
|
data={stepsWithVariables}
|
||||||
options={options}
|
options={options}
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import MuiIconButton from '@mui/material/IconButton';
|
import Stack from '@mui/material/Stack';
|
||||||
import MuiTabs from '@mui/material/Tabs';
|
import MuiTabs from '@mui/material/Tabs';
|
||||||
|
|
||||||
export const FakeDropdownButton = styled(MuiIconButton)`
|
export const ActionButtonsWrapper = styled(Stack)`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: ${({ theme }) => theme.spacing(1)};
|
right: 0;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
`;
|
`;
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import useConfig from 'hooks/useConfig';
|
import useConfig from 'hooks/useConfig';
|
||||||
|
import { LogoImage } from './style.ee';
|
||||||
|
|
||||||
const CustomLogo = () => {
|
const CustomLogo = () => {
|
||||||
const { config, loading } = useConfig(['logo.svgData']);
|
const { config, loading } = useConfig(['logo.svgData']);
|
||||||
@@ -8,7 +9,10 @@ const CustomLogo = () => {
|
|||||||
const logoSvgData = config['logo.svgData'] as string;
|
const logoSvgData = config['logo.svgData'] as string;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img src={`data:image/svg+xml;utf8,${encodeURIComponent(logoSvgData)}`} />
|
<LogoImage
|
||||||
|
data-test="custom-logo"
|
||||||
|
src={`data:image/svg+xml;utf8,${encodeURIComponent(logoSvgData)}`}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
8
packages/web/src/components/CustomLogo/style.ee.ts
Normal file
8
packages/web/src/components/CustomLogo/style.ee.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
export const LogoImage = styled('img')(() => ({
|
||||||
|
maxWidth: 200,
|
||||||
|
maxHeight: 50,
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
}));
|
@@ -68,19 +68,22 @@ export default function Drawer(props: DrawerProps): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<List sx={{ py: 0, mt: 3 }}>
|
<List sx={{ py: 0, mt: 3 }}>
|
||||||
{bottomLinks.map(({ Icon, badgeContent, primary, to }, index) => (
|
{bottomLinks.map(
|
||||||
<ListItemLink
|
({ Icon, badgeContent, primary, to, dataTest }, index) => (
|
||||||
key={`${to}-${index}`}
|
<ListItemLink
|
||||||
icon={
|
key={`${to}-${index}`}
|
||||||
<Badge badgeContent={badgeContent} color="secondary" max={99}>
|
icon={
|
||||||
<Icon htmlColor={theme.palette.primary.main} />
|
<Badge badgeContent={badgeContent} color="secondary" max={99}>
|
||||||
</Badge>
|
<Icon htmlColor={theme.palette.primary.main} />
|
||||||
}
|
</Badge>
|
||||||
primary={formatMessage(primary)}
|
}
|
||||||
to={to}
|
primary={formatMessage(primary)}
|
||||||
onClick={closeOnClick}
|
to={to}
|
||||||
/>
|
onClick={closeOnClick}
|
||||||
))}
|
data-test={dataTest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</List>
|
</List>
|
||||||
</BaseDrawer>
|
</BaseDrawer>
|
||||||
);
|
);
|
||||||
|
@@ -6,6 +6,8 @@ import Tooltip from '@mui/material/Tooltip';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import type { IExecution } from '@automatisch/types';
|
import type { IExecution } from '@automatisch/types';
|
||||||
|
|
||||||
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
|
|
||||||
type ExecutionHeaderProps = {
|
type ExecutionHeaderProps = {
|
||||||
execution: IExecution;
|
execution: IExecution;
|
||||||
};
|
};
|
||||||
@@ -19,13 +21,18 @@ function ExecutionName(props: Pick<IExecution['flow'], 'name'>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ExecutionId(props: Pick<IExecution, 'id'>) {
|
function ExecutionId(props: Pick<IExecution, 'id'>) {
|
||||||
|
const formatMessage = useFormatMessage();
|
||||||
|
|
||||||
|
const id = (
|
||||||
|
<Typography variant="body1" component="span">
|
||||||
|
{props.id}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
Execution ID:{' '}
|
{formatMessage('execution.id', { id })}
|
||||||
<Typography variant="body1" component="span">
|
|
||||||
{props.id}
|
|
||||||
</Typography>
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@@ -21,6 +21,7 @@ import {
|
|||||||
AppIconStatusIconWrapper,
|
AppIconStatusIconWrapper,
|
||||||
Content,
|
Content,
|
||||||
Header,
|
Header,
|
||||||
|
Metadata,
|
||||||
Wrapper,
|
Wrapper,
|
||||||
} from './style';
|
} from './style';
|
||||||
|
|
||||||
@@ -31,6 +32,24 @@ type ExecutionStepProps = {
|
|||||||
executionStep: IExecutionStep;
|
executionStep: IExecutionStep;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ExecutionStepId(props: Pick<IExecutionStep, 'id'>) {
|
||||||
|
const formatMessage = useFormatMessage();
|
||||||
|
|
||||||
|
const id = (
|
||||||
|
<Typography variant="caption" component="span">
|
||||||
|
{props.id}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }} gridArea="id">
|
||||||
|
<Typography variant="caption" fontWeight="bold">
|
||||||
|
{formatMessage('executionStep.id', { id })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ExecutionStepDate(props: Pick<IExecutionStep, 'createdAt'>) {
|
function ExecutionStepDate(props: Pick<IExecutionStep, 'createdAt'>) {
|
||||||
const formatMessage = useFormatMessage();
|
const formatMessage = useFormatMessage();
|
||||||
const createdAt = DateTime.fromMillis(parseInt(props.createdAt, 10));
|
const createdAt = DateTime.fromMillis(parseInt(props.createdAt, 10));
|
||||||
@@ -76,30 +95,37 @@ export default function ExecutionStep(
|
|||||||
return (
|
return (
|
||||||
<Wrapper elevation={1} data-test="execution-step">
|
<Wrapper elevation={1} data-test="execution-step">
|
||||||
<Header>
|
<Header>
|
||||||
<Stack direction="row" gap={2}>
|
<Stack direction="row" gap={3}>
|
||||||
<AppIconWrapper>
|
<AppIconWrapper>
|
||||||
<AppIcon url={app?.iconUrl} name={app?.name} />
|
|
||||||
|
|
||||||
<AppIconStatusIconWrapper>
|
<AppIconStatusIconWrapper>
|
||||||
|
<AppIcon url={app?.iconUrl} name={app?.name} />
|
||||||
|
|
||||||
{validationStatusIcon}
|
{validationStatusIcon}
|
||||||
</AppIconStatusIconWrapper>
|
</AppIconStatusIconWrapper>
|
||||||
</AppIconWrapper>
|
</AppIconWrapper>
|
||||||
|
|
||||||
<Box flex="1">
|
<Metadata flex="1">
|
||||||
<Typography variant="caption">
|
<ExecutionStepId id={executionStep.step.id} />
|
||||||
{isTrigger
|
|
||||||
? formatMessage('flowStep.triggerType')
|
|
||||||
: formatMessage('flowStep.actionType')}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography variant="body2">
|
<Box flex="1" gridArea="step">
|
||||||
{step.position}. {app?.name}
|
<Typography variant="caption">
|
||||||
</Typography>
|
{isTrigger && formatMessage('flowStep.triggerType')}
|
||||||
</Box>
|
{isAction && formatMessage('flowStep.actionType')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<Box alignSelf="flex-end">
|
<Typography variant="body2">
|
||||||
<ExecutionStepDate createdAt={executionStep.createdAt} />
|
{step.position}. {app?.name}
|
||||||
</Box>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
justifyContent={["left", "right"]}
|
||||||
|
gridArea="date"
|
||||||
|
>
|
||||||
|
<ExecutionStepDate createdAt={executionStep.createdAt} />
|
||||||
|
</Box>
|
||||||
|
</Metadata>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
|
@@ -1,18 +1,22 @@
|
|||||||
import { styled, alpha } from '@mui/material/styles';
|
import { styled, alpha } from '@mui/material/styles';
|
||||||
import Card from '@mui/material/Card';
|
import Card from '@mui/material/Card';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
export const AppIconWrapper = styled('div')`
|
export const AppIconWrapper = styled('div')`
|
||||||
position: relative;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
export const AppIconStatusIconWrapper = styled('span')`
|
export const AppIconStatusIconWrapper = styled('span')`
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
transform: translate(50%, -50%);
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
// to make it distinguishable over an app icon
|
// to make it distinguishable over an app icon
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
@@ -31,7 +35,7 @@ type HeaderProps = {
|
|||||||
|
|
||||||
export const Header = styled('div', {
|
export const Header = styled('div', {
|
||||||
shouldForwardProp: (prop) => prop !== 'collapsed',
|
shouldForwardProp: (prop) => prop !== 'collapsed',
|
||||||
})<HeaderProps>`
|
}) <HeaderProps>`
|
||||||
padding: ${({ theme }) => theme.spacing(2)};
|
padding: ${({ theme }) => theme.spacing(2)};
|
||||||
cursor: ${({ collapsed }) => (collapsed ? 'pointer' : 'unset')};
|
cursor: ${({ collapsed }) => (collapsed ? 'pointer' : 'unset')};
|
||||||
`;
|
`;
|
||||||
@@ -42,3 +46,20 @@ export const Content = styled('div')`
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
padding: ${({ theme }) => theme.spacing(2, 0)};
|
padding: ${({ theme }) => theme.spacing(2, 0)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const Metadata = styled(Box)`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"step id"
|
||||||
|
"step date";
|
||||||
|
|
||||||
|
${({ theme }) => theme.breakpoints.down('sm')} {
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"id"
|
||||||
|
"step"
|
||||||
|
"date";
|
||||||
|
}
|
||||||
|
` as typeof Box;
|
||||||
|
@@ -18,7 +18,7 @@ type FlowRowProps = {
|
|||||||
flow: IFlow;
|
flow: IFlow;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getFlowStatusTranslationKey(status: IFlow["status"]): string {
|
function getFlowStatusTranslationKey(status: IFlow['status']): string {
|
||||||
if (status === 'published') {
|
if (status === 'published') {
|
||||||
return 'flow.published';
|
return 'flow.published';
|
||||||
} else if (status === 'paused') {
|
} else if (status === 'paused') {
|
||||||
@@ -28,7 +28,16 @@ function getFlowStatusTranslationKey(status: IFlow["status"]): string {
|
|||||||
return 'flow.draft';
|
return 'flow.draft';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFlowStatusColor(status: IFlow["status"]): 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' {
|
function getFlowStatusColor(
|
||||||
|
status: IFlow['status']
|
||||||
|
):
|
||||||
|
| 'default'
|
||||||
|
| 'primary'
|
||||||
|
| 'secondary'
|
||||||
|
| 'error'
|
||||||
|
| 'info'
|
||||||
|
| 'success'
|
||||||
|
| 'warning' {
|
||||||
if (status === 'published') {
|
if (status === 'published') {
|
||||||
return 'success';
|
return 'success';
|
||||||
} else if (status === 'paused') {
|
} else if (status === 'paused') {
|
||||||
@@ -64,8 +73,12 @@ export default function FlowRow(props: FlowRowProps): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card sx={{ mb: 1 }}>
|
<Card sx={{ mb: 1 }} data-test="flow-row">
|
||||||
<CardActionArea component={Link} to={URLS.FLOW(flow.id)}>
|
<CardActionArea
|
||||||
|
component={Link}
|
||||||
|
to={URLS.FLOW(flow.id)}
|
||||||
|
data-test="card-action-area"
|
||||||
|
>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Apps direction="row" gap={1} sx={{ gridArea: 'apps' }}>
|
<Apps direction="row" gap={1} sx={{ gridArea: 'apps' }}>
|
||||||
<FlowAppIcons steps={flow.steps} />
|
<FlowAppIcons steps={flow.steps} />
|
||||||
@@ -98,9 +111,7 @@ export default function FlowRow(props: FlowRowProps): React.ReactElement {
|
|||||||
size="small"
|
size="small"
|
||||||
color={getFlowStatusColor(flow?.status)}
|
color={getFlowStatusColor(flow?.status)}
|
||||||
variant={flow?.active ? 'filled' : 'outlined'}
|
variant={flow?.active ? 'filled' : 'outlined'}
|
||||||
label={formatMessage(
|
label={formatMessage(getFlowStatusTranslationKey(flow?.status))}
|
||||||
getFlowStatusTranslationKey(flow?.status)
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@@ -71,17 +71,18 @@ function generateValidationSchema(substeps: ISubstep[]) {
|
|||||||
substepArgumentValidations[key] = yup.mixed();
|
substepArgumentValidations[key] = yup.mixed();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof substepArgumentValidations[key] === 'object' && (arg.type === 'string' || arg.type === 'dropdown')) {
|
if (
|
||||||
|
typeof substepArgumentValidations[key] === 'object' &&
|
||||||
|
(arg.type === 'string' || arg.type === 'dropdown')
|
||||||
|
) {
|
||||||
// if the field is required, add the required validation
|
// if the field is required, add the required validation
|
||||||
if (required) {
|
if (required) {
|
||||||
substepArgumentValidations[key] = substepArgumentValidations[
|
substepArgumentValidations[key] = substepArgumentValidations[key]
|
||||||
key
|
|
||||||
]
|
|
||||||
.required(`${key} is required.`)
|
.required(`${key} is required.`)
|
||||||
.test(
|
.test(
|
||||||
'empty-check',
|
'empty-check',
|
||||||
`${key} must be not empty`,
|
`${key} must be not empty`,
|
||||||
(value: any) => !isEmpty(value),
|
(value: any) => !isEmpty(value)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +167,9 @@ export default function FlowStep(
|
|||||||
|
|
||||||
const actionsOrTriggers: Array<ITrigger | IAction> =
|
const actionsOrTriggers: Array<ITrigger | IAction> =
|
||||||
(isTrigger ? app?.triggers : app?.actions) || [];
|
(isTrigger ? app?.triggers : app?.actions) || [];
|
||||||
const actionOrTrigger = actionsOrTriggers?.find(({ key }) => key === step.key);
|
const actionOrTrigger = actionsOrTriggers?.find(
|
||||||
|
({ key }) => key === step.key
|
||||||
|
);
|
||||||
const substeps = actionOrTrigger?.substeps || [];
|
const substeps = actionOrTrigger?.substeps || [];
|
||||||
|
|
||||||
const handleChange = React.useCallback(({ step }: { step: IStep }) => {
|
const handleChange = React.useCallback(({ step }: { step: IStep }) => {
|
||||||
@@ -187,7 +190,12 @@ export default function FlowStep(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!apps) {
|
if (!apps) {
|
||||||
return <CircularProgress sx={{ display: 'block', my: 2 }} />;
|
return (
|
||||||
|
<CircularProgress
|
||||||
|
data-test="step-circular-loader"
|
||||||
|
sx={{ display: 'block', my: 2 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const onContextMenuClose = (event: React.SyntheticEvent) => {
|
const onContextMenuClose = (event: React.SyntheticEvent) => {
|
||||||
@@ -279,7 +287,8 @@ export default function FlowStep(
|
|||||||
step={step}
|
step={step}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{actionOrTrigger && substeps?.length > 0 &&
|
{actionOrTrigger &&
|
||||||
|
substeps?.length > 0 &&
|
||||||
substeps.map((substep: ISubstep, index: number) => (
|
substeps.map((substep: ISubstep, index: number) => (
|
||||||
<React.Fragment key={`${substep?.name}-${index}`}>
|
<React.Fragment key={`${substep?.name}-${index}`}>
|
||||||
{substep.key === 'chooseConnection' && app && (
|
{substep.key === 'chooseConnection' && app && (
|
||||||
@@ -304,7 +313,11 @@ export default function FlowStep(
|
|||||||
onSubmit={expandNextStep}
|
onSubmit={expandNextStep}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onContinue={onContinue}
|
onContinue={onContinue}
|
||||||
showWebhookUrl={'showWebhookUrl' in actionOrTrigger ? actionOrTrigger.showWebhookUrl : false}
|
showWebhookUrl={
|
||||||
|
'showWebhookUrl' in actionOrTrigger
|
||||||
|
? actionOrTrigger.showWebhookUrl
|
||||||
|
: false
|
||||||
|
}
|
||||||
step={step}
|
step={step}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@@ -9,12 +9,12 @@ const Logo = () => {
|
|||||||
const { config, loading } = useConfig(['logo.svgData']);
|
const { config, loading } = useConfig(['logo.svgData']);
|
||||||
|
|
||||||
const logoSvgData = config?.['logo.svgData'] as string;
|
const logoSvgData = config?.['logo.svgData'] as string;
|
||||||
if (loading && !logoSvgData) return (<React.Fragment />);
|
if (loading && !logoSvgData) return <React.Fragment />;
|
||||||
|
|
||||||
if (logoSvgData) return <CustomLogo />;
|
if (logoSvgData) return <CustomLogo />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Typography variant="h6" component="h1" noWrap>
|
<Typography variant="h6" component="h1" data-test="typography-logo" noWrap>
|
||||||
<FormattedMessage id="brandText" />
|
<FormattedMessage id="brandText" />
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
|
@@ -9,4 +9,4 @@ export default function Element(props: any) {
|
|||||||
default:
|
default:
|
||||||
return <p {...attributes}>{children}</p>;
|
return <p {...attributes}>{children}</p>;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
@@ -3,7 +3,13 @@ import { withHistory } from 'slate-history';
|
|||||||
import { ReactEditor, withReact } from 'slate-react';
|
import { ReactEditor, withReact } from 'slate-react';
|
||||||
import { IFieldDropdownOption } from '@automatisch/types';
|
import { IFieldDropdownOption } from '@automatisch/types';
|
||||||
|
|
||||||
import type { CustomEditor, CustomElement, CustomText, ParagraphElement, VariableElement } from './types';
|
import type {
|
||||||
|
CustomEditor,
|
||||||
|
CustomElement,
|
||||||
|
CustomText,
|
||||||
|
ParagraphElement,
|
||||||
|
VariableElement,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
type StepWithVariables = {
|
type StepWithVariables = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,7 +19,7 @@ type StepWithVariables = {
|
|||||||
sampleValue: string;
|
sampleValue: string;
|
||||||
value: string;
|
value: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type StepsWithVariables = StepWithVariables[];
|
type StepsWithVariables = StepWithVariables[];
|
||||||
|
|
||||||
@@ -26,10 +32,7 @@ function isCustomText(value: any): value is CustomText {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStepPosition(
|
function getStepPosition(id: string, stepsWithVariables: StepsWithVariables) {
|
||||||
id: string,
|
|
||||||
stepsWithVariables: StepsWithVariables
|
|
||||||
) {
|
|
||||||
const stepIndex = stepsWithVariables.findIndex((stepWithVariables) => {
|
const stepIndex = stepsWithVariables.findIndex((stepWithVariables) => {
|
||||||
return stepWithVariables.id === id;
|
return stepWithVariables.id === id;
|
||||||
});
|
});
|
||||||
@@ -48,29 +51,36 @@ function getVariableStepId(variable: string) {
|
|||||||
return stepId;
|
return stepId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariableSampleValue(variable: string, stepsWithVariables: StepsWithVariables) {
|
function getVariableSampleValue(
|
||||||
|
variable: string,
|
||||||
|
stepsWithVariables: StepsWithVariables
|
||||||
|
) {
|
||||||
const variableStepId = getVariableStepId(variable);
|
const variableStepId = getVariableStepId(variable);
|
||||||
const stepWithVariables = stepsWithVariables.find(({ id }: { id: string }) => id === variableStepId);
|
const stepWithVariables = stepsWithVariables.find(
|
||||||
|
({ id }: { id: string }) => id === variableStepId
|
||||||
|
);
|
||||||
|
|
||||||
if (!stepWithVariables) return null;
|
if (!stepWithVariables) return null;
|
||||||
|
|
||||||
const variableName = getVariableName(variable);
|
const variableName = getVariableName(variable);
|
||||||
const variableData = stepWithVariables.output.find(({ value }) => variableName === value);
|
const variableData = stepWithVariables.output.find(
|
||||||
|
({ value }) => variableName === value
|
||||||
|
);
|
||||||
|
|
||||||
if (!variableData) return null;
|
if (!variableData) return null;
|
||||||
|
|
||||||
return variableData.sampleValue;
|
return variableData.sampleValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariableDetails(variable: string, stepsWithVariables: StepsWithVariables) {
|
function getVariableDetails(
|
||||||
|
variable: string,
|
||||||
|
stepsWithVariables: StepsWithVariables
|
||||||
|
) {
|
||||||
const variableName = getVariableName(variable);
|
const variableName = getVariableName(variable);
|
||||||
const stepId = getVariableStepId(variableName);
|
const stepId = getVariableStepId(variableName);
|
||||||
const stepPosition = getStepPosition(stepId, stepsWithVariables);
|
const stepPosition = getStepPosition(stepId, stepsWithVariables);
|
||||||
const sampleValue = getVariableSampleValue(variable, stepsWithVariables);
|
const sampleValue = getVariableSampleValue(variable, stepsWithVariables);
|
||||||
const label = variableName.replace(
|
const label = variableName.replace(`step.${stepId}.`, `step${stepPosition}.`);
|
||||||
`step.${stepId}.`,
|
|
||||||
`step${stepPosition}.`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sampleValue,
|
sampleValue,
|
||||||
@@ -114,7 +124,10 @@ export const deserialize = (
|
|||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
children: nodes.map((node) => {
|
children: nodes.map((node) => {
|
||||||
if (node.match(variableRegExp)) {
|
if (node.match(variableRegExp)) {
|
||||||
const variableDetails = getVariableDetails(node, stepsWithVariables);
|
const variableDetails = getVariableDetails(
|
||||||
|
node,
|
||||||
|
stepsWithVariables
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'variable',
|
type: 'variable',
|
||||||
@@ -199,7 +212,10 @@ export const insertVariable = (
|
|||||||
variableData: Record<string, unknown>,
|
variableData: Record<string, unknown>,
|
||||||
stepsWithVariables: StepsWithVariables
|
stepsWithVariables: StepsWithVariables
|
||||||
) => {
|
) => {
|
||||||
const variableDetails = getVariableDetails(`{{${variableData.value}}}`, stepsWithVariables);
|
const variableDetails = getVariableDetails(
|
||||||
|
`{{${variableData.value}}}`,
|
||||||
|
stepsWithVariables
|
||||||
|
);
|
||||||
|
|
||||||
const variable: VariableElement = {
|
const variable: VariableElement = {
|
||||||
type: 'variable',
|
type: 'variable',
|
||||||
@@ -217,15 +233,18 @@ export const insertVariable = (
|
|||||||
export const focusEditor = (editor: CustomEditor) => {
|
export const focusEditor = (editor: CustomEditor) => {
|
||||||
ReactEditor.focus(editor);
|
ReactEditor.focus(editor);
|
||||||
editor.move();
|
editor.move();
|
||||||
}
|
};
|
||||||
|
|
||||||
export const resetEditor = (editor: CustomEditor, options?: { focus: boolean }) => {
|
export const resetEditor = (
|
||||||
|
editor: CustomEditor,
|
||||||
|
options?: { focus: boolean }
|
||||||
|
) => {
|
||||||
const focus = options?.focus || false;
|
const focus = options?.focus || false;
|
||||||
|
|
||||||
editor.removeNodes({
|
editor.removeNodes({
|
||||||
at: {
|
at: {
|
||||||
anchor: editor.start([]),
|
anchor: editor.start([]),
|
||||||
focus: editor.end([])
|
focus: editor.end([]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,9 +254,12 @@ export const resetEditor = (editor: CustomEditor, options?: { focus: boolean })
|
|||||||
if (focus) {
|
if (focus) {
|
||||||
focusEditor(editor);
|
focusEditor(editor);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const overrideEditorValue = (editor: CustomEditor, options: { option: IFieldDropdownOption, focus: boolean }) => {
|
export const overrideEditorValue = (
|
||||||
|
editor: CustomEditor,
|
||||||
|
options: { option: IFieldDropdownOption; focus: boolean }
|
||||||
|
) => {
|
||||||
const { option, focus } = options;
|
const { option, focus } = options;
|
||||||
|
|
||||||
const variable: ParagraphElement = {
|
const variable: ParagraphElement = {
|
||||||
@@ -245,8 +267,8 @@ export const overrideEditorValue = (editor: CustomEditor, options: { option: IFi
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
value: option.value as string,
|
value: option.value as string,
|
||||||
text: option.label as string
|
text: option.label as string,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -254,7 +276,7 @@ export const overrideEditorValue = (editor: CustomEditor, options: { option: IFi
|
|||||||
editor.removeNodes({
|
editor.removeNodes({
|
||||||
at: {
|
at: {
|
||||||
anchor: editor.start([]),
|
anchor: editor.start([]),
|
||||||
focus: editor.end([])
|
focus: editor.end([]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,9 +292,9 @@ export const createTextNode = (text: string): ParagraphElement => ({
|
|||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
text
|
text,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const customizeEditor = (editor: CustomEditor): CustomEditor => {
|
export const customizeEditor = (editor: CustomEditor): CustomEditor => {
|
||||||
|
@@ -24,11 +24,11 @@ function SsoProviders() {
|
|||||||
<Button
|
<Button
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
component="a"
|
component="a"
|
||||||
href={URLS.SSO_LOGIN(provider.issuer)}
|
href={provider.loginUrl}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
{formatMessage('ssoProviders.loginWithProvider', {
|
{formatMessage('ssoProviders.loginWithProvider', {
|
||||||
providerName: provider.name
|
providerName: provider.name,
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
74
packages/web/src/components/Switch/index.tsx
Normal file
74
packages/web/src/components/Switch/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Controller, useFormContext } from 'react-hook-form';
|
||||||
|
import FormControlLabel, {
|
||||||
|
FormControlLabelProps,
|
||||||
|
} from '@mui/material/FormControlLabel';
|
||||||
|
import MuiSwitch, { SwitchProps as MuiSwitchProps } from '@mui/material/Switch';
|
||||||
|
|
||||||
|
type SwitchProps = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
shouldUnregister?: boolean;
|
||||||
|
FormControlLabelProps?: Partial<FormControlLabelProps>;
|
||||||
|
} & MuiSwitchProps;
|
||||||
|
|
||||||
|
export default function Switch(props: SwitchProps): React.ReactElement {
|
||||||
|
const { control } = useFormContext();
|
||||||
|
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
const {
|
||||||
|
required,
|
||||||
|
name,
|
||||||
|
defaultChecked = false,
|
||||||
|
shouldUnregister = false,
|
||||||
|
disabled = false,
|
||||||
|
onBlur,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
FormControlLabelProps,
|
||||||
|
...switchProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
rules={{ required }}
|
||||||
|
name={name}
|
||||||
|
defaultValue={defaultChecked}
|
||||||
|
control={control}
|
||||||
|
shouldUnregister={shouldUnregister}
|
||||||
|
render={({
|
||||||
|
field: {
|
||||||
|
ref,
|
||||||
|
onChange: controllerOnChange,
|
||||||
|
onBlur: controllerOnBlur,
|
||||||
|
value,
|
||||||
|
...field
|
||||||
|
},
|
||||||
|
}) => (
|
||||||
|
<FormControlLabel
|
||||||
|
{...FormControlLabelProps}
|
||||||
|
control={
|
||||||
|
<MuiSwitch
|
||||||
|
{...switchProps}
|
||||||
|
{...field}
|
||||||
|
checked={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(...args) => {
|
||||||
|
controllerOnChange(...args);
|
||||||
|
onChange?.(...args);
|
||||||
|
}}
|
||||||
|
onBlur={(...args) => {
|
||||||
|
controllerOnBlur();
|
||||||
|
onBlur?.(...args);
|
||||||
|
}}
|
||||||
|
inputRef={(element) => {
|
||||||
|
inputRef.current = element;
|
||||||
|
ref(element);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@@ -67,6 +67,7 @@ export default function TextField(props: TextFieldProps): React.ReactElement {
|
|||||||
<MuiTextField
|
<MuiTextField
|
||||||
{...textFieldProps}
|
{...textFieldProps}
|
||||||
{...field}
|
{...field}
|
||||||
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(...args) => {
|
onChange={(...args) => {
|
||||||
controllerOnChange(...args);
|
controllerOnChange(...args);
|
||||||
|
@@ -0,0 +1,89 @@
|
|||||||
|
import { useTheme } from '@mui/material';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import FirstPageIcon from '@mui/icons-material/FirstPage';
|
||||||
|
import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft';
|
||||||
|
import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight';
|
||||||
|
import LastPageIcon from '@mui/icons-material/LastPage';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
interface TablePaginationActionsProps {
|
||||||
|
count: number;
|
||||||
|
page: number;
|
||||||
|
rowsPerPage: number;
|
||||||
|
onPageChange: (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>,
|
||||||
|
newPage: number
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TablePaginationActions(
|
||||||
|
props: TablePaginationActionsProps
|
||||||
|
) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { count, page, rowsPerPage, onPageChange } = props;
|
||||||
|
|
||||||
|
const handleFirstPageButtonClick = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>
|
||||||
|
) => {
|
||||||
|
onPageChange(event, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackButtonClick = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>
|
||||||
|
) => {
|
||||||
|
onPageChange(event, page - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextButtonClick = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>
|
||||||
|
) => {
|
||||||
|
onPageChange(event, page + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLastPageButtonClick = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement>
|
||||||
|
) => {
|
||||||
|
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ flexShrink: 0, ml: 2.5 }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleFirstPageButtonClick}
|
||||||
|
disabled={page === 0}
|
||||||
|
aria-label="first page"
|
||||||
|
>
|
||||||
|
{theme.direction === 'rtl' ? <LastPageIcon /> : <FirstPageIcon />}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleBackButtonClick}
|
||||||
|
disabled={page === 0}
|
||||||
|
aria-label="previous page"
|
||||||
|
>
|
||||||
|
{theme.direction === 'rtl' ? (
|
||||||
|
<KeyboardArrowRight />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowLeft />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleNextButtonClick}
|
||||||
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
|
aria-label="next page"
|
||||||
|
>
|
||||||
|
{theme.direction === 'rtl' ? (
|
||||||
|
<KeyboardArrowLeft />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowRight />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleLastPageButtonClick}
|
||||||
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
|
aria-label="last page"
|
||||||
|
>
|
||||||
|
{theme.direction === 'rtl' ? <FirstPageIcon /> : <LastPageIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
@@ -11,89 +11,132 @@ import Paper from '@mui/material/Paper';
|
|||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import TableFooter from '@mui/material/TableFooter';
|
||||||
|
|
||||||
import DeleteUserButton from 'components/DeleteUserButton/index.ee';
|
import DeleteUserButton from 'components/DeleteUserButton/index.ee';
|
||||||
import ListLoader from 'components/ListLoader';
|
import ListLoader from 'components/ListLoader';
|
||||||
import useUsers from 'hooks/useUsers';
|
import useUsers from 'hooks/useUsers';
|
||||||
import useFormatMessage from 'hooks/useFormatMessage';
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
import * as URLS from 'config/urls';
|
import * as URLS from 'config/urls';
|
||||||
|
import TablePaginationActions from './TablePaginationActions';
|
||||||
|
import { TablePagination } from './style';
|
||||||
|
|
||||||
export default function UserList(): React.ReactElement {
|
export default function UserList(): React.ReactElement {
|
||||||
const formatMessage = useFormatMessage();
|
const formatMessage = useFormatMessage();
|
||||||
const { users, loading } = useUsers();
|
const [page, setPage] = React.useState(0);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = React.useState(10);
|
||||||
|
const {
|
||||||
|
users,
|
||||||
|
pageInfo,
|
||||||
|
totalCount,
|
||||||
|
loading,
|
||||||
|
} = useUsers(page, rowsPerPage);
|
||||||
|
|
||||||
|
const handleChangePage = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement> | null,
|
||||||
|
newPage: number
|
||||||
|
) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeRowsPerPage = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
setRowsPerPage(+event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper}>
|
<>
|
||||||
<Table>
|
<TableContainer component={Paper}>
|
||||||
<TableHead>
|
<Table>
|
||||||
<TableRow>
|
<TableHead>
|
||||||
<TableCell component="th">
|
<TableRow>
|
||||||
<Typography
|
<TableCell component="th">
|
||||||
variant="subtitle1"
|
<Typography
|
||||||
sx={{ color: 'text.secondary', fontWeight: 700 }}
|
variant="subtitle1"
|
||||||
>
|
sx={{ color: 'text.secondary', fontWeight: 700 }}
|
||||||
{formatMessage('userList.fullName')}
|
>
|
||||||
</Typography>
|
{formatMessage('userList.fullName')}
|
||||||
</TableCell>
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell component="th">
|
<TableCell component="th">
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle1"
|
variant="subtitle1"
|
||||||
sx={{ color: 'text.secondary', fontWeight: 700 }}
|
sx={{ color: 'text.secondary', fontWeight: 700 }}
|
||||||
>
|
>
|
||||||
{formatMessage('userList.email')}
|
{formatMessage('userList.email')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell component="th">
|
<TableCell component="th">
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle1"
|
variant="subtitle1"
|
||||||
sx={{ color: 'text.secondary', fontWeight: 700 }}
|
sx={{ color: 'text.secondary', fontWeight: 700 }}
|
||||||
>
|
>
|
||||||
{formatMessage('userList.role')}
|
{formatMessage('userList.role')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell component="th" />
|
<TableCell component="th" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading && <ListLoader rowsNumber={3} columnsNumber={2} />}
|
{loading && <ListLoader rowsNumber={3} columnsNumber={2} />}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={user.id}
|
key={user.id}
|
||||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||||
>
|
>
|
||||||
<TableCell scope="row">
|
<TableCell scope="row">
|
||||||
<Typography variant="subtitle2">{user.fullName}</Typography>
|
<Typography variant="subtitle2">{user.fullName}</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography variant="subtitle2">{user.email}</Typography>
|
<Typography variant="subtitle2">{user.email}</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography variant="subtitle2">{user.role.name}</Typography>
|
<Typography variant="subtitle2">
|
||||||
</TableCell>
|
{user.role.name}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Stack direction="row" gap={1} justifyContent="right">
|
<Stack direction="row" gap={1} justifyContent="right">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={URLS.USER(user.id)}
|
to={URLS.USER(user.id)}
|
||||||
>
|
>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<DeleteUserButton userId={user.id} />
|
<DeleteUserButton userId={user.id} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
{totalCount && (
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TablePagination
|
||||||
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
|
page={page}
|
||||||
|
count={totalCount}
|
||||||
|
onPageChange={handleChangePage}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
|
ActionsComponent={TablePaginationActions}
|
||||||
|
/>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableFooter>
|
||||||
</TableBody>
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
12
packages/web/src/components/UserList/style.ts
Normal file
12
packages/web/src/components/UserList/style.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import MuiTablePagination, {
|
||||||
|
tablePaginationClasses,
|
||||||
|
} from '@mui/material/TablePagination';
|
||||||
|
|
||||||
|
export const TablePagination = styled(MuiTablePagination)(() => ({
|
||||||
|
[`& .${tablePaginationClasses.selectLabel}, & .${tablePaginationClasses.displayedRows}`]:
|
||||||
|
{
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
}));
|
@@ -1,26 +1,16 @@
|
|||||||
type Config = {
|
type Config = {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiUrl: string;
|
|
||||||
graphqlUrl: string;
|
graphqlUrl: string;
|
||||||
notificationsUrl: string;
|
|
||||||
chatwootBaseUrl: string;
|
chatwootBaseUrl: string;
|
||||||
supportEmailAddress: string;
|
supportEmailAddress: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
baseUrl: process.env.REACT_APP_BASE_URL as string,
|
baseUrl: process.env.REACT_APP_BASE_URL as string,
|
||||||
apiUrl: process.env.REACT_APP_API_URL as string,
|
|
||||||
graphqlUrl: process.env.REACT_APP_GRAPHQL_URL as string,
|
graphqlUrl: process.env.REACT_APP_GRAPHQL_URL as string,
|
||||||
notificationsUrl: process.env.REACT_APP_NOTIFICATIONS_URL as string,
|
|
||||||
chatwootBaseUrl: 'https://app.chatwoot.com',
|
chatwootBaseUrl: 'https://app.chatwoot.com',
|
||||||
supportEmailAddress: 'support@automatisch.io'
|
supportEmailAddress: 'support@automatisch.io',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!config.apiUrl && !config.graphqlUrl) {
|
|
||||||
config.apiUrl = '/';
|
|
||||||
} else if (!config.apiUrl) {
|
|
||||||
config.apiUrl = (new URL(config.graphqlUrl)).origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
@@ -1,14 +1,10 @@
|
|||||||
import appConfig from './app';
|
|
||||||
|
|
||||||
export const CONNECTIONS = '/connections';
|
export const CONNECTIONS = '/connections';
|
||||||
export const EXECUTIONS = '/executions';
|
export const EXECUTIONS = '/executions';
|
||||||
export const EXECUTION_PATTERN = '/executions/:executionId';
|
export const EXECUTION_PATTERN = '/executions/:executionId';
|
||||||
export const EXECUTION = (executionId: string) =>
|
export const EXECUTION = (executionId: string) => `/executions/${executionId}`;
|
||||||
`/executions/${executionId}`;
|
|
||||||
|
|
||||||
export const LOGIN = '/login';
|
export const LOGIN = '/login';
|
||||||
export const LOGIN_CALLBACK = `${LOGIN}/callback`;
|
export const LOGIN_CALLBACK = `${LOGIN}/callback`;
|
||||||
export const SSO_LOGIN = (issuer: string) => `${appConfig.apiUrl}/login/saml/${issuer}`;
|
|
||||||
export const SIGNUP = '/sign-up';
|
export const SIGNUP = '/sign-up';
|
||||||
export const FORGOT_PASSWORD = '/forgot-password';
|
export const FORGOT_PASSWORD = '/forgot-password';
|
||||||
export const RESET_PASSWORD = '/reset-password';
|
export const RESET_PASSWORD = '/reset-password';
|
||||||
@@ -17,18 +13,19 @@ export const APPS = '/apps';
|
|||||||
export const NEW_APP_CONNECTION = '/apps/new';
|
export const NEW_APP_CONNECTION = '/apps/new';
|
||||||
export const APP = (appKey: string) => `/app/${appKey}`;
|
export const APP = (appKey: string) => `/app/${appKey}`;
|
||||||
export const APP_PATTERN = '/app/:appKey';
|
export const APP_PATTERN = '/app/:appKey';
|
||||||
export const APP_CONNECTIONS = (appKey: string) =>
|
export const APP_CONNECTIONS = (appKey: string) => `/app/${appKey}/connections`;
|
||||||
`/app/${appKey}/connections`;
|
|
||||||
export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections';
|
export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections';
|
||||||
export const APP_ADD_CONNECTION = (appKey: string, shared = false) =>
|
export const APP_ADD_CONNECTION = (appKey: string, shared = false) =>
|
||||||
`/app/${appKey}/connections/add?shared=${shared}`;
|
`/app/${appKey}/connections/add?shared=${shared}`;
|
||||||
export const APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID = (appKey: string, appAuthClientId: string) =>
|
export const APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID = (
|
||||||
`/app/${appKey}/connections/add?appAuthClientId=${appAuthClientId}`;
|
appKey: string,
|
||||||
|
appAuthClientId: string
|
||||||
|
) => `/app/${appKey}/connections/add?appAuthClientId=${appAuthClientId}`;
|
||||||
export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add';
|
export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add';
|
||||||
export const APP_RECONNECT_CONNECTION = (
|
export const APP_RECONNECT_CONNECTION = (
|
||||||
appKey: string,
|
appKey: string,
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
appAuthClientId?: string,
|
appAuthClientId?: string
|
||||||
) => {
|
) => {
|
||||||
const path = `/app/${appKey}/connections/${connectionId}/reconnect`;
|
const path = `/app/${appKey}/connections/${connectionId}/reconnect`;
|
||||||
|
|
||||||
@@ -96,6 +93,8 @@ export const ROLES = `${ADMIN_SETTINGS}/roles`;
|
|||||||
export const ROLE = (roleId: string) => `${ROLES}/${roleId}`;
|
export const ROLE = (roleId: string) => `${ROLES}/${roleId}`;
|
||||||
export const ROLE_PATTERN = `${ROLES}/:roleId`;
|
export const ROLE_PATTERN = `${ROLES}/:roleId`;
|
||||||
export const CREATE_ROLE = `${ROLES}/create`;
|
export const CREATE_ROLE = `${ROLES}/create`;
|
||||||
|
export const USER_INTERFACE = `${ADMIN_SETTINGS}/user-interface`;
|
||||||
|
export const AUTHENTICATION = `${ADMIN_SETTINGS}/authentication`;
|
||||||
|
|
||||||
export const DASHBOARD = FLOWS;
|
export const DASHBOARD = FLOWS;
|
||||||
|
|
||||||
|
@@ -0,0 +1,9 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const UPSERT_SAML_AUTH_PROVIDER = gql`
|
||||||
|
mutation UpsertSamlAuthProvider($input: UpsertSamlAuthProviderInput) {
|
||||||
|
upsertSamlAuthProvider(input: $input) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
12
packages/web/src/graphql/queries/get-notifications.ts
Normal file
12
packages/web/src/graphql/queries/get-notifications.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const GET_NOTIFICATIONS = gql`
|
||||||
|
query GetNotifications {
|
||||||
|
getNotifications {
|
||||||
|
name
|
||||||
|
createdAt
|
||||||
|
documentationUrl
|
||||||
|
description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
19
packages/web/src/graphql/queries/get-saml-auth-provider.ts
Normal file
19
packages/web/src/graphql/queries/get-saml-auth-provider.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const GET_SAML_AUTH_PROVIDER = gql`
|
||||||
|
query GetSamlAuthProvider {
|
||||||
|
getSamlAuthProvider {
|
||||||
|
name
|
||||||
|
certificate
|
||||||
|
signatureAlgorithm
|
||||||
|
issuer
|
||||||
|
entryPoint
|
||||||
|
firstnameAttributeName
|
||||||
|
surnameAttributeName
|
||||||
|
emailAttributeName
|
||||||
|
roleAttributeName
|
||||||
|
active
|
||||||
|
defaultRoleId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
@@ -13,6 +13,7 @@ export const GET_USERS = gql`
|
|||||||
currentPage
|
currentPage
|
||||||
totalPages
|
totalPages
|
||||||
}
|
}
|
||||||
|
totalCount
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
|
@@ -5,6 +5,7 @@ export const LIST_SAML_AUTH_PROVIDERS = gql`
|
|||||||
listSamlAuthProviders {
|
listSamlAuthProviders {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
loginUrl
|
||||||
issuer
|
issuer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
18
packages/web/src/helpers/nestObject.ts
Normal file
18
packages/web/src/helpers/nestObject.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { IJSONObject } from '@automatisch/types';
|
||||||
|
import set from 'lodash/set';
|
||||||
|
|
||||||
|
export default function nestObject<T = IJSONObject>(
|
||||||
|
config: IJSONObject | undefined
|
||||||
|
): Partial<T> {
|
||||||
|
if (!config) return {};
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const key in config) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(config, key)) {
|
||||||
|
const value = config[key];
|
||||||
|
set(result, key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
@@ -1,26 +1,20 @@
|
|||||||
import * as React from 'react';
|
import { useQuery } from '@apollo/client';
|
||||||
import appConfig from 'config/app';
|
import type { Notification } from '@automatisch/types';
|
||||||
|
|
||||||
interface INotification {
|
import { GET_NOTIFICATIONS } from 'graphql/queries/get-notifications';
|
||||||
name: string;
|
|
||||||
createdAt: string;
|
type UseNotificationsReturn = {
|
||||||
documentationUrl: string;
|
notifications: Notification[];
|
||||||
description: string;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useNotifications(): INotification[] {
|
export default function useNotifications(): UseNotificationsReturn {
|
||||||
const [notifications, setNotifications] = React.useState<INotification[]>([]);
|
const { data, loading } = useQuery(GET_NOTIFICATIONS);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const notifications = data?.getNotifications || [];
|
||||||
fetch(`${appConfig.notificationsUrl}/notifications.json`)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((notifications) => {
|
|
||||||
if (Array.isArray(notifications) && notifications.length) {
|
|
||||||
setNotifications(notifications);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return notifications;
|
return {
|
||||||
|
loading,
|
||||||
|
notifications,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user