diff --git a/cypress/e2e/projects.cy.ts b/cypress/e2e/projects.cy.ts index bb8e20d6c..5d8e804c6 100644 --- a/cypress/e2e/projects.cy.ts +++ b/cypress/e2e/projects.cy.ts @@ -19,6 +19,7 @@ import { projectsCombobox, filtersPanelTitle, dashboardLoadMore, + goalTableList, } from '../../src/utils/domObjects'; import { exactUrl } from '../helpers'; import { routes } from '../../src/hooks/router'; @@ -38,6 +39,10 @@ const testProjectKeyRu = keyPredictor(testProjectTitleRu); const customKeyRu = 'КАСТОМНЫЙ КЛЮЧ РУ'; const customKeyRuPredict = keyPredictor(customKeyRu, { allowVowels: true }); +before(() => { + cy.loadLangFile(); +}); + describe('Projects', () => { beforeEach(() => { cy.signInViaEmail(); @@ -255,3 +260,75 @@ describe('Projects', () => { }); }); }); + +describe('Personal project', () => { + const testUser = { + name: 'Test user 1', + email: 'user1@taskany.org', + password: 'password', + provider: 'provider1', + }; + + const testUser2 = { + name: 'Test user 2', + email: 'user2@taskany.org', + password: 'password', + provider: 'provider2', + }; + + before(() => { + cy.task('db:create:user', testUser).then((u) => { + Cypress.env('testUser', u); + }); + cy.task('db:create:user', testUser2).then((u) => { + Cypress.env('testUser2', u); + }); + }); + + after(() => { + cy.task('db:remove:user', Cypress.env('testUser')); + cy.task('db:remove:user', Cypress.env('testUser2')); + }); + + beforeEach(() => { + cy.interceptWhatsNew(); + cy.intercept('/api/trpc/*v2.project.getUserDashboardProjects*?*').as('userDashboard'); + cy.intercept('/api/trpc/*v2.project.getProjectGoalsById*?*').as('goalsByProject'); + cy.signInViaEmail(testUser); + cy.wait('@whatsnew.check'); + // @ts-ignore + cy.createPersonalGoal({ + title: 'Personal test goal', + description: 'Personal test goal description', + mode: 'personal', + }); + }); + + it('should be able on personal dashboard', () => { + cy.wait(['@userDashboard', '@goalsByProject']); + + cy.get(projectListItem.query) + .should('exist') + .get(projectListItemTitle.query) + .should('include.text', testUser.name); + + cy.get(projectListItem.query) + .get(goalTableList.query) + .should('have.length.gt', 0) + .and('contain.text', 'Personal test goal'); + cy.logout(); + + cy.signInViaEmail(testUser2); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + cy.visit(routes.project(Cypress.env('createdGoal')!.projectId as string), { + failOnStatusCode: false, + }); + cy.get('body').should('contain.text', '404'); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + cy.visit(routes.goal(Cypress.env('createdGoal')!._shortId as string), { + failOnStatusCode: false, + }); + cy.get('body').should('contain.text', '404'); + }); +}); diff --git a/cypress/index.d.ts b/cypress/index.d.ts index eb92799bc..361bed6fe 100644 --- a/cypress/index.d.ts +++ b/cypress/index.d.ts @@ -57,6 +57,7 @@ declare global { hideEmptyProjectOnGoalLists(): Chainable; createProject(fields: ProjectCreate): Chainable; createGoal(projectTitle: string, fields: GoalCommon): Chainable; + createPersonalGoal(fields: GoalCommon): Chainable; updateGoal(shortId: string, filelds: GoalUpdate): Chainable; deleteGoal(shortId: string): Chainable; createComment(fields: GoalCommentCreateSchema): Chainable; @@ -64,7 +65,7 @@ declare global { deleteComment(id: string): Chainable; task( event: 'db:create:project', - data: { title: string; key: string; description?: string; ownerEmail: string }, + data: { title: string; key: string; description?: string; ownerEmail: string; personal?: boolean }, ): Chainable; task(event: 'db:remove:project', data: { id: string }): Chainable; task( @@ -75,7 +76,7 @@ declare global { task(event: 'db:remove:user', data?: { ids: string[] }): Chainable; task( event: 'db:create:goal', - data: { title: string; projectId: string; ownerEmail: string }, + data: { title: string; projectId: string; ownerEmail: string; private?: boolean }, ): Chainable; task(event: 'db:create:tag', data: { title: string; userEmail: string }): Chainable; task(event: 'db:remove:tag', data: { id: string }): Chainable; diff --git a/cypress/support/index.ts b/cypress/support/index.ts index eb24191a0..f913b6aa9 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -33,6 +33,10 @@ import { sortPanelDropdownTrigger, sortPanel, sortPanelEmptyProjectsCheckbox, + goalPersonalityToggle, + createPersonalGoalItem, + estimateCombobox, + estimateQuarterTrigger, } from '../../src/utils/domObjects'; import { keyPredictor } from '../../src/utils/keyPredictor'; import { SignInFields } from '..'; @@ -40,6 +44,8 @@ import { GoalCommentCreateSchema, GoalCommon, GoalUpdate } from '../../src/schem import { CommentEditSchema } from '../../src/schema/comment'; import { ProjectCreate } from '../../src/schema/project'; +import { getTranslation } from './lang'; + Cypress.Commands.addAll({ logout: () => { cy.visit(routes.userSettings()); @@ -133,6 +139,41 @@ Cypress.Commands.addAll({ }); }, + createPersonalGoal: (fields: GoalCommon) => { + const translations = getTranslation({ + Dropdown: ['Not chosen'], + EstimateDropdown: ['Choose quarter'], + }); + cy.intercept('/api/trpc/*goal.create?*').as('createGoalRequest'); + cy.get(createSelectButton.query).click(); + cy.get(createPersonalGoalItem.query).click(); + cy.get(goalForm.query).should('exist').and('be.visible'); + cy.get(goalTitleInput.query).type(fields.title); + cy.get(goalDescriptionInput.query).type(fields.description); + cy.get(goalPersonalityToggle.query).should('exist'); + cy.get(projectsCombobox.query).should('not.exist'); + + cy.get(estimateCombobox.query).should('contain.text', translations.Dropdown['Not chosen']()).click(); + cy.get(estimateQuarterTrigger.query) + .find(`:button:contains(${translations.EstimateDropdown['Choose quarter']()})`) + .click(); + + cy.get(estimateQuarterTrigger.query).children().find(':button:contains(@current)').click(); + cy.get(estimateCombobox.query).should('not.contain.text', translations.Dropdown['Not chosen']()).click(); + + cy.get(goalActionCreateOnly.query).should('exist').and('be.visible').and('be.enabled'); + cy.get(goalActionCreateOnly.query).click(); + + cy.wait('@createGoalRequest') + .its('response') + .then((res) => { + const createdGoal = res.body[0].result.data; + + Cypress.env('createdGoal', createdGoal); + cy.wrap(createdGoal).as('createdGoal'); + }); + }, + updateGoal: (shortId: string, fields: GoalUpdate) => { cy.visit(routes.goal(shortId)); cy.intercept('/api/trpc/goal.update?*').as('updateGoal'); @@ -168,7 +209,7 @@ Cypress.Commands.addAll({ const createdGoal = Cypress.env('createdGoal'); - if (createdGoal._shortId === shortId) { + if (createdGoal?._shortId === shortId) { Cypress.env('createdGoal', null); } }); @@ -237,7 +278,7 @@ Cypress.Commands.addAll({ const createdComment = Cypress.env('createdComment'); - if (createdComment.id === id) { + if (createdComment?.id === id) { Cypress.env('createdComment', null); } }); diff --git a/package-lock.json b/package-lock.json index 2e9c6ed48..259dda531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26304,6 +26304,126 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/react-email/node_modules/@next/swc-darwin-x64": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.2.tgz", + "integrity": "sha512-iZuYr7ZvGLPjPmfhhMl0ISm+z8EiyLBC1bLyFwGBxkWmPXqdJ60mzuTaDSr5WezDwv0fz32HB7JHmRC6JVHSZg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/react-email/node_modules/@next/swc-linux-arm64-gnu": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.2.tgz", + "integrity": "sha512-2xVabFtIge6BJTcJrW8YuUnYTuQjh4jEuRuS2mscyNVOj6zUZkom3CQg+egKOoS+zh2rrro66ffSKIS+ztFJTg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/react-email/node_modules/@next/swc-linux-arm64-musl": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.2.tgz", + "integrity": "sha512-wKRCQ27xCUJx5d6IivfjYGq8oVngqIhlhSAJntgXLt7Uo9sRT/3EppMHqUZRfyuNBTbykEre1s5166z+pvRB5A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/react-email/node_modules/@next/swc-linux-x64-gnu": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.2.tgz", + "integrity": "sha512-NpCa+UVhhuNeaFVUP1Bftm0uqtvLWq2JTm7+Ta48+2Uqj2mNXrDIvyn1DY/ZEfmW/1yvGBRaUAv9zkMkMRixQA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/react-email/node_modules/@next/swc-linux-x64-musl": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.2.tgz", + "integrity": "sha512-ZWVC72x0lW4aj44e3khvBrj2oSYj1bD0jESmyah3zG/3DplEy/FOtYkMzbMjHTdDSheso7zH8GIlW6CDQnKhmQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/react-email/node_modules/@next/swc-win32-arm64-msvc": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.2.tgz", + "integrity": "sha512-pLT+OWYpzJig5K4VKhLttlIfBcVZfr2+Xbjra0Tjs83NQSkFS+y7xx+YhCwvpEmXYLIvaggj2ONPyjbiigOvHQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/react-email/node_modules/@next/swc-win32-ia32-msvc": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.2.tgz", + "integrity": "sha512-dhpiksQCyGca4WY0fJyzK3FxMDFoqMb0Cn+uDB+9GYjpU2K5//UGPQlCwiK4JHxuhg8oLMag5Nf3/IPSJNG8jw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/react-email/node_modules/@next/swc-win32-x64-msvc": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.2.tgz", + "integrity": "sha512-O7bort1Vld00cu8g0jHZq3cbSTUNMohOEvYqsqE10+yfohhdPHzvzO+ziJRz4Dyyr/fYKREwS7gR4JC0soSOMw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/src/components/GoalCreateForm/GoalCreateForm.tsx b/src/components/GoalCreateForm/GoalCreateForm.tsx index 4bc806107..9ad1820b7 100644 --- a/src/components/GoalCreateForm/GoalCreateForm.tsx +++ b/src/components/GoalCreateForm/GoalCreateForm.tsx @@ -116,6 +116,7 @@ const GoalCreateForm: React.FC = ({ utils.v2.project.userProjects.invalidate(); utils.v2.project.getProjectChildrenTree.invalidate(); utils.v2.project.getProjectGoalsById.invalidate(); + utils.v2.project.getUserDashboardProjects.invalidate(); if (form.parent && form.mode === 'default') { utils.project.getDeepInfo.invalidate({ id: form.parent.id }); diff --git a/src/components/GoalForm/GoalForm.tsx b/src/components/GoalForm/GoalForm.tsx index 73d9cb2f9..93490fc28 100644 --- a/src/components/GoalForm/GoalForm.tsx +++ b/src/components/GoalForm/GoalForm.tsx @@ -26,6 +26,9 @@ import { combobox, estimateCombobox, goalDescriptionInput, + goalPersonalityToggle, + goalPersonalityToggleProjectValue, + goalPersonalityTogglePersonalValue, goalTagList, goalTagListItem, goalTagListItemClean, @@ -224,9 +227,21 @@ export const GoalForm: React.FC = ({ control={control} render={({ field }) => (
- setValue('mode', value)}> - - + setValue('mode', value)} + {...goalPersonalityToggle.attr} + > + +
diff --git a/src/components/GoalTableList/GoalTableList.tsx b/src/components/GoalTableList/GoalTableList.tsx index 237935c7d..8360b231b 100644 --- a/src/components/GoalTableList/GoalTableList.tsx +++ b/src/components/GoalTableList/GoalTableList.tsx @@ -1,5 +1,5 @@ import { Badge, Table, Tag, Text, UserGroup, ListViewItem, Tooltip } from '@taskany/bricks/harmony'; -import { MouseEventHandler, useCallback, useEffect, useMemo } from 'react'; +import React, { MouseEventHandler, useCallback, useEffect, useMemo } from 'react'; import { nullable, formateEstimate } from '@taskany/bricks'; import { IconGitBranchOutline, IconMessageTextOutline } from '@taskany/icons'; @@ -18,11 +18,11 @@ import { InlineUserBadge } from '../InlineUserBadge/InlineUserBadge'; import { State } from '../State'; import { GoalCriteriaPreview } from '../GoalCriteria/GoalCriteria'; import { useGoalPreview } from '../GoalPreview/GoalPreviewProvider'; -import { participants } from '../../utils/domObjects'; +import { participants, goalTableListItem } from '../../utils/domObjects'; import s from './GoalTableList.module.css'; -interface GoalTableListProps { +interface GoalTableListProps extends React.ComponentProps { goals: T[]; onGoalPreviewShow?: (goal: T) => MouseEventHandler; onGoalClick?: MouseEventHandler; @@ -220,6 +220,7 @@ export const GoalTableList = ({ {row.list.map(({ content, width, className }, index) => ( diff --git a/src/components/ProjectGoalList/ProjectGoalList.tsx b/src/components/ProjectGoalList/ProjectGoalList.tsx index 6ecea24d1..a7392f9a0 100644 --- a/src/components/ProjectGoalList/ProjectGoalList.tsx +++ b/src/components/ProjectGoalList/ProjectGoalList.tsx @@ -11,6 +11,7 @@ import { GoalTableList, mapToRenderProps } from '../GoalTableList/GoalTableList' import { LoadMoreButton } from '../LoadMoreButton/LoadMoreButton'; import { NoGoalsText } from '../NoGoalsText/NoGoalsText'; import { Loader } from '../Loader/Loader'; +import { goalTableList } from '../../utils/domObjects'; interface ProjectGoalListProps { id: string; @@ -94,6 +95,7 @@ export const ProjectGoalList: FC = ({ }))} onTagClick={setTagsFilterOutside} onGoalClick={onGoalClickHandler} + {...goalTableList.attr} /> {nullable(hasNextPage, () => ( void} /> diff --git a/src/hooks/router.ts b/src/hooks/router.ts index a0601fd28..6cb844636 100644 --- a/src/hooks/router.ts +++ b/src/hooks/router.ts @@ -1,4 +1,5 @@ -import { useRouter as NextRouter } from 'next/router'; +import { useRouter as useNextRouter } from 'next/router'; +import { useMemo } from 'react'; import { AvailableHelpPages } from '../types/help'; import { TLocale } from '../utils/getLang'; @@ -33,26 +34,29 @@ export const routes = { jiraTask: (id: string) => `${process.env.NEXT_PUBLIC_JIRA_URL}browse/${id}`, }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any export const useRouter = () => { - const router = NextRouter(); + const router = useNextRouter(); - return { - index: () => router.push(routes.index()), + return useMemo( + () => ({ + index: () => router.push(routes.index()), - project: (id: string) => router.push(routes.project(id)), - projectSettings: (id: string) => router.push(routes.projectSettings(id)), + project: (id: string) => router.push(routes.project(id)), + projectSettings: (id: string) => router.push(routes.projectSettings(id)), - goals: () => router.push(routes.goals()), - goal: (shortId: string) => router.push(routes.goal(shortId)), + goals: () => router.push(routes.goals()), + goal: (shortId: string) => router.push(routes.goal(shortId)), - signIn: () => router.push(routes.signIn()), - userSettings: () => router.push(routes.userSettings()), + signIn: () => router.push(routes.signIn()), + userSettings: () => router.push(routes.userSettings()), - exploreProjects: () => router.push(routes.exploreProjects()), - exploreTopProjects: () => router.push(routes.exploreTopProjects()), - exploreGoals: () => router.push(routes.exploreGoals()), + exploreProjects: () => router.push(routes.exploreProjects()), + exploreTopProjects: () => router.push(routes.exploreTopProjects()), + exploreGoals: () => router.push(routes.exploreGoals()), - help: (slug: AvailableHelpPages) => router.push(slug), - }; + help: (slug: AvailableHelpPages) => router.push(slug), + appRouter: router, + }), + [router], + ); }; diff --git a/src/utils/domObjects.ts b/src/utils/domObjects.ts index a83fa026d..e5d1842bc 100644 --- a/src/utils/domObjects.ts +++ b/src/utils/domObjects.ts @@ -65,6 +65,9 @@ export const goalUpdateButton = goalForm.add('updateButton'); export const goalTagList = goalForm.add('tagList'); export const goalTagListItem = goalTagList.add('item'); export const goalTagListItemClean = goalTagListItem.add('clean'); +export const goalPersonalityToggle = goalForm.add('goalPersonalityToggle'); +export const goalPersonalityToggleProjectValue = goalPersonalityToggle.add('project'); +export const goalPersonalityTogglePersonalValue = goalPersonalityToggle.add('personal'); export const goalDeleteForm = DO('goalDeleteForm'); export const goalDeleteShortIdInput = goalDeleteForm.add('shortIdInput'); @@ -153,3 +156,6 @@ export const dashboardLoadMore = DO('dashboardLoadMore'); export const watch = DO('watch'); export const participants = DO('participants'); + +export const goalTableList = DO('goalTableList'); +export const goalTableListItem = goalTableList.add('item'); diff --git a/trpc/access/accessCheckers.ts b/trpc/access/accessCheckers.ts index 22202b81c..53c8a3de5 100644 --- a/trpc/access/accessCheckers.ts +++ b/trpc/access/accessCheckers.ts @@ -18,10 +18,16 @@ const notAllowed = (errorMessage: string): AccessCheckerResult => ({ allowed: fa export const goalAccessChecker = (session: Session, goal: GoalEntity) => { const { activityId, role } = session.user; + const { _isEditable, _isIssuer, _isOwner, _isParticipant, _isStarred, _isWatching } = addCalculatedGoalsFields( + goal, + activityId, + role, + ); - return goal.project && checkProjectAccess(goal.project, activityId, role) - ? allowed() - : notAllowed(tr('No access to update Goal')); + const accessFromProjectLevel = goal.project && checkProjectAccess(goal.project, activityId, role); + const accessByGoalLevel = _isParticipant || _isStarred || _isWatching || ((_isOwner || _isIssuer) && _isEditable); + + return accessFromProjectLevel || accessByGoalLevel ? allowed() : notAllowed(tr('No access to update Goal')); }; export const goalEditAccessChecker = (session: Session, goal: GoalEntity) => { diff --git a/trpc/access/accessEntityGetters.ts b/trpc/access/accessEntityGetters.ts index e6ff46054..75332cb9f 100644 --- a/trpc/access/accessEntityGetters.ts +++ b/trpc/access/accessEntityGetters.ts @@ -48,22 +48,54 @@ export const getComment = (id: string) => export type CommentEntity = NonNullable>>; export const getProject = (id: string) => - prisma.project.findUnique({ - where: { id }, - include: { - accessUsers: { - include: { - user: true, - ghost: true, + prisma.project + .findUnique({ + where: { id }, + include: { + accessUsers: { + include: { + user: true, + ghost: true, + }, }, - }, - participants: { - include: { - user: true, - ghost: true, + participants: { + include: { + user: true, + ghost: true, + }, + }, + goals: { + include: { + stargizers: true, + watchers: true, + participants: true, + }, }, }, - }, - }); + }) + .then((project) => { + if (project?.goals == null || project.goals.length === 0) { + return project; + } + + const { goals, ...restProject } = project; + + const goalAccessActivityIds = new Set(); + + for (const goal of goals) { + const { ownerId, activityId, watchers, stargizers, participants } = goal; + + const unionSubArray = [...watchers, ...stargizers, ...participants]; + + [ownerId, activityId, ...unionSubArray.map(({ id }) => id)].filter(Boolean).forEach((id) => { + goalAccessActivityIds.add(id); + }); + } + + return { + ...restProject, + goalAccessIds: Array.from(goalAccessActivityIds), + }; + }); export type ProjectEntity = NonNullable>>; diff --git a/trpc/queries/project.ts b/trpc/queries/project.ts index 40c3df199..04e9c685a 100644 --- a/trpc/queries/project.ts +++ b/trpc/queries/project.ts @@ -41,7 +41,15 @@ export const addCalculatedProjectFields = < }; }; -export const checkProjectAccess = ( +export const checkProjectAccess = < + T extends { + accessUsers: WithId[]; + activityId: string; + archived?: boolean | null; + personal: boolean | null; + goalAccessIds?: string[]; + }, +>( project: T, activityId: string, role: Role, @@ -50,12 +58,27 @@ export const checkProjectAccess = p.id === activityId) - ); + if (project.archived) { + return false; + } + + if (project.personal) { + if (project.goalAccessIds?.length) { + return project.goalAccessIds.includes(activityId); + } + + if (project.accessUsers.length) { + return project.accessUsers.some(({ id }) => id === activityId); + } + + return project.activityId === activityId; + } + + if (project.accessUsers.length) { + return project.accessUsers.some(({ id }) => id === activityId); + } + + return true; }; export const getProjectSchema = ({ diff --git a/trpc/router/project.ts b/trpc/router/project.ts index a0e1dd5b0..4ce3346f4 100644 --- a/trpc/router/project.ts +++ b/trpc/router/project.ts @@ -41,7 +41,10 @@ export const project = router({ .optional(), ) .query( - async ({ ctx, input: { cursor, skip, limit, firstLevel, goalsQuery, includePersonal = false } = {} }) => { + async ({ + ctx, + input: { cursor, skip = 0, limit = 20, firstLevel, goalsQuery, includePersonal = false } = {}, + }) => { const { activityId, role } = ctx.session.user; const projectIds = goalsQuery?.project ?? [];