diff --git a/client/src/components/CardModal/CardModal.jsx b/client/src/components/CardModal/CardModal.jsx old mode 100755 new mode 100644 index b31a1f05..7fb02b92 --- a/client/src/components/CardModal/CardModal.jsx +++ b/client/src/components/CardModal/CardModal.jsx @@ -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( )} +
+
+ +
{t('common.githubLink')}
+ {githubLink ? ( + + {githubLink} + + ) : ( + + )} + {isGithubLinkInputVisible && ( +
+ + +
+ )} +
+
{ + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/client/src/models/Task.js b/client/src/models/Task.js old mode 100755 new mode 100644 index bb6b10be..02cd9e5e --- a/client/src/models/Task.js +++ b/client/src/models/Task.js @@ -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', diff --git a/client/src/utils/validator.js b/client/src/utils/validator.js index 6d391a79..5aae28aa 100644 --- a/client/src/utils/validator.js +++ b/client/src/utils/validator.js @@ -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/'); +}; diff --git a/server/api/controllers/cards/show.js b/server/api/controllers/cards/show.js old mode 100755 new mode 100644 index bc37eb06..b6074ab2 --- a/server/api/controllers/cards/show.js +++ b/server/api/controllers/cards/show.js @@ -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, }, }; diff --git a/server/api/controllers/cards/update.js b/server/api/controllers/cards/update.js old mode 100755 new mode 100644 index df1c8c66..cee83a27 --- a/server/api/controllers/cards/update.js +++ b/server/api/controllers/cards/update.js @@ -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