Compare commits
20 Commits
v0.8.3
...
contributi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813bed3676 | ||
|
|
eecbcacb90 | ||
|
|
cfbe4cfea0 | ||
|
|
8f039b77e7 | ||
|
|
672a1bbb82 | ||
|
|
b2f3585047 | ||
|
|
e6434ea2d1 | ||
|
|
a21d6a37e4 | ||
|
|
e9fdffa9d9 | ||
|
|
6bd30abddb | ||
|
|
920c8ea95c | ||
|
|
8de3717587 | ||
|
|
cc863f774a | ||
|
|
bcd08284b4 | ||
|
|
8e8161f207 | ||
|
|
7b4b50cf4b | ||
|
|
bca3e51fdf | ||
|
|
6faa378577 | ||
|
|
012b13ad4a | ||
|
|
ad8770f12c |
68
.github/workflows/e2e.yml
vendored
Normal file
68
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
name: E2E
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
paths:
|
||||
- '**.ts'
|
||||
- '**.tsx'
|
||||
- '**/tsconfig.json'
|
||||
- 'yarn.lock'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.ts'
|
||||
- '**.tsx'
|
||||
- '**/tsconfig.json'
|
||||
- 'yarn.lock'
|
||||
- '.github/workflows/e2e.yml'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
test_setup:
|
||||
name: Test setup
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
preview_url: ${{ steps.waitForVercelPreviewDeployment.outputs.url }}
|
||||
steps:
|
||||
- name: Wait for Vercel preview deployment to be ready
|
||||
uses: patrickedqvist/wait-for-vercel-preview@v1.3.1
|
||||
id: waitForVercelPreviewDeployment
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
max_timeout: 3000
|
||||
|
||||
test_e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_setup
|
||||
name: Playwright tests
|
||||
timeout-minutes: 15
|
||||
environment: ${{ vars.ENVIRONMENT_STAGE }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14 # Need for npm >=7.7
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Playwright with deps
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
PLAYWRIGHT_TEST_BASE_URL: ${{ needs.test_setup.outputs.preview_url }}
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test-results/
|
||||
retention-days: 30
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
data
|
||||
.env
|
||||
.env
|
||||
test-results/
|
||||
132
CONTRIBUTING.md
Normal file
132
CONTRIBUTING.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Contributing Guidelines
|
||||
|
||||
Thank you for considering contributing to our project! We appreciate your interest and welcome any contributions you may have.
|
||||
|
||||
Please read through this document before submitting any issues or pull requests to ensure we have all the necessary information to effectively respond to your bug report or contribution.
|
||||
|
||||
## Sections
|
||||
|
||||
- [General Instructions](#general-instructions)
|
||||
- [Contribute to Backend](#contribute-to-backend)
|
||||
- [Contribute to Frontend](#contribute-to-frontend)
|
||||
- [Other Ways to Contribute](#other-ways-to-contribute)
|
||||
|
||||
## General Instructions
|
||||
|
||||
## For Pull Request(s)
|
||||
|
||||
Contributions via pull requests are much appreciated. Once the approach is agreed upon ✅, make your changes and open a Pull Request(s). Before sending us a pull request, please ensure that,
|
||||
|
||||
- Fork the repo on GitHub, clone it on your machine.
|
||||
- Create a branch with your changes.
|
||||
- You are working against the latest source on the `develop` branch.
|
||||
- Modify the source; please focus only on the specific change.
|
||||
- Ensure local tests pass.
|
||||
- Commit to your fork using clear commit messages.
|
||||
- Send us a pull request.
|
||||
- Pay attention to any automated CI failures reported in the pull request.
|
||||
- Stay involved in the conversation
|
||||
|
||||
⚠️ Please note: If you want to work on an issue, please ask the maintainers to assign the issue to you before starting work on it. This would help us understand who is working on an issue and prevent duplicate work. 🙏🏻
|
||||
|
||||
---
|
||||
|
||||
## Contribute to Backend
|
||||
|
||||
- Clone the `bigcapital` repository and `cd` into `bigcapital` directory.
|
||||
- Install all npm dependencies of the monorepo, you don't have to change directory to the `backend` package. just hit the command on root directory and it will install dependencies of all packages.
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run bootstrap
|
||||
```
|
||||
|
||||
- Run all required docker containers in the development, we already configured all containers under `docker-compose.yml`.
|
||||
|
||||
```
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Wait some seconds, and hit `docker-compose ps` to see the result and you should see the same result below.
|
||||
|
||||
```
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
d974edfab9df bigcapital-mysql "docker-entrypoint.s…" 7 seconds ago Up 1 second 0.0.0.0:3306->3306/tcp, 33060/tcp bigcapital-mysql-1
|
||||
cefa73fe2881 bigcapital-redis "docker-entrypoint.s…" 7 seconds ago Up 1 second 6379/tcp bigcapital-redis-1
|
||||
1ea059198cb4 bigcapital-mongo "docker-entrypoint.s…" 7 seconds ago Up 1 second 0.0.0.0:27017->27017/tcp bigcapital-mongo-1
|
||||
```
|
||||
|
||||
- There're some CLI commands we should run before running the server like databaase migration, so we need to build the `server` app first.
|
||||
|
||||
```
|
||||
npm run build:server
|
||||
```
|
||||
|
||||
- Run the database migration for system database.
|
||||
|
||||
```
|
||||
node packages/server/build/commands.js system:migrate:latest
|
||||
```
|
||||
|
||||
And you should get something like that.
|
||||
|
||||
```
|
||||
Batch 1 run: 6 migrations
|
||||
```
|
||||
|
||||
- Next, start the webapp application.
|
||||
|
||||
```
|
||||
npm run dev:server
|
||||
```
|
||||
|
||||
**[`^top^`](#)**
|
||||
|
||||
----
|
||||
|
||||
## Contribute to Frontend
|
||||
|
||||
- Clone the `bigcapital` repository and cd into `bigcapital` directory.
|
||||
|
||||
```
|
||||
git clone https://github.com/bigcapital/bigcapital.git && cd bigcaptial
|
||||
```
|
||||
|
||||
- Install all npm dependencies of the monorepo, you don't have to change directory to the `frontend` package. just hit that command and will install all packages across all application.
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run bootstrap
|
||||
```
|
||||
|
||||
- Next, start the webapp application.
|
||||
|
||||
```
|
||||
npm run dev:webapp
|
||||
```
|
||||
|
||||
**[`^top^`](#)**
|
||||
|
||||
---
|
||||
|
||||
## Code Review
|
||||
|
||||
We welcome constructive criticism and feedback on code submitted by contributors. All feedback should be constructive and respectful, and should focus on the code rather than the contributor. Code review may include suggestions for improvement or changes to the code.
|
||||
|
||||
---
|
||||
|
||||
## Other Ways to Contribute
|
||||
|
||||
There are many other ways to get involved with the community and to participate in this project:
|
||||
|
||||
- Use the product, submitting GitHub issues when a problem is found.
|
||||
- Help code review pull requests and participate in issue threads.
|
||||
- Submit a new feature request as an issue.
|
||||
- Help answer questions on forums such as Stack Overflow and SigNoz Community Slack Channel.
|
||||
- Tell others about the project on Twitter, your blog, etc.
|
||||
|
||||
**[`^top^`](#)**
|
||||
|
||||
Again, Feel free to ping us on [`#contributing`](https://discord.com/invite/c8nPBJafeb) on our Discord community if you need any help on this :)
|
||||
|
||||
Thank You!
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
environment:
|
||||
# Mail
|
||||
- MAIL_HOST=${MAIL_HOST}
|
||||
- MAIL_USERNAME=${MAIL_USERNAM}
|
||||
- MAIL_USERNAME=${MAIL_USERNAME}
|
||||
- MAIL_PASSWORD=${MAIL_PASSWORD}
|
||||
- MAIL_PORT=${MAIL_PORT}
|
||||
- MAIL_SECURE=${MAIL_SECURE}
|
||||
@@ -77,22 +77,24 @@ services:
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: docker/migration/Dockerfile
|
||||
args:
|
||||
- DB_HOST=mysql
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_CHARSET=${DB_CHARSET}
|
||||
- SYSTEM_DB_NAME=${SYSTEM_DB_NAME}
|
||||
environment:
|
||||
- DB_HOST=mysql
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_CHARSET=${DB_CHARSET}
|
||||
- SYSTEM_DB_NAME=${SYSTEM_DB_NAME}
|
||||
depends_on:
|
||||
- mysql
|
||||
|
||||
mysql:
|
||||
container_name: bigcapital-mysql
|
||||
build:
|
||||
context: ./docker/mysql
|
||||
args:
|
||||
- MYSQL_DATABASE=${SYSTEM_DB_NAME}
|
||||
- MYSQL_USER=${DB_NAME}
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
|
||||
environment:
|
||||
- MYSQL_DATABASE=${SYSTEM_DB_NAME}
|
||||
- MYSQL_USER=${DB_USER}
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
|
||||
volumes:
|
||||
- mysql:/var/lib/mysql
|
||||
expose:
|
||||
@@ -127,4 +129,4 @@ volumes:
|
||||
|
||||
redis:
|
||||
name: bigcapital_prod_redis
|
||||
driver: local
|
||||
driver: local
|
||||
|
||||
@@ -9,11 +9,11 @@ services:
|
||||
mysql:
|
||||
build:
|
||||
context: ./docker/mysql
|
||||
args:
|
||||
- MYSQL_DATABASE=${SYSTEM_DB_NAME}
|
||||
- MYSQL_USER=${DB_NAME}
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
|
||||
environment:
|
||||
- MYSQL_DATABASE=${SYSTEM_DB_NAME}
|
||||
- MYSQL_USER=${DB_USER}
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
|
||||
volumes:
|
||||
- mysql:/var/lib/mysql
|
||||
expose:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
FROM mysql:5.7
|
||||
|
||||
USER root
|
||||
ADD my.cnf /etc/mysql/conf.d/my.cnf
|
||||
|
||||
ARG MYSQL_DATABASE=default_database
|
||||
@@ -12,5 +13,14 @@ ENV MYSQL_USER=$MYSQL_USER
|
||||
ENV MYSQL_PASSWORD=$MYSQL_PASSWORD
|
||||
ENV MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD
|
||||
|
||||
# Copy init sql file with env vars and then the script will substitute the variables.
|
||||
COPY ./init.sql /scripts/init.template.sql
|
||||
COPY ./docker-entrypoint.sh /docker-entrypoint-initdb.d/docker-initialize.sh
|
||||
|
||||
# The scripts in the docker-entrypoint-initdb.d/ directory are executed as
|
||||
# the mysql user inside the MySQL Docker container.
|
||||
RUN chown -R mysql:root /docker-entrypoint-initdb.d
|
||||
RUN chown -R mysql:root /scripts
|
||||
|
||||
CMD ["mysqld"]
|
||||
EXPOSE 3306
|
||||
18
docker/mysql/docker-entrypoint.sh
Normal file
18
docker/mysql/docker-entrypoint.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# chmod u+rwx /scripts/init.template.sql
|
||||
cp /scripts/init.template.sql /scripts/init.sql
|
||||
|
||||
# Replace environment variables in SQL files with their values
|
||||
if [ -n "$MYSQL_USER" ]; then
|
||||
sed -i "s/{MYSQL_USER}/$MYSQL_USER/g" /scripts/init.sql
|
||||
fi
|
||||
if [ -n "$MYSQL_PASSWORD" ]; then
|
||||
sed -i "s/{MYSQL_PASSWORD}/$MYSQL_PASSWORD/g" /scripts/init.sql
|
||||
fi
|
||||
if [ -n "$MYSQL_DATABASE" ]; then
|
||||
sed -i "s/{MYSQL_DATABASE}/$MYSQL_DATABASE/g" /scripts/init.sql
|
||||
fi
|
||||
|
||||
# Execute SQL file
|
||||
mysql -u root -p$MYSQL_ROOT_PASSWORD < /scripts/init.sql
|
||||
2
docker/mysql/init.sql
Normal file
2
docker/mysql/init.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
GRANT ALL PRIVILEGES ON *.* TO '{MYSQL_USER}'@'%' IDENTIFIED BY '{MYSQL_PASSWORD}' WITH GRANT OPTION;
|
||||
FLUSH PRIVILEGES;
|
||||
68
e2e/authentication.spec.ts
Normal file
68
e2e/authentication.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
let authPage: Page;
|
||||
|
||||
test.describe('authentication', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
authPage = await browser.newPage();
|
||||
});
|
||||
|
||||
test.describe('login', () => {
|
||||
test.beforeAll(async () => {
|
||||
await authPage.goto('/auth/login');
|
||||
});
|
||||
test('should show the login page.', async () => {
|
||||
await expect(authPage.locator('body')).toContainText(
|
||||
"Don't have an account? Sign up"
|
||||
);
|
||||
});
|
||||
test('should email and password be required.', async () => {
|
||||
await authPage.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
await expect(authPage.locator('form')).toContainText(
|
||||
'Email is a required field'
|
||||
);
|
||||
await expect(authPage.locator('form')).toContainText(
|
||||
'Password is a required field'
|
||||
);
|
||||
});
|
||||
test('should go to the register page when click on sign up link', async () => {
|
||||
await authPage.getByRole('link', { name: 'Sign up' }).click();
|
||||
await expect(authPage.url()).toContain('/auth/register');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('register', () => {
|
||||
test.beforeAll(async () => {
|
||||
await authPage.goto('/auth/register');
|
||||
});
|
||||
test('should first name, last name, email and password be required.', async () => {
|
||||
await authPage.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
await expect(authPage.locator('form')).toContainText(
|
||||
'First name is a required field'
|
||||
);
|
||||
await expect(authPage.locator('form')).toContainText(
|
||||
'Last name is a required field'
|
||||
);
|
||||
await expect(authPage.locator('form')).toContainText(
|
||||
'Email is a required field'
|
||||
);
|
||||
await expect(authPage.locator('form')).toContainText(
|
||||
'Password is a required field'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('reset password', () => {
|
||||
test.beforeAll(async () => {
|
||||
await authPage.goto('/auth/send_reset_password');
|
||||
});
|
||||
test('should email be required.', async () => {
|
||||
await authPage.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(authPage.locator('form')).toContainText(
|
||||
'Email is a required field'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -941,6 +941,23 @@
|
||||
"esquery": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"@playwright/test": {
|
||||
"version": "1.32.3",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.3.tgz",
|
||||
"integrity": "sha512-BvWNvK0RfBriindxhLVabi8BRe3X0J9EVjKlcmhxjg4giWBD/xleLcg2dz7Tx0agu28rczjNIPQWznwzDwVsZQ==",
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"fsevents": "2.3.2",
|
||||
"playwright-core": "1.32.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-core": {
|
||||
"version": "1.32.3",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.3.tgz",
|
||||
"integrity": "sha512-SB+cdrnu74ZIn5Ogh/8278ngEh9NEEV0vR4sJFmK04h2iZpybfbqBY0bX6+BLYWVdV12JLLI+JEFtSnYgR+mWg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tootallnate/once": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
@@ -986,8 +1003,7 @@
|
||||
"@types/node": {
|
||||
"version": "18.14.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.6.tgz",
|
||||
"integrity": "sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA=="
|
||||
},
|
||||
"@types/normalize-package-data": {
|
||||
"version": "2.4.1",
|
||||
@@ -2304,6 +2320,12 @@
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"optional": true
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dev:server": "lerna run dev --scope \"@bigcapital/server\"",
|
||||
"build:server": "lerna run build --scope \"@bigcapital/server\"",
|
||||
"serve:server": "lerna run serve --scope \"@bigcapital/server\"",
|
||||
"test:e2e": "playwright test",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"workspaces": [
|
||||
@@ -21,7 +22,8 @@
|
||||
"@commitlint/config-lerna-scopes": "^17.4.2",
|
||||
"husky": "^8.0.3",
|
||||
"lerna": "^6.4.1",
|
||||
"@commitlint/cli": "^17.4.2"
|
||||
"@commitlint/cli": "^17.4.2",
|
||||
"@playwright/test": "^1.32.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "14.x"
|
||||
@@ -30,6 +32,5 @@
|
||||
"hooks": {
|
||||
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
|
||||
}
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export default class InviteUsersController extends BaseController {
|
||||
|
||||
try {
|
||||
await this.inviteUsersService.sendInvite(tenantId, sendInviteDTO, user);
|
||||
|
||||
return res.status(200).send({
|
||||
type: 'success',
|
||||
code: 'INVITE.SENT.SUCCESSFULLY',
|
||||
|
||||
@@ -177,7 +177,7 @@ export default class ItemsController extends BaseController {
|
||||
/**
|
||||
* Validate list query schema.
|
||||
*/
|
||||
get validateListQuerySchema() {
|
||||
private get validateListQuerySchema() {
|
||||
return [
|
||||
query('column_sort_by').optional().trim().escape(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
@@ -193,32 +193,20 @@ export default class ItemsController extends BaseController {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate autocomplete list query schema.
|
||||
*/
|
||||
get autocompleteQuerySchema() {
|
||||
return [
|
||||
query('column_sort_by').optional().trim().escape(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
query('limit').optional().isNumeric().toInt(),
|
||||
|
||||
query('keyword').optional().isString().trim().escape(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the given item details to the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async newItem(req: Request, res: Response, next: NextFunction) {
|
||||
private async newItem(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const itemDTO: IItemDTO = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
const storedItem = await this.itemsApplication.createItem(tenantId, itemDTO);
|
||||
const storedItem = await this.itemsApplication.createItem(
|
||||
tenantId,
|
||||
itemDTO
|
||||
);
|
||||
|
||||
return res.status(200).send({
|
||||
id: storedItem.id,
|
||||
@@ -234,7 +222,7 @@ export default class ItemsController extends BaseController {
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async editItem(req: Request, res: Response, next: NextFunction) {
|
||||
private async editItem(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const itemId: number = req.params.id;
|
||||
const item: IItemDTO = this.matchedBodyData(req);
|
||||
@@ -257,7 +245,7 @@ export default class ItemsController extends BaseController {
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async activateItem(req: Request, res: Response, next: NextFunction) {
|
||||
private async activateItem(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const itemId: number = req.params.id;
|
||||
|
||||
@@ -279,7 +267,11 @@ export default class ItemsController extends BaseController {
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async inactivateItem(req: Request, res: Response, next: NextFunction) {
|
||||
private async inactivateItem(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const { tenantId } = req;
|
||||
const itemId: number = req.params.id;
|
||||
|
||||
@@ -300,7 +292,7 @@ export default class ItemsController extends BaseController {
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deleteItem(req: Request, res: Response, next: NextFunction) {
|
||||
private async deleteItem(req: Request, res: Response, next: NextFunction) {
|
||||
const itemId: number = req.params.id;
|
||||
const { tenantId } = req;
|
||||
|
||||
@@ -322,7 +314,7 @@ export default class ItemsController extends BaseController {
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async getItem(req: Request, res: Response, next: NextFunction) {
|
||||
private async getItem(req: Request, res: Response, next: NextFunction) {
|
||||
const itemId: number = req.params.id;
|
||||
const { tenantId } = req;
|
||||
|
||||
@@ -342,7 +334,7 @@ export default class ItemsController extends BaseController {
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getItemsList(req: Request, res: Response, next: NextFunction) {
|
||||
private async getItemsList(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const filter = {
|
||||
|
||||
@@ -42,6 +42,7 @@ export enum AccountNormal {
|
||||
|
||||
export interface IAccountsTransactionsFilter {
|
||||
accountId?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface IAccountTransaction {
|
||||
|
||||
@@ -25,6 +25,7 @@ import SyncSystemSendInvite from '@/services/InviteUsers/SyncSystemSendInvite';
|
||||
import InviteSendMainNotification from '@/services/InviteUsers/InviteSendMailNotification';
|
||||
import SyncTenantAcceptInvite from '@/services/InviteUsers/SyncTenantAcceptInvite';
|
||||
import SyncTenantUserMutate from '@/services/Users/SyncTenantUserSaved';
|
||||
import { SyncTenantUserDelete } from '@/services/Users/SyncTenantUserDeleted';
|
||||
import OrgSyncTenantAdminUserSubscriber from '@/subscribers/Organization/SyncTenantAdminUser';
|
||||
import OrgBuildSmsNotificationSubscriber from '@/subscribers/Organization/BuildSmsNotification';
|
||||
import PurgeUserAbilityCache from '@/services/Users/PurgeUserAbilityCache';
|
||||
@@ -113,6 +114,7 @@ export const susbcribers = () => {
|
||||
SyncTenantAcceptInvite,
|
||||
InviteSendMainNotification,
|
||||
SyncTenantUserMutate,
|
||||
SyncTenantUserDelete,
|
||||
OrgSyncTenantAdminUserSubscriber,
|
||||
OrgBuildSmsNotificationSubscriber,
|
||||
PurgeUserAbilityCache,
|
||||
|
||||
@@ -106,7 +106,7 @@ export default class AccountTransactionTransformer extends Transformer {
|
||||
* @returns {string}
|
||||
*/
|
||||
protected formattedFcCredit(transaction: IAccountTransaction) {
|
||||
return this.formatMoney(this.fcDebit(transaction), {
|
||||
return this.formatMoney(this.fcCredit(transaction), {
|
||||
currencyCode: transaction.currencyCode,
|
||||
excerptZero: true,
|
||||
});
|
||||
@@ -117,7 +117,7 @@ export default class AccountTransactionTransformer extends Transformer {
|
||||
* @returns {string}
|
||||
*/
|
||||
protected formattedFcDebit(transaction: IAccountTransaction) {
|
||||
return this.formatMoney(this.fcCredit(transaction), {
|
||||
return this.formatMoney(this.fcDebit(transaction), {
|
||||
currencyCode: transaction.currencyCode,
|
||||
excerptZero: true,
|
||||
});
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
import {
|
||||
ICurrencyEditDTO,
|
||||
ICurrencyDTO,
|
||||
ICurrenciesService,
|
||||
ICurrency,
|
||||
} from '@/interfaces';
|
||||
import {
|
||||
EventDispatcher,
|
||||
EventDispatcherInterface,
|
||||
} from 'decorators/eventDispatcher';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import TenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { Tenant } from '@/system/models';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
import { CurrencyTransformer } from './CurrencyTransformer';
|
||||
|
||||
const ERRORS = {
|
||||
CURRENCY_NOT_FOUND: 'currency_not_found',
|
||||
@@ -23,14 +20,11 @@ const ERRORS = {
|
||||
|
||||
@Service()
|
||||
export default class CurrenciesService implements ICurrenciesService {
|
||||
@Inject('logger')
|
||||
logger: any;
|
||||
|
||||
@EventDispatcher()
|
||||
eventDispatcher: EventDispatcherInterface;
|
||||
@Inject()
|
||||
private tenancy: TenancyService;
|
||||
|
||||
@Inject()
|
||||
tenancy: TenancyService;
|
||||
private transformer: TransformerInjectable;
|
||||
|
||||
/**
|
||||
* Retrieve currency by given currency code or throw not found error.
|
||||
@@ -105,7 +99,7 @@ export default class CurrenciesService implements ICurrenciesService {
|
||||
*/
|
||||
public async newCurrency(tenantId: number, currencyDTO: ICurrencyDTO) {
|
||||
const { Currency } = this.tenancy.models(tenantId);
|
||||
|
||||
|
||||
// Validate currency code uniquiness.
|
||||
await this.validateCurrencyCodeUniquiness(
|
||||
tenantId,
|
||||
@@ -141,13 +135,15 @@ export default class CurrenciesService implements ICurrenciesService {
|
||||
* @param {number} tenantId
|
||||
* @param {string} currencyCode
|
||||
*/
|
||||
validateCannotDeleteBaseCurrency(tenantId: number, currencyCode: string) {
|
||||
const settings = this.tenancy.settings(tenantId);
|
||||
const baseCurrency = settings.get({
|
||||
group: 'organization',
|
||||
key: 'base_currency',
|
||||
});
|
||||
if (baseCurrency === currencyCode) {
|
||||
private async validateCannotDeleteBaseCurrency(
|
||||
tenantId: number,
|
||||
currencyCode: string
|
||||
) {
|
||||
const tenant = await Tenant.query()
|
||||
.findById(tenantId)
|
||||
.withGraphFetched('metadata');
|
||||
|
||||
if (tenant.metadata.baseCurrency === currencyCode) {
|
||||
throw new ServiceError(ERRORS.CANNOT_DELETE_BASE_CURRENCY);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +152,7 @@ export default class CurrenciesService implements ICurrenciesService {
|
||||
* Delete the given currency code.
|
||||
* @param {number} tenantId
|
||||
* @param {string} currencyCode
|
||||
* @return {Promise<}
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
public async deleteCurrency(
|
||||
tenantId: number,
|
||||
@@ -180,19 +176,13 @@ export default class CurrenciesService implements ICurrenciesService {
|
||||
public async listCurrencies(tenantId: number): Promise<ICurrency[]> {
|
||||
const { Currency } = this.tenancy.models(tenantId);
|
||||
|
||||
const settings = this.tenancy.settings(tenantId);
|
||||
const baseCurrency = settings.get({
|
||||
group: 'organization',
|
||||
key: 'base_currency',
|
||||
});
|
||||
|
||||
const currencies = await Currency.query().onBuild((query) => {
|
||||
query.orderBy('createdAt', 'ASC');
|
||||
});
|
||||
const formattedCurrencies = currencies.map((currency) => ({
|
||||
isBaseCurrency: baseCurrency === currency.currencyCode,
|
||||
...currency,
|
||||
}));
|
||||
return formattedCurrencies;
|
||||
return this.transformer.transform(
|
||||
tenantId,
|
||||
currencies,
|
||||
new CurrencyTransformer()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Transformer } from '@/lib/Transformer/Transformer';
|
||||
|
||||
export class CurrencyTransformer extends Transformer {
|
||||
/**
|
||||
* Include these attributes to sale invoice object.
|
||||
* @returns {Array}
|
||||
*/
|
||||
public includeAttributes = (): string[] => {
|
||||
return ['isBaseCurrency'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Detarmines whether the currency is base currency.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
public isBaseCurrency(currency): boolean {
|
||||
return this.context.organization.baseCurrency === currency.currencyCode;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Inject, Service } from 'typedi';
|
||||
@Service()
|
||||
export default class InviteSendMainNotificationSubscribe {
|
||||
@Inject('agenda')
|
||||
agenda: any;
|
||||
private agenda: any;
|
||||
|
||||
/**
|
||||
* Attaches events with handlers.
|
||||
|
||||
@@ -3,7 +3,6 @@ import uniqid from 'uniqid';
|
||||
import moment from 'moment';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import TenancyService from '@/services/Tenancy/TenancyService';
|
||||
import InviteUsersMailMessages from '@/services/InviteUsers/InviteUsersMailMessages';
|
||||
import events from '@/subscribers/events';
|
||||
import {
|
||||
ISystemUser,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
IUserInvitedEventPayload,
|
||||
IUserInviteResendEventPayload,
|
||||
} from '@/interfaces';
|
||||
import TenantsManagerService from '@/services/Tenancy/TenantsManager';
|
||||
import { ERRORS } from './constants';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import RolesService from '@/services/Roles/RolesService';
|
||||
@@ -21,25 +19,13 @@ import RolesService from '@/services/Roles/RolesService';
|
||||
@Service()
|
||||
export default class InviteTenantUserService implements IInviteUserService {
|
||||
@Inject()
|
||||
eventPublisher: EventPublisher;
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
@Inject()
|
||||
tenancy: TenancyService;
|
||||
|
||||
@Inject('logger')
|
||||
logger: any;
|
||||
private tenancy: TenancyService;
|
||||
|
||||
@Inject()
|
||||
mailMessages: InviteUsersMailMessages;
|
||||
|
||||
@Inject('repositories')
|
||||
sysRepositories: any;
|
||||
|
||||
@Inject()
|
||||
tenantsManager: TenantsManagerService;
|
||||
|
||||
@Inject()
|
||||
rolesService: RolesService;
|
||||
private rolesService: RolesService;
|
||||
|
||||
/**
|
||||
* Sends invite mail to the given email from the given tenant and user.
|
||||
@@ -99,8 +85,6 @@ export default class InviteTenantUserService implements IInviteUserService {
|
||||
): Promise<{
|
||||
user: ITenantUser;
|
||||
}> {
|
||||
const { User } = this.tenancy.models(tenantId);
|
||||
|
||||
// Retrieve the user by id or throw not found service error.
|
||||
const user = await this.getUserByIdOrThrowError(tenantId, userId);
|
||||
|
||||
|
||||
@@ -10,18 +10,18 @@ export class RoleTransformer extends Transformer {
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Retrieves the localized role name if is predefined or stored name.
|
||||
* @param role
|
||||
* @returns
|
||||
* @returns {string}
|
||||
*/
|
||||
public name(role) {
|
||||
return role.predefined ? this.context.i18n.__(role.name) : role.name;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Retrieves the localized role description if is predefined or stored description.
|
||||
* @param role
|
||||
* @returns
|
||||
* @returns {string}
|
||||
*/
|
||||
public description(role) {
|
||||
return role.predefined
|
||||
|
||||
26
packages/server/src/services/Users/SyncTenantUserDeleted.ts
Normal file
26
packages/server/src/services/Users/SyncTenantUserDeleted.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import events from '@/subscribers/events';
|
||||
import { ITenantUserDeletedPayload } from '@/interfaces';
|
||||
import { SystemUser } from '@/system/models';
|
||||
|
||||
export class SyncTenantUserDelete {
|
||||
/**
|
||||
* Attaches events with handlers.
|
||||
* @param bus
|
||||
*/
|
||||
public attach(bus) {
|
||||
bus.subscribe(
|
||||
events.tenantUser.onDeleted,
|
||||
this.syncSystemUserOnceUserDeleted
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the system user once tenant user be deleted.
|
||||
* @param {ITenantUserDeletedPayload} payload -
|
||||
*/
|
||||
private syncSystemUserOnceUserDeleted = async ({
|
||||
tenantUser,
|
||||
}: ITenantUserDeletedPayload) => {
|
||||
await SystemUser.query().where('id', tenantUser.systemUserId).delete();
|
||||
};
|
||||
}
|
||||
50
packages/server/src/services/Users/UserTransformer.ts
Normal file
50
packages/server/src/services/Users/UserTransformer.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Transformer } from '@/lib/Transformer/Transformer';
|
||||
|
||||
export class UserTransformer extends Transformer {
|
||||
/**
|
||||
* Exclude these attributes from user object.
|
||||
* @returns {Array}
|
||||
*/
|
||||
public excludeAttributes = (): string[] => {
|
||||
return ['role'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Includeded attributes.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
public includeAttributes = (): string[] => {
|
||||
return ['roleName', 'roleDescription', 'roleSlug'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the localized role name if is predefined or stored name.
|
||||
* @param role
|
||||
* @returns {string}
|
||||
*/
|
||||
public roleName(user) {
|
||||
return user.role.predefined
|
||||
? this.context.i18n.__(user.role.name)
|
||||
: user.role.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the localized role description if is predefined or stored description.
|
||||
* @param user
|
||||
* @returns {string}
|
||||
*/
|
||||
public roleDescription(user) {
|
||||
return user.role.predefined
|
||||
? this.context.i18n.__(user.role.description)
|
||||
: user.role.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the role slug.
|
||||
* @param user
|
||||
* @returns {string}
|
||||
*/
|
||||
public roleSlug(user) {
|
||||
return user.role.slug;
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,11 @@ import RolesService from '@/services/Roles/RolesService';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { ERRORS } from './constants';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
import { UserTransformer } from './UserTransformer';
|
||||
|
||||
@Service()
|
||||
export default class UsersService {
|
||||
@Inject('repositories')
|
||||
private repositories: any;
|
||||
|
||||
@Inject()
|
||||
private rolesService: RolesService;
|
||||
|
||||
@@ -29,6 +28,9 @@ export default class UsersService {
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
@Inject()
|
||||
private transformer: TransformerInjectable;
|
||||
|
||||
/**
|
||||
* Creates a new user.
|
||||
* @param {number} tenantId - Tenant id.
|
||||
@@ -91,9 +93,10 @@ export default class UsersService {
|
||||
// Retrieve user details or throw not found service error.
|
||||
const tenantUser = await this.getTenantUserOrThrowError(tenantId, userId);
|
||||
|
||||
// Validate the delete user should not be the last user.
|
||||
await this.validateNotLastUserDelete(tenantId);
|
||||
|
||||
// Validate the delete user should not be the last active user.
|
||||
if (tenantUser.isInviteAccepted) {
|
||||
await this.validateNotLastUserDelete(tenantId);
|
||||
}
|
||||
// Delete user from the storage.
|
||||
await User.query().findById(userId).delete();
|
||||
|
||||
@@ -183,7 +186,7 @@ export default class UsersService {
|
||||
|
||||
const users = await User.query().withGraphFetched('role');
|
||||
|
||||
return users;
|
||||
return this.transformer.transform(tenantId, users, new UserTransformer());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,11 +226,13 @@ export default class UsersService {
|
||||
* @param {number} tenantId
|
||||
*/
|
||||
private async validateNotLastUserDelete(tenantId: number) {
|
||||
const { systemUserRepository } = this.repositories;
|
||||
const { User } = this.tenancy.models(tenantId);
|
||||
|
||||
const usersFound = await systemUserRepository.find({ tenantId });
|
||||
const inviteAcceptedUsers = await User.query()
|
||||
.select(['id'])
|
||||
.whereNotNull('invite_accepted_at');
|
||||
|
||||
if (usersFound.length === 1) {
|
||||
if (inviteAcceptedUsers.length === 1) {
|
||||
throw new ServiceError(ERRORS.CANNOT_DELETE_LAST_USER);
|
||||
}
|
||||
}
|
||||
@@ -291,9 +296,9 @@ export default class UsersService {
|
||||
|
||||
/**
|
||||
* Validate the authorized user cannot mutate its role.
|
||||
* @param {ITenantUser} oldTenantUser
|
||||
* @param {IEditUserDTO} editUserDTO
|
||||
* @param {ISystemUser} authorizedUser
|
||||
* @param {ITenantUser} oldTenantUser
|
||||
* @param {IEditUserDTO} editUserDTO
|
||||
* @param {ISystemUser} authorizedUser
|
||||
*/
|
||||
validateMutateRoleNotAuthorizedUser(
|
||||
oldTenantUser: ITenantUser,
|
||||
@@ -307,5 +312,4 @@ export default class UsersService {
|
||||
throw new ServiceError(ERRORS.CANNOT_AUTHORIZED_USER_MUTATE_ROLE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from 'react';
|
||||
import withBreadcrumbs from 'react-router-breadcrumbs-hoc';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { getDashboardRoutes } from '@/routes/dashboard';
|
||||
|
||||
import { If, Icon } from '@/components';
|
||||
import { FormattedMessage as T } from '@/components';
|
||||
import withDashboard from '@/containers/Dashboard/withDashboard';
|
||||
|
||||
@@ -16,6 +16,11 @@ import { transformApiErrors } from './utils';
|
||||
|
||||
import { compose, objectKeysTransform } from '@/utils';
|
||||
|
||||
const initialValues = {
|
||||
email: '',
|
||||
role_id: ''
|
||||
}
|
||||
|
||||
function InviteUserForm({
|
||||
// #withDialogActions
|
||||
closeDialog,
|
||||
@@ -23,7 +28,8 @@ function InviteUserForm({
|
||||
const { dialogName, isEditMode, inviteUserMutate, userId } =
|
||||
useInviteUserFormContext();
|
||||
|
||||
const initialValues = {
|
||||
const initialFormValues = {
|
||||
...initialValues,
|
||||
status: 1,
|
||||
...(isEditMode &&
|
||||
pick(
|
||||
@@ -66,7 +72,7 @@ function InviteUserForm({
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={InviteUserFormSchema}
|
||||
initialValues={initialValues}
|
||||
initialValues={initialFormValues}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<InviteUserFormContent />
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ListSelect,
|
||||
FieldRequiredHint,
|
||||
FormattedMessage as T,
|
||||
FFormGroup,
|
||||
FInputGroup,
|
||||
} from '@/components';
|
||||
import { CLASSES } from '@/constants/classes';
|
||||
import classNames from 'classnames';
|
||||
@@ -32,19 +34,13 @@ function InviteUserFormContent({
|
||||
<T id={'your_access_to_your_team'} />
|
||||
</p>
|
||||
{/* ----------- Email ----------- */}
|
||||
<FastField name={'email'}>
|
||||
{({ field, meta: { error, touched } }) => (
|
||||
<FormGroup
|
||||
label={<T id={'email'} />}
|
||||
labelInfo={<FieldRequiredHint />}
|
||||
className={classNames('form-group--email', CLASSES.FILL)}
|
||||
intent={inputIntent({ error, touched })}
|
||||
helperText={<ErrorMessage name="email" />}
|
||||
>
|
||||
<InputGroup medium={true} {...field} />
|
||||
</FormGroup>
|
||||
)}
|
||||
</FastField>
|
||||
<FFormGroup
|
||||
name={'email'}
|
||||
label={<T id={'invite_user.label.email'} />}
|
||||
labelInfo={<FieldRequiredHint />}
|
||||
>
|
||||
<FInputGroup name={'email'} />
|
||||
</FFormGroup>
|
||||
{/* ----------- Role name ----------- */}
|
||||
<FastField name={'role_id'}>
|
||||
{({ form, field: { value }, meta: { error, touched } }) => (
|
||||
@@ -78,7 +74,13 @@ function InviteUserFormContent({
|
||||
<T id={'cancel'} />
|
||||
</Button>
|
||||
|
||||
<Button intent={Intent.PRIMARY} type="submit" disabled={isSubmitting}>
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
style={{ minWidth: '95px' }}
|
||||
>
|
||||
{isEditMode ? <T id={'edit'} /> : <T id={'invite'} />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -41,17 +41,29 @@ function UserFormContent({
|
||||
<UserFormCalloutAlerts calloutCodes={calloutCode} />
|
||||
|
||||
{/* ----------- Email ----------- */}
|
||||
<FFormGroup name={'email'} label={<T id={'email'} />}>
|
||||
<FFormGroup
|
||||
name={'email'}
|
||||
label={<T id={'email'} />}
|
||||
labelInfo={<FieldRequiredHint />}
|
||||
>
|
||||
<FInputGroup name={'email'} />
|
||||
</FFormGroup>
|
||||
|
||||
{/* ----------- First name ----------- */}
|
||||
<FFormGroup name={'first_name'} label={<T id={'first_name'} />}>
|
||||
<FFormGroup
|
||||
name={'first_name'}
|
||||
label={<T id={'first_name'} />}
|
||||
labelInfo={<FieldRequiredHint />}
|
||||
>
|
||||
<FInputGroup name={'first_name'} />
|
||||
</FFormGroup>
|
||||
|
||||
{/* ----------- Last name ----------- */}
|
||||
<FFormGroup name={'last_name'} label={<T id={'last_name'} />}>
|
||||
<FFormGroup
|
||||
name={'last_name'}
|
||||
label={<T id={'last_name'} />}
|
||||
labelInfo={<FieldRequiredHint />}
|
||||
>
|
||||
<FInputGroup name={'last_name'} />
|
||||
</FFormGroup>
|
||||
|
||||
@@ -94,6 +106,7 @@ function UserFormContent({
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
style={{ minWidth: '85px' }}
|
||||
>
|
||||
<T id={'edit'} />
|
||||
</Button>
|
||||
|
||||
@@ -4,23 +4,24 @@ import intl from 'react-intl-universal';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { compose } from '@/utils';
|
||||
import { TableStyle } from '@/constants';
|
||||
import { Card, DataTable, If } from '@/components';
|
||||
import { AccountDrawerTableOptionsProvider } from './AccountDrawerTableOptionsProvider';
|
||||
import { AccountDrawerTableHeader } from './AccountDrawerTableHeader';
|
||||
|
||||
import { useAccountReadEntriesColumns } from './utils';
|
||||
import { useAppIntlContext } from '@/components/AppIntlProvider';
|
||||
import { useAccountDrawerContext } from './AccountDrawerProvider';
|
||||
|
||||
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
|
||||
|
||||
import { compose } from '@/utils';
|
||||
|
||||
/**
|
||||
* account drawer table.
|
||||
*/
|
||||
function AccountDrawerTable({ closeDrawer }) {
|
||||
const { account, accounts, drawerName } = useAccountDrawerContext();
|
||||
|
||||
// Account read-only entries table columns.
|
||||
const columns = useAccountReadEntriesColumns();
|
||||
const { accounts, drawerName } = useAccountDrawerContext();
|
||||
|
||||
// Handle view more link click.
|
||||
const handleLinkClick = () => {
|
||||
@@ -31,27 +32,41 @@ function AccountDrawerTable({ closeDrawer }) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={accounts}
|
||||
payload={{ account }}
|
||||
styleName={TableStyle.Constrant}
|
||||
/>
|
||||
<AccountDrawerTableOptionsProvider>
|
||||
<AccountDrawerTableHeader />
|
||||
<AccountDrawerDataTable />
|
||||
|
||||
<If condition={accounts.length > 0}>
|
||||
<TableFooter>
|
||||
<Link
|
||||
to={`/financial-reports/general-ledger`}
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
{isRTL ? '→' : '←'} {intl.get('view_more_transactions')}
|
||||
</Link>
|
||||
</TableFooter>
|
||||
</If>
|
||||
<If condition={accounts.length > 0}>
|
||||
<TableFooter>
|
||||
<Link
|
||||
to={`/financial-reports/general-ledger`}
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
{isRTL ? '→' : '←'} {intl.get('view_more_transactions')}
|
||||
</Link>
|
||||
</TableFooter>
|
||||
</If>
|
||||
</AccountDrawerTableOptionsProvider>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountDrawerDataTable() {
|
||||
const { account, accounts } = useAccountDrawerContext();
|
||||
|
||||
// Account read-only entries table columns.
|
||||
const columns = useAccountReadEntriesColumns();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={accounts}
|
||||
payload={{ account }}
|
||||
styleName={TableStyle.Constrant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default compose(withDrawerActions)(AccountDrawerTable);
|
||||
|
||||
const TableFooter = styled.div`
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Button, ButtonGroup } from '@blueprintjs/core';
|
||||
import styled from 'styled-components';
|
||||
import { useAccountDrawerTableOptionsContext } from './AccountDrawerTableOptionsProvider';
|
||||
|
||||
export function AccountDrawerTableHeader() {
|
||||
const {
|
||||
setBCYCurrencyType,
|
||||
setFYCCurrencyType,
|
||||
isBCYCurrencyType,
|
||||
isFCYCurrencyType,
|
||||
} = useAccountDrawerTableOptionsContext();
|
||||
|
||||
const handleBCYBtnClick = () => {
|
||||
setBCYCurrencyType();
|
||||
};
|
||||
const handleFCYBtnClick = () => {
|
||||
setFYCCurrencyType();
|
||||
};
|
||||
|
||||
return (
|
||||
<TableHeaderRoot>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
small
|
||||
outlined
|
||||
onClick={handleFCYBtnClick}
|
||||
active={isFCYCurrencyType}
|
||||
>
|
||||
FCY
|
||||
</Button>
|
||||
<Button
|
||||
small
|
||||
outlined
|
||||
onClick={handleBCYBtnClick}
|
||||
active={isBCYCurrencyType}
|
||||
>
|
||||
BCY
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</TableHeaderRoot>
|
||||
);
|
||||
}
|
||||
|
||||
const TableHeaderRoot = styled.div`
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
interface AccountDrawerTableOptionsContextValue {
|
||||
setFYCCurrencyType: () => void;
|
||||
setBCYCurrencyType: () => void;
|
||||
isFCYCurrencyType: boolean;
|
||||
isBCYCurrencyType: boolean;
|
||||
currencyType: ForeignCurrencyType;
|
||||
}
|
||||
|
||||
const AccountDrawerTableOptionsContext = React.createContext(
|
||||
{} as AccountDrawerTableOptionsContextValue,
|
||||
);
|
||||
|
||||
enum ForeignCurrencyTypes {
|
||||
FCY = 'FCY',
|
||||
BCY = 'BCY',
|
||||
}
|
||||
type ForeignCurrencyType = ForeignCurrencyTypes.FCY | ForeignCurrencyTypes.BCY;
|
||||
|
||||
function AccountDrawerTableOptionsProvider({
|
||||
initialCurrencyType = ForeignCurrencyTypes.FCY,
|
||||
...props
|
||||
}) {
|
||||
const [currencyType, setCurrentType] =
|
||||
useState<ForeignCurrencyType>(initialCurrencyType);
|
||||
|
||||
const setFYCCurrencyType = useCallback(
|
||||
() => setCurrentType(ForeignCurrencyTypes.FCY),
|
||||
[setCurrentType],
|
||||
);
|
||||
const setBCYCurrencyType = useCallback(
|
||||
() => setCurrentType(ForeignCurrencyTypes.BCY),
|
||||
[setCurrentType],
|
||||
);
|
||||
|
||||
// Provider.
|
||||
const provider = {
|
||||
setFYCCurrencyType,
|
||||
setBCYCurrencyType,
|
||||
isFCYCurrencyType: currencyType === ForeignCurrencyTypes.FCY,
|
||||
isBCYCurrencyType: currencyType === ForeignCurrencyTypes.BCY,
|
||||
currencyType,
|
||||
};
|
||||
|
||||
return (
|
||||
<AccountDrawerTableOptionsContext.Provider value={provider} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const useAccountDrawerTableOptionsContext = () =>
|
||||
React.useContext(AccountDrawerTableOptionsContext);
|
||||
|
||||
export {
|
||||
AccountDrawerTableOptionsProvider,
|
||||
useAccountDrawerTableOptionsContext,
|
||||
};
|
||||
@@ -3,27 +3,15 @@ import intl from 'react-intl-universal';
|
||||
import React from 'react';
|
||||
|
||||
import { FormatDateCell } from '@/components';
|
||||
import { isBlank } from '@/utils';
|
||||
|
||||
/**
|
||||
* Debit/credit table cell.
|
||||
*/
|
||||
function DebitCreditTableCell({ value, payload: { account } }) {
|
||||
return !isBlank(value) && value !== 0 ? account.formatted_amount : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Running balance table cell.
|
||||
*/
|
||||
function RunningBalanceTableCell({ value, payload: { account } }) {
|
||||
return account.formatted_amount;
|
||||
}
|
||||
import { useAccountDrawerTableOptionsContext } from './AccountDrawerTableOptionsProvider';
|
||||
|
||||
/**
|
||||
* Retrieve entries columns of read-only account view.
|
||||
*/
|
||||
export const useAccountReadEntriesColumns = () =>
|
||||
React.useMemo(
|
||||
export const useAccountReadEntriesColumns = () => {
|
||||
const { isFCYCurrencyType } = useAccountDrawerTableOptionsContext();
|
||||
|
||||
return React.useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: intl.get('transaction_date'),
|
||||
@@ -34,14 +22,15 @@ export const useAccountReadEntriesColumns = () =>
|
||||
},
|
||||
{
|
||||
Header: intl.get('transaction_type'),
|
||||
accessor: 'reference_type_formatted',
|
||||
accessor: 'transaction_type_formatted',
|
||||
width: 100,
|
||||
textOverview: true,
|
||||
},
|
||||
{
|
||||
Header: intl.get('credit'),
|
||||
accessor: 'credit',
|
||||
Cell: DebitCreditTableCell,
|
||||
accessor: isFCYCurrencyType
|
||||
? 'formatted_fc_credit'
|
||||
: 'formatted_credit',
|
||||
width: 80,
|
||||
className: 'credit',
|
||||
align: 'right',
|
||||
@@ -49,22 +38,13 @@ export const useAccountReadEntriesColumns = () =>
|
||||
},
|
||||
{
|
||||
Header: intl.get('debit'),
|
||||
accessor: 'debit',
|
||||
Cell: DebitCreditTableCell,
|
||||
accessor: isFCYCurrencyType ? 'formatted_fc_debit' : 'formatted_debit',
|
||||
width: 80,
|
||||
className: 'debit',
|
||||
align: 'right',
|
||||
textOverview: true,
|
||||
},
|
||||
{
|
||||
Header: intl.get('running_balance'),
|
||||
Cell: RunningBalanceTableCell,
|
||||
accessor: 'running_balance',
|
||||
width: 110,
|
||||
className: 'running_balance',
|
||||
align: 'right',
|
||||
textOverview: true,
|
||||
},
|
||||
],
|
||||
[],
|
||||
[isFCYCurrencyType],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import React, { useMemo } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
Menu,
|
||||
Popover,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
MenuItem,
|
||||
MenuDivider,
|
||||
Intent,
|
||||
Tag,
|
||||
} from '@blueprintjs/core';
|
||||
import { Icon } from '@/components';
|
||||
import { safeCallback } from '@/utils';
|
||||
@@ -52,12 +54,25 @@ export const ActionsCell = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const CurrencyNameAccessor = (value) => {
|
||||
return (
|
||||
<CurrencyNameRoot>
|
||||
{value.currency_name} {value.is_base_currency && <Tag>Base Currency</Tag>}
|
||||
</CurrencyNameRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const CurrencyNameRoot = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
export function useCurrenciesTableColumns() {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: intl.get('currency_name'),
|
||||
accessor: 'currency_name',
|
||||
accessor: CurrencyNameAccessor,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,6 +22,14 @@ function UsersDataTable({
|
||||
// #withAlertActions
|
||||
openAlert,
|
||||
}) {
|
||||
const { mutateAsync: resendInviation } = useResendInvitation();
|
||||
|
||||
// Users list columns.
|
||||
const columns = useUsersListColumns();
|
||||
|
||||
// Users list context.
|
||||
const { users, isUsersLoading, isUsersFetching } = useUsersListContext();
|
||||
|
||||
// Handle edit user action.
|
||||
const handleEditUserAction = useCallback(
|
||||
(user) => {
|
||||
@@ -50,9 +58,6 @@ function UsersDataTable({
|
||||
},
|
||||
[openAlert],
|
||||
);
|
||||
|
||||
const { mutateAsync: resendInviation } = useResendInvitation();
|
||||
|
||||
const handleResendInvitation = useCallback((user) => {
|
||||
resendInviation(user.id)
|
||||
.then(() => {
|
||||
@@ -71,17 +76,12 @@ function UsersDataTable({
|
||||
AppToaster.show({
|
||||
message:
|
||||
'This person was recently invited. No need to invite them again just yet.',
|
||||
intent: Intent.DANGER,
|
||||
intent: Intent.WARNING,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
// Users list columns.
|
||||
const columns = useUsersListColumns();
|
||||
|
||||
// Users list context.
|
||||
const { users, isUsersLoading, isUsersFetching } = useUsersListContext();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
||||
@@ -132,15 +132,9 @@ export const useUsersListColumns = () => {
|
||||
{
|
||||
id: 'role_name',
|
||||
Header: intl.get('users.column.role_name'),
|
||||
accessor: 'role.name',
|
||||
accessor: 'role_name',
|
||||
width: 120,
|
||||
},
|
||||
// {
|
||||
// id: 'phone_number',
|
||||
// Header: intl.get('phone_number'),
|
||||
// accessor: 'phone_number',
|
||||
// width: 120,
|
||||
// },
|
||||
{
|
||||
id: 'status',
|
||||
Header: intl.get('status'),
|
||||
|
||||
@@ -2228,5 +2228,8 @@
|
||||
"project_billable_entries.dialog.show": "Show",
|
||||
"project_billable_entries.alert.there_is_no_billable_entries": "There is no billable entries for that project.",
|
||||
"project_billable_entries.billable_type": "Billable {value}",
|
||||
"add_billable_entries": "Add Billable Entries"
|
||||
"add_billable_entries": "Add Billable Entries",
|
||||
|
||||
"invite_user.label.email": "Email",
|
||||
"invite_user.label.role_name": "Role name"
|
||||
}
|
||||
38
playwright.config.ts
Normal file
38
playwright.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import path from 'path';
|
||||
import { PlaywrightTestConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// Reference: https://playwright.dev/docs/test-configuration
|
||||
const config: PlaywrightTestConfig = {
|
||||
// Timeout per test
|
||||
timeout: 60 * 1000,
|
||||
workers: 1,
|
||||
// Test directory
|
||||
testDir: path.join(__dirname, 'e2e'),
|
||||
// If a test fails, retry it additional 2 times
|
||||
retries: 0,
|
||||
// Artifacts folder where screenshots, videos, and traces are stored.
|
||||
outputDir: 'test-results/',
|
||||
use: {
|
||||
// Retry a test if its failing with enabled tracing. This allows you to analyse the DOM, console logs, network traffic etc.
|
||||
// More information: https://playwright.dev/docs/trace-viewer
|
||||
trace: 'retain-on-failure',
|
||||
|
||||
// All available context options: https://playwright.dev/docs/api/class-browser#browser-new-context
|
||||
// contextOptions: {
|
||||
// ignoreHTTPSErrors: true,
|
||||
// },
|
||||
baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:4000',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'Desktop Chrome',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
export default config;
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"installCommand": "npm install && npm run bootstrap",
|
||||
"buildCommand": "CI='' npm run build:webapp",
|
||||
"outputDirectory": "packages/webapp/build"
|
||||
}
|
||||
"outputDirectory": "packages/webapp/build",
|
||||
"rewrites": [{
|
||||
"source": "/(.*)",
|
||||
"destination": "/"
|
||||
}]
|
||||
}
|
||||
Reference in New Issue
Block a user