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:
Emilien Chauvet 2023-07-31 16:14:35 -07:00 committed by GitHub
parent 700b567320
commit 22ca00bb67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 625 additions and 143 deletions

View File

@ -17,6 +17,7 @@ import { SettingsExperience } from '~/pages/settings/SettingsExperience';
import { SettingsProfile } from '~/pages/settings/SettingsProfile'; import { SettingsProfile } from '~/pages/settings/SettingsProfile';
import { SettingsWorksapce } from '~/pages/settings/SettingsWorkspace'; import { SettingsWorksapce } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers'; import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
import { Tasks } from '~/pages/tasks/Tasks';
import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks'; import { AppInternalHooks } from '~/sync-hooks/AppInternalHooks';
// TEMP FEATURE FLAG FOR VIEW FIELDS // TEMP FEATURE FLAG FOR VIEW FIELDS
@ -39,6 +40,7 @@ export function App() {
<Route path={AppPath.PersonShowPage} element={<PersonShow />} /> <Route path={AppPath.PersonShowPage} element={<PersonShow />} />
<Route path={AppPath.CompaniesPage} element={<Companies />} /> <Route path={AppPath.CompaniesPage} element={<Companies />} />
<Route path={AppPath.CompanyShowPage} element={<CompanyShow />} /> <Route path={AppPath.CompanyShowPage} element={<CompanyShow />} />
<Route path={AppPath.TasksPage} element={<Tasks />} />
<Route path={AppPath.Impersonate} element={<Impersonate />} /> <Route path={AppPath.Impersonate} element={<Impersonate />} />
<Route path={AppPath.OpportunitiesPage} element={<Opportunities />} /> <Route path={AppPath.OpportunitiesPage} element={<Opportunities />} />

View File

@ -5,6 +5,7 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { SettingsNavbar } from '@/settings/components/SettingsNavbar'; import { SettingsNavbar } from '@/settings/components/SettingsNavbar';
import { import {
IconBuildingSkyscraper, IconBuildingSkyscraper,
IconCheckbox,
IconInbox, IconInbox,
IconSearch, IconSearch,
IconSettings, IconSettings,
@ -45,6 +46,11 @@ export function AppNavbar() {
to="/settings/profile" to="/settings/profile"
icon={<IconSettings size={theme.icon.size.md} />} icon={<IconSettings size={theme.icon.size.md} />}
/> />
<NavItem
label="Tasks"
to="/tasks"
icon={<IconCheckbox size={theme.icon.size.md} />}
/>
<NavTitle label="Workspace" /> <NavTitle label="Workspace" />
<NavItem <NavItem
label="Companies" label="Companies"

View File

@ -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 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<{ export type GetActivityQueryVariables = Exact<{
activityId: Scalars['String']; activityId: Scalars['String'];
}>; }>;
@ -2870,6 +2878,69 @@ export function useGetActivitiesByTargetsLazyQuery(baseOptions?: Apollo.LazyQuer
export type GetActivitiesByTargetsQueryHookResult = ReturnType<typeof useGetActivitiesByTargetsQuery>; export type GetActivitiesByTargetsQueryHookResult = ReturnType<typeof useGetActivitiesByTargetsQuery>;
export type GetActivitiesByTargetsLazyQueryHookResult = ReturnType<typeof useGetActivitiesByTargetsLazyQuery>; export type GetActivitiesByTargetsLazyQueryHookResult = ReturnType<typeof useGetActivitiesByTargetsLazyQuery>;
export type GetActivitiesByTargetsQueryResult = Apollo.QueryResult<GetActivitiesByTargetsQuery, GetActivitiesByTargetsQueryVariables>; 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` export const GetActivityDocument = gql`
query GetActivity($activityId: String!) { query GetActivity($activityId: String!) {
findManyActivities(where: {id: {equals: $activityId}}) { findManyActivities(where: {id: {equals: $activityId}}) {

View File

@ -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>
);
}

View File

@ -1,7 +1,4 @@
import styled from '@emotion/styled'; import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips';
import { CompanyChip } from '@/companies/components/CompanyChip';
import { PersonChip } from '@/people/components/PersonChip';
import { EditableField } from '@/ui/editable-field/components/EditableField'; import { EditableField } from '@/ui/editable-field/components/EditableField';
import { FieldContext } from '@/ui/editable-field/states/FieldContext'; import { FieldContext } from '@/ui/editable-field/states/FieldContext';
import { IconArrowUpRight } from '@/ui/icon'; import { IconArrowUpRight } from '@/ui/icon';
@ -13,16 +10,9 @@ import {
useGetCompaniesQuery, useGetCompaniesQuery,
useGetPeopleQuery, useGetPeopleQuery,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { getLogoUrlFromDomainName } from '~/utils';
import { ActivityRelationEditableFieldEditMode } from './ActivityRelationEditableFieldEditMode'; import { ActivityRelationEditableFieldEditMode } from './ActivityRelationEditableFieldEditMode';
const StyledDisplayModeContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
`;
type OwnProps = { type OwnProps = {
activity?: Pick<Activity, 'id'> & { activity?: Pick<Activity, 'id'> & {
activityTargets?: Array< activityTargets?: Array<
@ -74,26 +64,10 @@ export function ActivityRelationEditableField({ activity }: OwnProps) {
} }
label="Relations" label="Relations"
displayModeContent={ displayModeContent={
<StyledDisplayModeContainer> <ActivityTargetChips
{targetCompanies?.companies && targetCompanies={targetCompanies}
targetCompanies.companies.map((company) => ( targetPeople={targetPeople}
<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>
} }
/> />
</RecoilScope> </RecoilScope>

View File

@ -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` export const GET_ACTIVITY = gql`
query GetActivity($activityId: String!) { query GetActivity($activityId: String!) {
findManyActivities(where: { id: { equals: $activityId } }) { findManyActivities(where: { id: { equals: $activityId } }) {

View 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 ?? []} />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const TasksContext = createContext<string | null>(null);

View File

@ -0,0 +1,3 @@
import { GetActivitiesQuery } from '~/generated/graphql';
export type TaskForList = GetActivitiesQuery['findManyActivities'][0];

View File

@ -15,6 +15,7 @@ export enum AppPath {
CompaniesPage = '/companies', CompaniesPage = '/companies',
CompanyShowPage = '/companies/:companyId', CompanyShowPage = '/companies/:companyId',
PersonShowPage = '/person/:personId', PersonShowPage = '/person/:personId',
TasksPage = '/tasks',
OpportunitiesPage = '/opportunities', OpportunitiesPage = '/opportunities',
SettingsCatchAll = `/settings/*`, SettingsCatchAll = `/settings/*`,

View File

@ -6,6 +6,7 @@ import SortAndFilterBar from '@/ui/filter-n-sort/components/SortAndFilterBar';
import { SortDropdownButton } from '@/ui/filter-n-sort/components/SortDropdownButton'; import { SortDropdownButton } from '@/ui/filter-n-sort/components/SortDropdownButton';
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope'; import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface'; import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { TopBar } from '@/ui/top-bar/TopBar';
type OwnProps<SortField> = { type OwnProps<SortField> = {
viewName: string; viewName: string;
@ -15,24 +16,6 @@ type OwnProps<SortField> = {
context: Context<string | null>; 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` const StyledIcon = styled.div`
display: flex; display: flex;
margin-left: ${({ theme }) => theme.spacing(1)}; 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>({ export function BoardHeader<SortField>({
viewName, viewName,
viewIcon, viewIcon,
@ -83,35 +56,37 @@ export function BoardHeader<SortField>({
); );
return ( return (
<StyledContainer> <TopBar
<StyledBoardHeader> leftComponent={
<StyledViewSection> <>
<StyledIcon>{viewIcon}</StyledIcon> <StyledIcon>{viewIcon}</StyledIcon>
{viewName} {viewName}
</StyledViewSection> </>
<StyledFilters> }
<FilterDropdownButton rightComponents={[
context={context} <FilterDropdownButton
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton} context={context}
/> HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
<SortDropdownButton<SortField> />,
isSortSelected={sorts.length > 0} <SortDropdownButton<SortField>
availableSorts={availableSorts || []} isSortSelected={sorts.length > 0}
onSortSelect={sortSelect} availableSorts={availableSorts || []}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton} onSortSelect={sortSelect}
/> HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
</StyledFilters> />,
</StyledBoardHeader> ]}
<SortAndFilterBar bottomComponent={
context={context} <SortAndFilterBar
sorts={sorts} context={context}
onRemoveSort={sortUnselect} sorts={sorts}
onCancelClick={() => { onRemoveSort={sortUnselect}
innerSetSorts([]); onCancelClick={() => {
onSortsUpdate && onSortsUpdate([]); innerSetSorts([]);
}} onSortsUpdate && onSortsUpdate([]);
/> }}
</StyledContainer> />
}
/>
); );
} }

View File

@ -9,6 +9,7 @@ export { IconUser } from '@tabler/icons-react';
export { IconList } from '@tabler/icons-react'; export { IconList } from '@tabler/icons-react';
export { IconInbox } from '@tabler/icons-react'; export { IconInbox } from '@tabler/icons-react';
export { IconSearch } from '@tabler/icons-react'; export { IconSearch } from '@tabler/icons-react';
export { IconArchive } from '@tabler/icons-react';
export { IconSettings } from '@tabler/icons-react'; export { IconSettings } from '@tabler/icons-react';
export { IconLogout } from '@tabler/icons-react'; export { IconLogout } from '@tabler/icons-react';
export { IconColorSwatch } from '@tabler/icons-react'; export { IconColorSwatch } from '@tabler/icons-react';

View File

@ -3,6 +3,7 @@ import styled from '@emotion/styled';
type OwnProps = { type OwnProps = {
title: string; title: string;
icon?: React.ReactNode;
active?: boolean; active?: boolean;
className?: string; className?: string;
onClick?: () => void; 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 ( return (
<StyledTab onClick={onClick} active={active} className={className}> <StyledTab onClick={onClick} active={active} className={className}>
{title} {icon} {title}
</StyledTab> </StyledTab>
); );
} }

View 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);
}}
/>
))}
</>
);
}

View File

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { IconCheckbox } from '@tabler/icons-react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator'; import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
@ -22,7 +23,7 @@ export const Default: Story = {
}; };
export const Catalog: Story = { export const Catalog: Story = {
args: { title: 'Tab title' }, args: { title: 'Tab title', icon: <IconCheckbox /> },
argTypes: { argTypes: {
active: { control: false }, active: { control: false },
onClick: { control: false }, onClick: { control: false },

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const activeTabIdScopedState = atomFamily<string | null, string>({
key: 'activeTabIdScopedState',
default: null,
});

View File

@ -6,6 +6,7 @@ import SortAndFilterBar from '@/ui/filter-n-sort/components/SortAndFilterBar';
import { SortDropdownButton } from '@/ui/filter-n-sort/components/SortDropdownButton'; import { SortDropdownButton } from '@/ui/filter-n-sort/components/SortDropdownButton';
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope'; import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface'; import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { TopBar } from '@/ui/top-bar/TopBar';
import { TableContext } from '../../states/TableContext'; import { TableContext } from '../../states/TableContext';
@ -16,23 +17,6 @@ type OwnProps<SortField> = {
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void; 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` const StyledIcon = styled.div`
display: flex; display: flex;
margin-left: ${({ theme }) => theme.spacing(1)}; 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>({ export function TableHeader<SortField>({
viewName, viewName,
viewIcon, viewIcon,
@ -82,35 +56,37 @@ export function TableHeader<SortField>({
); );
return ( return (
<StyledContainer> <TopBar
<StyledTableHeader> leftComponent={
<StyledViewSection> <>
<StyledIcon>{viewIcon}</StyledIcon> <StyledIcon>{viewIcon}</StyledIcon>
{viewName} {viewName}
</StyledViewSection> </>
<StyledFilters> }
<FilterDropdownButton rightComponents={[
context={TableContext} <FilterDropdownButton
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton} context={TableContext}
/> HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
<SortDropdownButton<SortField> />,
isSortSelected={sorts.length > 0} <SortDropdownButton<SortField>
availableSorts={availableSorts || []} isSortSelected={sorts.length > 0}
onSortSelect={sortSelect} availableSorts={availableSorts || []}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton} onSortSelect={sortSelect}
/> HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
</StyledFilters> />,
</StyledTableHeader> ]}
<SortAndFilterBar bottomComponent={
context={TableContext} <SortAndFilterBar
sorts={sorts} context={TableContext}
onRemoveSort={sortUnselect} sorts={sorts}
onCancelClick={() => { onRemoveSort={sortUnselect}
innerSetSorts([]); onCancelClick={() => {
onSortsUpdate && onSortsUpdate([]); innerSetSorts([]);
}} onSortsUpdate && onSortsUpdate([]);
/> }}
</StyledContainer> />
}
/>
); );
} }

View 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>
);
}

View 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>
);
}