Compare commits

..

48 Commits

Author SHA1 Message Date
Faruk AYDIN
8156b8b356 Release v0.9.3 2023-09-01 12:35:19 +02:00
Faruk AYDIN
3a2cbae0a0 chore: Update version to 0.9.3 in Dockerfiles 2023-09-01 12:34:25 +02:00
Ömer Faruk Aydın
0ad8da097b fix(rss): get text for internal ID if the guid or id is object (#1257) 2023-09-01 12:10:42 +02:00
Ömer Faruk Aydın
e2dcdd2811 feat(formatter): add extract number transform to text action (#1255) 2023-08-31 16:35:28 +02:00
Ömer Faruk Aydın
8074f9146b Merge pull request #1253 from automatisch/refactor-notifications
refactor: fetch notifications over graphql query
2023-08-29 16:37:37 +02:00
Ali BARIN
df24bac913 refactor: fetch notifications over graphql query 2023-08-28 20:44:55 +00:00
Ali BARIN
4d4091adcc test: write login page tests 2023-08-28 20:11:21 +02:00
Ali BARIN
cac54c41a1 chore: run automatisch in playwright workflow 2023-08-28 20:11:21 +02:00
Ali BARIN
130931d7af fix: use axios with proxy in license check (#1252) 2023-08-28 17:19:19 +02:00
Ömer Faruk Aydın
d35b08b35e Merge pull request #1250 from automatisch/release/0.9.2
Release v0.9.2
2023-08-28 16:54:32 +02:00
Faruk AYDIN
82031da6a6 Release v0.9.2 2023-08-28 16:30:29 +02:00
Faruk AYDIN
9df5ee7b11 chore: Update version to 0.9.2 in Dockerfiles 2023-08-28 16:29:53 +02:00
Ömer Faruk Aydın
2ed1a57cd9 Merge pull request #1249 from automatisch/permission-contions
chore: Convert conditions of permissions to array
2023-08-28 16:27:29 +02:00
Faruk AYDIN
101450cba6 chore: Convert conditions of permissions to array 2023-08-28 16:24:39 +02:00
Ömer Faruk Aydın
6bab5b3f7c Merge pull request #1248 from automatisch/release/0.9.1
Release v0.9.1
2023-08-28 15:15:25 +02:00
Faruk AYDIN
ca3c0e00a7 Release v0.9.1 2023-08-28 14:47:05 +02:00
Faruk AYDIN
6d64daf324 chore: Update version to 0.9.1 in Dockerfiles 2023-08-28 14:46:26 +02:00
Ömer Faruk Aydın
9ae4578e19 Merge pull request #1247 from automatisch/remove-api-url
chore(web): Remove API url env variable
2023-08-28 14:44:41 +02:00
Faruk AYDIN
e06b7ab87a chore(web): Remove API url env variable 2023-08-28 14:37:10 +02:00
Ömer Faruk Aydın
1e2adedcbf Merge pull request #1246 from automatisch/release/0.9.0
Release v0.9.0
2023-08-28 13:34:24 +02:00
Faruk AYDIN
adf763c1b0 Release v0.9.0 2023-08-28 13:13:23 +02:00
Faruk AYDIN
36ee0df256 chore: Update version to 0.9.0 in Dockerfiles 2023-08-28 13:06:20 +02:00
Rıdvan Akca
823d85b24a feat(custom-logo): constraint svg logo dimensions (#1243) 2023-08-25 21:43:53 +02:00
Rıdvan Akca
a3b3038709 test(user-interface-configuration): write initial tests (#1242)
* test(user-interface): add tests with playwright

* test: refactor UI configuration tests

---------

Co-authored-by: Ali BARIN <ali.barin53@gmail.com>
2023-08-25 21:31:02 +02:00
kattoczko
ddeb18f626 feat: introduce authentication page (#1241)
* feat: introduce authentication page

* feat: update page width

* fix(saml): cover non-existing role mapping on onboarding

---------

Co-authored-by: Ali BARIN <ali.barin53@gmail.com>
2023-08-25 15:24:50 +02:00
Rıdvan Akca
90cd11bd38 feat: align admin pages vertically (#1240) 2023-08-24 16:34:18 +02:00
Ömer Faruk Aydın
e9ba37b8de fix: use withSoftDeleted scope to remove user associations permanently (#1239) 2023-08-24 16:34:07 +02:00
Faruk AYDIN
d5e4a1b1ad fix: Soft delete existing associations of soft deleted users 2023-08-24 15:05:54 +02:00
Faruk AYDIN
129e6d60e5 fix: Remove all related records when user is deleted 2023-08-24 15:05:54 +02:00
Faruk AYDIN
4b77f2f590 fix: Remove deleted flows from Redis 2023-08-24 15:05:54 +02:00
Rıdvan Akca
a909966562 feat(executions): display execution step id (#1232)
* feat(executions): display execution step id

* refactor: remove conditional components in execution steps

---------

Co-authored-by: Ali BARIN <ali.barin53@gmail.com>
2023-08-23 21:53:16 +02:00
Ömer Faruk Aydın
fd184239d6 Merge pull request #1233 from automatisch/enhance-variable-coverage
feat: enhance step variable coverage
2023-08-23 12:35:07 +02:00
Ali BARIN
52bc49dc6a feat: enhance step variable coverage 2023-08-22 16:17:20 +02:00
Ali BARIN
b9352ccc06 fix(mutations/update-flow-status): correct permission check 2023-08-22 16:17:20 +02:00
Ali BARIN
525b2baf06 fix(mutations/execute-flow): correct permission check 2023-08-22 16:17:20 +02:00
Ali BARIN
a8edeb2459 fix(mutations/update-step): correct permission check 2023-08-22 16:17:20 +02:00
Ali BARIN
e3830d64e0 feat: add getSamlAuthProviderRoleMappings query (#1229) 2023-08-22 14:50:01 +02:00
Ali BARIN
91f3e2c2b4 feat: make user.role_id not nullable (#1217) 2023-08-22 14:49:53 +02:00
Ali BARIN
77b4408416 chore: correct e2e test results path in GH actions (#1231) 2023-08-22 10:57:40 +03:00
QAComet
cede96f018 test: refactor create flow test cases with test.step (#1228) 2023-08-22 00:27:10 +02:00
dependabot[bot]
8e0a28d238 chore(deps): bump @node-saml/node-saml from 4.0.4 to 4.0.5 (#1227)
Bumps [@node-saml/node-saml](https://github.com/node-saml/node-saml) from 4.0.4 to 4.0.5.
- [Release notes](https://github.com/node-saml/node-saml/releases)
- [Changelog](https://github.com/node-saml/node-saml/blob/v4.0.5/CHANGELOG.md)
- [Commits](https://github.com/node-saml/node-saml/compare/v4.0.4...v4.0.5)

---
updated-dependencies:
- dependency-name: "@node-saml/node-saml"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-21 23:35:38 +02:00
Rıdvan Akca
da5d594428 feat(user-interface): introduce user interface page (#1226) 2023-08-21 23:11:25 +02:00
kattoczko
9f9ee0bb58 feat: create clear button for ControlledCustomAutocomplete (#1222)
Co-authored-by: Ali BARIN <ali.barin53@gmail.com>
2023-08-21 22:52:59 +02:00
Rıdvan Akca
163aca6179 feat(user-list): add pagination (#1219)
* feat(user-list): add pagination

* feat: add actual total count in getUsers

---------

Co-authored-by: Ali BARIN <ali.barin53@gmail.com>
2023-08-21 21:15:07 +02:00
Ali BARIN
cb06d3b0ae test: add in-between assertions and more fixtures (#1224) 2023-08-18 18:34:52 +02:00
Ali BARIN
dbe18dd100 chore: configure login env. vars. in e2e test workflow (#1221) 2023-08-18 09:48:41 +02:00
Ali BARIN
217970667a chore: make e2e tests manually triggerable (#1220) 2023-08-18 10:25:43 +03:00
Rıdvan Akca
dace794167 feat: introduce playwright (#1194)
* feat: introduce playwright

* test: migrate apps folder to playwright (#1201)

* test: rewrite connections tests with playwright (#1203)

* test: rewrite executions tests with playwright (#1207)

* test: rewrite flow editor tests with playwright (#1212)

* test(flow-editor): rewrite tests using serial mode (#1218)

* test: update custom connection creation paths

* test: move login logic to page fixture

* test: remove cypress tests and deps

---------

Co-authored-by: Ali BARIN <ali.barin53@gmail.com>
2023-08-17 23:31:38 +02:00
112 changed files with 2374 additions and 1469 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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',

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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));

View File

@@ -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();
});
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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,
}) })

View File

@@ -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!');
} }
} }

View 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;

View File

@@ -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;

View File

@@ -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);
}; };

View File

@@ -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,

View File

@@ -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

View File

@@ -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,
}, },
}, },

View File

@@ -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'],

View File

@@ -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,

View File

@@ -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

View File

@@ -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,
})), })),

View File

@@ -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`,

View File

@@ -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 }
); );

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,
},
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});
});

View File

@@ -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);
});

View File

@@ -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')

View File

@@ -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');
} }
} }

View 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');
}
}

View File

@@ -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();
}
} }

View File

@@ -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();

View File

@@ -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 });
}
} }

View File

@@ -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 });
} }
} }

View File

@@ -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;

View 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();
}
}

View 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}`;
}
}

View File

@@ -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"

View File

@@ -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: [
{ {

View File

@@ -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();
}); });
}); });
}); });

View 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');
});
});

View File

@@ -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')

View File

@@ -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();

View File

@@ -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',
}); });

View File

@@ -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();
});
});
});
test.describe('arrange Ntfy action', () => { await test.step('has two steps by default', async () => {
test.describe('choose app and event substep', () => { await expect(page.getByTestId('flow-step')).toHaveCount(2);
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 test.step('continue to next step', async () => {
await expect(flowEditorPage.unpublishFlowButton).toBeVisible(); await flowEditorPage.continueButton.click();
await flowEditorPage.unpublishFlowButton.click(); });
await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible();
await flowEditorPage.screenshot({ await test.step('collapses the substep', async () => {
path: 'Unpublished flow.png', 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');
});
});
}
);

View File

@@ -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',
});
});
});
});

View File

@@ -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' {

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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 />}

View File

@@ -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 (

View File

@@ -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

View 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;

View 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;

View 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',
}}
/>
)}
/>
);
}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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%);
`; `;

View File

@@ -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)}`}
/>
); );
}; };

View File

@@ -0,0 +1,8 @@
import styled from '@emotion/styled';
export const LogoImage = styled('img')(() => ({
maxWidth: 200,
maxHeight: 50,
width: '100%',
height: 'auto',
}));

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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;

View File

@@ -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

View File

@@ -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}
/> />
)} )}

View File

@@ -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>
); );

View File

@@ -9,4 +9,4 @@ export default function Element(props: any) {
default: default:
return <p {...attributes}>{children}</p>; return <p {...attributes}>{children}</p>;
} }
}; }

View File

@@ -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 => {

View File

@@ -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>
))} ))}

View 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}
/>
)}
/>
);
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>
</>
); );
} }

View 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,
},
}));

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const UPSERT_SAML_AUTH_PROVIDER = gql`
mutation UpsertSamlAuthProvider($input: UpsertSamlAuthProviderInput) {
upsertSamlAuthProvider(input: $input) {
id
}
}
`;

View File

@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const GET_NOTIFICATIONS = gql`
query GetNotifications {
getNotifications {
name
createdAt
documentationUrl
description
}
}
`;

View 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
}
}
`;

View File

@@ -13,6 +13,7 @@ export const GET_USERS = gql`
currentPage currentPage
totalPages totalPages
} }
totalCount
edges { edges {
node { node {
id id

View File

@@ -5,6 +5,7 @@ export const LIST_SAML_AUTH_PROVIDERS = gql`
listSamlAuthProviders { listSamlAuthProviders {
id id
name name
loginUrl
issuer issuer
} }
} }

View 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;
}

View File

@@ -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