Skip to content

Commit

Permalink
feat(GoalCriteria): support external task as criteria
Browse files Browse the repository at this point in the history
  • Loading branch information
LamaEats committed Sep 12, 2024
1 parent 52ddba4 commit 052d2ea
Show file tree
Hide file tree
Showing 8 changed files with 423 additions and 153 deletions.
2 changes: 2 additions & 0 deletions src/components/CriteriaForm/CriteriaForm.i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"Passed weight is not in range": "Weight must be between 1 and {upTo}",
"Simple": "Todo",
"Goal": "",
"Task": "External Task",
"Weight": "Weight (%)",
"ex: NN%": "ex: {val}",
"Add weight": "",
Expand All @@ -17,5 +18,6 @@
"Suggestions": "",
"This binding is already exist": "",
"Criteria title": "",
"Place link here": "Insert task link or type title",
"Reset": "Reset"
}
2 changes: 2 additions & 0 deletions src/components/CriteriaForm/CriteriaForm.i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"Passed weight is not in range": "Вес критерия должен быть от 1 до {upTo}",
"Simple": "Критерий",
"Goal": "Цель",
"Task": "Внешняя задача",
"Weight": "Вес (%)",
"Add weight": "Добавить вес",
"ex: NN%": "ex: {val}",
Expand All @@ -17,5 +18,6 @@
"Suggestions": "Предложения",
"This binding is already exist": "Такая связка уже существует",
"Criteria title": "Заголовок критерия",
"Place link here": "Ссылка на задачу или ее название",
"Reset": "Сброс"
}
96 changes: 78 additions & 18 deletions src/components/CriteriaForm/CriteriaForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { nullable } from '@taskany/bricks';
import { ComponentProps, forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { IconDatabaseOutline, IconTargetOutline } from '@taskany/icons';
import React, { ComponentProps, forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import {
Expand All @@ -18,23 +19,35 @@ import { GoalBadge } from '../GoalBadge';
import { FilterAutoCompleteInput } from '../FilterAutoCompleteInput/FilterAutoCompleteInput';
import { AddInlineTrigger } from '../AddInlineTrigger/AddInlineTrigger';
import { StateDot } from '../StateDot/StateDot';
import { TaskBadge, TaskBadgeIcon } from '../TaskBadge/TaskBadge';

import { tr } from './CriteriaForm.i18n';
import s from './CriteriaForm.module.css';

type GoalStateProps = ComponentProps<typeof StateDot>['state'];
type TaskTypeProps = NonNullable<ComponentProps<typeof TaskBadge>['type']>;
type TaskStateProps = NonNullable<ComponentProps<typeof TaskBadge>['state']>;

interface SuggestItem {
id: string;
title: string;
state?: ComponentProps<typeof StateDot>['state'] | null;
state?: GoalStateProps | TaskStateProps | null;
type?: TaskTypeProps | null;
_shortId: string;
}

const isGoalStateProps = (props: unknown): props is GoalStateProps =>
typeof props === 'object' && props != null && 'lightForeground' in props && 'darkForeground' in props;

const isTaskStateProps = (props: unknown): props is TaskStateProps =>
typeof props === 'object' && props != null && 'src' in props && 'title' in props;

interface ValidityData {
title: string[];
sumOfCriteria: number;
}

type CriteriaFormMode = 'simple' | 'goal';
type CriteriaFormMode = 'simple' | 'goal' | 'task';

export const maxPossibleWeight = 100;
export const minPossibleWeight = 1;
Expand Down Expand Up @@ -84,6 +97,13 @@ function patchZodSchema<T extends FormValues>(
stateColor: z.number().optional(),
}),
}),
z.object({
mode: z.literal('task'),
id: z.string(),
selected: z.object({
externalKey: z.string(),
}),
}),
])
.and(
z.object({
Expand Down Expand Up @@ -221,10 +241,23 @@ const CriteriaTitleField: React.FC<CriteriaTitleFieldProps> = ({
const { selected, title } = errors;

const icon = useMemo(() => {
if (mode === 'simple') return false;
if (!selectedItem || !selectedItem?.state) return;
if (mode === 'goal') {
if (isGoalStateProps(selectedItem?.state)) {
return <StateDot size="s" state={selectedItem.state} view="stroke" />;
}

return <IconTargetOutline size="s" />;
}

if (mode === 'task') {
if (isTaskStateProps(selectedItem?.type)) {
return <TaskBadgeIcon src={selectedItem.type.src} />;
}

return <IconDatabaseOutline size="s" />;
}

return <StateDot size="s" state={selectedItem.state} view="stroke" />;
return null;
}, [mode, selectedItem]);

const error = useMemo(() => {
Expand All @@ -245,19 +278,35 @@ const CriteriaTitleField: React.FC<CriteriaTitleFieldProps> = ({
return undefined;
}, [mode, selected, title]);

const placeholder = useMemo(() => {
if (mode === 'goal') {
return undefined;
}

return mode === 'simple' ? tr('Criteria title') : tr('Place link here');
}, [mode]);

return (
<FilterAutoCompleteInput
name={name}
icon={icon}
value={value}
error={error}
onChange={onChange}
placeholder={mode === 'simple' ? tr('Criteria title') : undefined}
placeholder={placeholder}
autoFocus
/>
);
};

const isGoalProps = (props: unknown): props is React.ComponentProps<typeof GoalBadge> => {
return typeof props === 'object' && props != null && '_shortId' in props;
};

const isTaskProps = (props: unknown): props is React.ComponentProps<typeof TaskBadge> => {
return typeof props === 'object' && props != null && 'type' in props;
};

export const CriteriaForm = ({
onInputChange,
onItemChange,
Expand All @@ -268,7 +317,7 @@ export const CriteriaForm = ({
validityData,
validateBindingsFor,
values,
value,
value: _,
mode: defaultMode,
setMode,
}: CriteriaFormProps) => {
Expand All @@ -293,6 +342,7 @@ export const CriteriaForm = ({
const radios: Array<{ value: CriteriaFormMode; title: string }> = [
{ title: tr('Simple'), value: 'simple' },
{ title: tr('Goal'), value: 'goal' },
{ title: tr('Task'), value: 'task' },
];

const title = watch('title');
Expand All @@ -316,7 +366,7 @@ export const CriteriaForm = ({
{ selected: undefined },
);
const subSelected = watch(({ selected, mode }, { name }) => {
if (mode === 'goal' && (name === 'selected' || name === 'selected.id' || name === 'selected.title')) {
if (mode !== 'simple' && (name === 'selected' || name === 'selected.id' || name === 'selected.title')) {
onItemChange?.(selected as Required<SuggestItem>);

trigger('selected');
Expand Down Expand Up @@ -360,11 +410,7 @@ export const CriteriaForm = ({
return !!title;
}

if (mode === 'goal') {
return !!(title && selected?.id);
}

return false;
return !!(title && selected?.id);
}, [mode, title, selected?.id]);

const resetHandler = useCallback(() => {
Expand All @@ -389,11 +435,25 @@ export const CriteriaForm = ({
<GoalSelect
mode="single"
items={items}
value={value}
onClick={handleSelectItem}
renderItem={(props) => (
<GoalBadge title={props.item.title} state={props.item.state ?? undefined} className={s.GoalBadge} />
)}
renderItem={(props) => {
const renderData = props.item;

if (mode === 'goal' && isGoalProps(renderData)) {
return (
<GoalBadge
title={renderData.title}
// @ts-ignore
state={renderData.state ?? undefined}
className={s.GoalBadge}
/>
);
}

if (mode === 'task' && isTaskProps(renderData)) {
return <TaskBadge {...renderData} />;
}
}}
>
<>
{nullable(withModeSwitch, () => (
Expand Down
55 changes: 42 additions & 13 deletions src/components/GoalActivityFeed/GoalActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ interface GoalActivityFeedProps {
onInvalidate?: () => void;
}

type AddCriteriaMode = NonNullable<React.ComponentProps<typeof GoalCriteriaSuggest>['defaultMode']>;

export const GoalActivityFeed = forwardRef<HTMLDivElement, GoalActivityFeedProps>(
({ goal, shortId, onGoalDeleteConfirm, onInvalidate }, ref) => {
const { user } = usePageContext();
Expand Down Expand Up @@ -84,35 +86,60 @@ export const GoalActivityFeed = forwardRef<HTMLDivElement, GoalActivityFeedProps
);

const handleCreateCriteria = useCallback(
async (data: { title: string; weight: string; selected?: { id?: string } }) => {
async (data: {
title: string;
weight: string;
selected?: { id?: string; externalKey?: string };
mode: AddCriteriaMode;
}) => {
await onGoalCriteriaAdd({
title: data.title,
weight: String(data.weight),
goalId: goal.id,
criteriaGoal: data.selected?.id
? {
id: data.selected.id,
}
: undefined,
criteriaGoal:
data.mode === 'goal' && data.selected?.id
? {
id: data.selected.id,
}
: undefined,
externalTask:
data.mode === 'task' && data.selected?.externalKey
? {
externalKey: data.selected.externalKey,
}
: undefined,
});
},
[goal.id, onGoalCriteriaAdd],
);

const handleUpdateCriteria = useCallback(
async (data: { id?: string; title: string; weight?: number; criteriaGoal?: { id?: string } }) => {
async (data: {
id?: string;
title: string;
weight?: number;
selected?: { id?: string; externalKey?: string };
mode: AddCriteriaMode;
}) => {
if (!data.id) return;

await onGoalCriteriaUpdate({
id: data.id,
title: data.title,
weight: String(data.weight),
goalId: goal.id,
criteriaGoal: data.criteriaGoal?.id
? {
id: data.criteriaGoal.id,
}
: undefined,
criteriaGoal:
data.mode === 'goal' && data.selected?.id
? {
id: data.selected.id,
}
: undefined,
externalTask:
data.mode === 'task' && data.selected?.externalKey
? {
externalKey: data.selected.externalKey,
}
: undefined,
});
},
[goal.id, onGoalCriteriaUpdate],
Expand Down Expand Up @@ -162,7 +189,9 @@ export const GoalActivityFeed = forwardRef<HTMLDivElement, GoalActivityFeedProps
onCheck={handleUpdateCriteriaState}
onConvert={handleConvertCriteriaToGoal}
onRemove={handleRemoveCriteria}
list={goal._criteria?.map((criteria) => mapCriteria(criteria, criteria.criteriaGoal))}
list={goal._criteria?.map((criteria) =>
mapCriteria(criteria, criteria.criteriaGoal, criteria.externalTask),
)}
>
{nullable(goal._isEditable, () => (
<GoalFormPopupTrigger
Expand Down
2 changes: 1 addition & 1 deletion src/components/GoalBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { StateDot } from './StateDot/StateDot';
interface GoalBadgeProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'color' | 'title'> {
title: React.ReactNode;
href?: string;
state?: ComponentProps<typeof StateDot>['state'];
state: ComponentProps<typeof StateDot>['state'];
strike?: boolean;
children?: React.ReactNode;
className?: string;
Expand Down
Loading

0 comments on commit 052d2ea

Please sign in to comment.