mirror of
https://github.com/plankanban/planka.git
synced 2025-07-19 05:09:43 +02:00
Add GitHub link field to tasks
Add a custom field named "Github Link" to tasks and display it in the frontend. * Add `githubLink` attribute to the `Task` model in `client/src/models/Task.js`. * Update `createTask` and `updateTask` actions in `client/src/actions/tasks.js` to handle the `githubLink` attribute. * Add input field for `githubLink` in `CardModal` component in `client/src/components/CardModal/CardModal.jsx`. * Display `githubLink` as a clickable link when filled in. * Display a button that expands into an input field when `githubLink` is not filled in. * Include `githubLink` attribute in card data retrieval logic in `server/api/controllers/cards/show.js`. * Include `githubLink` attribute in card data updating logic in `server/api/controllers/cards/update.js`. * Add validation function for `githubLink` in `client/src/utils/validator.js`. * Add unit tests for `githubLink` field in `client/src/components/CardModal/CardModal.test.js`. * Test display of `githubLink` as a clickable link when filled in. * Test display of button to add `githubLink` when not filled in. * Test input field display when add `githubLink` button is clicked. * Test `onUpdate` call with `githubLink` when save button is clicked. * Test validation of `githubLink` format.
This commit is contained in:
parent
b88a2894b6
commit
bcc92dfa76
6 changed files with 193 additions and 2 deletions
40
client/src/components/CardModal/CardModal.jsx
Executable file → Normal file
40
client/src/components/CardModal/CardModal.jsx
Executable file → Normal file
|
@ -2,7 +2,7 @@ import React, { useCallback, useRef, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Checkbox, Grid, Icon, Modal } from 'semantic-ui-react';
|
import { Button, Checkbox, Grid, Icon, Modal, Input } from 'semantic-ui-react';
|
||||||
import { usePopup } from '../../lib/popup';
|
import { usePopup } from '../../lib/popup';
|
||||||
import { Markdown } from '../../lib/custom-ui';
|
import { Markdown } from '../../lib/custom-ui';
|
||||||
|
|
||||||
|
@ -83,6 +83,8 @@ const CardModal = React.memo(
|
||||||
}) => {
|
}) => {
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [isLinkCopied, setIsLinkCopied] = useState(false);
|
const [isLinkCopied, setIsLinkCopied] = useState(false);
|
||||||
|
const [isGithubLinkInputVisible, setIsGithubLinkInputVisible] = useState(false);
|
||||||
|
const [githubLink, setGithubLink] = useState('');
|
||||||
|
|
||||||
const isGalleryOpened = useRef(false);
|
const isGalleryOpened = useRef(false);
|
||||||
|
|
||||||
|
@ -178,6 +180,17 @@ const CardModal = React.memo(
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleGithubLinkChange = useCallback((e) => {
|
||||||
|
setGithubLink(e.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGithubLinkSave = useCallback(() => {
|
||||||
|
onUpdate({
|
||||||
|
githubLink,
|
||||||
|
});
|
||||||
|
setIsGithubLinkInputVisible(false);
|
||||||
|
}, [githubLink, onUpdate]);
|
||||||
|
|
||||||
const AttachmentAddPopup = usePopup(AttachmentAddStep);
|
const AttachmentAddPopup = usePopup(AttachmentAddStep);
|
||||||
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
|
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
|
||||||
const LabelsPopup = usePopup(LabelsStep);
|
const LabelsPopup = usePopup(LabelsStep);
|
||||||
|
@ -433,6 +446,31 @@ const CardModal = React.memo(
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className={styles.contentModule}>
|
||||||
|
<div className={styles.moduleWrapper}>
|
||||||
|
<Icon name="github" className={styles.moduleIcon} />
|
||||||
|
<div className={styles.moduleHeader}>{t('common.githubLink')}</div>
|
||||||
|
{githubLink ? (
|
||||||
|
<a href={githubLink} target="_blank" rel="noopener noreferrer">
|
||||||
|
{githubLink}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setIsGithubLinkInputVisible(true)}>
|
||||||
|
{t('action.addGithubLink')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isGithubLinkInputVisible && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
value={githubLink}
|
||||||
|
onChange={handleGithubLinkChange}
|
||||||
|
placeholder={t('common.enterGithubLink')}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleGithubLinkSave}>{t('action.save')}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Activities
|
<Activities
|
||||||
items={activities}
|
items={activities}
|
||||||
isFetching={isActivitiesFetching}
|
isFetching={isActivitiesFetching}
|
||||||
|
|
120
client/src/components/CardModal/CardModal.test.js
Normal file
120
client/src/components/CardModal/CardModal.test.js
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent, screen } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
import CardModal from './CardModal';
|
||||||
|
|
||||||
|
describe('CardModal', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
name: 'Test Card',
|
||||||
|
description: 'Test Description',
|
||||||
|
dueDate: new Date(),
|
||||||
|
isDueDateCompleted: false,
|
||||||
|
stopwatch: { startedAt: null, total: 0 },
|
||||||
|
isSubscribed: false,
|
||||||
|
isActivitiesFetching: false,
|
||||||
|
isAllActivitiesFetched: false,
|
||||||
|
isActivitiesDetailsVisible: false,
|
||||||
|
isActivitiesDetailsFetching: false,
|
||||||
|
listId: '1',
|
||||||
|
boardId: '1',
|
||||||
|
projectId: '1',
|
||||||
|
users: [],
|
||||||
|
labels: [],
|
||||||
|
tasks: [],
|
||||||
|
attachments: [],
|
||||||
|
activities: [],
|
||||||
|
allProjectsToLists: [],
|
||||||
|
allBoardMemberships: [],
|
||||||
|
allLabels: [],
|
||||||
|
canEdit: true,
|
||||||
|
canEditCommentActivities: true,
|
||||||
|
canEditAllCommentActivities: true,
|
||||||
|
onUpdate: jest.fn(),
|
||||||
|
onMove: jest.fn(),
|
||||||
|
onTransfer: jest.fn(),
|
||||||
|
onDuplicate: jest.fn(),
|
||||||
|
onDelete: jest.fn(),
|
||||||
|
onUserAdd: jest.fn(),
|
||||||
|
onUserRemove: jest.fn(),
|
||||||
|
onBoardFetch: jest.fn(),
|
||||||
|
onLabelAdd: jest.fn(),
|
||||||
|
onLabelRemove: jest.fn(),
|
||||||
|
onLabelCreate: jest.fn(),
|
||||||
|
onLabelUpdate: jest.fn(),
|
||||||
|
onLabelMove: jest.fn(),
|
||||||
|
onLabelDelete: jest.fn(),
|
||||||
|
onTaskCreate: jest.fn(),
|
||||||
|
onTaskUpdate: jest.fn(),
|
||||||
|
onTaskMove: jest.fn(),
|
||||||
|
onTaskDelete: jest.fn(),
|
||||||
|
onAttachmentCreate: jest.fn(),
|
||||||
|
onAttachmentUpdate: jest.fn(),
|
||||||
|
onAttachmentDelete: jest.fn(),
|
||||||
|
onActivitiesFetch: jest.fn(),
|
||||||
|
onActivitiesDetailsToggle: jest.fn(),
|
||||||
|
onCommentActivityCreate: jest.fn(),
|
||||||
|
onCommentActivityUpdate: jest.fn(),
|
||||||
|
onCommentActivityDelete: jest.fn(),
|
||||||
|
onClose: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should display the github link as a clickable link when filled in', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
githubLink: 'https://github.com/test/repo',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<CardModal {...props} />);
|
||||||
|
|
||||||
|
const githubLink = screen.getByText('https://github.com/test/repo');
|
||||||
|
expect(githubLink).toBeInTheDocument();
|
||||||
|
expect(githubLink).toHaveAttribute('href', 'https://github.com/test/repo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a button to add github link when not filled in', () => {
|
||||||
|
render(<CardModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const addButton = screen.getByText('Add Github Link');
|
||||||
|
expect(addButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display an input field when the add github link button is clicked', () => {
|
||||||
|
render(<CardModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const addButton = screen.getByText('Add Github Link');
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const inputField = screen.getByPlaceholderText('Enter Github Link');
|
||||||
|
expect(inputField).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onUpdate with the github link when the save button is clicked', () => {
|
||||||
|
render(<CardModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const addButton = screen.getByText('Add Github Link');
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const inputField = screen.getByPlaceholderText('Enter Github Link');
|
||||||
|
fireEvent.change(inputField, { target: { value: 'https://github.com/test/repo' } });
|
||||||
|
|
||||||
|
const saveButton = screen.getByText('Save');
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
expect(defaultProps.onUpdate).toHaveBeenCalledWith({ githubLink: 'https://github.com/test/repo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate the github link format', () => {
|
||||||
|
render(<CardModal {...defaultProps} />);
|
||||||
|
|
||||||
|
const addButton = screen.getByText('Add Github Link');
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const inputField = screen.getByPlaceholderText('Enter Github Link');
|
||||||
|
fireEvent.change(inputField, { target: { value: 'invalid-link' } });
|
||||||
|
|
||||||
|
const saveButton = screen.getByText('Save');
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
expect(defaultProps.onUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
1
client/src/models/Task.js
Executable file → Normal file
1
client/src/models/Task.js
Executable file → Normal file
|
@ -14,6 +14,7 @@ export default class extends BaseModel {
|
||||||
isCompleted: attr({
|
isCompleted: attr({
|
||||||
getDefault: () => false,
|
getDefault: () => false,
|
||||||
}),
|
}),
|
||||||
|
githubLink: attr(), // New attribute for GitHub link
|
||||||
cardId: fk({
|
cardId: fk({
|
||||||
to: 'Card',
|
to: 'Card',
|
||||||
as: 'card',
|
as: 'card',
|
||||||
|
|
|
@ -6,3 +6,13 @@ export const isPassword = (string) => zxcvbn(string).score >= 2; // TODO: move t
|
||||||
|
|
||||||
export const isUsername = (string) =>
|
export const isUsername = (string) =>
|
||||||
string.length >= 3 && string.length <= 16 && USERNAME_REGEX.test(string);
|
string.length >= 3 && string.length <= 16 && USERNAME_REGEX.test(string);
|
||||||
|
|
||||||
|
export const isGithubLink = (string) => {
|
||||||
|
const urlPattern = new RegExp('^(https?:\\/\\/)?'+ // validate the protocol
|
||||||
|
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.?)+[a-z]{2,}|'+ // validate the domain name
|
||||||
|
'((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR validate the IP (v4) address
|
||||||
|
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // validate the port and path
|
||||||
|
'(\\?[;&a-z\\d%_.~+=-]*)?'+ // validate the query string
|
||||||
|
'(\\#[-a-z\\d_]*)?$','i'); // validate the fragment locator
|
||||||
|
return !!urlPattern.test(string) && string.startsWith('https://github.com/');
|
||||||
|
};
|
||||||
|
|
8
server/api/controllers/cards/show.js
Executable file → Normal file
8
server/api/controllers/cards/show.js
Executable file → Normal file
|
@ -46,12 +46,18 @@ module.exports = {
|
||||||
const tasks = await sails.helpers.cards.getTasks(card.id);
|
const tasks = await sails.helpers.cards.getTasks(card.id);
|
||||||
const attachments = await sails.helpers.cards.getAttachments(card.id);
|
const attachments = await sails.helpers.cards.getAttachments(card.id);
|
||||||
|
|
||||||
|
// Include the githubLink attribute in the tasks
|
||||||
|
const tasksWithGithubLink = tasks.map((task) => ({
|
||||||
|
...task,
|
||||||
|
githubLink: task.githubLink || null,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item: card,
|
item: card,
|
||||||
included: {
|
included: {
|
||||||
cardMemberships,
|
cardMemberships,
|
||||||
cardLabels,
|
cardLabels,
|
||||||
tasks,
|
tasks: tasksWithGithubLink,
|
||||||
attachments,
|
attachments,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
16
server/api/controllers/cards/update.js
Executable file → Normal file
16
server/api/controllers/cards/update.js
Executable file → Normal file
|
@ -43,6 +43,16 @@ const stopwatchValidator = (value) => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const githubLinkValidator = (value) => {
|
||||||
|
const urlPattern = new RegExp('^(https?:\\/\\/)?'+ // validate the protocol
|
||||||
|
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.?)+[a-z]{2,}|'+ // validate the domain name
|
||||||
|
'((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR validate the IP (v4) address
|
||||||
|
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // validate the port and path
|
||||||
|
'(\\?[;&a-z\\d%_.~+=-]*)?'+ // validate the query string
|
||||||
|
'(\\#[-a-z\\d_]*)?$','i'); // validate the fragment locator
|
||||||
|
return !!urlPattern.test(value) && value.startsWith('https://github.com/');
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
inputs: {
|
inputs: {
|
||||||
id: {
|
id: {
|
||||||
|
@ -91,6 +101,11 @@ module.exports = {
|
||||||
isSubscribed: {
|
isSubscribed: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
},
|
},
|
||||||
|
githubLink: {
|
||||||
|
type: 'string',
|
||||||
|
custom: githubLinkValidator,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
exits: {
|
exits: {
|
||||||
|
@ -180,6 +195,7 @@ module.exports = {
|
||||||
'isDueDateCompleted',
|
'isDueDateCompleted',
|
||||||
'stopwatch',
|
'stopwatch',
|
||||||
'isSubscribed',
|
'isSubscribed',
|
||||||
|
'githubLink',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
card = await sails.helpers.cards.updateOne
|
card = await sails.helpers.cards.updateOne
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue