mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-18 09:02:11 +03:00
Add tasks page (#1015)
* Refactor top bar component * Add task page with tabs * Add tasks * Add logic for task status * Fix isoweek definition * Enable click on task * Deduplicate component * Lint --------- Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
parent
700b567320
commit
22ca00bb67
@ -17,6 +17,7 @@ import { SettingsExperience } from '~/pages/settings/SettingsExperience';
|
||||
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
|
||||
import { SettingsWorksapce } from '~/pages/settings/SettingsWorkspace';
|
||||
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
|
||||
import { Tasks } from '~/pages/tasks/Tasks';
|
||||
import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks';
|
||||
|
||||
// TEMP FEATURE FLAG FOR VIEW FIELDS
|
||||
@ -39,6 +40,7 @@ export function App() {
|
||||
<Route path={AppPath.PersonShowPage} element={<PersonShow />} />
|
||||
<Route path={AppPath.CompaniesPage} element={<Companies />} />
|
||||
<Route path={AppPath.CompanyShowPage} element={<CompanyShow />} />
|
||||
<Route path={AppPath.TasksPage} element={<Tasks />} />
|
||||
<Route path={AppPath.Impersonate} element={<Impersonate />} />
|
||||
|
||||
<Route path={AppPath.OpportunitiesPage} element={<Opportunities />} />
|
||||
|
@ -5,6 +5,7 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { SettingsNavbar } from '@/settings/components/SettingsNavbar';
|
||||
import {
|
||||
IconBuildingSkyscraper,
|
||||
IconCheckbox,
|
||||
IconInbox,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
@ -45,6 +46,11 @@ export function AppNavbar() {
|
||||
to="/settings/profile"
|
||||
icon={<IconSettings size={theme.icon.size.md} />}
|
||||
/>
|
||||
<NavItem
|
||||
label="Tasks"
|
||||
to="/tasks"
|
||||
icon={<IconCheckbox size={theme.icon.size.md} />}
|
||||
/>
|
||||
<NavTitle label="Workspace" />
|
||||
<NavItem
|
||||
label="Companies"
|
||||
|
@ -2259,6 +2259,14 @@ export type GetActivitiesByTargetsQueryVariables = Exact<{
|
||||
|
||||
export type GetActivitiesByTargetsQuery = { __typename?: 'Query', findManyActivities: Array<{ __typename?: 'Activity', id: string, createdAt: string, title?: string | null, body?: string | null, type: ActivityType, completedAt?: string | null, dueAt?: string | null, assignee?: { __typename?: 'User', id: string, firstName?: string | null, lastName?: string | null, displayName: string } | null, author: { __typename?: 'User', id: string, firstName?: string | null, lastName?: string | null, displayName: string }, comments?: Array<{ __typename?: 'Comment', id: string, body: string, createdAt: string, updatedAt: string, author: { __typename?: 'User', id: string, displayName: string, firstName?: string | null, lastName?: string | null, avatarUrl?: string | null } }> | null, activityTargets?: Array<{ __typename?: 'ActivityTarget', id: string, commentableType?: CommentableType | null, commentableId?: string | null }> | null }> };
|
||||
|
||||
export type GetActivitiesQueryVariables = Exact<{
|
||||
where: ActivityWhereInput;
|
||||
orderBy?: InputMaybe<Array<ActivityOrderByWithRelationInput> | ActivityOrderByWithRelationInput>;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetActivitiesQuery = { __typename?: 'Query', findManyActivities: Array<{ __typename?: 'Activity', id: string, createdAt: string, title?: string | null, body?: string | null, type: ActivityType, completedAt?: string | null, dueAt?: string | null, assignee?: { __typename?: 'User', id: string, firstName?: string | null, lastName?: string | null, displayName: string, avatarUrl?: string | null } | null, author: { __typename?: 'User', id: string, firstName?: string | null, lastName?: string | null, displayName: string }, comments?: Array<{ __typename?: 'Comment', id: string }> | null, activityTargets?: Array<{ __typename?: 'ActivityTarget', id: string, commentableType?: CommentableType | null, commentableId?: string | null }> | null }> };
|
||||
|
||||
export type GetActivityQueryVariables = Exact<{
|
||||
activityId: Scalars['String'];
|
||||
}>;
|
||||
@ -2870,6 +2878,69 @@ export function useGetActivitiesByTargetsLazyQuery(baseOptions?: Apollo.LazyQuer
|
||||
export type GetActivitiesByTargetsQueryHookResult = ReturnType<typeof useGetActivitiesByTargetsQuery>;
|
||||
export type GetActivitiesByTargetsLazyQueryHookResult = ReturnType<typeof useGetActivitiesByTargetsLazyQuery>;
|
||||
export type GetActivitiesByTargetsQueryResult = Apollo.QueryResult<GetActivitiesByTargetsQuery, GetActivitiesByTargetsQueryVariables>;
|
||||
export const GetActivitiesDocument = gql`
|
||||
query GetActivities($where: ActivityWhereInput!, $orderBy: [ActivityOrderByWithRelationInput!]) {
|
||||
findManyActivities(orderBy: $orderBy, where: $where) {
|
||||
id
|
||||
createdAt
|
||||
title
|
||||
body
|
||||
type
|
||||
completedAt
|
||||
dueAt
|
||||
assignee {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
author {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
displayName
|
||||
}
|
||||
comments {
|
||||
id
|
||||
}
|
||||
activityTargets {
|
||||
id
|
||||
commentableType
|
||||
commentableId
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetActivitiesQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetActivitiesQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetActivitiesQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetActivitiesQuery({
|
||||
* variables: {
|
||||
* where: // value for 'where'
|
||||
* orderBy: // value for 'orderBy'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetActivitiesQuery(baseOptions: Apollo.QueryHookOptions<GetActivitiesQuery, GetActivitiesQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetActivitiesQuery, GetActivitiesQueryVariables>(GetActivitiesDocument, options);
|
||||
}
|
||||
export function useGetActivitiesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetActivitiesQuery, GetActivitiesQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetActivitiesQuery, GetActivitiesQueryVariables>(GetActivitiesDocument, options);
|
||||
}
|
||||
export type GetActivitiesQueryHookResult = ReturnType<typeof useGetActivitiesQuery>;
|
||||
export type GetActivitiesLazyQueryHookResult = ReturnType<typeof useGetActivitiesLazyQuery>;
|
||||
export type GetActivitiesQueryResult = Apollo.QueryResult<GetActivitiesQuery, GetActivitiesQueryVariables>;
|
||||
export const GetActivityDocument = gql`
|
||||
query GetActivity($activityId: String!) {
|
||||
findManyActivities(where: {id: {equals: $activityId}}) {
|
||||
|
@ -0,0 +1,43 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CompanyChip } from '@/companies/components/CompanyChip';
|
||||
import { PersonChip } from '@/people/components/PersonChip';
|
||||
import { GetCompaniesQuery, GetPeopleQuery } from '~/generated/graphql';
|
||||
import { getLogoUrlFromDomainName } from '~/utils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export function ActivityTargetChips({
|
||||
targetCompanies,
|
||||
targetPeople,
|
||||
}: {
|
||||
targetCompanies?: GetCompaniesQuery;
|
||||
targetPeople?: GetPeopleQuery;
|
||||
}) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
{targetCompanies?.companies &&
|
||||
targetCompanies.companies.map((company) => (
|
||||
<CompanyChip
|
||||
key={company.id}
|
||||
id={company.id}
|
||||
name={company.name}
|
||||
pictureUrl={getLogoUrlFromDomainName(company.domainName)}
|
||||
/>
|
||||
))}
|
||||
{targetPeople?.people &&
|
||||
targetPeople.people.map((person) => (
|
||||
<PersonChip
|
||||
key={person.id}
|
||||
id={person.id}
|
||||
name={person.displayName}
|
||||
pictureUrl={person.avatarUrl ?? ''}
|
||||
/>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
@ -1,7 +1,4 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { CompanyChip } from '@/companies/components/CompanyChip';
|
||||
import { PersonChip } from '@/people/components/PersonChip';
|
||||
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
|
||||
import { EditableField } from '@/ui/editable-field/components/EditableField';
|
||||
import { FieldContext } from '@/ui/editable-field/states/FieldContext';
|
||||
import { IconArrowUpRight } from '@/ui/icon';
|
||||
@ -13,16 +10,9 @@ import {
|
||||
useGetCompaniesQuery,
|
||||
useGetPeopleQuery,
|
||||
} from '~/generated/graphql';
|
||||
import { getLogoUrlFromDomainName } from '~/utils';
|
||||
|
||||
import { ActivityRelationEditableFieldEditMode } from './ActivityRelationEditableFieldEditMode';
|
||||
|
||||
const StyledDisplayModeContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
type OwnProps = {
|
||||
activity?: Pick<Activity, 'id'> & {
|
||||
activityTargets?: Array<
|
||||
@ -74,26 +64,10 @@ export function ActivityRelationEditableField({ activity }: OwnProps) {
|
||||
}
|
||||
label="Relations"
|
||||
displayModeContent={
|
||||
<StyledDisplayModeContainer>
|
||||
{targetCompanies?.companies &&
|
||||
targetCompanies.companies.map((company) => (
|
||||
<CompanyChip
|
||||
key={company.id}
|
||||
id={company.id}
|
||||
name={company.name}
|
||||
pictureUrl={getLogoUrlFromDomainName(company.domainName)}
|
||||
/>
|
||||
))}
|
||||
{targetPeople?.people &&
|
||||
targetPeople.people.map((person) => (
|
||||
<PersonChip
|
||||
key={person.id}
|
||||
id={person.id}
|
||||
name={person.displayName}
|
||||
pictureUrl={person.avatarUrl ?? ''}
|
||||
/>
|
||||
))}
|
||||
</StyledDisplayModeContainer>
|
||||
<ActivityTargetChips
|
||||
targetCompanies={targetCompanies}
|
||||
targetPeople={targetPeople}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</RecoilScope>
|
||||
|
@ -52,6 +52,44 @@ export const GET_ACTIVITIES_BY_TARGETS = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_ACTIVITIES = gql`
|
||||
query GetActivities(
|
||||
$where: ActivityWhereInput!
|
||||
$orderBy: [ActivityOrderByWithRelationInput!]
|
||||
) {
|
||||
findManyActivities(orderBy: $orderBy, where: $where) {
|
||||
id
|
||||
createdAt
|
||||
title
|
||||
body
|
||||
type
|
||||
completedAt
|
||||
dueAt
|
||||
assignee {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
author {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
displayName
|
||||
}
|
||||
comments {
|
||||
id
|
||||
}
|
||||
activityTargets {
|
||||
id
|
||||
commentableType
|
||||
commentableId
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_ACTIVITY = gql`
|
||||
query GetActivity($activityId: String!) {
|
||||
findManyActivities(where: { id: { equals: $activityId } }) {
|
||||
|
13
front/src/modules/tasks/components/TaskGroups.tsx
Normal file
13
front/src/modules/tasks/components/TaskGroups.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { useTasks } from '../hooks/useTasks';
|
||||
|
||||
import { TaskList } from './TaskList';
|
||||
|
||||
export function TaskGroups() {
|
||||
const { todayTasks, otherTasks } = useTasks();
|
||||
return (
|
||||
<>
|
||||
<TaskList title="Today" tasks={todayTasks ?? []} />
|
||||
<TaskList title="Others" tasks={otherTasks ?? []} />
|
||||
</>
|
||||
);
|
||||
}
|
61
front/src/modules/tasks/components/TaskList.tsx
Normal file
61
front/src/modules/tasks/components/TaskList.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { TaskForList } from '../types/TaskForList';
|
||||
|
||||
import { TaskRow } from './TaskRow';
|
||||
|
||||
type OwnProps = {
|
||||
title: string;
|
||||
tasks: TaskForList[];
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 8px 24px;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.h3`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledCount = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTaskRows = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledEmptyListMessage = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
padding: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export function TaskList({ title, tasks }: OwnProps) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledTitle>
|
||||
{title} <StyledCount>{tasks ? tasks.length : 0}</StyledCount>
|
||||
</StyledTitle>
|
||||
{tasks && tasks.length > 0 ? (
|
||||
<StyledTaskRows>
|
||||
{tasks.map((task) => (
|
||||
<TaskRow key={task.id} task={task} />
|
||||
))}
|
||||
</StyledTaskRows>
|
||||
) : (
|
||||
<StyledEmptyListMessage>No task in this section</StyledEmptyListMessage>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
105
front/src/modules/tasks/components/TaskRow.tsx
Normal file
105
front/src/modules/tasks/components/TaskRow.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
|
||||
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
|
||||
import { IconCalendar, IconComment } from '@/ui/icon';
|
||||
import {
|
||||
Checkbox,
|
||||
CheckboxShape,
|
||||
} from '@/ui/input/checkbox/components/Checkbox';
|
||||
import { useGetCompaniesQuery, useGetPeopleQuery } from '~/generated/graphql';
|
||||
import { beautifyExactDate } from '~/utils/date-utils';
|
||||
|
||||
import { TaskForList } from '../types/TaskForList';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: ${({ theme }) => theme.spacing(12)};
|
||||
padding: 0 ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledSeparator = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StyledTaskTitle = styled.div`
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledCommentIcon = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
`;
|
||||
|
||||
const StyledDueDate = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export function TaskRow({ task }: { task: TaskForList }) {
|
||||
const theme = useTheme();
|
||||
const openActivityRightDrawer = useOpenActivityRightDrawer();
|
||||
const { data: targetPeople } = useGetPeopleQuery({
|
||||
variables: {
|
||||
where: {
|
||||
id: {
|
||||
in: task?.activityTargets
|
||||
? task?.activityTargets
|
||||
.filter((target) => target.commentableType === 'Person')
|
||||
.map((target) => target.commentableId ?? '')
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data: targetCompanies } = useGetCompaniesQuery({
|
||||
variables: {
|
||||
where: {
|
||||
id: {
|
||||
in: task?.activityTargets
|
||||
? task?.activityTargets
|
||||
.filter((target) => target.commentableType === 'Company')
|
||||
.map((target) => target.commentableId ?? '')
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
onClick={() => {
|
||||
openActivityRightDrawer(task.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={false} shape={CheckboxShape.Rounded} />
|
||||
</div>
|
||||
<StyledTaskTitle>{task.title}</StyledTaskTitle>
|
||||
{task.comments && task.comments.length > 0 && (
|
||||
<StyledCommentIcon>
|
||||
<IconComment size={theme.icon.size.md} />
|
||||
</StyledCommentIcon>
|
||||
)}
|
||||
<StyledSeparator />
|
||||
<ActivityTargetChips
|
||||
targetCompanies={targetCompanies}
|
||||
targetPeople={targetPeople}
|
||||
/>
|
||||
<StyledDueDate>
|
||||
<IconCalendar size={theme.icon.size.md} />
|
||||
{task.dueAt && beautifyExactDate(task.dueAt)}
|
||||
</StyledDueDate>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
47
front/src/modules/tasks/hooks/useTasks.ts
Normal file
47
front/src/modules/tasks/hooks/useTasks.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { activeTabIdScopedState } from '@/ui/tab/states/activeTabIdScopedState';
|
||||
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
|
||||
import { ActivityType, useGetActivitiesQuery } from '~/generated/graphql';
|
||||
import { parseDate } from '~/utils/date-utils';
|
||||
|
||||
import { TasksContext } from '../states/TasksContext';
|
||||
|
||||
export function useTasks() {
|
||||
const [activeTabId] = useRecoilScopedState(
|
||||
activeTabIdScopedState,
|
||||
TasksContext,
|
||||
);
|
||||
|
||||
const { data, loading } = useGetActivitiesQuery({
|
||||
variables: {
|
||||
where: {
|
||||
type: { equals: ActivityType.Task },
|
||||
completedAt:
|
||||
activeTabId === 'done' ? { not: { equals: null } } : { equals: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const todayTasks = data?.findManyActivities.filter((task) => {
|
||||
if (!task.dueAt) {
|
||||
return false;
|
||||
}
|
||||
const dueDate = parseDate(task.dueAt).toJSDate();
|
||||
const today = new Date();
|
||||
return dueDate.getDate() === today.getDate();
|
||||
});
|
||||
|
||||
const otherTasks = data?.findManyActivities.filter((task) => {
|
||||
if (!task.dueAt) {
|
||||
return false;
|
||||
}
|
||||
const dueDate = parseDate(task.dueAt).toJSDate();
|
||||
const today = new Date();
|
||||
return dueDate.getDate() !== today.getDate();
|
||||
});
|
||||
|
||||
return {
|
||||
todayTasks,
|
||||
otherTasks,
|
||||
loading,
|
||||
};
|
||||
}
|
3
front/src/modules/tasks/states/TasksContext.ts
Normal file
3
front/src/modules/tasks/states/TasksContext.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const TasksContext = createContext<string | null>(null);
|
3
front/src/modules/tasks/types/TaskForList.ts
Normal file
3
front/src/modules/tasks/types/TaskForList.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { GetActivitiesQuery } from '~/generated/graphql';
|
||||
|
||||
export type TaskForList = GetActivitiesQuery['findManyActivities'][0];
|
@ -15,6 +15,7 @@ export enum AppPath {
|
||||
CompaniesPage = '/companies',
|
||||
CompanyShowPage = '/companies/:companyId',
|
||||
PersonShowPage = '/person/:personId',
|
||||
TasksPage = '/tasks',
|
||||
OpportunitiesPage = '/opportunities',
|
||||
SettingsCatchAll = `/settings/*`,
|
||||
|
||||
|
@ -6,6 +6,7 @@ import SortAndFilterBar from '@/ui/filter-n-sort/components/SortAndFilterBar';
|
||||
import { SortDropdownButton } from '@/ui/filter-n-sort/components/SortDropdownButton';
|
||||
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
|
||||
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
|
||||
import { TopBar } from '@/ui/top-bar/TopBar';
|
||||
|
||||
type OwnProps<SortField> = {
|
||||
viewName: string;
|
||||
@ -15,24 +16,6 @@ type OwnProps<SortField> = {
|
||||
context: Context<string | null>;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledBoardHeader = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
height: 40px;
|
||||
justify-content: space-between;
|
||||
padding-left: ${({ theme }) => theme.spacing(3)};
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledIcon = styled.div`
|
||||
display: flex;
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
@ -43,16 +26,6 @@ const StyledIcon = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledViewSection = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledFilters = styled.div`
|
||||
display: flex;
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
export function BoardHeader<SortField>({
|
||||
viewName,
|
||||
viewIcon,
|
||||
@ -83,35 +56,37 @@ export function BoardHeader<SortField>({
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledBoardHeader>
|
||||
<StyledViewSection>
|
||||
<TopBar
|
||||
leftComponent={
|
||||
<>
|
||||
<StyledIcon>{viewIcon}</StyledIcon>
|
||||
{viewName}
|
||||
</StyledViewSection>
|
||||
<StyledFilters>
|
||||
<FilterDropdownButton
|
||||
context={context}
|
||||
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
|
||||
/>
|
||||
<SortDropdownButton<SortField>
|
||||
isSortSelected={sorts.length > 0}
|
||||
availableSorts={availableSorts || []}
|
||||
onSortSelect={sortSelect}
|
||||
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
|
||||
/>
|
||||
</StyledFilters>
|
||||
</StyledBoardHeader>
|
||||
<SortAndFilterBar
|
||||
context={context}
|
||||
sorts={sorts}
|
||||
onRemoveSort={sortUnselect}
|
||||
onCancelClick={() => {
|
||||
innerSetSorts([]);
|
||||
onSortsUpdate && onSortsUpdate([]);
|
||||
}}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</>
|
||||
}
|
||||
rightComponents={[
|
||||
<FilterDropdownButton
|
||||
context={context}
|
||||
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
|
||||
/>,
|
||||
<SortDropdownButton<SortField>
|
||||
isSortSelected={sorts.length > 0}
|
||||
availableSorts={availableSorts || []}
|
||||
onSortSelect={sortSelect}
|
||||
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
|
||||
/>,
|
||||
]}
|
||||
bottomComponent={
|
||||
<SortAndFilterBar
|
||||
context={context}
|
||||
sorts={sorts}
|
||||
onRemoveSort={sortUnselect}
|
||||
onCancelClick={() => {
|
||||
innerSetSorts([]);
|
||||
onSortsUpdate && onSortsUpdate([]);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ export { IconUser } from '@tabler/icons-react';
|
||||
export { IconList } from '@tabler/icons-react';
|
||||
export { IconInbox } from '@tabler/icons-react';
|
||||
export { IconSearch } from '@tabler/icons-react';
|
||||
export { IconArchive } from '@tabler/icons-react';
|
||||
export { IconSettings } from '@tabler/icons-react';
|
||||
export { IconLogout } from '@tabler/icons-react';
|
||||
export { IconColorSwatch } from '@tabler/icons-react';
|
||||
|
@ -3,6 +3,7 @@ import styled from '@emotion/styled';
|
||||
|
||||
type OwnProps = {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
@ -28,10 +29,16 @@ const StyledTab = styled.div<{ active?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export function Tab({ title, active = false, onClick, className }: OwnProps) {
|
||||
export function Tab({
|
||||
title,
|
||||
icon,
|
||||
active = false,
|
||||
onClick,
|
||||
className,
|
||||
}: OwnProps) {
|
||||
return (
|
||||
<StyledTab onClick={onClick} active={active} className={className}>
|
||||
{title}
|
||||
{icon} {title}
|
||||
</StyledTab>
|
||||
);
|
||||
}
|
||||
|
47
front/src/modules/ui/tab/components/TabList.tsx
Normal file
47
front/src/modules/ui/tab/components/TabList.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { activeTabIdScopedState } from '../states/activeTabIdScopedState';
|
||||
|
||||
import { Tab } from './Tab';
|
||||
|
||||
type SingleTabProps = {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type OwnProps = {
|
||||
tabs: SingleTabProps[];
|
||||
context: React.Context<string | null>;
|
||||
};
|
||||
|
||||
export function TabList({ tabs, context }: OwnProps) {
|
||||
const initialActiveTabId = tabs[0].id;
|
||||
|
||||
const [activeTabId, setActiveTabId] = useRecoilScopedState(
|
||||
activeTabIdScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveTabId(initialActiveTabId);
|
||||
}, [initialActiveTabId, setActiveTabId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
title={tab.title}
|
||||
icon={tab.icon}
|
||||
active={tab.id === activeTabId}
|
||||
onClick={() => {
|
||||
setActiveTabId(tab.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { IconCheckbox } from '@tabler/icons-react';
|
||||
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
@ -22,7 +23,7 @@ export const Default: Story = {
|
||||
};
|
||||
|
||||
export const Catalog: Story = {
|
||||
args: { title: 'Tab title' },
|
||||
args: { title: 'Tab title', icon: <IconCheckbox /> },
|
||||
argTypes: {
|
||||
active: { control: false },
|
||||
onClick: { control: false },
|
||||
|
@ -0,0 +1,6 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const activeTabIdScopedState = atomFamily<string | null, string>({
|
||||
key: 'activeTabIdScopedState',
|
||||
default: null,
|
||||
});
|
@ -6,6 +6,7 @@ import SortAndFilterBar from '@/ui/filter-n-sort/components/SortAndFilterBar';
|
||||
import { SortDropdownButton } from '@/ui/filter-n-sort/components/SortDropdownButton';
|
||||
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
|
||||
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
|
||||
import { TopBar } from '@/ui/top-bar/TopBar';
|
||||
|
||||
import { TableContext } from '../../states/TableContext';
|
||||
|
||||
@ -16,23 +17,6 @@ type OwnProps<SortField> = {
|
||||
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledTableHeader = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
height: 40px;
|
||||
justify-content: space-between;
|
||||
padding-left: ${({ theme }) => theme.spacing(3)};
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledIcon = styled.div`
|
||||
display: flex;
|
||||
margin-left: ${({ theme }) => theme.spacing(1)};
|
||||
@ -43,16 +27,6 @@ const StyledIcon = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledViewSection = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledFilters = styled.div`
|
||||
display: flex;
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
export function TableHeader<SortField>({
|
||||
viewName,
|
||||
viewIcon,
|
||||
@ -82,35 +56,37 @@ export function TableHeader<SortField>({
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledTableHeader>
|
||||
<StyledViewSection>
|
||||
<TopBar
|
||||
leftComponent={
|
||||
<>
|
||||
<StyledIcon>{viewIcon}</StyledIcon>
|
||||
{viewName}
|
||||
</StyledViewSection>
|
||||
<StyledFilters>
|
||||
<FilterDropdownButton
|
||||
context={TableContext}
|
||||
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
|
||||
/>
|
||||
<SortDropdownButton<SortField>
|
||||
isSortSelected={sorts.length > 0}
|
||||
availableSorts={availableSorts || []}
|
||||
onSortSelect={sortSelect}
|
||||
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
|
||||
/>
|
||||
</StyledFilters>
|
||||
</StyledTableHeader>
|
||||
<SortAndFilterBar
|
||||
context={TableContext}
|
||||
sorts={sorts}
|
||||
onRemoveSort={sortUnselect}
|
||||
onCancelClick={() => {
|
||||
innerSetSorts([]);
|
||||
onSortsUpdate && onSortsUpdate([]);
|
||||
}}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</>
|
||||
}
|
||||
rightComponents={[
|
||||
<FilterDropdownButton
|
||||
context={TableContext}
|
||||
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
|
||||
/>,
|
||||
<SortDropdownButton<SortField>
|
||||
isSortSelected={sorts.length > 0}
|
||||
availableSorts={availableSorts || []}
|
||||
onSortSelect={sortSelect}
|
||||
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
|
||||
/>,
|
||||
]}
|
||||
bottomComponent={
|
||||
<SortAndFilterBar
|
||||
context={TableContext}
|
||||
sorts={sorts}
|
||||
onRemoveSort={sortUnselect}
|
||||
onCancelClick={() => {
|
||||
innerSetSorts([]);
|
||||
onSortsUpdate && onSortsUpdate([]);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
51
front/src/modules/ui/top-bar/TopBar.tsx
Normal file
51
front/src/modules/ui/top-bar/TopBar.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type OwnProps = {
|
||||
leftComponent?: ReactNode;
|
||||
rightComponents?: ReactNode[];
|
||||
bottomComponent?: ReactNode;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledTableHeader = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
height: 40px;
|
||||
justify-content: space-between;
|
||||
padding-left: ${({ theme }) => theme.spacing(3)};
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledLeftSection = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledRightSection = styled.div`
|
||||
display: flex;
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
export function TopBar({
|
||||
leftComponent,
|
||||
rightComponents,
|
||||
bottomComponent,
|
||||
}: OwnProps) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledTableHeader>
|
||||
<StyledLeftSection>{leftComponent}</StyledLeftSection>
|
||||
<StyledRightSection>{rightComponents}</StyledRightSection>
|
||||
</StyledTableHeader>
|
||||
{bottomComponent}
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
51
front/src/pages/tasks/Tasks.tsx
Normal file
51
front/src/pages/tasks/Tasks.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { TaskGroups } from '@/tasks/components/TaskGroups';
|
||||
import { TasksContext } from '@/tasks/states/TasksContext';
|
||||
import { IconArchive, IconCheck, IconCheckbox } from '@/ui/icon/index';
|
||||
import { WithTopBarContainer } from '@/ui/layout/components/WithTopBarContainer';
|
||||
import { TabList } from '@/ui/tab/components/TabList';
|
||||
import { TopBar } from '@/ui/top-bar/TopBar';
|
||||
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
|
||||
|
||||
const StyledTasksContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
export function Tasks() {
|
||||
const theme = useTheme();
|
||||
|
||||
const TASK_TABS = [
|
||||
{
|
||||
id: 'to-do',
|
||||
title: 'To do',
|
||||
icon: <IconCheck size={theme.icon.size.md} />,
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
title: 'Done',
|
||||
icon: <IconArchive size={theme.icon.size.md} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<WithTopBarContainer
|
||||
title="Tasks"
|
||||
icon={<IconCheckbox size={theme.icon.size.md} />}
|
||||
>
|
||||
<StyledTasksContainer>
|
||||
<RecoilScope SpecificContext={TasksContext}>
|
||||
<TopBar
|
||||
leftComponent={<TabList context={TasksContext} tabs={TASK_TABS} />}
|
||||
/>
|
||||
<TaskGroups />
|
||||
</RecoilScope>
|
||||
</StyledTasksContainer>
|
||||
</WithTopBarContainer>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user