From 95e73f5f48b395ec08b32011c0949addd74fd6e3 Mon Sep 17 00:00:00 2001 From: Maksim Sviridov Date: Fri, 16 Jun 2023 15:46:53 +0300 Subject: [PATCH] feat(GoalAchievement): add remove criteria action fix(GoalAchievement): review comments fix(GoalSuggesstions): add query limit results --- .../migration.sql | 6 +- prisma/schema.prisma | 6 +- .../CreateCriteriaForm/CreateCreteriaForm.tsx | 272 ------------ .../CriteriaForm.i18n}/en.json | 4 +- .../CriteriaForm.i18n}/index.ts | 0 .../CriteriaForm.i18n}/ru.json | 4 +- src/components/CriteriaForm/CriteriaForm.tsx | 365 ++++++++++++++++ .../GoalCriteria.i18n}/en.json | 0 .../GoalCriteria.i18n}/index.ts | 0 .../GoalCriteria.i18n}/ru.json | 0 src/components/GoalCriteria/GoalCriteria.tsx | 392 ++++++++++++++++++ .../GoalCriterion/GoalCriterion.tsx | 249 ----------- src/components/GoalPage/GoalPage.tsx | 15 +- src/components/GoalPreview/GoalPreview.tsx | 15 +- .../IssueDependenciesForm.tsx | 2 +- src/hooks/useCriteriaResource.ts | 21 +- src/schema/common.ts | 7 + src/schema/criteria.ts | 16 +- src/schema/schema.i18n/en.json | 4 +- src/schema/schema.i18n/ru.json | 4 +- trpc/router/goal.ts | 35 +- 21 files changed, 821 insertions(+), 596 deletions(-) rename prisma/migrations/{20230613115108_ => 20230616115656_}/migration.sql (82%) delete mode 100644 src/components/CreateCriteriaForm/CreateCreteriaForm.tsx rename src/components/{CreateCriteriaForm/CreateCriteriaForm.i18n => CriteriaForm/CriteriaForm.i18n}/en.json (71%) rename src/components/{CreateCriteriaForm/CreateCriteriaForm.i18n => CriteriaForm/CriteriaForm.i18n}/index.ts (100%) rename src/components/{CreateCriteriaForm/CreateCriteriaForm.i18n => CriteriaForm/CriteriaForm.i18n}/ru.json (74%) create mode 100644 src/components/CriteriaForm/CriteriaForm.tsx rename src/components/{GoalCriterion/GoalCriterion.i18n => GoalCriteria/GoalCriteria.i18n}/en.json (100%) rename src/components/{GoalCriterion/GoalCriterion.i18n => GoalCriteria/GoalCriteria.i18n}/index.ts (100%) rename src/components/{GoalCriterion/GoalCriterion.i18n => GoalCriteria/GoalCriteria.i18n}/ru.json (100%) create mode 100644 src/components/GoalCriteria/GoalCriteria.tsx delete mode 100644 src/components/GoalCriterion/GoalCriterion.tsx diff --git a/prisma/migrations/20230613115108_/migration.sql b/prisma/migrations/20230616115656_/migration.sql similarity index 82% rename from prisma/migrations/20230613115108_/migration.sql rename to prisma/migrations/20230616115656_/migration.sql index 57f33ea35..014ca13ce 100644 --- a/prisma/migrations/20230613115108_/migration.sql +++ b/prisma/migrations/20230616115656_/migration.sql @@ -1,7 +1,7 @@ -- CreateTable CREATE TABLE "GoalAchiveCriteria" ( "id" TEXT NOT NULL, - "linkedGoalId" TEXT NOT NULL, + "goalId" TEXT NOT NULL, "goalIdAsCriteria" TEXT, "title" TEXT NOT NULL, "weight" INTEGER NOT NULL, @@ -20,10 +20,10 @@ CREATE UNIQUE INDEX "GoalAchiveCriteria_goalIdAsCriteria_key" ON "GoalAchiveCrit CREATE INDEX "GoalAchiveCriteria_title_idx" ON "GoalAchiveCriteria"("title"); -- CreateIndex -CREATE INDEX "GoalAchiveCriteria_linkedGoalId_idx" ON "GoalAchiveCriteria"("linkedGoalId"); +CREATE INDEX "GoalAchiveCriteria_goalId_idx" ON "GoalAchiveCriteria"("goalId"); -- AddForeignKey -ALTER TABLE "GoalAchiveCriteria" ADD CONSTRAINT "GoalAchiveCriteria_linkedGoalId_fkey" FOREIGN KEY ("linkedGoalId") REFERENCES "Goal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "GoalAchiveCriteria" ADD CONSTRAINT "GoalAchiveCriteria_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "Goal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "GoalAchiveCriteria" ADD CONSTRAINT "GoalAchiveCriteria_goalIdAsCriteria_fkey" FOREIGN KEY ("goalIdAsCriteria") REFERENCES "Goal"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a9894ce75..855d21363 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -352,8 +352,8 @@ model GoalHistory { model GoalAchiveCriteria { id String @id @default(cuid()) - linkedGoal Goal @relation("GoalCriterion", fields: [linkedGoalId], references: [id]) - linkedGoalId String + goal Goal @relation("GoalCriterion", fields: [goalId], references: [id]) + goalId String goalAsCriteria Goal? @relation("GoalAsCriteria", fields: [goalIdAsCriteria], references: [id]) goalIdAsCriteria String? @unique title String @@ -366,5 +366,5 @@ model GoalAchiveCriteria { updatedAt DateTime @default(now()) @updatedAt @@index([title]) - @@index([linkedGoalId]) + @@index([goalId]) } diff --git a/src/components/CreateCriteriaForm/CreateCreteriaForm.tsx b/src/components/CreateCriteriaForm/CreateCreteriaForm.tsx deleted file mode 100644 index ef7020129..000000000 --- a/src/components/CreateCriteriaForm/CreateCreteriaForm.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import styled from 'styled-components'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { ComboBox, Button, AddIcon, Form, GoalIcon, nullable, FormInput } from '@taskany/bricks'; -import { gray7, gray8 } from '@taskany/colors'; -import { SubmitHandler, UseFormRegister, UseFormRegisterReturn, UseFormSetError, useForm } from 'react-hook-form'; - -import { AddCriteriaScheme, criteriaSchema } from '../../schema/criteria'; -import { Table, TableRow, TitleItem, ContentItem, Title, TextItem, TitleContainer } from '../Table'; -import { StateDot } from '../StateDot'; -import { errorsProvider } from '../../utils/forms'; - -import { tr } from './CreateCriteriaForm.i18n'; - -const maxPossibleWeigth = 100; - -const StyledPlainButton = styled(Button)` - background-color: unset; - border: none; - padding: 0; - - color: ${gray8}; - - border-radius: 0; - - &:hover:not([disabled]), - &:focus:not([disabled]) { - background-color: unset; - } - - &:active:not([disabled]) { - transform: none; - } -`; - -const StyledTableResults = styled(Table)` - grid-template-columns: 30px 210px 40px minmax(max-content, 120px); - width: fit-content; -`; - -const StyledFormInput = styled(FormInput)` - font-size: 14px; - font-weight: normal; - padding: 5px 10px; - - border: 1px solid ${gray7}; - box-sizing: border-box; - - & ~ div { - top: 50%; - } -`; - -const StyledTableRow = styled.div` - display: grid; - padding-left: 14px; - grid-template-columns: - minmax(calc(300px + 10px /* gap of sibling table */), 30%) - repeat(2, max-content); -`; - -const StyledSubmitButton = styled(Button)` - display: inline-flex; - align-items: center; - justify-content: center; -`; - -const GoalSuggestItem = (props: any): React.ReactElement => { - const handleClick = (event: any) => { - event.preventDefault(); - props.onClick(); - }; - - return ( - - - - - - - {props.title} - - - - {nullable(props.state, (s) => ( - - ))} - - - {props.projectId} - - - ); -}; - -interface WeightFieldProps { - registerProps: UseFormRegisterReturn<'weight'>; - errorsResolver: (field: 'weight') => { message?: string } | undefined; - maxValue: number; - setError: UseFormSetError; -} - -const WeightField: React.FC = ({ registerProps, errorsResolver, maxValue, setError }) => { - const handleChange = useCallback>( - (event) => { - const { value } = event.target; - - const parsedValue = parseInt(value, 10); - let message: string | undefined; - - if (!value) { - message = tr('Weight is required'); - } else if (Number.isNaN(parsedValue)) { - message = tr('Weight must be integer'); - } else if (parsedValue <= 0 || maxValue + parsedValue > maxPossibleWeigth) { - message = tr - .raw('Weight must be in range', { - upTo: `${maxPossibleWeigth - maxValue}`, - }) - .join(''); - } - - setError('weight', { message }); - }, - [setError, maxValue], - ); - - return ( - - ); -}; - -export const CreateCriteriaForm: React.FC = ({ onSubmit, onSearch, items, goalId, sumOfWeights = 0 }) => { - const [[text, type], setQuery] = useState<[string, 'plain' | 'search']>(['', 'plain']); - const [selectedGoal, setSelectedGoal] = useState<(typeof items)[number]>(null); - - const { - handleSubmit, - register, - reset, - setValue, - setError, - formState: { errors, isSubmitSuccessful }, - } = useForm({ - resolver: zodResolver(criteriaSchema), - mode: 'all', - reValidateMode: 'onChange', - criteriaMode: 'all', - defaultValues: { - linkedGoalId: goalId, - title: '', - weight: '', - }, - }); - - const submitHandler: SubmitHandler = (values) => { - const data = { ...values }; - - if (selectedGoal) { - data.goalAsGriteria = selectedGoal.id; - } - - onSubmit(data); - }; - - useEffect(() => { - if (isSubmitSuccessful) { - reset(); - } - }, [isSubmitSuccessful, reset]); - - const onClickOutside = useCallback( - (cb: () => void) => { - reset(); - cb(); - }, - [reset], - ); - - const errorResolver = errorsProvider(errors, true); - - const handleInputChange = useCallback>((event) => { - const { value } = event.target; - - setQuery(() => { - const isSearchInput = value[0] === '#'; - let val = value; - - if (isSearchInput) { - val = val.slice(1); - } - - return [val, isSearchInput ? 'search' : 'plain']; - }); - }, []); - - useEffect(() => { - if (type === 'search' && text.length) { - onSearch(text); - } - }, [type, text, onSearch]); - - const handleSelectGoal = useCallback((item: (typeof items)[number]) => { - setSelectedGoal(item); - }, []); - - useEffect(() => { - if (selectedGoal != null) { - setValue('title', selectedGoal.title); - setQuery(['', 'plain']); - onSearch(''); - } - }, [setValue, selectedGoal, onSearch]); - - return ( -
- ( - - - - - - )} - renderTrigger={(props) => ( - } - view="default" - outline={false} - onClick={props.onClick} - /> - )} - renderItems={(children) => ( - {children as React.ReactNode} - )} - renderItem={({ item, index, cursor }) => ( - handleSelectGoal(item)} - key={item.id} - /> - )} - /> - - ); -}; diff --git a/src/components/CreateCriteriaForm/CreateCriteriaForm.i18n/en.json b/src/components/CriteriaForm/CriteriaForm.i18n/en.json similarity index 71% rename from src/components/CreateCriteriaForm/CreateCriteriaForm.i18n/en.json rename to src/components/CriteriaForm/CriteriaForm.i18n/en.json index ce0c6dc90..e7bfdee7a 100644 --- a/src/components/CreateCriteriaForm/CreateCriteriaForm.i18n/en.json +++ b/src/components/CriteriaForm/CriteriaForm.i18n/en.json @@ -1,9 +1,9 @@ { "Weight is required": "", "Weight must be integer": "", - "Weight": "", + "Weight": "No more than {upTo}", "Criteria or Goal": "", - "Add achivement criteria": "", + "Add achievement criteria": "", "Weight must be in range": "Weight must be between 1 and {upTo}", "Add": "" } diff --git a/src/components/CreateCriteriaForm/CreateCriteriaForm.i18n/index.ts b/src/components/CriteriaForm/CriteriaForm.i18n/index.ts similarity index 100% rename from src/components/CreateCriteriaForm/CreateCriteriaForm.i18n/index.ts rename to src/components/CriteriaForm/CriteriaForm.i18n/index.ts diff --git a/src/components/CreateCriteriaForm/CreateCriteriaForm.i18n/ru.json b/src/components/CriteriaForm/CriteriaForm.i18n/ru.json similarity index 74% rename from src/components/CreateCriteriaForm/CreateCriteriaForm.i18n/ru.json rename to src/components/CriteriaForm/CriteriaForm.i18n/ru.json index 891f47c53..bb9fb21c5 100644 --- a/src/components/CreateCriteriaForm/CreateCriteriaForm.i18n/ru.json +++ b/src/components/CriteriaForm/CriteriaForm.i18n/ru.json @@ -1,9 +1,9 @@ { "Weight is required": "Вес обязателен", "Weight must be integer": "Вес должен быть числом", - "Weight": "Вес критерия", + "Weight": "Не более {upTo}", "Criteria or Goal": "Критерий или цель", - "Add achivement criteria": "Добавить критерий", + "Add achievement criteria": "Добавить критерий", "Weight must be in range": "Вес критерия должен быть от 1 до {upTo}", "Add": "Добавить" } diff --git a/src/components/CriteriaForm/CriteriaForm.tsx b/src/components/CriteriaForm/CriteriaForm.tsx new file mode 100644 index 000000000..ff03a406b --- /dev/null +++ b/src/components/CriteriaForm/CriteriaForm.tsx @@ -0,0 +1,365 @@ +import React, { useState, useEffect, useCallback, useReducer, useRef, forwardRef, ReactEventHandler } from 'react'; +import styled from 'styled-components'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ComboBox, Button, AddIcon, GoalIcon, nullable, FormInput, useClickOutside } from '@taskany/bricks'; +import { gray7, gray8 } from '@taskany/colors'; +import { Controller, UseFormRegisterReturn, UseFormSetError, useForm } from 'react-hook-form'; +import { Goal, State } from '@prisma/client'; + +import { AddCriteriaScheme, criteriaSchema } from '../../schema/criteria'; +import { Table, TableRow, TitleItem, ContentItem, Title, TextItem, TitleContainer } from '../Table'; +import { StateDot } from '../StateDot'; +import { errorsProvider } from '../../utils/forms'; +import { trpc } from '../../utils/trpcClient'; + +import { tr } from './CriteriaForm.i18n'; + +const maxPossibleWeigth = 100; + +const StyledPlainButton = styled(Button)` + background-color: unset; + border: none; + padding: 0; + + color: ${gray8}; + + border-radius: 0; + + &:hover:not([disabled]), + &:focus:not([disabled]) { + background-color: unset; + } + + &:active:not([disabled]) { + transform: none; + } +`; + +const StyledTableResults = styled(Table)` + grid-template-columns: 30px 210px 40px minmax(max-content, 120px); + width: fit-content; +`; + +const StyledFormInput = styled(FormInput)` + font-size: 14px; + font-weight: normal; + padding: 5px 10px; + + border: 1px solid ${gray7}; + box-sizing: border-box; + + & ~ div { + top: 50%; + } +`; + +const StyledTableRow = styled.div` + display: grid; + padding-left: 14px; + grid-template-columns: + minmax(calc(250px + 10px /* gap of sibling table */), 20%) + repeat(2, max-content); +`; + +const StyledSubmitButton = styled(Button)` + display: inline-flex; + align-items: center; + justify-content: center; +`; + +interface GoalSuggestItemProps { + title: string; + state?: State | null; + projectId: string; + focused: boolean; + onClick: () => void; +} + +const GoalSuggestItem: React.FC = ({ + onClick, + title, + projectId, + state, + focused, +}): React.ReactElement => { + const handleClick = useCallback>( + (event) => { + event.preventDefault(); + onClick(); + }, + [onClick], + ); + + return ( + + + + + + + {title} + + + + {nullable(state, (s) => ( + + ))} + + + {projectId} + + + ); +}; + +interface WeightFieldProps { + registerProps: UseFormRegisterReturn<'weight'>; + errorsResolver: (field: 'weight') => { message?: string } | undefined; + maxValue: number; + setError: UseFormSetError; +} + +const WeightField: React.FC = ({ registerProps, errorsResolver, maxValue, setError }) => { + const handleChange = useCallback>( + (event) => { + const { value } = event.target; + + const parsedValue = parseInt(value, 10); + let message: string | undefined; + + if (Number.isNaN(parsedValue)) { + message = tr('Weight must be integer'); + } else if (parsedValue <= 0 || maxValue + parsedValue > maxPossibleWeigth) { + message = tr + .raw('Weight must be in range', { + upTo: `${maxPossibleWeigth - maxValue}`, + }) + .join(''); + } + + setError('weight', { message }); + }, + [setError, maxValue], + ); + + return ( + + ); +}; + +interface CriteriaTitleFieldProps { + name: 'title'; + value?: string; + errorsResolver: (field: CriteriaTitleFieldProps['name']) => { message?: string } | undefined; + onSelect: (goal: T) => void; + onChange: ReactEventHandler; +} + +export const CriteriaTitleField = forwardRef( + ({ name, value = '', errorsResolver, onSelect, onChange }, ref) => { + const [completionVisible, setCompletionVisibility] = useState(false); + const [[text, type], setQuery] = useState<[string, 'plain' | 'search']>([value, 'plain']); + + const results = trpc.goal.suggestions.useQuery( + { + input: text.slice(1), + limit: 5, + }, + { + enabled: type === 'search' && text.length > 2, + staleTime: 0, + cacheTime: 0, + }, + ); + + useEffect(() => { + if (value !== text) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setQuery(([_, prevType]) => { + return [value, prevType]; + }); + } + }, [value, text]); + + const handleInputChange = useCallback>((event) => { + const { value } = event.target; + + setQuery(() => { + const isSearchInput = value[0] === '#'; + + return [value, isSearchInput ? 'search' : 'plain']; + }); + + setCompletionVisibility(true); + }, []); + + type GoalFromResults = NonNullable[number]; + + const handleSelectGoal = useCallback( + (item: GoalFromResults) => { + if (type === 'search') { + onSelect(item); + setQuery(['', 'plain']); + setCompletionVisibility(false); + } + }, + [onSelect, type], + ); + + return ( + ( + { + handleInputChange(...args); + onChange(...args); + }} + {...props} + /> + )} + renderItems={(children) => ( + {children as React.ReactNode} + )} + renderItem={({ item, index, cursor, onClick }) => ( + onClick(item)} key={item.id} /> + )} + /> + ); + }, +); + +interface CriteriaFormProps { + onSubmit: (values: AddCriteriaScheme) => void; + goalId: string; + sumOfWeights: number; +} + +export const CriteriaForm: React.FC = ({ onSubmit, goalId, sumOfWeights = 0 }) => { + const [formVisible, toggle] = useReducer((state) => !state, false); + const wrapperRef = useRef(null); + + const { + handleSubmit, + register, + control, + reset, + setValue, + setError, + formState: { errors, isSubmitSuccessful }, + } = useForm({ + resolver: zodResolver(criteriaSchema), + mode: 'all', + reValidateMode: 'onChange', + criteriaMode: 'all', + defaultValues: { + goalId, + title: '', + weight: '', + goalAsGriteria: null, + }, + }); + + useEffect(() => { + if (isSubmitSuccessful) { + reset({ + title: '', + weight: '', + goalAsGriteria: null, + }); + } + }, [isSubmitSuccessful, reset]); + + const onClickOutside = useCallback(() => { + if (formVisible) { + reset({ + title: '', + weight: '', + goalAsGriteria: null, + }); + toggle(); + } + }, [reset, formVisible]); + + useClickOutside(wrapperRef, onClickOutside); + + const errorResolver = errorsProvider(errors, true); + + const handleSelectGoal = useCallback( + (goal: Goal) => { + if (goal != null) { + setValue('title', goal.title); + setValue('goalAsGriteria', { id: goal.id }); + } + }, + [setValue], + ); + + return ( +
+ {formVisible ? ( +
+ + ( + + )} + /> + + + + } + /> + } + /> + + ) : ( + } + view="default" + outline={false} + onClick={toggle} + /> + )} +
+ ); +}; diff --git a/src/components/GoalCriterion/GoalCriterion.i18n/en.json b/src/components/GoalCriteria/GoalCriteria.i18n/en.json similarity index 100% rename from src/components/GoalCriterion/GoalCriterion.i18n/en.json rename to src/components/GoalCriteria/GoalCriteria.i18n/en.json diff --git a/src/components/GoalCriterion/GoalCriterion.i18n/index.ts b/src/components/GoalCriteria/GoalCriteria.i18n/index.ts similarity index 100% rename from src/components/GoalCriterion/GoalCriterion.i18n/index.ts rename to src/components/GoalCriteria/GoalCriteria.i18n/index.ts diff --git a/src/components/GoalCriterion/GoalCriterion.i18n/ru.json b/src/components/GoalCriteria/GoalCriteria.i18n/ru.json similarity index 100% rename from src/components/GoalCriterion/GoalCriterion.i18n/ru.json rename to src/components/GoalCriteria/GoalCriteria.i18n/ru.json diff --git a/src/components/GoalCriteria/GoalCriteria.tsx b/src/components/GoalCriteria/GoalCriteria.tsx new file mode 100644 index 000000000..860410cf2 --- /dev/null +++ b/src/components/GoalCriteria/GoalCriteria.tsx @@ -0,0 +1,392 @@ +import React, { useCallback, useMemo, memo } from 'react'; +import styled, { css } from 'styled-components'; +import { + Text, + CircleIcon, + TickCirclecon, + MessageTickIcon, + GoalIcon, + nullable, + CrossIcon, + Button, +} from '@taskany/bricks'; +import { State } from '@prisma/client'; +import { backgroundColor, brandColor, gray10, gray6, gray7, gray9, gray8 } from '@taskany/colors'; +import NextLink from 'next/link'; + +import { AddCriteriaScheme, RemoveCriteriaScheme, UpdateCriteriaScheme } from '../../schema/criteria'; +import { TitleItem, TitleContainer, Title, ContentItem, TextItem, Table } from '../Table'; +import { StateDot } from '../StateDot'; +import { ActivityFeedItem } from '../ActivityFeed'; +import { ActivityByIdReturnType, GoalEstimate, GoalAchiveCriteria } from '../../../trpc/inferredTypes'; +import { estimateToString } from '../../utils/estimateToString'; +import { UserGroup } from '../UserGroup'; +import { routes } from '../../hooks/router'; + +import { tr } from './GoalCriteria.i18n'; + +const StyledActivityFeedItem = styled(ActivityFeedItem)` + padding-top: 20px; + padding-left: 4px; + padding-right: 4px; +`; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; +`; + +const StyledIcon = styled(MessageTickIcon)` + width: 24px; + height: 24px; + display: flex; + background-color: ${gray7}; + align-items: center; + justify-content: center; + overflow: hidden; + transform: translateY(-3px); + border-radius: 50%; + padding: 2px; + box-sizing: border-box; + + text-align: center; +`; + +const StyledCircleIcon = styled(CircleIcon)` + width: 15px; + height: 15px; + display: inline-flex; + color: ${gray8}; + + &:hover { + color: ${gray10}; + } +`; + +const StyledTickIcon = styled(TickCirclecon)` + background-color: ${brandColor}; + color: ${backgroundColor}; + border-radius: 50%; + width: 15px; + height: 15px; + display: inline-flex; +`; + +const StyledCheckboxWrapper = styled.div<{ canEdit: boolean }>` + display: flex; + align-items: center; + + ${({ canEdit }) => + !canEdit && + css` + pointer-events: none; + `} + + ${({ canEdit }) => + canEdit && + css` + cursor: pointer; + `} +`; + +interface GoalCriteriaCheckBoxProps { + checked: boolean; + canEdit: boolean; + onClick: () => void; +} + +const GoalCriteriaCheckBox: React.FC = ({ checked, canEdit, onClick }) => { + const Icon = !checked ? StyledCircleIcon : StyledTickIcon; + return ( + + + + ); +}; + +const StyledActionButton = styled(Button).attrs({ + ghost: true, +})` + appearance: none; + width: 15px; + height: 15px; + opacity: 0; + transition: opacity 0.2s ease; + will-change: opacity; + padding: 0; + display: flex; + background-color: transparent; + color: ${gray7}; + cursor: pointer; + line-height: 15px; + min-height: 15px; + + &:hover:not([disabled]), + &:active:not([disabled]) { + color: ${gray10}; + background-color: transparent; + border-color: transparent; + } + + & + & { + margin-left: 5px; + } +`; + +const StyledTable = styled(Table)` + width: 100%; + grid-template-columns: 14px minmax(250px, 20%) repeat(5, max-content) 1fr; + column-gap: 10px; + margin-bottom: 10px; +`; + +const StyledTableRow = styled.div` + display: contents; + & > *, + & > *:first-child, + & > *:last-child { + padding: 0; + } + + &:hover { + background-color: ${gray8}; + } + + &:hover ${StyledActionButton} { + opacity: 1; + } +`; + +const StyledHeading = styled(Text)` + padding-bottom: 7px; + display: block; +`; + +const StyledGoalAnchor = styled.a` + text-decoration: none; + cursor: pointer; +`; + +const StyledActionContentItem = styled(ContentItem)` + align-items: center; + justify-content: flex-end; +`; + +interface CommonCriteriaProps { + isDone: boolean; + title: string; + weight: number; + canEdit: boolean; + onRemove: () => void; + onCheck?: (val: boolean) => void; + projectId?: string | null; + scopeId?: number | null; + issuer?: ActivityByIdReturnType | null; + owner?: ActivityByIdReturnType | null; + estimate?: GoalEstimate | null; + state?: State | null; +} + +interface CriteriaAsGoalProps extends CommonCriteriaProps { + projectId: string | null; + scopeId: number | null; + issuer: ActivityByIdReturnType | null; + owner: ActivityByIdReturnType | null; + estimate: GoalEstimate | null; + state: State | null; +} + +interface GoalCriteriaItemProps { + isDone: boolean; + title: string; + weight: number; + canEdit: boolean; + onRemove: () => void; + onCheck?: (val: boolean) => void; + projectId?: string | null; + scopeId?: number | null; + issuer?: ActivityByIdReturnType | null; + owner?: ActivityByIdReturnType | null; + estimate?: GoalEstimate | null; + state?: State | null; +} + +interface CriteriaTitleProps { + title: string; + checked: boolean; + canEdit: boolean; + onClick: () => void; +} + +const CriteriaTitle: React.FC = ({ title, checked, canEdit, onClick }) => { + return ( + <> + + + + + + + {title} + + + + + ); +}; + +interface CriteriaGoalTitleProps { + title: string; + projectId: string | null; + scopeId: number | null; +} + +const CriteriaGoalTitle: React.FC = ({ projectId, scopeId, title }) => { + return ( + <> + + + + + + + + + {title} + + + + + + ); +}; + +const criteriaGuard = (props: GoalCriteriaItemProps): props is CriteriaAsGoalProps => { + return 'projectId' in props && props.projectId != null; +}; + +const GoalCriteriaItem: React.FC = memo((props) => { + const { onCheck, canEdit, onRemove, title, isDone, weight, issuer, owner, projectId, state, estimate } = props; + const onToggle = useCallback(() => { + onCheck?.(!isDone); + }, [onCheck, isDone]); + + const issuers = useMemo(() => { + if (criteriaGuard(props)) { + if (issuer && owner && owner.id === issuer.id) { + return [owner]; + } + + return [issuer, owner].filter(Boolean) as NonNullable[]; + } + + return null; + }, [issuer, owner, props]); + + return ( + + {criteriaGuard(props) ? ( + + ) : ( + + )} + + + {weight} + + + + {nullable(state, (s) => ( + + ))} + + + {nullable(projectId, (p) => ( + {p} + ))} + + + {nullable(issuers, (list) => ( + + ))} + + + {nullable(estimate, (e) => estimateToString(e))} + + + {canEdit ? ( + <> + {/* TODO: implements edit criteria */} + {/* } /> */} + } onClick={onRemove} /> + + ) : null} + + + ); +}); + +interface GoalCriteriaProps { + goalId?: string; + criteriaList?: GoalAchiveCriteria[]; + canEdit: boolean; + onAddCriteria: (val: AddCriteriaScheme) => void; + onToggleCriteria: (val: UpdateCriteriaScheme) => void; + onRemoveCriteria: (val: RemoveCriteriaScheme) => void; + renderForm: (props: { onAddCriteria: GoalCriteriaProps['onAddCriteria']; sumOfWeights: number }) => React.ReactNode; +} + +export const GoalCriteria: React.FC = ({ + goalId, + criteriaList = [], + canEdit, + onAddCriteria, + onToggleCriteria, + onRemoveCriteria, + renderForm, +}) => { + const onAddHandler = useCallback( + (val: AddCriteriaScheme) => { + if (goalId) { + onAddCriteria({ ...val, goalId }); + } + }, + [onAddCriteria, goalId], + ); + const sumOfWeights = useMemo(() => { + return criteriaList.reduce((acc, { weight }) => acc + weight, 0); + }, [criteriaList]); + + return ( + + + + + {tr('Achivement Criteria')} + + + {criteriaList.map((item) => ( + onToggleCriteria({ ...item, isDone: state })} + onRemove={() => onRemoveCriteria({ id: item.id })} + canEdit={canEdit} + /> + ))} + + {renderForm({ onAddCriteria: onAddHandler, sumOfWeights })} + + + ); +}; diff --git a/src/components/GoalCriterion/GoalCriterion.tsx b/src/components/GoalCriterion/GoalCriterion.tsx deleted file mode 100644 index 8e699f68a..000000000 --- a/src/components/GoalCriterion/GoalCriterion.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { useCallback, useMemo, memo } from 'react'; -import styled, { css } from 'styled-components'; -import { Text, CircleIcon, TickCirclecon, MessageTickIcon, GoalIcon, nullable } from '@taskany/bricks'; -import { State } from '@prisma/client'; -import { backgroundColor, brandColor, gray10, gray6, gray7, gray9 } from '@taskany/colors'; - -import { AddCriteriaScheme, RemoveCriteriaScheme, UpdateCriteriaScheme } from '../../schema/criteria'; -import { TitleItem, TitleContainer, Title, ContentItem, TextItem, Table } from '../Table'; -import { StateDot } from '../StateDot'; -import { ActivityFeedItem } from '../ActivityFeed'; -import { ActivityByIdReturnType, GoalEstimate, GoalAchiveCriteria } from '../../../trpc/inferredTypes'; -import { estimateToString } from '../../utils/estimateToString'; -import { UserGroup } from '../UserGroup'; - -import { tr } from './GoalCriterion.i18n'; - -const StyledActivityFeedItem = styled(ActivityFeedItem)` - padding-top: 20px; - padding-left: 4px; -`; - -const Wrapper = styled.div` - display: flex; - flex-direction: column; -`; - -const StyledIcon = styled(MessageTickIcon)` - width: 24px; - height: 24px; - display: flex; - background-color: ${gray7}; - align-items: center; - justify-content: center; - overflow: hidden; - transform: translateY(-3px); - border-radius: 50%; - padding: 2px; - box-sizing: border-box; - - text-align: center; -`; - -const StyledCircleIcon = styled(CircleIcon)` - width: 15px; - height: 15px; - display: inline-flex; - color: ${gray9}; -`; - -const StyledTickIcon = styled(TickCirclecon)` - background-color: ${brandColor}; - color: ${backgroundColor}; - border-radius: 50%; - width: 15px; - height: 15px; - display: inline-flex; -`; - -const StyledCheckboxWrapper = styled.div<{ canEdit: boolean }>` - display: flex; - align-items: center; - - ${({ canEdit }) => - !canEdit && - css` - pointer-events: none; - `} - - ${({ canEdit }) => - canEdit && - css` - cursor: pointer; - `} -`; - -interface GoalCreteriaCheckBoxProps { - checked: boolean; - canEdit: boolean; - onClick: () => void; -} - -const GoalCreteriaCheckBox: React.FC = ({ checked, canEdit, onClick }) => { - const Icon = !checked ? StyledCircleIcon : StyledTickIcon; - return ( - - - - ); -}; - -const StyledTable = styled(Table)` - grid-template-columns: 14px minmax(300px, 30%) repeat(4, max-content) 1fr; - column-gap: 10px; - margin-bottom: 10px; -`; - -const StyledTableRow = styled.div` - display: contents; - & > *, - & > *:first-child, - & > *:last-child { - padding: 0; - } -`; - -const StyledHeading = styled(Text)` - padding-bottom: 7px; - display: block; -`; - -interface GoalCriteriaProps { - isDone: boolean; - title: string; - weight: number; - projectId?: string | null; - issuer?: ActivityByIdReturnType | null; - owner?: ActivityByIdReturnType | null; - estimate?: GoalEstimate | null; - state?: State | null; - canEdit: boolean; - onCheck?: (val: boolean) => void; -} - -const GoalCriteria: React.FC = memo( - ({ isDone, title, weight, estimate, owner, issuer, projectId, state, onCheck, canEdit }) => { - const onToggle = useCallback(() => { - onCheck?.(!isDone); - }, [onCheck, isDone]); - - const issuers = useMemo(() => { - if (issuer && owner && owner.id === issuer.id) { - return [owner]; - } - - return [issuer, owner].filter(Boolean) as NonNullable[]; - }, [issuer, owner]); - - return ( - - - {projectId == null ? ( - - ) : ( - - )} - - - - - {title} - - - - - - {weight} - - - - {nullable(state, (s) => ( - - ))} - - - {nullable(projectId, (p) => ( - {p} - ))} - - - {nullable(issuers.length, () => ( - - ))} - - - {nullable(estimate, (e) => estimateToString(e))} - - - ); - }, -); - -interface GoalCriterionProps { - goalId?: string; - criterion?: GoalAchiveCriteria[]; - canEdit: boolean; - onAddCriteria: (val: AddCriteriaScheme) => void; - onToggleCriteria: (val: UpdateCriteriaScheme) => void; - onRemoveCriteria: (val: RemoveCriteriaScheme) => void; - renderForm: (props: { - onAddCriteria: GoalCriterionProps['onAddCriteria']; - sumOfWeights: number; - }) => React.ReactNode; -} - -export const GoalCriterion: React.FC = ({ - goalId, - criterion = [], - canEdit, - onAddCriteria, - onToggleCriteria, - // onRemoveCriteria, - renderForm, -}) => { - const onAddHandler = useCallback( - (val: AddCriteriaScheme) => { - if (goalId) { - onAddCriteria({ - ...val, - linkedGoalId: goalId, - }); - } - }, - [onAddCriteria, goalId], - ); - const sumOfWeights = useMemo(() => { - return criterion.reduce((acc, { weight }) => acc + weight, 0); - }, [criterion]); - - return ( - - - - - {tr('Achivement Criteria')} - - - {criterion.map((item) => ( - onToggleCriteria({ ...item, isDone: state })} - canEdit={canEdit} - /> - ))} - - {renderForm({ onAddCriteria: onAddHandler, sumOfWeights })} - - - ); -}; diff --git a/src/components/GoalPage/GoalPage.tsx b/src/components/GoalPage/GoalPage.tsx index 56d725e13..d629b33a6 100644 --- a/src/components/GoalPage/GoalPage.tsx +++ b/src/components/GoalPage/GoalPage.tsx @@ -47,8 +47,8 @@ import { refreshInterval } from '../../utils/config'; import { notifyPromise } from '../../utils/notifyPromise'; import { GoalUpdateReturnType } from '../../../trpc/inferredTypes'; import { GoalActivity } from '../GoalActivity'; -import { CreateCriteriaForm } from '../CreateCriteriaForm/CreateCreteriaForm'; -import { GoalCriterion } from '../GoalCriterion/GoalCriterion'; +import { CriteriaForm } from '../CriteriaForm/CriteriaForm'; +import { GoalCriteria } from '../GoalCriteria/GoalCriteria'; import { useCriteriaResource } from '../../hooks/useCriteriaResource'; import { tr } from './GoalPage.i18n'; @@ -247,8 +247,7 @@ export const GoalPage = ({ user, locale, ssrTime, params: { id } }: ExternalPage commentsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, []); - const { onAddHandler, onRemoveHandler, onToggleHandler, goals, updateSuggestionQuery } = - useCriteriaResource(invalidateFn); + const { onAddHandler, onRemoveHandler, onToggleHandler } = useCriteriaResource(invalidateFn); if (!goal) return null; @@ -378,19 +377,17 @@ export const GoalPage = ({ user, locale, ssrTime, params: { id } }: ExternalPage {nullable(goal?.goalAchiveCriteria.length || goal?._isEditable, () => ( - nullable(goal?._isEditable, () => ( - diff --git a/src/components/GoalPreview/GoalPreview.tsx b/src/components/GoalPreview/GoalPreview.tsx index 1452b847c..9f78d4168 100644 --- a/src/components/GoalPreview/GoalPreview.tsx +++ b/src/components/GoalPreview/GoalPreview.tsx @@ -46,8 +46,8 @@ import { trpc } from '../../utils/trpcClient'; import { notifyPromise } from '../../utils/notifyPromise'; import { GoalStateChangeSchema } from '../../schema/goal'; import { GoalActivity } from '../GoalActivity'; -import { GoalCriterion } from '../GoalCriterion/GoalCriterion'; -import { CreateCriteriaForm } from '../CreateCriteriaForm/CreateCreteriaForm'; +import { GoalCriteria } from '../GoalCriteria/GoalCriteria'; +import { CriteriaForm } from '../CriteriaForm/CriteriaForm'; import { tr } from './GoalPreview.i18n'; @@ -192,8 +192,7 @@ const GoalPreview: React.FC = ({ preview, onClose, onDelete }) invalidateFn(); }, [onDelete, archiveMutation, preview.id, invalidateFn]); - const { onAddHandler, onRemoveHandler, onToggleHandler, goals, updateSuggestionQuery } = - useCriteriaResource(invalidateFn); + const { onAddHandler, onRemoveHandler, onToggleHandler } = useCriteriaResource(invalidateFn); const commentsRef = useRef(null); const contentRef = useRef(null); @@ -335,19 +334,17 @@ const GoalPreview: React.FC = ({ preview, onClose, onDelete }) {nullable(goal?.goalAchiveCriteria.length || goal?._isEditable, () => ( - nullable(goal?._isEditable, () => ( - diff --git a/src/components/IssueDependenciesForm/IssueDependenciesForm.tsx b/src/components/IssueDependenciesForm/IssueDependenciesForm.tsx index 8330ad620..2962b160e 100644 --- a/src/components/IssueDependenciesForm/IssueDependenciesForm.tsx +++ b/src/components/IssueDependenciesForm/IssueDependenciesForm.tsx @@ -54,7 +54,7 @@ const IssueDependenciesForm: React.FC = ({ issue, on const [query, setQuery] = useState([]); const [completionVisible, setCompletionVisible] = useState(false); - const { data: goalsData } = trpc.goal.suggestions.useQuery(query.join('-')); + const { data: goalsData } = trpc.goal.suggestions.useQuery({ input: query.join('-'), limit: 10 }); const dependKeys = useMemo( () => ({ diff --git a/src/hooks/useCriteriaResource.ts b/src/hooks/useCriteriaResource.ts index 68baf269e..8f4327a91 100644 --- a/src/hooks/useCriteriaResource.ts +++ b/src/hooks/useCriteriaResource.ts @@ -1,20 +1,13 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; import { trpc } from '../utils/trpcClient'; import { AddCriteriaScheme, RemoveCriteriaScheme, UpdateCriteriaScheme } from '../schema/criteria'; -export const useCriteriaResource = (invalidateFn: () => Promise) => { - const [query, setQuery] = useState(''); +export const useCriteriaResource = (invalidateFn: () => Promise) => { const add = trpc.goal.addCriteria.useMutation(); const toggle = trpc.goal.updateCriteriaState.useMutation(); const remove = trpc.goal.removeCriteria.useMutation(); - const goals = trpc.goal.suggestions.useQuery(query, { - enabled: query.length > 2, - staleTime: 0, - cacheTime: 0, - }); - const onAddHandler = useCallback( async (val: AddCriteriaScheme) => { await add.mutateAsync(val); @@ -22,6 +15,7 @@ export const useCriteriaResource = (invalidateFn: () => Promise) => { }, [add, invalidateFn], ); + const onToggleHandler = useCallback( async (val: UpdateCriteriaScheme) => { await toggle.mutateAsync(val); @@ -29,6 +23,7 @@ export const useCriteriaResource = (invalidateFn: () => Promise) => { }, [invalidateFn, toggle], ); + const onRemoveHandler = useCallback( async (val: RemoveCriteriaScheme) => { await remove.mutateAsync(val); @@ -37,17 +32,9 @@ export const useCriteriaResource = (invalidateFn: () => Promise) => { [invalidateFn, remove], ); - useEffect(() => { - if (query === '') { - goals.remove(); - } - }, [goals, query]); - return { onAddHandler, onToggleHandler, onRemoveHandler, - updateSuggestionQuery: setQuery, - goals, }; }; diff --git a/src/schema/common.ts b/src/schema/common.ts index 86ac09c09..3626ed6d8 100644 --- a/src/schema/common.ts +++ b/src/schema/common.ts @@ -36,3 +36,10 @@ export const queryWithFiltersSchema = z.object({ }); export type QueryWithFilters = z.infer; + +export const suggestionsQueryScheme = z.object({ + limit: z.number().optional(), + input: z.string(), +}); + +export type SuggestionsQueryScheme = z.infer; diff --git a/src/schema/criteria.ts b/src/schema/criteria.ts index dcd488cac..512192e5f 100644 --- a/src/schema/criteria.ts +++ b/src/schema/criteria.ts @@ -12,13 +12,17 @@ export const criteriaSchema = z.object({ }), weight: z .string({ - required_error: tr('Criteria Weight is required'), + required_error: tr('Criteria weight is required'), }) .min(1, { - message: tr('Criteria Weight must be longer than 1 symbol'), + message: tr('Criteria weight must be longer than 1 symbol'), }), - linkedGoalId: z.string(), - goalAsGriteria: z.string().optional(), + goalId: z.string(), + goalAsGriteria: z + .object({ + id: z.string(), + }) + .nullish(), }); export const updateCriteriaState = z.object({ @@ -26,7 +30,9 @@ export const updateCriteriaState = z.object({ isDone: z.boolean(), }); -export const removeCriteria = z.string(); +export const removeCriteria = z.object({ + id: z.string(), +}); export type AddCriteriaScheme = z.infer; export type UpdateCriteriaScheme = z.infer; diff --git a/src/schema/schema.i18n/en.json b/src/schema/schema.i18n/en.json index a9e0ec8f8..e398f9803 100644 --- a/src/schema/schema.i18n/en.json +++ b/src/schema/schema.i18n/en.json @@ -13,6 +13,6 @@ "Goal's description is required": "", "Goal's description must be a string": "", "Goal's project or team are required": "", - "Criteria Weight is required": "", - "Criteria Weight must be longer than 1 symbol": "" + "Criteria weight is required": "", + "Criteria weight must be longer than 1 symbol": "" } diff --git a/src/schema/schema.i18n/ru.json b/src/schema/schema.i18n/ru.json index a9e0ec8f8..e398f9803 100644 --- a/src/schema/schema.i18n/ru.json +++ b/src/schema/schema.i18n/ru.json @@ -13,6 +13,6 @@ "Goal's description is required": "", "Goal's description must be a string": "", "Goal's project or team are required": "", - "Criteria Weight is required": "", - "Criteria Weight must be longer than 1 symbol": "" + "Criteria weight is required": "", + "Criteria weight must be longer than 1 symbol": "" } diff --git a/trpc/router/goal.ts b/trpc/router/goal.ts index e90f57193..c714ea028 100644 --- a/trpc/router/goal.ts +++ b/trpc/router/goal.ts @@ -1,6 +1,6 @@ import z from 'zod'; import { TRPCError } from '@trpc/server'; -import { GoalHistory, Prisma, Tag } from '@prisma/client'; +import { GoalHistory, Prisma } from '@prisma/client'; import { prisma } from '../../src/utils/prisma'; import { protectedProcedure, router } from '../trpcBackend'; @@ -22,7 +22,7 @@ import { userGoalsSchema, goalCreateCommentSchema, } from '../../src/schema/goal'; -import { ToggleSubscriptionSchema } from '../../src/schema/common'; +import { ToggleSubscriptionSchema, suggestionsQueryScheme } from '../../src/schema/common'; import { connectionMap } from '../queries/connections'; import { createGoal, @@ -38,8 +38,8 @@ import { criteriaSchema, removeCriteria, updateCriteriaState } from '../../src/s import { addCalculatedProjectFields } from './project'; export const goal = router({ - suggestions: protectedProcedure.input(z.string()).query(async ({ input }) => { - const splittedInput = input.split('-'); + suggestions: protectedProcedure.input(suggestionsQueryScheme).query(async ({ input }) => { + const splittedInput = input.input.split('-'); let selectParams = {}; if (splittedInput.length === 2 && Number.isNaN(+splittedInput[1])) { @@ -62,13 +62,13 @@ export const goal = router({ } return prisma.goal.findMany({ - take: 10, + take: input.limit || 5, where: { OR: [ selectParams, { title: { - contains: input, + contains: input.input, mode: 'insensitive', }, }, @@ -799,7 +799,7 @@ export const goal = router({ }), addCriteria: protectedProcedure.input(criteriaSchema).mutation(async ({ input, ctx }) => { const actualGoal = await prisma.goal.findUnique({ - where: { id: input.linkedGoalId }, + where: { id: input.goalId }, }); if (!actualGoal) { @@ -817,19 +817,14 @@ export const goal = router({ id: ctx.session.user.activityId, }, }, - linkedGoal: { - connect: { - id: input.linkedGoalId, - }, + goal: { + connect: { id: input.goalId }, }, - goalAsCriteria: - input.goalAsGriteria != null - ? { - connect: { - id: input.goalAsGriteria, - }, - } - : undefined, + goalAsCriteria: input.goalAsGriteria?.id + ? { + connect: { id: input.goalAsGriteria.id }, + } + : undefined, }, }), // TODO: implements create new history record @@ -898,7 +893,7 @@ export const goal = router({ removeCriteria: protectedProcedure.input(removeCriteria).mutation(async ({ input }) => { try { await prisma.goalAchiveCriteria.delete({ - where: { id: input }, + where: { id: input.id }, }); } catch (error: any) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(error.message), cause: error });