Compare commits

..

19 Commits

Author SHA1 Message Date
Maksim Eltyshev
1d2193c381 chore: Update version 2024-10-27 22:05:18 +01:00
dependabot[bot]
70f40e26af chore(deps): Bump http-proxy-middleware from 2.0.6 to 2.0.7 in /client (#922)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-27 21:18:12 +01:00
Holden Hu
814b5810ac feat: Improve UX of comment actions (#924)
Closes #915
2024-10-27 21:17:08 +01:00
Zananok
f372113def feat: Add multiple task creation with Ctrl+Enter (#921) 2024-10-27 21:03:59 +01:00
leroyloren
14dff96434 fix: Update Czech translation (#920) 2024-10-24 15:39:20 +02:00
Maksim Eltyshev
7580afdb1c chore: Update version 2024-10-22 21:41:07 +02:00
Maksim Eltyshev
2458709031 feat: Attachments icon on front of cards
Closes #225
2024-10-22 21:38:04 +02:00
Maksim Eltyshev
e0d5d7f4ff feat: Description icon on front of cards
Closes #563
2024-10-22 21:22:46 +02:00
Maksim Eltyshev
bedd1f6e2a chore: Bump sails version 2024-10-22 21:00:37 +02:00
Maksim Eltyshev
d9464dc3f9 docs: Add invitation to TestFlight 2024-10-22 20:37:29 +02:00
Maksim Eltyshev
87c1eeb5f8 chore: Bump postgres version to 16 in compose files 2024-10-22 20:22:23 +02:00
Holden Hu
98413d759a chore: Pin postgres version for docker-compose-db (#919)
Closes #918
2024-10-22 20:16:26 +02:00
Maksim Eltyshev
fa3b1d7b28 fix: Format-agnostic time parsing
Closes #916
2024-10-21 14:11:26 +02:00
Maksim Eltyshev
23e5f1a326 ci: Add lint workflow 2024-10-17 23:51:47 +02:00
dependabot[bot]
de44b520af chore(deps): Bump cookie and express in /client (#912)
Bumps [cookie](https://github.com/jshttp/cookie) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `cookie` from 0.6.0 to 0.7.1
- [Release notes](https://github.com/jshttp/cookie/releases)
- [Commits](https://github.com/jshttp/cookie/compare/v0.6.0...v0.7.1)

Updates `express` from 4.21.0 to 4.21.1
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.1/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.0...4.21.1)

---
updated-dependencies:
- dependency-name: cookie
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-17 22:15:39 +02:00
Nalem7
e7d7425768 test: Add BDD UI tests using Playwright (#911) 2024-10-17 22:06:48 +02:00
Amrita
df7746f896 test: Setup UI test using BDD approach (#152) 2024-10-17 20:18:31 +02:00
Maksim Eltyshev
01a7e3a57d ci: Omit version completely from prebuild asset 2024-10-09 14:53:16 +02:00
Maksim Eltyshev
234bd1e8b3 ci: Fix naming of prebuild asset 2024-10-09 14:40:22 +02:00
38 changed files with 1594 additions and 127 deletions

View File

@@ -47,10 +47,10 @@ jobs:
- name: Dist create .zip file
run: |
mv dist/ planka/
zip -r planka-prebuild-${{ github.event.release.tag_name }}.zip planka
zip -r planka-prebuild.zip planka
- name: Dist upload assets
run: |
gh release upload ${{ github.event.release.tag_name }} planka-prebuild-${{ github.event.release.tag_name }}.zip#planka-prebuild.zip
gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
env:
GH_TOKEN: ${{ github.token }}

73
.github/workflows/build-and-test.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Build and test
on:
pull_request:
branches:
- master
push:
branches:
- master
jobs:
setup:
runs-on: ubuntu-latest
env:
POSTGRES_DB: planka_db
POSTGRES_USER: user
POSTGRES_PASSWORD: password
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Setup PostgreSQL
uses: ikalnytskyi/action-setup-postgres@v5
with:
database: ${{ env.POSTGRES_DB }}
username: ${{ env.POSTGRES_USER }}
password: ${{ env.POSTGRES_PASSWORD }}
- name: Cache Node.js modules
uses: actions/cache@v3
with:
path: client/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: |
npm install
cd client
npm run build
- name: Setup server
env:
DEFAULT_ADMIN_EMAIL: demo@demo.demo
DEFAULT_ADMIN_PASSWORD: demo
DEFAULT_ADMIN_NAME: Demo Demo
DEFAULT_ADMIN_USERNAME: demo
run: |
client/tests/setup-symlinks.sh
cd server
cp .env.sample .env
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DB}|" .env
npm run db:init
npm start --prod &
- name: Wait for development server
run: |
sudo apt-get install wait-for-it -y
wait-for-it -h localhost -p 1337 -t 10
- name: Run UI tests
run: |
cd client
npm install
npx playwright install chromium
npm run test:acceptance tests

33
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Lint
on:
pull_request:
branches:
- master
jobs:
setup:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Cache Node.js modules
uses: actions/cache@v3
with:
path: client/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm install
- name: Run linter
run: npm run lint

View File

@@ -31,6 +31,8 @@ Here is the [mobile app repository](https://github.com/LouisHDev/planka_app) mai
Alternatively, you can download the [Android APK](https://github.com/LouisHDev/planka_app/releases/latest/download/app-release.apk) directly.
If you have an iOS device and would like to test the app, you can join [TestFlight](https://testflight.apple.com/join/Uwn41eY4) (limited to 200 participants).
## Contact
- If you want to get a hosted version of Planka, you can contact us via email contact@planka.cloud

View File

@@ -15,13 +15,13 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.2.11
version: 0.2.13
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.23.2"
appVersion: "1.23.4"
dependencies:
- alias: postgresql

941
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"eject": "react-scripts eject",
"lint": "eslint --ext js,jsx src config-overrides.js",
"start": "react-app-rewired start",
"test": "react-app-rewired test"
"test": "react-app-rewired test",
"test:acceptance": "cucumber-js --require tests/acceptance/cucumber.conf.js --require tests/acceptance/stepDefinitions/**/*.js --format @cucumber/pretty-formatter"
},
"browserslist": {
"production": [
@@ -108,9 +109,13 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@cucumber/cucumber": "^7.3.1",
"@cucumber/pretty-formatter": "^1.0.1",
"@playwright/test": "^1.46.1",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^15.0.7",
"@testing-library/user-event": "^14.5.2",
"axios": "^1.6.2",
"babel-preset-airbnb": "^5.0.0",
"chai": "^4.5.0",
"eslint": "^8.57.0",
@@ -119,6 +124,7 @@
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.36.1",
"eslint-plugin-react-hooks": "^4.6.2",
"playwright": "^1.46.1",
"react-test-renderer": "18.2.0"
}
}

View File

@@ -23,6 +23,7 @@ const Card = React.memo(
id,
index,
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
@@ -31,6 +32,7 @@ const Card = React.memo(
listId,
projectId,
isPersisted,
attachmentsTotal,
notificationsTotal,
users,
labels,
@@ -106,7 +108,11 @@ const Card = React.memo(
)}
<div className={styles.name}>{name}</div>
{tasks.length > 0 && <Tasks items={tasks} />}
{(dueDate || stopwatch || notificationsTotal > 0) && (
{(description ||
dueDate ||
stopwatch ||
attachmentsTotal > 0 ||
notificationsTotal > 0) && (
<span className={styles.attachments}>
{notificationsTotal > 0 && (
<span
@@ -135,6 +141,21 @@ const Card = React.memo(
/>
</span>
)}
{description && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="align left" />
</span>
</span>
)}
{attachmentsTotal > 0 && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="attach" />
{attachmentsTotal}
</span>
</span>
)}
</span>
)}
{users.length > 0 && (
@@ -221,6 +242,7 @@ Card.propTypes = {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
dueDate: PropTypes.instanceOf(Date),
isDueDateCompleted: PropTypes.bool,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
@@ -229,6 +251,7 @@ Card.propTypes = {
listId: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
isPersisted: PropTypes.bool.isRequired,
attachmentsTotal: PropTypes.number.isRequired,
notificationsTotal: PropTypes.number.isRequired,
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
@@ -256,6 +279,7 @@ Card.propTypes = {
};
Card.defaultProps = {
description: undefined,
dueDate: undefined,
isDueDateCompleted: undefined,
stopwatch: undefined,

View File

@@ -39,6 +39,13 @@
vertical-align: top;
}
.attachmentContent {
color: #6a808b;
font-size: 12px;
line-height: 20px;
padding: 0px 3px;
}
.attachmentLeft {
margin-right: 4px;
}

View File

@@ -10,7 +10,7 @@ import { focusEnd } from '../../../utils/element-helpers';
import styles from './CommentEdit.module.scss';
const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref) => {
const CommentEdit = React.forwardRef(({ defaultData, onUpdate, text, actions }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(null);
@@ -76,7 +76,12 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
}, [isOpened]);
if (!isOpened) {
return children;
return (
<>
{actions}
{text}
</>
);
}
return (
@@ -101,9 +106,10 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
});
CommentEdit.propTypes = {
children: PropTypes.element.isRequired,
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
text: PropTypes.element.isRequired,
actions: PropTypes.element.isRequired,
};
export default React.memo(CommentEdit);

View File

@@ -31,44 +31,51 @@ const ItemComment = React.memo(
<User name={user.name} avatarUrl={user.avatarUrl} />
</span>
<div className={classNames(styles.content)}>
<div className={styles.title}>
<span className={styles.author}>{user.name}</span>
<span className={styles.date}>
{t(`format:${getDateFormat(createdAt)}`, {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</div>
<CommentEdit ref={commentEdit} defaultData={data} onUpdate={onUpdate}>
<>
<CommentEdit
ref={commentEdit}
defaultData={data}
onUpdate={onUpdate}
text={
<div className={styles.text}>
<Markdown linkTarget="_blank">{data.text}</Markdown>
</div>
{canEdit && (
<Comment.Actions>
<Comment.Action
as="button"
content={t('action.edit')}
disabled={!isPersisted}
onClick={handleEditClick}
/>
<DeletePopup
title="common.deleteComment"
content="common.areYouSureYouWantToDeleteThisComment"
buttonContent="action.deleteComment"
onConfirm={onDelete}
>
}
actions={
<div className={styles.title}>
<span>
<span className={styles.author}>{user.name}</span>
<span className={styles.date}>
{t(`format:${getDateFormat(createdAt)}`, {
postProcess: 'formatDate',
value: createdAt,
})}
</span>
</span>
{canEdit && (
<Comment.Actions>
<Comment.Action
as="button"
content={t('action.delete')}
content={t('action.edit')}
disabled={!isPersisted}
onClick={handleEditClick}
/>
</DeletePopup>
</Comment.Actions>
)}
</>
</CommentEdit>
<DeletePopup
title="common.deleteComment"
content="common.areYouSureYouWantToDeleteThisComment"
buttonContent="action.deleteComment"
onConfirm={onDelete}
>
<Comment.Action
as="button"
content={t('action.delete')}
disabled={!isPersisted}
/>
</DeletePopup>
</Comment.Actions>
)}
</div>
}
/>
</div>
</Comment>
);

View File

@@ -38,6 +38,12 @@
.title {
padding-bottom: 4px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: -1em;
background: #f5f6f7;
}
.user {

View File

@@ -369,7 +369,7 @@ const CardModal = React.memo(
{(description || canEdit) && (
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="align justify" className={styles.moduleIcon} />
<Icon name="align left" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.description')}</div>
{canEdit ? (
<DescriptionEdit defaultValue={description} onUpdate={handleDescriptionUpdate}>

View File

@@ -13,6 +13,8 @@ const DEFAULT_DATA = {
name: '',
};
const MULTIPLE_REGEX = /\s*\r?\n\s*/;
const Add = React.forwardRef(({ children, onCreate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
@@ -29,22 +31,34 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
setIsOpened(false);
}, []);
const submit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
const submit = useCallback(
(isMultiple = false) => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.ref.current.select();
return;
}
if (!cleanData.name) {
nameField.current.ref.current.select();
return;
}
onCreate(cleanData);
if (isMultiple) {
cleanData.name.split(MULTIPLE_REGEX).forEach((name) => {
onCreate({
...cleanData,
name,
});
});
} else {
onCreate(cleanData);
}
setData(DEFAULT_DATA);
focusNameField();
}, [onCreate, data, setData, focusNameField]);
setData(DEFAULT_DATA);
focusNameField();
},
[onCreate, data, setData, focusNameField],
);
useImperativeHandle(
ref,
@@ -63,8 +77,7 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
submit(event.ctrlKey);
}
},
[submit],

View File

@@ -7,6 +7,7 @@ import { useDidUpdate, useToggle } from '../../lib/hooks';
import { Input, Popup } from '../../lib/custom-ui';
import { useForm } from '../../hooks';
import parseTime from '../../utils/parse-time';
import styles from './DueDateEditStep.module.scss';
@@ -66,10 +67,7 @@ const DueDateEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose })
return;
}
const value = t('format:dateTime', {
postProcess: 'parseDate',
value: `${data.date} ${data.time}`,
});
const value = parseTime(data.time, nullableDate);
if (Number.isNaN(value.getTime())) {
timeField.current.select();
@@ -81,7 +79,7 @@ const DueDateEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose })
}
onClose();
}, [defaultValue, onUpdate, onClose, data, nullableDate, t]);
}, [defaultValue, onUpdate, onClose, data, nullableDate]);
const handleClearClick = useCallback(() => {
if (defaultValue) {

View File

@@ -20,12 +20,22 @@ const makeMapStateToProps = () => {
const allLabels = selectors.selectLabelsForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const { name, dueDate, isDueDateCompleted, stopwatch, coverUrl, boardId, listId, isPersisted } =
selectCardById(state, id);
const {
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
coverUrl,
boardId,
listId,
isPersisted,
} = selectCardById(state, id);
const users = selectUsersByCardId(state, id);
const labels = selectLabelsByCardId(state, id);
const tasks = selectTasksByCardId(state, id);
const attachmentsTotal = selectors.selectAttachmentsTotalByCardId(state, id);
const notificationsTotal = selectNotificationsTotalByCardId(state, id);
const isCurrentUserEditor =
@@ -35,6 +45,7 @@ const makeMapStateToProps = () => {
id,
index,
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
@@ -43,6 +54,7 @@ const makeMapStateToProps = () => {
listId,
projectId,
isPersisted,
attachmentsTotal,
notificationsTotal,
users,
labels,

View File

@@ -184,6 +184,7 @@ export default {
addTask: 'Přidat úkol',
addToCard: 'Přidat na kartu',
addUser: 'Přidat uživatele',
copyLink_title: 'Zkopírovat odkaz',
createBoard: 'Vytvořit tabuli',
createFile: 'Vytvořit soubor',
createLabel: 'Vytvořit štítek',

View File

@@ -3,6 +3,7 @@ export default {
common: {
emailOrUsername: 'E-mail nebo uživatelské jméno',
invalidEmailOrUsername: 'Nesprávný e-mail nebo uživatelské jméno',
invalidCredentials: 'Neplatné přihlašovací údaje',
invalidPassword: 'Nesprávné heslo',
logInToPlanka: 'Přihlásit se do Planka',
noInternetConnection: 'Bez připojení k internetu',
@@ -11,6 +12,7 @@ export default {
projectManagement: 'Správa projektu',
serverConnectionFailed: 'Připojení k serveru selhalo',
unknownError: 'Neznámá chyba, zkuste to později',
useSingleSignOn: 'Použít jednorázové přihlášení',
},
action: {

View File

@@ -115,6 +115,23 @@ export const makeSelectTasksByCardId = () =>
export const selectTasksByCardId = makeSelectTasksByCardId();
export const makeSelectAttachmentsTotalByCardId = () =>
createSelector(
orm,
(_, id) => id,
({ Card }, id) => {
const cardModel = Card.withId(id);
if (!cardModel) {
return cardModel;
}
return cardModel.attachments.count();
},
);
export const selectAttachmentsTotalByCardId = makeSelectAttachmentsTotalByCardId();
export const makeSelectLastActivityIdByCardId = () =>
createSelector(
orm,
@@ -334,6 +351,8 @@ export default {
selectTaskIdsByCardId,
makeSelectTasksByCardId,
selectTasksByCardId,
makeSelectAttachmentsTotalByCardId,
selectAttachmentsTotalByCardId,
makeSelectLastActivityIdByCardId,
selectLastActivityIdByCardId,
makeSelectNotificationsByCardId,

View File

@@ -0,0 +1,102 @@
import parseDate from 'date-fns/parse';
const TIME_REGEX =
/^((\d{1,2})((:|\.)?(\d{1,2}))?)(a|p|(am|a\.m\.|midnight|mi|pm|p\.m\.|noon|n))?$/;
const ALTERNATIVE_AM_MERIDIEMS_SET = new Set(['am', 'a.m.', 'midnight', 'mi']);
const ALTERNATIVE_PM_MERIDIEMS_SET = new Set(['pm', 'p.m.', 'noon', 'n']);
const TimeFormats = {
TWENTY_FOUR_HOUR: 'twentyFourHour',
TWELVE_HOUR: 'twelveHour',
};
const PATTERNS_GROUPS_BY_TIME_FORMAT = {
[TimeFormats.TWENTY_FOUR_HOUR]: {
byNumbersTotal: {
1: ['H'],
2: ['HH'],
3: ['Hmm'],
4: ['HHmm'],
},
withDelimiter: ['H:m', 'H:mm', 'HH:m', 'HH:mm'],
},
[TimeFormats.TWELVE_HOUR]: {
byNumbersTotal: {
1: ['haaaaa'],
2: ['hhaaaaa'],
3: ['hmmaaaaa'],
4: ['hhmmaaaaa'],
},
withDelimiter: ['h:maaaaa', 'h:mmaaaaa', 'hh:maaaaa', 'hh:mmaaaaa'],
},
};
const INVALID_DATE = new Date('invalid-date');
const normalizeDelimeter = (delimeter) => (delimeter === '.' ? ':' : delimeter);
const normalizeMeridiem = (meridiem, alternativeMeridiem) => {
if (meridiem && alternativeMeridiem) {
if (ALTERNATIVE_AM_MERIDIEMS_SET.has(alternativeMeridiem)) {
return 'a';
}
if (ALTERNATIVE_PM_MERIDIEMS_SET.has(alternativeMeridiem)) {
return 'p';
}
}
return meridiem;
};
const makeTimeString = (hours, minutes, delimeter, meridiem) => {
let timeString = hours;
if (delimeter) {
timeString += delimeter;
}
if (minutes) {
timeString += minutes;
}
if (meridiem) {
timeString += meridiem;
}
return timeString;
};
export default (string, referenceDate) => {
const match = string.replace(/\s/g, '').toLowerCase().match(TIME_REGEX);
if (!match) {
return INVALID_DATE;
}
const [, hoursAndMinutes, hours, , delimeter, minutes, meridiem, alternativeMeridiem] = match;
const normalizedDelimeter = normalizeDelimeter(delimeter);
const normalizedMeridiem = normalizeMeridiem(meridiem, alternativeMeridiem);
const timeString = makeTimeString(hours, minutes, normalizedDelimeter, normalizedMeridiem);
const timeFormat = meridiem ? TimeFormats.TWELVE_HOUR : TimeFormats.TWENTY_FOUR_HOUR;
const patternsGroups = PATTERNS_GROUPS_BY_TIME_FORMAT[timeFormat];
const patterns = delimeter
? patternsGroups.withDelimiter
: patternsGroups.byNumbersTotal[hoursAndMinutes.length];
if (!referenceDate) {
referenceDate = new Date(); // eslint-disable-line no-param-reassign
}
for (let i = 0; i < patterns.length; i += 1) {
const parsedDate = parseDate(timeString, patterns[i], referenceDate);
if (!Number.isNaN(parsedDate.getTime())) {
return parsedDate;
}
}
return INVALID_DATE;
};

View File

@@ -1 +1 @@
export default '1.23.2';
export default '1.23.4';

View File

@@ -0,0 +1,12 @@
module.exports = {
// environment
adminUser: {
email: 'demo@demo.demo',
password: 'demo',
},
baseUrl: process.env.BASE_URL ?? 'http://localhost:1337/',
// playwright
slowMo: parseInt(process.env.SLOW_MO, 10) || 1000,
timeout: parseInt(process.env.TIMEOUT, 10) || 6000,
headless: process.env.HEADLESS !== 'true',
};

View File

@@ -0,0 +1,35 @@
// cucumber.conf.js file
const { Before, BeforeAll, AfterAll, After, setDefaultTimeout } = require('@cucumber/cucumber');
const { chromium } = require('playwright');
const { deleteProject } = require('./testHelpers/apiHelpers');
const config = require('./config');
setDefaultTimeout(config.timeout);
// launch the browser
BeforeAll(async function () {
global.browser = await chromium.launch({
// makes true for CI
headless: config.headless,
slowMo: config.slowMo,
});
});
// close the browser
AfterAll(async function () {
await global.browser.close();
});
// Create a new browser context and page per scenario
Before(async function () {
global.context = await global.browser.newContext();
global.page = await global.context.newPage();
});
// Cleanup after each scenario
After(async function () {
await deleteProject();
await global.page.close();
await global.context.close();
});

View File

@@ -0,0 +1,10 @@
Feature: dashboard
As a admin
I want to create a project
So that I can manage project
Scenario: create a new project
Given user has browsed to the login page
And user has logged in with email "demo@demo.demo" and password "demo"
When the user creates a project with name "testproject" using the webUI
Then the created project "testproject" should be opened

View File

@@ -0,0 +1,27 @@
Feature: login
As a admin
I want to log in
So that I can manage project
Scenario: User logs in with valid credentials
Given user has browsed to the login page
When user logs in with username "demo@demo.demo" and password "demo" using the webUI
Then the user should be in dashboard page
Scenario Outline: login with invalid username and invalid password
Given user has browsed to the login page
When user logs in with username "<username>" and password "<password>" using the webUI
Then user should see the error message "<message>"
Examples:
| username | password | message |
| spiderman | spidy123 | Invalid credentials |
| ironman | iron123 | Invalid credentials |
| aquaman | aqua123 | Invalid credentials |
Scenario: User can log out
Given user has logged in with email "demo@demo.demo" and password "demo"
When user logs out using the webUI
Then the user should be in the login page

View File

@@ -0,0 +1,16 @@
class DashboardPage {
constructor() {
this.createProjectIconSelector = `.Projects_addTitle__tXhB4`;
this.projectTitleInputSelector = `input[name="name"]`;
this.createProjectButtonSelector = `//button[text()="Create project"]`;
this.projectTitleSelector = `//div[@class="item Header_item__OOEY7 Header_title__l+wMf"][text()="%s"]`;
}
async createProject(project) {
await page.click(this.createProjectIconSelector);
await page.fill(this.projectTitleInputSelector, project);
await page.click(this.createProjectButtonSelector);
}
}
module.exports = DashboardPage;

View File

@@ -0,0 +1,38 @@
const config = require(`../config`);
class LoginPage {
constructor() {
// url
this.homeUrl = config.baseUrl;
this.loginUrl = `${this.homeUrl}login`;
// selectors
this.loginButtonSelector = `//i[@class="right arrow icon"]`;
this.usernameSelector = `//input[@name='emailOrUsername']`;
this.passwordSelector = `//input[@name='password']`;
this.errorMessageSelector = `//div[@class='ui error visible message']`;
this.userActionSelector = `//span[@class="User_initials__9Wp90"]`;
this.logOutSelector = `//a[@class="item UserStep_menuItem__5pvtT"][contains(text(),'Log Out')]`;
}
async goToLoginUrl() {
await page.goto(this.loginUrl);
}
async logOut() {
await page.click(this.userActionSelector);
await page.click(this.logOutSelector);
}
async login(username, password) {
await page.fill(this.usernameSelector, username);
await page.fill(this.passwordSelector, password);
await page.click(this.loginButtonSelector);
}
async getErrorMessage() {
return page.innerText(this.errorMessageSelector);
}
}
module.exports = LoginPage;

View File

@@ -0,0 +1,17 @@
const { When, Then } = require('@cucumber/cucumber');
const util = require('util');
const { expect } = require('playwright/test');
const DashboardPage = require('../pageObjects/DashboardPage');
const dashboardPage = new DashboardPage();
When('the user creates a project with name {string} using the webUI', async function (project) {
await dashboardPage.createProject(project);
});
Then('the created project {string} should be opened', async function (project) {
expect(
await page.locator(util.format(dashboardPage.projectTitleSelector, project)),
).toBeVisible();
});

View File

@@ -0,0 +1,53 @@
const { Given, When, Then } = require('@cucumber/cucumber');
// import expect for assertion
const { expect } = require('@playwright/test');
// import assert
const assert = require('assert');
const LoginPage = require('../pageObjects/LoginPage');
const loginPage = new LoginPage();
Given('user has browsed to the login page', async function () {
await loginPage.goToLoginUrl();
await expect(page).toHaveURL(loginPage.loginUrl);
});
Given(
'user has logged in with email {string} and password {string}',
async function (username, password) {
await loginPage.goToLoginUrl();
await loginPage.login(username, password);
await expect(page).toHaveURL(loginPage.homeUrl);
},
);
When(
'user logs in with username {string} and password {string} using the webUI',
async function (username, password) {
await loginPage.login(username, password);
},
);
Then('the user should be in dashboard page', async function () {
await expect(page).toHaveURL(loginPage.homeUrl);
});
Then('user should see the error message {string}', async function (errorMessage) {
const actualErrorMessage = await loginPage.getErrorMessage();
assert.equal(
actualErrorMessage,
errorMessage,
`Expected message to be "${errorMessage}" but receive "${actualErrorMessage}"`,
);
});
When('user logs out using the webUI', async function () {
await loginPage.logOut();
});
Then('the user should be in the login page', async function () {
await expect(page).toHaveURL(loginPage.loginUrl);
});

View File

@@ -0,0 +1,57 @@
const axios = require('axios');
const config = require('../config');
async function getXauthToken() {
try {
const res = await axios.post(
`${config.baseUrl}api/access-tokens`,
{
emailOrUsername: config.adminUser.email,
password: config.adminUser.password,
},
{
headers: {
'Content-Type': 'application/json',
},
},
);
return res.data.item;
} catch (error) {
return `Error requesting access token: ${error.message}`;
}
}
async function getProjectIDs() {
try {
const res = await axios.get(`${config.baseUrl}api/projects`, {
headers: {
Authorization: `Bearer ${await getXauthToken()}`,
},
});
return res.data.items.map((project) => project.id);
} catch (error) {
return `Error requesting projectIDs: ${error.message}`;
}
}
async function deleteProject() {
try {
const projectIDs = await getProjectIDs();
await Promise.all(
projectIDs.map(async (project) => {
await axios.delete(`${config.baseUrl}api/projects/${project}`, {
headers: {
Authorization: `Bearer ${await getXauthToken()}`,
},
});
}),
);
return true;
} catch (error) {
return `Error deleting project: ${error.message}`;
}
}
module.exports = {
deleteProject,
};

23
client/tests/setup-symlinks.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# This script sets up symbolic links between the client build files and the server directories,
# Navigate to the root directory of the git repository
cd "$(git rev-parse --show-toplevel)" || { echo "Failed to navigate to the git repository root"; exit 1; }
# Store paths for the client build, server public directory, and server views directory
CLIENT_PATH=$(pwd)/client/build
SERVER_PUBLIC_PATH=$(pwd)/server/public
SERVER_VIEWS_PATH=$(pwd)/server/views
# Create symbolic links for the necessary client assets in the server's public and views directories
ln -s ${CLIENT_PATH}/asset-manifest.json ${SERVER_PUBLIC_PATH}/asset-manifest.json && echo "Linked asset-manifest.json successfully"
ln -s ${CLIENT_PATH}/favicon.ico ${SERVER_PUBLIC_PATH}/favicon.ico && echo "Linked favicon.ico successfully"
ln -s ${CLIENT_PATH}/logo192.png ${SERVER_PUBLIC_PATH}/logo192.png && echo "Linked logo192.png successfully"
ln -s ${CLIENT_PATH}/logo512.png ${SERVER_PUBLIC_PATH}/logo512.png && echo "Linked logo512.png successfully"
ln -s ${CLIENT_PATH}/manifest.json ${SERVER_PUBLIC_PATH}/manifest.json && echo "Linked manifest.json successfully"
ln -s ${CLIENT_PATH}/robots.txt ${SERVER_PUBLIC_PATH}/robots.txt && echo "Linked robots.txt successfully"
ln -s ${CLIENT_PATH}/static ${SERVER_PUBLIC_PATH}/static && echo "Linked static folder successfully"
ln -s ${CLIENT_PATH}/index.html ${SERVER_VIEWS_PATH}/index.ejs && echo "Linked index.html to index.ejs successfully"
echo "Setup symbolic links completed successfully."

View File

@@ -2,7 +2,7 @@ version: '3'
services:
postgres:
image: postgres:alpine
image: postgres:16-alpine
restart: unless-stopped
volumes:
- db-data:/var/lib/postgresql/data

View File

@@ -110,7 +110,7 @@ services:
condition: service_healthy
postgres:
image: postgres:latest
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data
environment:

View File

@@ -80,7 +80,7 @@ services:
condition: service_healthy
postgres:
image: postgres:14-alpine
image: postgres:16-alpine
restart: on-failure
volumes:
- db-data:/var/lib/postgresql/data

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "planka",
"version": "1.23.2",
"version": "1.23.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "planka",
"version": "1.23.2",
"version": "1.23.4",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "planka",
"version": "1.23.2",
"version": "1.23.4",
"private": true,
"homepage": "https://plankanban.github.io/planka",
"repository": {
@@ -32,7 +32,7 @@
"test": "npm run server:test && npm run client:test"
},
"lint-staged": {
"client/**/*.{js,jsx}": [
"client/src/**/*.{js,jsx}": [
"npm run client:lint"
],
"server/**/*.js": [

View File

@@ -5,7 +5,6 @@
"packages": {
"": {
"name": "planka-server",
"hasInstallScript": true,
"dependencies": {
"bcrypt": "^5.1.1",
"dotenv": "^16.4.5",
@@ -18,7 +17,7 @@
"nodemailer": "^6.9.15",
"openid-client": "^5.7.0",
"rimraf": "^5.0.10",
"sails": "^1.5.11",
"sails": "^1.5.12",
"sails-hook-orm": "^4.0.3",
"sails-hook-sockets": "^3.0.1",
"sails-postgresql": "^5.0.1",
@@ -755,11 +754,6 @@
"ms": "2.0.0"
}
},
"node_modules/@sailshq/router/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/@sailshq/router/node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1651,11 +1645,6 @@
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/compression/node_modules/safe-buffer": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
@@ -2686,11 +2675,6 @@
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/express-session/node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2765,11 +2749,6 @@
"node": ">= 0.8"
}
},
"node_modules/express/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/express/node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -6490,14 +6469,6 @@
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",

View File

@@ -8,7 +8,6 @@
"db:migrate": "knex migrate:latest --cwd db",
"db:seed": "knex seed:run --cwd db",
"lint": "eslint . --max-warnings=0 --report-unused-disable-directives && echo '✔ Your .js files look good.'",
"preinstall": "npx npm-force-resolutions",
"start": "nodemon",
"start:prod": "node app.js --prod",
"test": "mocha test/lifecycle.test.js test/integration/**/*.test.js test/utils/**/*.test.js"
@@ -39,7 +38,7 @@
"nodemailer": "^6.9.15",
"openid-client": "^5.7.0",
"rimraf": "^5.0.10",
"sails": "^1.5.11",
"sails": "^1.5.12",
"sails-hook-orm": "^4.0.3",
"sails-hook-sockets": "^3.0.1",
"sails-postgresql": "^5.0.1",
@@ -59,11 +58,6 @@
"mocha": "^10.7.3",
"nodemon": "^3.1.4"
},
"resolutions": {
"body-parser": "1.20.3",
"express": "4.21.0",
"send": "0.19.0"
},
"engines": {
"node": ">=18"
}