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:
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 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}
|
||||
|
|
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({
|
||||
getDefault: () => false,
|
||||
}),
|
||||
githubLink: attr(), // New attribute for GitHub link
|
||||
cardId: fk({
|
||||
to: 'Card',
|
||||
as: 'card',
|
||||
|
|
|
@ -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
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 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
16
server/api/controllers/cards/update.js
Executable file → Normal 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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue