diff --git a/package.json b/package.json index 8d6345e31..594481b66 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "nodemon": "^3.1.9", "open": "^10.1.0", "prettier": "^3.1.1", + "tailwind-merge": "^3.4.0", "tailwindcss": "^3.3.0", "text-table": "^0.2.0", "tsx": "^4.16.5", diff --git a/src/app/api/tasks/tasks.logger.ts b/src/app/api/tasks/tasks.logger.ts index f2df393ab..379df3670 100644 --- a/src/app/api/tasks/tasks.logger.ts +++ b/src/app/api/tasks/tasks.logger.ts @@ -56,22 +56,33 @@ export class TasksActivityLogger extends BaseService { } if (Array.isArray(this.task.associations) && Array.isArray(prevTask.associations)) { - const currentViewers = AssociationsSchema.parse(this.task.associations) || [] - const prevViewers = AssociationsSchema.parse(prevTask.associations) || [] + const currentAssociations = AssociationsSchema.parse(this.task.associations) || [] + const prevAssociations = AssociationsSchema.parse(prevTask.associations) || [] + const currentShared = this.task.isShared + const prevShared = prevTask.isShared + // handles the case to show activity log when a task is shared with association if ( - (!!currentViewers.length || !!prevViewers.length) && - (currentViewers[0]?.clientId !== prevViewers[0]?.clientId || - currentViewers[0]?.companyId !== prevViewers[0]?.companyId) + (!!currentAssociations.length || !!prevAssociations.length) && + (currentAssociations[0]?.clientId !== prevAssociations[0]?.clientId || + currentAssociations[0]?.companyId !== prevAssociations[0]?.companyId || + currentShared !== prevShared) && + (currentShared || prevShared) ) { - const currentViewerId = currentViewers[0]?.clientId || currentViewers[0]?.companyId || null - const previousViewerId = prevViewers[0]?.clientId || prevViewers[0]?.companyId || null - if (currentViewerId) { - if (previousViewerId) await this.logTaskViewerRemoved(previousViewerId) // if previous viewer exists, log removed event - await this.logTaskViewerUpdated(previousViewerId, currentViewerId) + const currentAssociationId = currentAssociations[0]?.clientId || currentAssociations[0]?.companyId || null + const prevAssociationId = prevAssociations[0]?.clientId || prevAssociations[0]?.companyId || null + + if (currentAssociationId) { + if (prevAssociationId && currentAssociationId !== prevAssociationId && prevShared) + await this.logTaskViewerRemoved(prevAssociationId) // if previous viewer exists, log removed event + if (currentShared) { + await this.logTaskViewerUpdated(prevAssociationId, currentAssociationId) + } else { + await this.logTaskViewerRemoved(currentAssociationId) + } setUpdate() - } else { - await this.logTaskViewerRemoved(previousViewerId) + } else if (prevAssociationId && prevShared) { + await this.logTaskViewerRemoved(prevAssociationId) setUpdate() } } diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index a65551e6d..aff570c7b 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -313,6 +313,13 @@ export class TasksService extends TasksSharedService { } } + private validateTaskShare(prevTask: Task, isTaskShared?: boolean) { + if (prevTask.associations.length) { + return !!isTaskShared + } + throw new APIError(httpStatus.BAD_REQUEST, 'Cannot share task when it has no association') + } + async updateOneTask(id: string, data: UpdateTaskRequest) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Update, Resource.Tasks) @@ -405,6 +412,7 @@ export class TasksService extends TasksSharedService { completedBy, completedByUserType, associations, + isShared: data.isShared !== undefined ? this.validateTaskShare(prevTask, data.isShared) : false, ...userAssignmentFields, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, diff --git a/src/app/detail/[task_id]/[user_type]/actions.ts b/src/app/detail/[task_id]/[user_type]/actions.ts index 085c101d1..e3b814e20 100644 --- a/src/app/detail/[task_id]/[user_type]/actions.ts +++ b/src/app/detail/[task_id]/[user_type]/actions.ts @@ -49,6 +49,7 @@ export const updateAssignee = async ( clientId: string | null, companyId: string | null, associations?: Associations, + isShared?: boolean, ) => { await fetch(`${apiUrl}/api/tasks/${task_id}?token=${token}`, { method: 'PATCH', @@ -57,6 +58,7 @@ export const updateAssignee = async ( clientId, companyId, ...(associations && { associations: !internalUserId ? [] : associations }), // if assignee is not internal user, remove associations. Only include associations if viewer are changed. Not including viewer means not chaning the current state of associations in DB. + ...(isShared && { isShared }), }), }) } diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index fb1ab5e05..b281d7684 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -37,7 +37,7 @@ import { RealTimeTemplates } from '@/hoc/RealtimeTemplates' import { WorkspaceResponse } from '@/types/common' import { AncestorTaskResponse, SubTaskStatusResponse, TaskResponse } from '@/types/dto/tasks.dto' import { UserType } from '@/types/interfaces' -import { getAssigneeCacheLookupKey, UserIdsWithViewersType } from '@/utils/assignee' +import { getAssigneeCacheLookupKey, UserIdsWithAssociationSharedType } from '@/utils/assignee' import { CopilotAPI } from '@/utils/CopilotAPI' import EscapeHandler from '@/utils/escapeHandler' import { getPreviewMode } from '@/utils/previewMode' @@ -257,9 +257,15 @@ export default async function TaskDetailPage(props: { ? await clientUpdateTask(token, task_id, workflowState.id) : await updateWorkflowStateIdOfTask(token, task_id, workflowState?.id) }} - updateAssignee={async ({ internalUserId, clientId, companyId, viewers }: UserIdsWithViewersType) => { + updateAssignee={async ({ + internalUserId, + clientId, + companyId, + associations, + isShared, + }: UserIdsWithAssociationSharedType) => { 'use server' - await updateAssignee(token, task_id, internalUserId, clientId, companyId, viewers) + await updateAssignee(token, task_id, internalUserId, clientId, companyId, associations, isShared) }} updateTask={async (payload) => { 'use server' diff --git a/src/app/detail/ui/ActivityLog.tsx b/src/app/detail/ui/ActivityLog.tsx index 1f78efcf1..3f1a00cb0 100644 --- a/src/app/detail/ui/ActivityLog.tsx +++ b/src/app/detail/ui/ActivityLog.tsx @@ -146,17 +146,15 @@ export const ActivityLog = ({ log }: Prop) => { [ActivityType.COMMENT_ADDED]: () => null, [ActivityType.VIEWER_ADDED]: (_from: string, to: string) => ( <> - added + shared the task with {to} - as a viewer ), [ActivityType.VIEWER_REMOVED]: (from: string) => ( <> - removed + stopped sharing the task with {from} - as a viewer ), diff --git a/src/app/detail/ui/Sidebar.tsx b/src/app/detail/ui/Sidebar.tsx index ccdc621d7..15b79db0c 100644 --- a/src/app/detail/ui/Sidebar.tsx +++ b/src/app/detail/ui/Sidebar.tsx @@ -27,10 +27,11 @@ import { getUserIds, isEmptyAssignee, UserIdsType, - UserIdsWithViewersType, + UserIdsWithAssociationSharedType, } from '@/utils/assignee' import { createDateFromFormattedDateString, formatDate } from '@/utils/dateHelper' import { NoAssignee } from '@/utils/noAssignee' +import { Box, Divider, Skeleton, Stack, styled, SxProps, Typography } from '@mui/material' import { getSelectedUserIds, getSelectedViewerIds, @@ -42,10 +43,10 @@ import { shouldConfirmBeforeReassignment, shouldConfirmViewershipBeforeReassignment, } from '@/utils/shouldConfirmBeforeReassign' -import { Box, Skeleton, Stack, styled, Typography } from '@mui/material' import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { z } from 'zod' +import { CopilotToggle } from '@/components/inputs/CopilotToggle' type StyledTypographyProps = { display?: string @@ -73,7 +74,7 @@ export const Sidebar = ({ selectedWorkflowState: WorkflowStateResponse selectedAssigneeId: string | undefined updateWorkflowState: (workflowState: WorkflowStateResponse) => void - updateAssignee: (userIds: UserIdsWithViewersType) => void + updateAssignee: (userIds: UserIdsWithAssociationSharedType) => void updateTask: (payload: UpdateTaskRequest) => void disabled: boolean workflowDisabled?: boolean @@ -101,7 +102,12 @@ export const Sidebar = ({ const [assigneeValue, setAssigneeValue] = useState() const [selectedAssignee, setSelectedAssignee] = useState(undefined) - const [taskViewerValue, setTaskViewerValue] = useState(null) + const [taskAssociationValue, setTaskAssociationValue] = useState(null) + const [isTaskShared, setIsTaskShared] = useState(false) + + const baseAssociationCondition = assigneeValue && assigneeValue.type === FilterByOptions.IUS + const showShareToggle = baseAssociationCondition && taskAssociationValue + const showAssociation = !assigneeValue || baseAssociationCondition const { renderingItem: _statusValue, updateRenderingItem: updateStatusValue } = useHandleSelectorComponent({ // item: selectedWorkflowState, @@ -129,18 +135,21 @@ export const Sidebar = ({ if (activeTask && assignee.length > 0) { const currentAssignee = getSelectorAssigneeFromTask(assignee, activeTask) setAssigneeValue(currentAssignee) - setTaskViewerValue(getSelectorViewerFromTask(assignee, activeTask) || null) + const currentAssociations = getSelectorViewerFromTask(assignee, activeTask) || null + setTaskAssociationValue(currentAssociations) + setIsTaskShared(!!activeTask.isShared) } }, [assignee, activeTask]) const windowWidth = useWindowWidth() const isMobile = windowWidth < 800 && windowWidth !== 0 - const checkViewersCompatibility = (userIds: UserIdsType): UserIdsWithViewersType => { + const checkViewersCompatibility = (userIds: UserIdsType): UserIdsWithAssociationSharedType => { // remove task viewers if assignee is cleared or changed to client or company if (!userIds.internalUserId) { - setTaskViewerValue(null) - return { ...userIds, viewers: [] } // remove viewers if assignee is cleared or changed to client or company + setTaskAssociationValue(null) + setIsTaskShared(false) + return { ...userIds, associations: [], isShared: false } // remove viewers if assignee is cleared or changed to client or company } return userIds // no viewers change. keep viewers as is. } @@ -176,7 +185,7 @@ export const Sidebar = ({ const previousAssignee = assignee.find((assignee) => assignee.id == getAssigneeId(getUserIds(activeTask))) const nextAssignee = getSelectorAssignee(assignee, inputValue) const shouldShowConfirmModal = shouldConfirmBeforeReassignment(previousAssignee, nextAssignee) - const shouldShowConfirmViewershipModal = shouldConfirmViewershipBeforeReassignment(taskViewerValue, nextAssignee) + const shouldShowConfirmViewershipModal = shouldConfirmViewershipBeforeReassignment(taskAssociationValue, nextAssignee) if (shouldShowConfirmModal) { setSelectedAssignee(newUserIds) store.dispatch(toggleShowConfirmAssignModal()) @@ -189,21 +198,35 @@ export const Sidebar = ({ } } - const handleTaskViewerChange = (inputValue: InputValue[]) => { - if (assigneeValue && assigneeValue.type === FilterByOptions.IUS) { + const handleTaskAssociationChange = (inputValue: InputValue[]) => { + if (showAssociation) { const newTaskViewerIds = getSelectedViewerIds(inputValue) - setTaskViewerValue(getSelectorAssignee(assignee, inputValue) || null) + setTaskAssociationValue(getSelectorAssignee(assignee, inputValue) || null) newTaskViewerIds && updateAssignee({ - internalUserId: assigneeValue.id, + internalUserId: assigneeValue ? assigneeValue.id : null, clientId: null, companyId: null, - viewers: newTaskViewerIds, + associations: newTaskViewerIds, + isShared: isTaskShared, }) } } + const handleTaskShared = () => { + if (showShareToggle) { + setIsTaskShared((prev) => !prev) + + updateAssignee({ + internalUserId: assigneeValue.id, + clientId: null, + companyId: null, + isShared: !isTaskShared, + }) + } + } + if (!showSidebar || fromNotificationCenter) { return ( } + startIcon={} buttonContent={ (taskViewerValue ? theme.color.gray[600] : theme.color.gray[400]), + color: (theme) => (taskAssociationValue ? theme.color.gray[600] : theme.color.gray[400]), textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', maxWidth: '135px', }} > - {getAssigneeName(taskViewerValue || undefined, 'Set client visibility')} + {getAssigneeName(taskAssociationValue || undefined, 'Set related to')} } /> @@ -366,8 +389,8 @@ export const Sidebar = ({ ) : ( <> - {getAssigneeName(getAssigneeValueFromViewers(taskViewerValue, assignee))} will also lose - visibility to the task. + {getAssigneeName(getAssigneeValueFromViewers(taskAssociationValue, assignee))} will also + lose visibility to the task. ) } @@ -517,10 +540,10 @@ export const Sidebar = ({ )} - {assigneeValue && assigneeValue.type === FilterByOptions.IUS && ( - + {showAssociation && ( + - Client visibility + Related to {assignee.length > 0 ? ( // show skeleton if assignee list is empty } + startIcon={} outlined={true} buttonContent={ (taskViewerValue ? theme.color.gray[600] : theme.color.gray[400]), + color: (theme) => (taskAssociationValue ? theme.color.gray[600] : theme.color.gray[400]), textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', @@ -558,7 +581,7 @@ export const Sidebar = ({ fontWeight: 400, }} > - {getAssigneeName(taskViewerValue || undefined, 'Set client visibility')} + {getAssigneeName(taskAssociationValue || undefined, 'Set related to')} } /> @@ -570,6 +593,12 @@ export const Sidebar = ({ )} )} + {showShareToggle && ( + <> + theme.color.borders.border, height: '1px' }} /> + + + )} ) : ( <> - {getAssigneeName(getAssigneeValueFromViewers(taskViewerValue, assignee))} will also lose - visibility to the task. + {getAssigneeName(getAssigneeValueFromViewers(taskAssociationValue, assignee))} will also + lose visibility to the task. ) } diff --git a/src/app/globals.css b/src/app/globals.css index 3858b316e..3a6ba3e57 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -98,7 +98,6 @@ a { /* Custom toggle wrapper to override design system styles */ .copilot-toggle-wrapper { - padding: 8px 6px 8px; & .cop-font-medium { font-weight: 400; color: var(--text-secondary); diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index 6a030bcab..f40b2c7dd 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -435,6 +435,7 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => ) } checked={store.getState().createTask.isShared} + className="p-1.5 py-2" // px-1.5 is not working /> )} diff --git a/src/components/inputs/CopilotToggle.tsx b/src/components/inputs/CopilotToggle.tsx index 5a4fbc41b..1c6d5bd2c 100644 --- a/src/components/inputs/CopilotToggle.tsx +++ b/src/components/inputs/CopilotToggle.tsx @@ -1,14 +1,15 @@ +import { cn } from '@/utils/twMerge' import { Toggle } from 'copilot-design-system' type CopilotToggleProps = { label: string onChange: () => void checked: boolean -} +} & React.HTMLAttributes -export const CopilotToggle = ({ label, onChange, checked }: CopilotToggleProps) => { +export const CopilotToggle = ({ label, onChange, checked, className }: CopilotToggleProps) => { return ( -
+
) diff --git a/src/types/dto/tasks.dto.ts b/src/types/dto/tasks.dto.ts index 2b4e6bce9..2e99eb6ad 100644 --- a/src/types/dto/tasks.dto.ts +++ b/src/types/dto/tasks.dto.ts @@ -98,9 +98,9 @@ export const UpdateTaskRequestSchema = z clientId: z.string().uuid().nullish(), companyId: z.string().uuid().nullish(), associations: AssociationsSchema, //right now, we only need the feature to have max of 1 viewer per task + isShared: z.boolean().optional(), }) .superRefine(validateUserIds) - .superRefine(validateAssociationAndTaskShare) export type UpdateTaskRequest = z.infer @@ -129,6 +129,7 @@ export const TaskResponseSchema = z.object({ clientId: z.string().uuid().nullish(), companyId: z.string().uuid().nullish(), associations: AssociationsSchema, + isShared: z.boolean().optional(), }) export type TaskResponse = z.infer diff --git a/src/utils/assignee.ts b/src/utils/assignee.ts index d291cd734..6e5edeb0e 100644 --- a/src/utils/assignee.ts +++ b/src/utils/assignee.ts @@ -17,7 +17,7 @@ export const UserIdsSchema = z.object({ export type UserIdsType = z.infer -export type UserIdsWithViewersType = UserIdsType & { viewers?: Associations } +export type UserIdsWithAssociationSharedType = UserIdsType & { associations?: Associations; isShared?: boolean } export const isAssigneeTextMatching = (newInputValue: string, assigneeValue: IAssigneeCombined): boolean => { const truncate = (newInputValue: string) => truncateText(newInputValue, TruncateMaxNumber.SELECTOR) diff --git a/src/utils/twMerge.ts b/src/utils/twMerge.ts new file mode 100644 index 000000000..d32b0fe65 --- /dev/null +++ b/src/utils/twMerge.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/yarn.lock b/yarn.lock index dd0819973..dfb833c35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9520,6 +9520,11 @@ tailwind-merge@^2.3.0: resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5" integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA== +tailwind-merge@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz#5a264e131a096879965f1175d11f8c36e6b64eca" + integrity sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g== + tailwindcss@^3.3.0: version "3.4.17" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63"