From ed16b8d919b79fd1a682ec64ece34fc2fc549aee Mon Sep 17 00:00:00 2001 From: Maksim Sviridov Date: Mon, 27 Nov 2023 13:02:38 +0300 Subject: [PATCH] fix(GoalCriteria): fetch suggestion goals by versa criteria fix(GoalCriteria): visibility GoalCriteria trigger fix(GoalCriteria): save editing criteria data fix(GoalCriteria): correct criterlia title in versa criteria --- .../CriteriaForm/CriteriaForm.i18n/en.json | 2 +- .../CriteriaForm/CriteriaForm.i18n/ru.json | 2 +- src/components/CriteriaForm/CriteriaForm.tsx | 162 +++++++++--------- src/components/GoalActivityFeed.tsx | 26 +-- src/components/GoalCriteria/GoalCriteria.tsx | 67 ++++++-- .../VersaCriteria/VersaCriteria.tsx | 10 +- trpc/router/goal.ts | 114 ++++-------- 7 files changed, 190 insertions(+), 193 deletions(-) diff --git a/src/components/CriteriaForm/CriteriaForm.i18n/en.json b/src/components/CriteriaForm/CriteriaForm.i18n/en.json index c240ce6f9..864ef6270 100644 --- a/src/components/CriteriaForm/CriteriaForm.i18n/en.json +++ b/src/components/CriteriaForm/CriteriaForm.i18n/en.json @@ -15,5 +15,5 @@ "Add": "", "Cancel": "", "Suggestions": "", - "These binding is already exist": "" + "This binding is already exist": "" } diff --git a/src/components/CriteriaForm/CriteriaForm.i18n/ru.json b/src/components/CriteriaForm/CriteriaForm.i18n/ru.json index 6370cd771..e91698b06 100644 --- a/src/components/CriteriaForm/CriteriaForm.i18n/ru.json +++ b/src/components/CriteriaForm/CriteriaForm.i18n/ru.json @@ -15,5 +15,5 @@ "Add": "Добавить", "Cancel": "Отменить", "Suggestions": "Предложения", - "These binding is already exist": "Такая связка уже существует" + "This binding is already exist": "Такая связка уже существует" } diff --git a/src/components/CriteriaForm/CriteriaForm.tsx b/src/components/CriteriaForm/CriteriaForm.tsx index 3f4b35e39..deeee96cb 100644 --- a/src/components/CriteriaForm/CriteriaForm.tsx +++ b/src/components/CriteriaForm/CriteriaForm.tsx @@ -44,32 +44,62 @@ type CriteriaFormMode = 'simple' | 'goal'; export const maxPossibleWeight = 100; export const minPossibleWeight = 1; -const schema = z.object({ - id: z.string().optional(), - mode: z.enum>(['simple', 'goal']), - weight: z.string().optional(), - title: z.string().optional(), - selected: z - .object({ - id: z.string(), - title: z.string(), - stateColor: z.number().optional(), - }) - .nullish(), -}); - -type CriteriaFormValues = z.infer; - -function patchZodSchema( +interface FormValues { + mode: CriteriaFormMode; + title: string; + weight?: string; + selected?: SuggestItem; +} + +function patchZodSchema( data: ValidityData, checkBindingsBetweenGoals: (selectedGoalId: string) => Promise, - defaultValues?: z.infer, + defaultValues?: T, ) { - return schema - .merge( + return z + .discriminatedUnion('mode', [ + z.object({ + mode: z.literal('simple'), + id: z.string().optional(), + selected: z.object({}).optional(), + }), + z.object({ + mode: z.literal('goal'), + id: z.string().optional(), + selected: z.object({ + id: z.string().refine( + async (val) => { + if (defaultValues?.selected?.id === val) { + return true; + } + + try { + await checkBindingsBetweenGoals(val); + return true; + } catch (_error) { + return false; + } + }, + { message: tr('This binding is already exist'), path: [] }, + ), + title: z.string().refine( + (val) => { + return !data.title.includes(val); + }, + { message: tr('Title must be unique') }, + ), + stateColor: z.number().optional(), + }), + }), + ]) + .and( z.object({ - /* INFO: https://github.com/colinhacks/zod#abort-early */ - weight: schema.shape.weight.superRefine((val, ctx): val is string => { + title: z + .string({ required_error: tr('Title is required') }) + .min(1, tr('Title must be longer than 1 symbol')) + .refine((val) => !data.title.includes(val), tr('Title must be unique')), + weight: z.string().superRefine((val, ctx): val is string => { + /* INFO: https://github.com/colinhacks/zod#abort-early */ if (!val || !val.length) { return z.NEVER; } @@ -96,58 +126,12 @@ function patchZodSchema( return z.NEVER; }), - selected: schema.shape.selected.refine(async (val) => { - if (!val?.id) { - return true; - } - - if (defaultValues?.selected?.id === val.id) { - return true; - } - - try { - await checkBindingsBetweenGoals(val.id); - return true; - } catch (_error) { - return false; - } - }, tr('These binding is already exist')), }), - ) - .superRefine((val, ctx): val is Required => { - if (val.mode === 'simple') { - if (!val.title) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: tr('Title is required'), - path: ['title'], - }); - } else if (val.title.length < 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: tr('Title must be longer than 1 symbol'), - path: ['title'], - }); - } else if (data.title.includes(val.selected?.title || val.title)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: tr('Title must be unique'), - path: ['title'], - }); - } - } - if (val.mode === 'goal' && !val.selected?.id.length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: tr('Goal must be selected'), - path: ['selected'], - }); - } - - return z.NEVER; - }); + ); } +type CriteriaFormValues = FormValues & z.infer>; + interface CriteriaFormProps { items: SuggestItem[]; defaultMode?: CriteriaFormMode; @@ -267,6 +251,9 @@ const CriteriaWeightField = forwardRef( ); }, ); +interface ErrorMessage { + message?: string; +} interface CriteriaTitleFieldProps { name: 'title'; @@ -274,8 +261,11 @@ interface CriteriaTitleFieldProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange: (...args: any[]) => void; errors?: { - title?: { message?: string }; - selected?: { message?: string }; + title?: ErrorMessage; + selected?: { + id?: ErrorMessage; + title?: ErrorMessage; + }; }; mode: CriteriaFormMode; selectedItem?: SuggestItem | null; @@ -307,7 +297,13 @@ const CriteriaTitleField: React.FC = ({ const error = useMemo(() => { if (mode === 'goal' && selected) { - return selected; + if (selected.id) { + return selected.id; + } + + if (selected.title) { + return selected.title; + } } if (mode === 'simple' && title) { @@ -353,7 +349,6 @@ export const CriteriaForm = forwardRef( mode: defaultMode, title: '', weight: '', - selected: undefined, }, values, mode: 'onChange', @@ -377,14 +372,23 @@ export const CriteriaForm = forwardRef( if (name === 'title') { onInputChange?.(currentValues.title); - if (currentValues.selected != null && currentValues.selected.id != null) { + if ( + 'selected' in currentValues && + currentValues.selected != null && + currentValues.selected.id != null + ) { resetField('selected'); resetField('weight', { defaultValue: '' }); } } + + return; } - if (name === 'selected' || name === 'selected.id' || name === 'selected.title') { + if ( + currentValues.mode === 'goal' && + (name === 'selected' || name === 'selected.id' || name === 'selected.title') + ) { onItemChange?.(currentValues.selected as Required); trigger('selected'); @@ -392,7 +396,7 @@ export const CriteriaForm = forwardRef( if (name === 'mode') { onModeChange?.(currentValues.mode as NonNullable); - if (!!currentValues.title && !currentValues.selected) { + if (currentValues.title) { trigger('title'); } } @@ -411,7 +415,7 @@ export const CriteriaForm = forwardRef( const handleCancel = useCallback(() => { reset({ - selected: null, + selected: undefined, title: undefined, }); onCancel?.(); diff --git a/src/components/GoalActivityFeed.tsx b/src/components/GoalActivityFeed.tsx index 85b5c22d0..f0c6dff42 100644 --- a/src/components/GoalActivityFeed.tsx +++ b/src/components/GoalActivityFeed.tsx @@ -84,11 +84,12 @@ export const GoalActivityFeed = forwardRef { - return validateGoalCriteriaBindings({ currentGoalId: goal.id, selectedGoalId: selectedId }); + return validateGoalCriteriaBindings({ criteriaGoalId: selectedId, goalId: goal.id }); }, [goal.id, validateGoalCriteriaBindings], ); @@ -184,7 +186,7 @@ export const GoalActivityFeed = forwardRef - {nullable(criteriaList || goal._isEditable, () => ( + {nullable(criteriaList?.length || goal._isEditable, () => ( = ({ }; interface CriteriaActionFn { - (val: T): void; + (val: T): void | Promise; } interface GoalCriteriaProps { @@ -330,6 +330,7 @@ export const GoalCriteria: React.FC = ({ }) => { const [addingCriteria, setAddingCriteria] = useState(false); const [query, setQuery] = useState(''); + const [criteriaEditMode, setEditMode] = useState(null); const { data: suggestions = [] } = trpc.goal.suggestions.useQuery( { @@ -339,6 +340,7 @@ export const GoalCriteria: React.FC = ({ { staleTime: 0, cacheTime: 0, + enabled: criteriaEditMode === 'goal', }, ); @@ -389,17 +391,24 @@ export const GoalCriteria: React.FC = ({ const handleFormSubmit = useCallback( (hideForm: () => void) => (values: CriteriaFormData) => { + let res: any; if (existingSubmittingData(values)) { if (addingCriteria) { - onCreate(values); + res = onCreate(values); } else { - onUpdate(values); + res = onUpdate(values); + } + + if (typeof res.then === 'function') { + res.then(hideForm); + } else { + hideForm(); } - } - hideForm(); + setEditMode(null); + } }, - [onCreate, onUpdate, addingCriteria], + [addingCriteria, onCreate, onUpdate], ); const dataForValidate = useMemo(() => { @@ -416,6 +425,37 @@ export const GoalCriteria: React.FC = ({ ); }, [sortedCriteriaItems]); + const mapCriteriaToValues = useCallback((criteria: CriteriaItemValue): CriteriaFormData => { + if (criteria.criteriaGoal) { + setEditMode('goal'); + + return { + mode: 'goal', + id: criteria.id, + title: criteria.title, + selected: { + id: criteria.criteriaGoal.id, + title: criteria.criteriaGoal.title, + stateColor: criteria.criteriaGoal.stateColor, + }, + weight: criteria.weight > 0 ? String(criteria.weight) : '', + }; + } + + setEditMode('simple'); + + return { + mode: 'simple', + id: criteria.id, + title: criteria.title, + weight: criteria.weight > 0 ? String(criteria.weight) : '', + }; + }, []); + + const handleChangeInput = useCallback((val = '') => { + setQuery(val); + }, []); + return ( @@ -441,19 +481,10 @@ export const GoalCriteria: React.FC = ({ renderForm={(props) => ( setEditMode(mode)} withModeSwitch defaultMode={criteria.criteriaGoal != null ? 'goal' : 'simple'} - values={ - !addingCriteria - ? { - id: criteria.id, - mode: criteria.criteriaGoal != null ? 'goal' : 'simple', - title: criteria.title, - selected: criteria.criteriaGoal, - weight: criteria.weight > 0 ? String(criteria.weight) : '', - } - : undefined - } + values={mapCriteriaToValues(criteria)} validityData={{ title: dataForValidate.title.filter((title) => title !== criteria.title), sumOfCriteria: dataForValidate.sumOfCriteria - criteria.weight, @@ -464,7 +495,7 @@ export const GoalCriteria: React.FC = ({ stateColor: goal.state?.hue, }))} onSubmit={handleFormSubmit(props.onEditCancel)} - onInputChange={(val = '') => setQuery(val)} + onInputChange={handleChangeInput} onCancel={props.onEditCancel} renderItem={(props) => ( Promise; onRemove: (...args: any[]) => Promise; - validateGoalCriteriaBindings: (values: { selectedGoalId: string; currentGoalId: string }) => Promise; + validateGoalCriteriaBindings: (values: { goalId: string; criteriaGoalId: string }) => Promise; } const StyledTextList = styled(TextList)` @@ -61,9 +61,9 @@ export const VersaCriteria: React.FC = ({ const handleConnectGoal = useCallback( async (values: FormValues) => { - if (values.title && values.selected) { + if ('selected' in values && values.selected != null) { await onSubmit({ - title: values.title, + title: values.selected.title, goalId: values.selected.id, weight: values.weight, criteriaGoal: { @@ -90,8 +90,8 @@ export const VersaCriteria: React.FC = ({ const validateBindings = useCallback( (selectedId: string) => { return validateGoalCriteriaBindings({ - currentGoalId: selectedId, - selectedGoalId: goalId, + criteriaGoalId: selectedId, + goalId, }); }, [goalId, validateGoalCriteriaBindings], diff --git a/trpc/router/goal.ts b/trpc/router/goal.ts index 5c86a662f..69cbd72af 100644 --- a/trpc/router/goal.ts +++ b/trpc/router/goal.ts @@ -93,8 +93,7 @@ export const goal = router({ selectParams = { ...selectParams, AND: { - ownerId: activityId, - activityId, + OR: [{ ownerId: activityId }, { activityId }], }, }; } @@ -120,35 +119,16 @@ export const goal = router({ }); 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; - } + const { _isEditable } = addCalculatedGoalsFields(goal, activityId, role); - if (current?.parent.length) { - parents.push(...current.parents); - } - } - - return false; + return _isEditable; }; - const filteredDataByOwnedProjects = data.filter(checkEnableGoalByProjectOwner); + if (onlyCurrentUser) { + return data.filter(checkEnableGoalByProjectOwner); + } - return filteredDataByOwnedProjects; + return data; }), getGoalsCount: protectedProcedure.input(batchGoalsSchema.pick({ query: true })).query(async ({ input, ctx }) => { const { query } = input; @@ -1686,67 +1666,47 @@ 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; - } + getGoalCriteriaList: protectedProcedure + .input(z.object({ id: z.string().optional() })) + .use(goalAccessMiddleware) + .query(async ({ input }) => { + if (!input.id) { + return; + } - try { - const criteriaList = await prisma.goalAchieveCriteria.findMany({ - where: { - AND: [ - { goalId: input.id }, - { - OR: [{ deleted: false }, { deleted: null }], - }, - ], - }, - }); + 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 }); - } - }), + 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(), + criteriaGoalId: z.string(), + goalId: z.string(), }), ) + .use(goalAccessMiddleware) .query(async ({ input }) => { + const { goalId, criteriaGoalId } = input; const criteria = await prisma.goalAchieveCriteria.findFirst({ where: { AND: { - goalId: input.selectedGoalId, - criteriaGoalId: input.currentGoalId, - deleted: { - not: true, - }, + goalId, + criteriaGoalId, + OR: [{ deleted: null }, { deleted: false }], }, }, });