Skip to content

Commit

Permalink
feat: project tasks
Browse files Browse the repository at this point in the history
  • Loading branch information
stepan662 committed Jul 15, 2024
1 parent 463f213 commit 6e085a6
Show file tree
Hide file tree
Showing 17 changed files with 382 additions and 115 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class TaskModel(
var description: String = "",
var type: TaskType = TaskType.TRANSLATE,
var language: LanguageModel,
var dueDate: Date? = null,
var dueDate: Long? = null,
var assignees: MutableSet<UserAccountModel> = mutableSetOf(),
var keys: MutableSet<Long> = mutableSetOf(),
) : RepresentationModel<TaskModel>()
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class TaskModelAssembler(
),
)
},
dueDate = entity.dueDate,
dueDate = entity.dueDate?.time,
assignees = entity.assignees.map { userAccountModelAssembler.toModel(it) }.toMutableSet(),
keys = entity.translations.map { it.translation.key.id }.toMutableSet(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,10 @@ import org.springframework.stereotype.Repository
interface TaskRepository : JpaRepository<Task, TaskId> {
@Query(
"""
select t
from Task t
where t.project.id = :projectId
""",
countQuery = """
select count(t) from Task t
where t.project.id = :projectId
""",
)
fun getAllByProjectId(
projectId: Long,
Expand All @@ -31,14 +28,17 @@ interface TaskRepository : JpaRepository<Task, TaskId> {
@Query(
nativeQuery = true,
value = """
select *
select key.id
from key
left join translation on (key.id = translation.key_id and translation.language_id = :languageId)
left join task_translation on translation.id = task_translation.translation_id
left join task on (task_translation.task_id = task.id and task_translation.task_project_id = :projectId)
left join (
select translation_id from task_translation
left join task on (task_translation.task_id = task.id and task_translation.task_project_id = :projectId)
where task.type = :taskType
) as task on task.translation_id = translation.id
where key.project_id = :projectId
and key.id in :keyIds
and (task IS NULL or task.type <> :taskType)
and task IS NULL
""",
)
fun getStatsKeys(
Expand Down
22 changes: 14 additions & 8 deletions backend/data/src/main/kotlin/io/tolgee/service/TaskService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import io.tolgee.exceptions.NotFoundException
import io.tolgee.model.Language
import io.tolgee.model.Project
import io.tolgee.model.UserAccount
import io.tolgee.model.enums.TaskType
import io.tolgee.model.key.Key
import io.tolgee.model.task.Task
import io.tolgee.model.task.TaskId
Expand Down Expand Up @@ -57,9 +58,9 @@ class TaskService(

val language = checkLanguage(dto.languageId!!, project)
val assignees = checkAssignees(dto.assignees ?: mutableSetOf(), project)
val keys = getOnlyProjectKeys(dto.keys ?: mutableSetOf(), project)
val keys = getOnlyProjectKeys(project, dto.languageId!!, dto.type, dto.keys ?: mutableSetOf())

val translations = getOrCreateTranslations(language.id, keys.map { it.id })
val translations = getOrCreateTranslations(language.id, keys)

val task = Task()

Expand Down Expand Up @@ -122,9 +123,7 @@ class TaskService(
}

dto.addKeys?.let { toAdd ->
val translationsToAdd =
getOrCreateTranslations(task.language.id, toAdd)
.map { it.id }.toMutableSet()
val translationsToAdd = getOnlyProjectKeys(projectEntity, task.language.id, task.type, toAdd)
val existingTranslations = task.translations.map { it.translation.id }.toMutableSet()
val nonExistingTranslations = translationsToAdd.subtract(existingTranslations).toMutableSet()
val taskTranslationsToAdd =
Expand Down Expand Up @@ -164,10 +163,17 @@ class TaskService(
}

private fun getOnlyProjectKeys(
keys: MutableSet<Long>,
project: Project,
): MutableSet<Key> {
return keyService.find(project.id, keys).toMutableSet()
languageId: Long,
type: TaskType,
keys: Collection<Long>,
): MutableSet<Long> {
return taskRepository.getStatsKeys(
project.id,
languageId,
type.toString(),
keys,
).toMutableSet()
}

private fun checkAssignees(
Expand Down
1 change: 1 addition & 0 deletions e2e/cypress/support/dataCyType.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ declare namespace DataCy {
"project-menu-item-members" |
"project-menu-item-projects" |
"project-menu-item-settings" |
"project-menu-item-tasks" |
"project-menu-item-translations" |
"project-menu-items" |
"project-mt-dialog-settings-inherited" |
Expand Down
31 changes: 17 additions & 14 deletions webapp/src/component/common/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,20 @@ export type TextFieldProps = React.ComponentProps<typeof MUITextField> & {
minHeight?: boolean;
};

export const TextField: FunctionComponent<TextFieldProps> = (props) => {
const { label, minHeight = true, sx, ...otherProps } = props;
return (
<StyledContainer>
{label && <StyledInputLabel>{label}</StyledInputLabel>}
<MUITextField
variant="outlined"
size="small"
sx={{ minHeight: minHeight ? '64px' : undefined, ...sx }}
{...otherProps}
/>
</StyledContainer>
);
};
export const TextField: FunctionComponent<TextFieldProps> = React.forwardRef(
function TextField(props, ref) {
const { label, minHeight = true, sx, ...otherProps } = props;
return (
<StyledContainer>
{label && <StyledInputLabel>{label}</StyledInputLabel>}
<MUITextField
ref={ref}
variant="outlined"
size="small"
sx={{ minHeight: minHeight ? '64px' : undefined, ...sx }}
{...otherProps}
/>
</StyledContainer>
);
}
);
2 changes: 2 additions & 0 deletions webapp/src/component/common/list/PaginatedHateoasList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const PaginatedHateoasList = <
>(
props: {
renderItem: (itemData: TItem) => ReactNode;
itemSeparator?: () => ReactNode;
loadable: UseQueryResult<TData, any>;
title?: ReactNode;
sortBy?: string[];
Expand Down Expand Up @@ -111,6 +112,7 @@ export const PaginatedHateoasList = <
}
data={items}
renderItem={props.renderItem}
itemSeparator={props.itemSeparator}
wrapperComponent={props.wrapperComponent}
wrapperComponentProps={props.wrapperComponentProps}
listComponent={props.listComponent}
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/component/common/list/SimpleList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const SimpleList = <
onPageChange: (page: number) => void;
};
renderItem: (item: DataItem) => ReactNode;
itemSeparator?: () => ReactNode;
} & OverridableListWrappers<WrapperComponent, ListComponent>
) => {
const { data, pagination } = props;
Expand All @@ -50,6 +51,7 @@ export const SimpleList = <
{data.map((item, index) => (
<React.Fragment key={(item as any).id || index}>
{props.renderItem(item)}
{index < data.length - 1 && props.itemSeparator?.()}
</React.Fragment>
))}
</ListWrapper>
Expand Down
17 changes: 17 additions & 0 deletions webapp/src/component/task/TaskId.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Box, styled, SxProps } from '@mui/material';
import React from 'react';

export const Container = styled(Box)`
color: ${({ theme }) => theme.palette.tokens.icon.secondary};
font-size: 15px;
`;

type Props = {
children: React.ReactNode;
sx?: SxProps;
className?: string;
};

export const TaskId = ({ children, sx, className }: Props) => {
return <Container {...{ sx, className }}>#{children}</Container>;
};
105 changes: 105 additions & 0 deletions webapp/src/component/task/TaskItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Box, IconButton, styled, Tooltip, useTheme } from '@mui/material';
import { FlagImage } from 'tg.component/languages/FlagImage';
import { components } from 'tg.service/apiSchema.generated';
import { TaskId } from './TaskId';
import { TaskTypeChip } from './TaskTypeChip';
import { BatchProgress } from 'tg.views/projects/translations/BatchOperations/OperationsSummary/BatchProgress';
import { useDateFormatter } from 'tg.hooks/useLocale';
import { AccessAlarm, MoreVert } from '@mui/icons-material';
import { AvatarImg } from 'tg.component/common/avatar/AvatarImg';

type TaskModel = components['schemas']['TaskModel'];

const StyledContainer = styled('div')`
display: contents;
&:hover > * {
background: ${({ theme }) => theme.palette.tokens.text._states.hover};
}
`;

const StyledItem = styled(Box)`
display: flex;
align-items: center;
align-self: stretch;
justify-self: stretch;
gap: 8px;
`;

const StyledName = styled(StyledItem)`
grid-column: 1;
padding: 8px 0px;
padding-left: 16px;
`;

const StyledProgress = styled(StyledItem)`
display: grid;
grid-template-columns: 80px 1fr;
gap: 24px;
color: ${({ theme }) => theme.palette.tokens.icon.secondary};
`;

const StyledAssignees = styled(StyledItem)`
justify-content: start;
display: flex;
flex-wrap: wrap;
`;

type Props = {
task: TaskModel;
};

export const TaskItem = ({ task }: Props) => {
const theme = useTheme();
const language = task.language;
const formatDate = useDateFormatter();
return (
<StyledContainer>
<StyledName>
<Tooltip title={`${language.name} (${language.tag})`}>
<FlagImage flagEmoji={language.flagEmoji!} height={20} />
</Tooltip>
<Box>{task.name}</Box>
<TaskId>{task.id}</TaskId>
<TaskTypeChip type={task.type} />
</StyledName>
<StyledItem
color={theme.palette.tokens.text.secondary}
alignItems="center"
justifyContent="center"
>
{task.keys.length}
</StyledItem>
<StyledProgress>
<BatchProgress progress={0.5} max={1} />
{task.dueDate ? (
<Box display="flex" alignItems="center" gap={0.5}>
<AccessAlarm sx={{ fontSize: 16 }} />
{formatDate(task.dueDate, { timeZone: 'UTC' })}
</Box>
) : null}
</StyledProgress>
<StyledAssignees>
{task.assignees.map((user) => (
<Tooltip key={user.id} title={<div>{user.username}</div>}>
<div>
<AvatarImg
owner={{
name: user.name,
avatar: user.avatar,
type: 'USER',
id: user.id,
}}
size={24}
/>
</div>
</Tooltip>
))}
</StyledAssignees>
<StyledItem sx={{ pr: 1 }}>
<IconButton size="small">
<MoreVert fontSize="small" />
</IconButton>
</StyledItem>
</StyledContainer>
);
};
33 changes: 33 additions & 0 deletions webapp/src/component/task/TaskTypeChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Chip, styled, useTheme } from '@mui/material';
import { components } from 'tg.service/apiSchema.generated';
import { useTaskTranslation } from 'tg.translationTools/useTaskTranslation';

type TaskType = components['schemas']['TaskModel']['type'];

const StyledChip = styled(Chip)``;

type Props = {
type: TaskType;
};

export const TaskTypeChip = ({ type }: Props) => {
const translateTaskType = useTaskTranslation();
const theme = useTheme();

function getBackgroundColor() {
switch (type) {
case 'TRANSLATE':
return theme.palette.tokens.text._states.focus;
case 'REVIEW':
return theme.palette.tokens.secondary._states.focus;
}
}

return (
<StyledChip
size="small"
label={translateTaskType(type)}
sx={{ background: getBackgroundColor() }}
/>
);
};
2 changes: 2 additions & 0 deletions webapp/src/constants/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ export class LINKS {
'single'
);

static PROJECT_TASKS = Link.ofParent(LINKS.PROJECT, 'tasks');

static PROJECT_EXPORT = Link.ofParent(LINKS.PROJECT, 'export');

static PROJECT_WEBSOCKETS_PREVIEW = Link.ofParent(
Expand Down
Loading

0 comments on commit 6e085a6

Please sign in to comment.