1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +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:
Mikhail Khromov 2025-02-26 21:56:32 +03:00
parent b88a2894b6
commit bcc92dfa76
6 changed files with 193 additions and 2 deletions

40
client/src/components/CardModal/CardModal.jsx Executable file → Normal file
View file

@ -2,7 +2,7 @@ import React, { useCallback, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
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 { Markdown } from '../../lib/custom-ui';
@ -83,6 +83,8 @@ const CardModal = React.memo(
}) => {
const [t] = useTranslation();
const [isLinkCopied, setIsLinkCopied] = useState(false);
const [isGithubLinkInputVisible, setIsGithubLinkInputVisible] = useState(false);
const [githubLink, setGithubLink] = useState('');
const isGalleryOpened = useRef(false);
@ -178,6 +180,17 @@ const CardModal = React.memo(
onClose();
}, [onClose]);
const handleGithubLinkChange = useCallback((e) => {
setGithubLink(e.target.value);
}, []);
const handleGithubLinkSave = useCallback(() => {
onUpdate({
githubLink,
});
setIsGithubLinkInputVisible(false);
}, [githubLink, onUpdate]);
const AttachmentAddPopup = usePopup(AttachmentAddStep);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep);
@ -433,6 +446,31 @@ const CardModal = React.memo(
</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
items={activities}
isFetching={isActivitiesFetching}

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

@ -14,6 +14,7 @@ export default class extends BaseModel {
isCompleted: attr({
getDefault: () => false,
}),
githubLink: attr(), // New attribute for GitHub link
cardId: fk({
to: 'Card',
as: 'card',

View file

@ -6,3 +6,13 @@ export const isPassword = (string) => zxcvbn(string).score >= 2; // TODO: move t
export const isUsername = (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
View file

@ -46,12 +46,18 @@ module.exports = {
const tasks = await sails.helpers.cards.getTasks(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 {
item: card,
included: {
cardMemberships,
cardLabels,
tasks,
tasks: tasksWithGithubLink,
attachments,
},
};

16
server/api/controllers/cards/update.js Executable file → Normal file
View file

@ -43,6 +43,16 @@ const stopwatchValidator = (value) => {
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 = {
inputs: {
id: {
@ -91,6 +101,11 @@ module.exports = {
isSubscribed: {
type: 'boolean',
},
githubLink: {
type: 'string',
custom: githubLinkValidator,
allowNull: true,
},
},
exits: {
@ -180,6 +195,7 @@ module.exports = {
'isDueDateCompleted',
'stopwatch',
'isSubscribed',
'githubLink',
]);
card = await sails.helpers.cards.updateOne