From 284aa67a853ea10935ba50a5de4c4a23baa640b9 Mon Sep 17 00:00:00 2001 From: Maksim Sviridov Date: Fri, 10 Nov 2023 12:27:10 +0300 Subject: [PATCH] feat(VersaCriteria): implements a new feature --- src/components/CriteriaForm/CriteriaForm.tsx | 74 ++-- .../CriteriaFormV2/CriteriaForm.tsx | 324 ++++++++++-------- .../CriteriaFormV2.i18n/en.json | 3 +- .../CriteriaFormV2.i18n/ru.json | 3 +- src/components/GoalBadge.tsx | 24 +- .../GoalCriteriaSuggest.i18n/en.json | 3 + .../GoalCriteriaSuggest.i18n/index.ts | 17 + .../GoalCriteriaSuggest.i18n/ru.json | 3 + .../GoalCriteriaSuggest.tsx | 134 ++++++++ src/components/GoalDependencyList.tsx | 5 +- src/components/GoalPage/GoalPage.tsx | 8 + src/components/GoalSidebar/GoalSidebar.tsx | 33 +- .../VersaCriteria/VersaCriteria.i18n/en.json | 3 + .../VersaCriteria/VersaCriteria.i18n/index.ts | 17 + .../VersaCriteria/VersaCriteria.i18n/ru.json | 3 + .../VersaCriteria/VersaCriteria.tsx | 131 +++++++ src/hooks/useGoalResource.ts | 2 + src/schema/common.ts | 1 + src/schema/criteria.ts | 59 ++++ trpc/router/goal.ts | 156 ++++++++- 20 files changed, 791 insertions(+), 212 deletions(-) create mode 100644 src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/en.json create mode 100644 src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/index.ts create mode 100644 src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/ru.json create mode 100644 src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.tsx create mode 100644 src/components/VersaCriteria/VersaCriteria.i18n/en.json create mode 100644 src/components/VersaCriteria/VersaCriteria.i18n/index.ts create mode 100644 src/components/VersaCriteria/VersaCriteria.i18n/ru.json create mode 100644 src/components/VersaCriteria/VersaCriteria.tsx diff --git a/src/components/CriteriaForm/CriteriaForm.tsx b/src/components/CriteriaForm/CriteriaForm.tsx index 11a377af0..41d1e9bea 100644 --- a/src/components/CriteriaForm/CriteriaForm.tsx +++ b/src/components/CriteriaForm/CriteriaForm.tsx @@ -19,14 +19,18 @@ import { Goal } from '@prisma/client'; import { z } from 'zod'; import { InlineTrigger } from '../InlineTrigger'; -import { criteriaSchema, updateCriteriaSchema } from '../../schema/criteria'; +import { + ValidityData, + criteriaSchema, + updateCriteriaSchema, + maxPossibleWeight, + patchZodSchema, + ValidityMessage, +} from '../../schema/criteria'; import { GoalSuggest } from '../GoalSuggest'; import { tr } from './CriteriaForm.i18n'; -const maxPossibleWeigth = 100; -const minPossibleWeight = 1; - const StyledInlineTrigger = styled(InlineTrigger)` width: fit-content; `; @@ -58,7 +62,7 @@ const WeightField = forwardRef( onChange={onChange} placeholder={tr .raw('Weight', { - upTo: maxPossibleWeigth - maxValue, + upTo: maxPossibleWeight - maxValue, }) .join('')} ref={ref} @@ -149,10 +153,7 @@ const CriteriaTitleField = forwardRef interface CriteriaFormProps { onReset?: () => void; goalId: string; - validityData: { - sum: number; - title: string[]; - }; + validityData: ValidityData; renderTrigger?: React.ComponentProps['renderTrigger']; } interface CriteriaFormPropsWithSchema extends CriteriaFormProps { @@ -251,55 +252,22 @@ const CriteriaForm: React.FC = ({ ); }; -function patchZodSchema( - schema: T, - data: CriteriaFormProps['validityData'], -) { - const patched = schema.merge( - z.object({ - title: schema.shape.title.refine((val) => !data.title.some((t) => t === val), { - message: tr('Title must be unique'), - }), - // INFO: https://github.com/colinhacks/zod#abort-early - weight: schema.shape.weight.superRefine((val, ctx): val is string => { - if (!val || !val.length) { - return z.NEVER; - } - - const parsed = Number(val); - - if (Number.isNaN(parsed)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: tr('Weight must be integer'), - }); - } - - if (parsed < minPossibleWeight || data.sum + parsed > maxPossibleWeigth) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: tr - .raw('Weight must be in range', { - upTo: `${maxPossibleWeigth - data.sum}`, - }) - .join(''), - }); - } - - return z.NEVER; - }), - }), - ); - - return patched; -} +const getErrorMessages = (data: ValidityData): ValidityMessage => ({ + uniqueTitle: tr('Title must be unique'), + weigthIsNan: tr('Weight must be integer'), + notInRange: tr + .raw('Weight must be in range', { + upTo: `${maxPossibleWeight - data.sum}`, + }) + .join(' '), +}); export const AddCriteriaForm: React.FC< Omit & { onSubmit: (val: z.infer) => void } > = ({ validityData, onSubmit, goalId, onReset }) => { return ( = ({ validityData, onSubmit, goalId, onReset, values }) => { return ( ; -function patchZodSchema(data: ValidityData) { +function patchZodSchema(data: ValidityData, checkBindingsBetweenGoals: (selectedGoalId: string) => Promise) { return schema .merge( z.object({ @@ -80,15 +80,31 @@ function patchZodSchema(data: ValidityData) { if (parsed < minPossibleWeight || data.sumOfCriteria + parsed > maxPossibleWeight) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: tr('Passed weight is not in range'), + message: tr + .raw('Passed weight is not in range', { + upTo: maxPossibleWeight - data.sumOfCriteria, + }) + .join(''), }); } return z.NEVER; }), + selected: schema.shape.selected.refine(async (val) => { + if (!val?.id) { + return true; + } + + try { + await checkBindingsBetweenGoals(val.id); + return true; + } catch (_error) { + return false; + } + }, tr('These binding is already exist')), }), ) - .superRefine((val, ctx) => { + .superRefine((val, ctx): val is Required => { if (val.mode === 'simple') { if (!val.title) { ctx.addIssue({ @@ -102,12 +118,6 @@ function patchZodSchema(data: ValidityData) { message: tr('Title must be longer than 1 symbol'), path: ['title'], }); - } else if (data.title.some((t) => t === val.title)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: tr('Title must be unique'), - path: ['title'], - }); } } if (val.mode === 'goal' && !val.selected?.id.length) { @@ -118,6 +128,14 @@ function patchZodSchema(data: ValidityData) { }); } + if (data.title.some((t) => t === val.title)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: tr('Title must be unique'), + path: ['title'], + }); + } + return z.NEVER; }); } @@ -126,7 +144,7 @@ interface CriteriaFormProps { items: SuggestItem[]; defaultMode?: CriteriaFormMode; withModeSwitch?: boolean; - values?: Partial; + values?: CriteriaFormValues; validityData: { title: string[]; sumOfCriteria: number }; onModeChange?: (mode: CriteriaFormMode) => void; @@ -134,6 +152,7 @@ interface CriteriaFormProps { onCancel: () => void; onItemChange?: (item?: SuggestItem) => void; onInputChange?: (value?: string) => void; + validateBindingsFor: (selectedId: string) => Promise; renderItem: React.ComponentProps>['renderItem']; } @@ -164,6 +183,7 @@ const StyledFormRow = styled.div` display: flex; flex-wrap: nowrap; margin-top: ${gapSm}; + margin-left: calc(${gapS} + 1px); // 'cause input have 1px border `; const StyledFormControlsWrapper = styled.div` @@ -295,152 +315,172 @@ const CriteriaTitleField: React.FC = ({ ); }; -export const CriteriaForm: React.FC = ({ - defaultMode, - onInputChange, - onItemChange, - onModeChange, - onSubmit, - onCancel, - items, - withModeSwitch, - renderItem, - validityData, - values, -}) => { - const { control, watch, setValue, register, resetField, handleSubmit, reset, setError } = - useForm({ - resolver: zodResolver(patchZodSchema(validityData)), - defaultValues: { ...values, mode: defaultMode }, - mode: 'onChange', - reValidateMode: 'onChange', - }); - - const isEditMode = values != null; - - const radios: Array<{ value: CriteriaFormMode; title: string }> = [ - { title: tr('Simple'), value: 'simple' }, - { title: tr('Goal'), value: 'goal' }, - ]; - - const title = watch('title'); - const selected = watch('selected'); - const mode = watch('mode', defaultMode); - - useEffect(() => { - const sub = watch((currentValues, { name, type }) => { - if (type === 'change') { - if (name === 'title') { - onInputChange?.(currentValues.title); - - if (currentValues.selected != null && currentValues.selected.id != null) { - resetField('selected'); - resetField('weight'); +export const CriteriaForm = forwardRef( + ( + { + defaultMode, + onInputChange, + onItemChange, + onModeChange, + onSubmit, + onCancel, + items, + withModeSwitch, + renderItem, + validityData, + validateBindingsFor, + values, + }, + ref, + ) => { + const { control, watch, setValue, register, resetField, handleSubmit, reset, setError, trigger } = + useForm({ + resolver: zodResolver(patchZodSchema(validityData, validateBindingsFor)), + defaultValues: { + mode: defaultMode, + title: '', + weight: '', + selected: undefined, + }, + values, + mode: 'onChange', + reValidateMode: 'onChange', + }); + + const isEditMode = values != null; + + const radios: Array<{ value: CriteriaFormMode; title: string }> = [ + { title: tr('Simple'), value: 'simple' }, + { title: tr('Goal'), value: 'goal' }, + ]; + + const title = watch('title'); + const selected = watch('selected'); + const mode = watch('mode', defaultMode); + + useEffect(() => { + const sub = watch((currentValues, { name, type }) => { + if (type === 'change') { + if (name === 'title') { + onInputChange?.(currentValues.title); + + if (currentValues.selected != null && currentValues.selected.id != null) { + resetField('selected'); + resetField('weight', { defaultValue: '' }); + } } } - } - if (name === 'selected' || name === 'selected.id' || name === 'selected.title') { - onItemChange?.(currentValues.selected as Required); - setError('selected', { message: undefined }); - } + if (name === 'selected' || name === 'selected.id' || name === 'selected.title') { + onItemChange?.(currentValues.selected as Required); - if (name === 'mode') { - onModeChange?.(currentValues.mode as NonNullable); - setError('title', { message: undefined }); - } - }); + trigger('selected'); + } - return () => sub.unsubscribe(); - }, [watch, onInputChange, onItemChange, onModeChange, resetField, setError]); + if (name === 'mode') { + onModeChange?.(currentValues.mode as NonNullable); + setError('title', { message: undefined }); + } + }); - const handleSelectItem = useCallback( - ([item]: SuggestItem[]) => { - setValue('selected', item); - setValue('title', item.title); - }, - [setValue], - ); + return () => sub.unsubscribe(); + }, [watch, onInputChange, onItemChange, onModeChange, resetField, setError, trigger]); - const handleCancel = useCallback(() => { - reset({ - selected: null, - title: undefined, - }); - onCancel(); - }, [reset, onCancel]); + const handleSelectItem = useCallback( + ([item]: SuggestItem[]) => { + setValue('selected', item); + setValue('title', item.title); + }, + [setValue], + ); - const needShowWeightField = useMemo(() => { - if (mode === 'simple') { - return !!title; - } + const handleCancel = useCallback(() => { + reset({ + selected: null, + title: undefined, + }); + onCancel(); + }, [reset, onCancel]); + + const needShowWeightField = useMemo(() => { + if (mode === 'simple') { + return !!title; + } - if (mode === 'goal') { - return !!(title && selected?.id); - } + if (mode === 'goal') { + return !!(title && selected?.id); + } - return false; - }, [mode, title, selected?.id]); + return false; + }, [mode, title, selected?.id]); - return ( -
- - ( - - )} - /> - - {nullable(withModeSwitch, () => ( - ( - setValue('mode', val.value)} - /> - )} - /> - ))} - - {nullable(needShowWeightField, () => ( + return ( +
+ + ( - ( + )} /> - ))} - -
+ ); + }, +); diff --git a/src/components/CriteriaFormV2/CriteriaFormV2.i18n/en.json b/src/components/CriteriaFormV2/CriteriaFormV2.i18n/en.json index 9b4b8ff42..c240ce6f9 100644 --- a/src/components/CriteriaFormV2/CriteriaFormV2.i18n/en.json +++ b/src/components/CriteriaFormV2/CriteriaFormV2.i18n/en.json @@ -14,5 +14,6 @@ "Save": "", "Add": "", "Cancel": "", - "Suggestions": "" + "Suggestions": "", + "These binding is already exist": "" } diff --git a/src/components/CriteriaFormV2/CriteriaFormV2.i18n/ru.json b/src/components/CriteriaFormV2/CriteriaFormV2.i18n/ru.json index 2314b0875..6370cd771 100644 --- a/src/components/CriteriaFormV2/CriteriaFormV2.i18n/ru.json +++ b/src/components/CriteriaFormV2/CriteriaFormV2.i18n/ru.json @@ -14,5 +14,6 @@ "Save": "Сохранить", "Add": "Добавить", "Cancel": "Отменить", - "Suggestions": "Предложения" + "Suggestions": "Предложения", + "These binding is already exist": "Такая связка уже существует" } diff --git a/src/components/GoalBadge.tsx b/src/components/GoalBadge.tsx index 6bcaecdfd..c87ee3786 100644 --- a/src/components/GoalBadge.tsx +++ b/src/components/GoalBadge.tsx @@ -1,27 +1,35 @@ import React from 'react'; import { Link, nullable } from '@taskany/bricks'; +import { IconTargetOutline } from '@taskany/icons'; +import colorLayer from 'color-layer'; import { Badge } from './Badge'; import { NextLink } from './NextLink'; -import { StateDot } from './StateDot'; interface GoalBadgeProps { title: string; href?: string; - state?: { - title?: string; - hue?: number; - } | null; + color?: number; + theme: number; children?: React.ReactNode; className?: string; - onClick?: () => void; + onClick?: React.MouseEventHandler; } -export const GoalBadge: React.FC = ({ href, title, state, children, className, onClick }) => { +export const GoalBadge: React.FC = ({ + href, + title, + color = 1, + theme, + children, + className, + onClick, +}) => { + const sat = color === 1 ? 0 : undefined; return ( } + icon={} text={nullable( href, () => ( diff --git a/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/en.json b/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/en.json new file mode 100644 index 000000000..2e8232615 --- /dev/null +++ b/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/en.json @@ -0,0 +1,3 @@ +{ + "Connect to goal": "" +} diff --git a/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/index.ts b/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/index.ts new file mode 100644 index 000000000..5c148475b --- /dev/null +++ b/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/index.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +// Do not edit, use generator to update +import { i18n, fmt, I18nLangSet } from 'easy-typed-intl'; +import getLang from '../../../utils/getLang'; + +import ru from './ru.json'; +import en from './en.json'; + +export type I18nKey = keyof typeof ru & keyof typeof en; +type I18nLang = 'ru' | 'en'; + +const keyset: I18nLangSet = {}; + +keyset['ru'] = ru; +keyset['en'] = en; + +export const tr = i18n(keyset, fmt, getLang); diff --git a/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/ru.json b/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/ru.json new file mode 100644 index 000000000..96a31d64e --- /dev/null +++ b/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.i18n/ru.json @@ -0,0 +1,3 @@ +{ + "Connect to goal": "Привязать к цели" +} diff --git a/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.tsx b/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.tsx new file mode 100644 index 000000000..fe6748ee8 --- /dev/null +++ b/src/components/GoalCriteriaSuggest/GoalCriteriaSuggest.tsx @@ -0,0 +1,134 @@ +import React, { useState, useRef, useMemo, useCallback } from 'react'; +import { KeyCode, MenuItem, Popup, nullable, useClickOutside, useKeyboard } from '@taskany/bricks'; +import { IconPlusCircleOutline } from '@taskany/icons'; +import styled from 'styled-components'; + +import { trpc } from '../../utils/trpcClient'; +import { InlineTrigger } from '../InlineTrigger'; +import { CriteriaForm } from '../CriteriaFormV2/CriteriaForm'; +import { GoalBadge } from '../GoalBadge'; + +import { tr } from './GoalCriteriaSuggest.i18n'; + +type CriteriaFormValues = Parameters['onSubmit']>[0]; + +interface GoalCriteriaComboBoxProps { + onSubmit: (values: CriteriaFormValues) => void; + checkGoalsBindingsFor: (selectedGoalId: string) => Promise; +} + +const StyledPopup = styled(Popup)` + pointer-events: all; +`; + +export const GoalCriteriaComboBox: React.FC = ({ onSubmit, checkGoalsBindingsFor }) => { + const [popupVisible, setPopupVisible] = useState(false); + const triggerRef = useRef(null); + const formRef = useRef(null); + const [query, setQuery] = useState(''); + const [selectedGoal, setSelectedGoal] = useState<{ id: string; title: string; stateColor?: number } | void>(); + + const [{ data: goals = [] }, { data: criteriaList = [] }] = trpc.useQueries((ctx) => [ + ctx.goal.suggestions( + { + input: query as string, + limit: 5, + onlyCurrentUser: true, + }, + { enabled: query != null && query.length > 2 }, + ), + ctx.goal.getGoalCriteriaList( + { + id: selectedGoal?.id, + }, + { enabled: selectedGoal != null }, + ), + ]); + + const [onESC] = useKeyboard([KeyCode.Escape], () => { + if (popupVisible) { + setPopupVisible(false); + } + }); + + useClickOutside(formRef, () => { + if (popupVisible) { + setPopupVisible(false); + } + }); + + const itemsToRender = useMemo(() => { + if (selectedGoal?.title === query) { + return []; + } + + return goals.map(({ id, title, state }) => ({ + id, + title, + stateColor: state?.hue, + })); + }, [goals, selectedGoal, query]); + + const validityData = useMemo(() => { + return criteriaList.reduce<{ sumOfCriteria: number; title: string[] }>( + (acc, { weight, title }) => { + acc.sumOfCriteria += weight; + acc.title.push(title); + return acc; + }, + { + sumOfCriteria: 0, + title: [], + }, + ); + }, [criteriaList]); + + const handleGoalChange = useCallback((item: typeof selectedGoal) => { + setSelectedGoal(item); + setQuery(item?.title); + }, []); + + return ( + <> + } + text={tr('Connect to goal')} + onClick={() => setPopupVisible((prev) => !prev)} + ref={triggerRef} + /> + + {nullable(popupVisible, () => ( + setPopupVisible(false)} + items={itemsToRender} + validityData={validityData} + validateBindingsFor={checkGoalsBindingsFor} + renderItem={(props) => ( + + + + )} + /> + ))} + + + ); +}; diff --git a/src/components/GoalDependencyList.tsx b/src/components/GoalDependencyList.tsx index 60d4aebec..5d973917a 100644 --- a/src/components/GoalDependencyList.tsx +++ b/src/components/GoalDependencyList.tsx @@ -6,6 +6,7 @@ import styled from 'styled-components'; import { ToggleGoalDependency } from '../schema/goal'; import { GoalDependencyItem } from '../../trpc/inferredTypes'; import { routes } from '../hooks/router'; +import { usePageContext } from '../hooks/usePageContext'; import { GoalBadge } from './GoalBadge'; @@ -22,6 +23,7 @@ interface GoalDependencyListByKindProps { } export const GoalDependencyListByKind = ({ id, goals = [], onClick, onRemove }: GoalDependencyListByKindProps) => { + const { themeId } = usePageContext(); const onClickHandler = useCallback( (goal: GoalDependency) => (e?: React.MouseEvent) => { if (onClick) { @@ -36,7 +38,8 @@ export const GoalDependencyListByKind = ({ id, goals = [], onClick, onRemove }: diff --git a/src/components/GoalPage/GoalPage.tsx b/src/components/GoalPage/GoalPage.tsx index 965aa940c..8d05c5ad2 100644 --- a/src/components/GoalPage/GoalPage.tsx +++ b/src/components/GoalPage/GoalPage.tsx @@ -112,6 +112,13 @@ export const GoalPage = ({ user, ssrTime, params: { id } }: ExternalPageProps<{ [setPreview], ); + const onVersaGoalClick = useCallback( + (shortId: string) => { + setPreview(shortId); + }, + [setPreview], + ); + const commentsRef = useRef(null); const onCommentsClick = useCallback(() => { @@ -185,6 +192,7 @@ export const GoalPage = ({ user, ssrTime, params: { id } }: ExternalPageProps<{ goal={goal} onGoalTransfer={onGoalTransfer((transferredGoal) => router.goal(transferredGoal._shortId))} onGoalDependencyClick={onGoalDependencyClick} + onGoalOpen={onVersaGoalClick} /> diff --git a/src/components/GoalSidebar/GoalSidebar.tsx b/src/components/GoalSidebar/GoalSidebar.tsx index 0b996093b..8a952b4b7 100644 --- a/src/components/GoalSidebar/GoalSidebar.tsx +++ b/src/components/GoalSidebar/GoalSidebar.tsx @@ -23,6 +23,7 @@ import { GoalFormPopupTrigger } from '../GoalFormPopupTrigger'; import { GoalDependency } from '../GoalDependency/GoalDependency'; import { TagsList } from '../TagsList'; import { dependencyKind } from '../../schema/goal'; +import { VersaCriteria } from '../VersaCriteria/VersaCriteria'; import { tr } from './GoalSidebar.i18n'; @@ -48,8 +49,11 @@ interface GoalSidebarProps { goal: NonNullable; onGoalTransfer: ComponentProps['onChange']; onGoalDependencyClick?: ComponentProps['onClick']; + onGoalOpen?: (shortId: string) => void; } +type VersaGoalItem = React.ComponentProps['versaCriterialList'][number]; + interface AddInlineTriggerProps { text: string; onClick: ComponentProps['onClick']; @@ -62,7 +66,7 @@ const AddInlineTrigger = forwardRef( ), ); -export const GoalSidebar: FC = ({ goal, onGoalTransfer, onGoalDependencyClick }) => { +export const GoalSidebar: FC = ({ goal, onGoalTransfer, onGoalDependencyClick, onGoalOpen }) => { const participantsFilter = useMemo(() => { const participantsIds = goal.participants.map(({ id }) => id); const { owner, activity: issuer } = goal; @@ -83,8 +87,11 @@ export const GoalSidebar: FC = ({ goal, onGoalTransfer, onGoal goalOwnerUpdate, onGoalDependencyAdd, onGoalDependencyRemove, + validateGoalCriteriaBindings, onGoalTagAdd, onGoalTagRemove, + onGoalCriteriaAdd, + onGoalCriteriaRemove, } = useGoalResource( { id: goal.id, @@ -232,6 +239,30 @@ export const GoalSidebar: FC = ({ goal, onGoalTransfer, onGoal ))} + {nullable(goal._versaCriteria?.length || goal._isEditable, () => ( + onGoalOpen(value.scopedId) : undefined} + validateGoalCriteriaBindings={validateGoalCriteriaBindings} + versaCriterialList={goal._versaCriteria.reduce( + (acc, { goal: { id: goalId, state, _scopedId }, id, title }) => { + acc.push({ + id: goalId, + title, + stateColor: state?.hue, + criteriaId: id, + scopedId: _scopedId, + }); + return acc; + }, + [], + )} + /> + ))} + {nullable(goal._isEditable || goal.tags.length, () => ( diff --git a/src/components/VersaCriteria/VersaCriteria.i18n/en.json b/src/components/VersaCriteria/VersaCriteria.i18n/en.json new file mode 100644 index 000000000..008993cd6 --- /dev/null +++ b/src/components/VersaCriteria/VersaCriteria.i18n/en.json @@ -0,0 +1,3 @@ +{ + "Is the criteria for": "" +} diff --git a/src/components/VersaCriteria/VersaCriteria.i18n/index.ts b/src/components/VersaCriteria/VersaCriteria.i18n/index.ts new file mode 100644 index 000000000..5c148475b --- /dev/null +++ b/src/components/VersaCriteria/VersaCriteria.i18n/index.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +// Do not edit, use generator to update +import { i18n, fmt, I18nLangSet } from 'easy-typed-intl'; +import getLang from '../../../utils/getLang'; + +import ru from './ru.json'; +import en from './en.json'; + +export type I18nKey = keyof typeof ru & keyof typeof en; +type I18nLang = 'ru' | 'en'; + +const keyset: I18nLangSet = {}; + +keyset['ru'] = ru; +keyset['en'] = en; + +export const tr = i18n(keyset, fmt, getLang); diff --git a/src/components/VersaCriteria/VersaCriteria.i18n/ru.json b/src/components/VersaCriteria/VersaCriteria.i18n/ru.json new file mode 100644 index 000000000..81ac27c18 --- /dev/null +++ b/src/components/VersaCriteria/VersaCriteria.i18n/ru.json @@ -0,0 +1,3 @@ +{ + "Is the criteria for": "Является критерием для" +} diff --git a/src/components/VersaCriteria/VersaCriteria.tsx b/src/components/VersaCriteria/VersaCriteria.tsx new file mode 100644 index 000000000..c5b81473b --- /dev/null +++ b/src/components/VersaCriteria/VersaCriteria.tsx @@ -0,0 +1,131 @@ +import React, { useCallback } from 'react'; +import { IconXCircleSolid } from '@taskany/icons'; +import { nullable } from '@taskany/bricks'; +import styled from 'styled-components'; +import { gapXs } from '@taskany/colors'; + +import { GoalBadge } from '../GoalBadge'; +import { GoalCriteriaComboBox } from '../GoalCriteriaSuggest/GoalCriteriaSuggest'; +import { IssueMeta } from '../IssueMeta'; +import { TextList, TextListItem } from '../TextList'; +import { usePageContext } from '../../hooks/usePageContext'; +import { routes } from '../../hooks/router'; +import { AddCriteriaSchema } from '../../schema/criteria'; + +import { tr } from './VersaCriteria.i18n'; + +type FormValues = Parameters['onSubmit']>[0]; + +interface GoalBadgeItemProps { + criteriaId: string; + stateColor?: number; + id: string; + title: string; + scopedId: string; +} + +interface VersaCriteriaProps { + goalId: string; + canEdit?: boolean; + onGoalClick?: (goal: GoalBadgeItemProps) => void; + versaCriterialList: GoalBadgeItemProps[]; + onSubmit: (values: AddCriteriaSchema) => Promise; + onRemove: (...args: any[]) => Promise; + validateGoalCriteriaBindings: (values: { selectedGoalId: string; currentGoalId: string }) => Promise; +} + +const StyledTextList = styled(TextList)` + margin-left: ${gapXs}; +`; + +export const VersaCriteria: React.FC = ({ + goalId, + canEdit, + onGoalClick, + versaCriterialList, + validateGoalCriteriaBindings, + onSubmit, + onRemove, +}) => { + const { themeId } = usePageContext(); + + const handleRemoveConnectedGoal = useCallback( + (id: string, removedGoalId: string) => async () => { + await onRemove({ + id, + goalId: removedGoalId, + }); + }, + [onRemove], + ); + + const handleConnectGoal = useCallback( + async (values: FormValues) => { + if (values.title && values.selected) { + await onSubmit({ + title: values.title, + goalId: values.selected.id, + weight: values.weight, + goalAsGriteria: { + id: goalId, + }, + }); + } + }, + [goalId, onSubmit], + ); + + const handleGoalClick = useCallback( + (goal: GoalBadgeItemProps) => { + return (event: React.MouseEvent) => { + event.preventDefault(); + if (onGoalClick) { + onGoalClick(goal); + } + }; + }, + [onGoalClick], + ); + + const validateBindings = useCallback( + (selectedId: string) => { + return validateGoalCriteriaBindings({ + currentGoalId: goalId, + selectedGoalId: selectedId, + }); + }, + [goalId, validateGoalCriteriaBindings], + ); + + return ( + + + {nullable(versaCriterialList, (goals) => + goals.map((goal) => ( + + + {nullable(canEdit, () => ( + + ))} + + + )), + )} + {nullable(canEdit, () => ( + + + + ))} + + + ); +}; diff --git a/src/hooks/useGoalResource.ts b/src/hooks/useGoalResource.ts index 3468db411..5ebae6c32 100644 --- a/src/hooks/useGoalResource.ts +++ b/src/hooks/useGoalResource.ts @@ -90,6 +90,7 @@ export const useGoalResource = (fields: GoalFields, config?: Configuration) => { const createGoalMutation = trpc.goal.create.useMutation(); const addPartnerProjectMutation = trpc.goal.addPartnerProject.useMutation(); const removePartnerProjectMutation = trpc.goal.removePartnerProject.useMutation(); + const validateGoalCriteriaBindings = utils.goal.checkGoalInExistingCriteria.fetch; const { highlightCommentId, setHighlightCommentId } = useHighlightedComment(); const { commentReaction } = useReactionsResource(); @@ -505,6 +506,7 @@ export const useGoalResource = (fields: GoalFields, config?: Configuration) => { onGoalCriteriaUpdate, onGoalCriteriaRemove, onGoalCriteriaConvert, + validateGoalCriteriaBindings, onGoalTagAdd, onGoalTagRemove, diff --git a/src/schema/common.ts b/src/schema/common.ts index c7a8bca41..8cc8aa180 100644 --- a/src/schema/common.ts +++ b/src/schema/common.ts @@ -46,6 +46,7 @@ export type QueryWithFilters = z.infer; export const suggestionsQuerySchema = z.object({ limit: z.number().optional(), input: z.string(), + onlyCurrentUser: z.boolean().optional(), }); export type SuggestionsQuerySchema = z.infer; diff --git a/src/schema/criteria.ts b/src/schema/criteria.ts index 1d79add0a..47fe0c1d9 100644 --- a/src/schema/criteria.ts +++ b/src/schema/criteria.ts @@ -47,8 +47,67 @@ export const convertCriteriaToGoalSchema = z.object({ }), }); +export const connectedGoalAsCriteria = z.object({ + title: z.string(), + targetId: z.string(), + goalId: z.string(), + weight: z.string().optional(), +}); + export type AddCriteriaSchema = z.infer; export type UpdateCriteriaStateSchema = z.infer; export type UpdateCriteriaSchema = z.infer; export type RemoveCriteriaSchema = z.infer; export type ConvertCriteriaToGoalSchema = z.infer; +export type ConnectedGoalAsCriteria = z.infer; +export interface ValidityData { + sum: number; + title: string[]; +} + +export interface ValidityMessage { + uniqueTitle: string; + notInRange: string; + weigthIsNan: string; +} + +export const maxPossibleWeight = 100; +export const minPossibleWeight = 1; + +export function patchZodSchema< + T extends typeof criteriaSchema | typeof updateCriteriaSchema | typeof connectedGoalAsCriteria, +>(schema: T, data: ValidityData, messages: ValidityMessage) { + const patched = schema.merge( + z.object({ + title: schema.shape.title.refine((val) => !data.title.some((t) => t === val), { + message: messages.uniqueTitle, + }), + // INFO: https://github.com/colinhacks/zod#abort-early + weight: schema.shape.weight.superRefine((val, ctx): val is string => { + if (!val || !val.length) { + return z.NEVER; + } + + const parsed = Number(val); + + if (Number.isNaN(parsed)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: messages.weigthIsNan, + }); + } + + if (parsed < minPossibleWeight || data.sum + parsed > maxPossibleWeight) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: messages.notInRange, + }); + } + + return z.NEVER; + }), + }), + ); + + return patched; +} diff --git a/trpc/router/goal.ts b/trpc/router/goal.ts index 621f1409d..495aa1f27 100644 --- a/trpc/router/goal.ts +++ b/trpc/router/goal.ts @@ -51,11 +51,12 @@ const updateProjectUpdatedAt = async (id?: string | null) => { data: { id }, }); }; + export const goal = router({ suggestions: protectedProcedure .input(suggestionsQuerySchema) - .query(async ({ ctx, input: { input, limit = 5 } }) => { - const { activityId } = ctx.session.user || {}; + .query(async ({ ctx, input: { input, limit = 5, onlyCurrentUser = false } }) => { + const { activityId, role } = ctx.session.user || {}; const splittedInput = input.split('-'); let selectParams: Prisma.GoalFindManyArgs['where'] = { @@ -82,7 +83,17 @@ export const goal = router({ }; } - return prisma.goal.findMany({ + if (role === 'USER' && onlyCurrentUser) { + selectParams = { + ...selectParams, + AND: { + ownerId: activityId, + activityId, + }, + }; + } + + const data = await prisma.goal.findMany({ take: limit, orderBy: { createdAt: 'desc', @@ -98,6 +109,37 @@ export const goal = router({ ...goalDeepQuery, }, }); + + const checkEnableGoalByProjectOwner = (goal: (typeof data)[number]) => { + if (goal.activityId === activityId || goal.ownerId === activityId || role === 'ADMIN') { + return true; + } + + if (goal.project == null) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parents = [goal.project as any]; + + while (parents.length) { + const current = parents.pop(); + + if (current?.activityId === activityId) { + return true; + } + + if (current?.parent.length) { + parents.push(...current.parents); + } + } + + return false; + }; + + const filteredDataByOwnedProjects = data.filter(checkEnableGoalByProjectOwner); + + return filteredDataByOwnedProjects; }), getGoalsCount: protectedProcedure.input(batchGoalsSchema.pick({ query: true })).query(async ({ input, ctx }) => { const { query } = input; @@ -221,6 +263,34 @@ export const goal = router({ const history = await getGoalHistory(goal.history || []); + const versaCriteriaGoals = await prisma.goalAchieveCriteria.findMany({ + where: { + AND: { + criteriaGoalId: goal.id, + OR: [{ deleted: false }, { deleted: null }], + }, + }, + include: { + goal: { + include: { + state: true, + activity: { + include: { + user: true, + ghost: true, + }, + }, + owner: { + include: { + user: true, + ghost: true, + }, + }, + }, + }, + }, + }); + return { ...goal, ...addCalculatedGoalsFields(goal, activityId, role), @@ -236,6 +306,13 @@ export const goal = router({ activityId, role, ), + _versaCriteria: versaCriteriaGoals.map(({ goal, ...rest }) => ({ + ...rest, + goal: { + ...goal, + _scopedId: `${goal.projectId}-${goal.scopeId}`, + }, + })), }; } catch (error: any) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: String(error.message), cause: error }); @@ -1186,8 +1263,8 @@ export const goal = router({ .input(removeCriteria) .use(criteriaAccessMiddleware) .mutation(async ({ input, ctx }) => { - const current = await prisma.goalAchieveCriteria.findUnique({ - where: { id: input.id }, + const current = await prisma.goalAchieveCriteria.findFirst({ + where: { id: input.id, OR: [{ deleted: false }, { deleted: null }] }, }); try { @@ -1585,4 +1662,73 @@ export const goal = router({ }); } }), + getConnectedGoalsByCriteria: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ input }) => { + try { + const criteriaListByGoal = await prisma.goalAchieveCriteria.findMany({ + where: { + goalIdAsCriteria: input.id, + AND: { + OR: [{ deleted: null }, { deleted: false }], + }, + }, + include: { + goal: { + include: { + state: true, + }, + }, + }, + }); + + return criteriaListByGoal; + } catch (error: any) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: error.message, cause: error }); + } + }), + getGoalCriteriaList: protectedProcedure.input(z.object({ id: z.string().optional() })).query(async ({ input }) => { + if (!input.id) { + return; + } + + try { + const criteriaList = await prisma.goalAchieveCriteria.findMany({ + where: { + AND: [ + { goalId: input.id }, + { + OR: [{ deleted: false }, { deleted: null }], + }, + ], + }, + }); + + return criteriaList; + } catch (error: any) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: error.message, cause: error }); + } + }), + checkGoalInExistingCriteria: protectedProcedure + .input( + z.object({ + currentGoalId: z.string(), + selectedGoalId: z.string(), + }), + ) + .query(async ({ input }) => { + const criteria = await prisma.goalAchieveCriteria.findFirst({ + where: { + AND: { + goalId: input.selectedGoalId, + criteriaGoalId: input.currentGoalId, + deleted: { + not: true, + }, + }, + }, + }); + + if (criteria != null) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'These bindings is already exist' }); + } + }), });