Skip to content

Commit

Permalink
feat: Implement Home view tasks section list (#10961)
Browse files Browse the repository at this point in the history
* feat: Implement Home view tasks section list + modification

* adjust width and friction

* add expanding and collapsing animation

* fix tests

* remove extra touchable

---------

Co-authored-by: Sultan <sultan.al-maari@artsymail.com>
  • Loading branch information
olerichter00 and MrSltun authored Dec 4, 2024
1 parent 9bd22a0 commit 70c2919
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 33 deletions.
2 changes: 1 addition & 1 deletion src/app/Components/Swipeable/Swipeable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import Animated, {
useSharedValue,
} from "react-native-reanimated"

const FRICTION = 1.2
const FRICTION = 1
const SWIPE_TO_INTERACT_THRESHOLD = 80

export interface SwipeableComponentProps extends SwipeableProps {
Expand Down
1 change: 1 addition & 0 deletions src/app/Components/Tasks/Task.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface TaskProps {
onPress?: () => void
task: Task_task$key
}

export const Task = forwardRef<SwipeableMethods, TaskProps>(
({ disableSwipeable, onClearTask, onPress, ...restProps }, ref) => {
const { tappedTaskGroup, tappedClearTask } = useHomeViewTracking()
Expand Down
147 changes: 123 additions & 24 deletions src/app/Scenes/HomeView/Sections/HomeViewSectionTasks.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,45 @@
import { ContextModule } from "@artsy/cohesion"
import { Flex, FlexProps, Skeleton, SkeletonBox, SkeletonText, Spacer } from "@artsy/palette-mobile"
import {
ArrowDownIcon,
ArrowUpIcon,
Box,
Flex,
FlexProps,
Skeleton,
SkeletonBox,
SkeletonText,
Spacer,
Text,
} from "@artsy/palette-mobile"
import { useIsFocused } from "@react-navigation/native"
import { HomeViewSectionTasksQuery } from "__generated__/HomeViewSectionTasksQuery.graphql"
import { HomeViewSectionTasks_section$key } from "__generated__/HomeViewSectionTasks_section.graphql"
import {
HomeViewSectionTasks_section$data,
HomeViewSectionTasks_section$key,
} from "__generated__/HomeViewSectionTasks_section.graphql"
import { SectionTitle } from "app/Components/SectionTitle"
import { Task } from "app/Components/Tasks/Task"
import { HomeViewSectionSentinel } from "app/Scenes/HomeView/Components/HomeViewSectionSentinel"
import { SectionSharedProps } from "app/Scenes/HomeView/Sections/Section"
import { GlobalStore } from "app/store/GlobalStore"
import { extractNodes } from "app/utils/extractNodes"
import { NoFallback, withSuspense } from "app/utils/hooks/withSuspense"
import { ExtractNodeType } from "app/utils/relayHelpers"
import { AnimatePresence, MotiView } from "moti"
import { useEffect, useRef, useState } from "react"
import { InteractionManager } from "react-native"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { CellRendererProps, InteractionManager, ListRenderItem } from "react-native"
import { FlatList } from "react-native-gesture-handler"
import { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"
import { Easing } from "react-native-reanimated"
import { graphql, useFragment, useLazyLoadQuery } from "react-relay"

const MAX_NUMBER_OF_TASKS = 10

// Height of each task + seperator
const TASK_CARD_HEIGHT = 92

type Task = ExtractNodeType<HomeViewSectionTasks_section$data["tasksConnection"]>

interface HomeViewSectionTasksProps extends FlexProps {
section: HomeViewSectionTasks_section$key
index: number
Expand All @@ -28,15 +51,20 @@ export const HomeViewSectionTasks: React.FC<HomeViewSectionTasksProps> = ({
...flexProps
}) => {
const swipeableRef = useRef<SwipeableMethods>(null)
const [displayTask, setDisplayTask] = useState(false)
const section = useFragment(tasksFragment, sectionProp)
const tasks = extractNodes(section.tasksConnection)
const isFocused = useIsFocused()

const { isDismissed } = GlobalStore.useAppState((state) => state.progressiveOnboarding)
const { dismiss } = GlobalStore.actions.progressiveOnboarding

// In the future, we may want to show multiple tasks
const [clearedTasks, setClearedTasks] = useState<string[]>([])
const [showAll, setShowAll] = useState(false)

const filteredTasks = tasks.filter((task) => !clearedTasks.includes(task.internalID))
const displayTaskStack = filteredTasks.length > 1 && !showAll
const HeaderIconComponent = showAll ? ArrowUpIcon : ArrowDownIcon

const task = tasks?.[0]

// adding the find-saved-artwork onboarding key to prevent overlap
Expand Down Expand Up @@ -64,33 +92,103 @@ export const HomeViewSectionTasks: React.FC<HomeViewSectionTasksProps> = ({
}
}, [shouldStartOnboardingAnimation])

useEffect(() => {
setDisplayTask(!!task)
}, [task])
const handleClearTask = (task: Task) => {
if (!task) {
return
}

if (!task) {
return null
setClearedTasks((prev) => [...prev, task.internalID])
}

const handleClearTask = () => {
setDisplayTask(false)
}
const renderCell = useCallback(({ index, ...rest }: CellRendererProps<Task>) => {
return <Box zIndex={-index} {...rest} />
}, [])

const renderItem = useCallback<ListRenderItem<Task>>(
({ item, index }) => {
let scaleX = 1
let translateY = 0
let opacity = 1

if (!showAll && index !== 0) {
scaleX = 1 - index * 0.05
translateY = -83 * index
opacity = 1 - index * 0.15
if (index > 2) {
opacity = 0
}
}

return (
<Flex>
<MotiView
key={item.internalID + index}
transition={{ type: "timing", duration: 500 }}
animate={{ transform: [{ scaleX }, { translateY }], opacity }}
>
<Task
disableSwipeable={displayTaskStack}
onClearTask={() => handleClearTask(item)}
onPress={displayTaskStack ? () => setShowAll((prev) => !prev) : undefined}
ref={swipeableRef}
task={item}
/>
</MotiView>
</Flex>
)
},
[displayTaskStack, handleClearTask, showAll]
)

const motiViewHeight = useMemo(() => {
// this is the height of the first task card + the section title height + padding
const singleTaskHeight = TASK_CARD_HEIGHT + 40 + 40

if (!showAll) {
return singleTaskHeight
}

return singleTaskHeight + (filteredTasks.length - 1) * TASK_CARD_HEIGHT
}, [filteredTasks, showAll])

return (
<AnimatePresence>
{!!displayTask && (
{!!filteredTasks.length && (
<MotiView
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ type: "timing" }}
animate={{ opacity: 1, height: motiViewHeight }}
exit={{ opacity: 0, height: 0 }}
exitTransition={{ type: "timing", easing: Easing.inOut(Easing.ease) }}
>
<Flex {...flexProps}>
<Flex mx={2}>
<SectionTitle title={section.component?.title} />
<SectionTitle
title={section.component?.title}
RightButtonContent={() => {
if (filteredTasks.length < 2) {
return null
}

return (
<Flex flexDirection="row">
<Text variant="xs">{showAll ? "Show Less" : "Show All"}</Text>
<HeaderIconComponent ml={5} mt="2px" />
</Flex>
)
}}
onPress={() => setShowAll((prev) => !prev)}
/>
</Flex>

<Flex mr={2}>
<Task ref={swipeableRef} task={task} onClearTask={handleClearTask} />
<FlatList
scrollEnabled={false}
data={filteredTasks}
keyExtractor={(item) => item.internalID}
CellRendererComponent={renderCell}
ItemSeparatorComponent={() => <Spacer y={1} />}
renderItem={renderItem}
/>
</Flex>

<HomeViewSectionSentinel
Expand All @@ -105,14 +203,15 @@ export const HomeViewSectionTasks: React.FC<HomeViewSectionTasksProps> = ({
}

const tasksFragment = graphql`
fragment HomeViewSectionTasks_section on HomeViewSectionTasks {
fragment HomeViewSectionTasks_section on HomeViewSectionTasks
@argumentDefinitions(numberOfTasks: { type: "Int", defaultValue: 10 }) {
internalID
contextModule
ownerType
component {
title
}
tasksConnection(first: 1) {
tasksConnection(first: $numberOfTasks) {
edges {
node {
internalID
Expand Down Expand Up @@ -142,10 +241,10 @@ const HomeViewSectionTasksPlaceholder: React.FC<FlexProps> = (flexProps) => {
}

const homeViewSectionTasksQuery = graphql`
query HomeViewSectionTasksQuery($id: String!) {
query HomeViewSectionTasksQuery($id: String!, $numberOfTasks: Int!) {
homeView {
section(id: $id) {
...HomeViewSectionTasks_section
...HomeViewSectionTasks_section @arguments(numberOfTasks: $numberOfTasks)
}
}
}
Expand All @@ -155,7 +254,7 @@ export const HomeViewSectionTasksQueryRenderer: React.FC<SectionSharedProps> = w
Component: ({ sectionID, index, refetchKey, ...flexProps }) => {
const data = useLazyLoadQuery<HomeViewSectionTasksQuery>(
homeViewSectionTasksQuery,
{ id: sectionID },
{ id: sectionID, numberOfTasks: MAX_NUMBER_OF_TASKS },
{
fetchKey: refetchKey,
fetchPolicy: "store-and-network",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,44 @@ describe("HomeViewSectionTasks", () => {

expect(screen.getByText("Task 1")).toBeOnTheScreen()
expect(screen.getByText("Task Message 1")).toBeOnTheScreen()

fireEvent.press(screen.getByText("Show All"))

expect(screen.getByText("Task 1")).toBeOnTheScreen()
expect(screen.getByText("Task 2")).toBeOnTheScreen()
expect(screen.getByText("Task Message 2")).toBeOnTheScreen()

expect(screen.getByText("Show Less")).toBeOnTheScreen()
})

it("shouldn't render Show All button when there is only one task", () => {
renderWithRelay({
HomeViewSectionTasks: () => ({
internalID: "home-view-section-recommended-tasks",
component: {
title: "Act Now",
},
tasksConnection: {
edges: [
{
node: {
actionLink: "/test-link",
actionMessage: "View",
imageUrl: "https://d2v80f5yrouhh2.cloudfront.net/1/1.jpg",
message: "Task Message 1",
title: "Task 1",
taskType: "send_wire",
},
},
],
},
}),
})

expect(screen.getByText("Act Now")).toBeOnTheScreen()
expect(screen.getByText("Task 1")).toBeOnTheScreen()
expect(screen.getByText("Task Message 1")).toBeOnTheScreen()
expect(screen.queryByText("Show All")).not.toBeOnTheScreen()
})

it("navigates and tracks when tapping a task", async () => {
Expand All @@ -60,6 +98,7 @@ describe("HomeViewSectionTasks", () => {
}),
})

fireEvent.press(screen.getByText("Show All"))
fireEvent.press(screen.getByText("Task 1"))

await waitFor(() => {
Expand All @@ -75,7 +114,7 @@ describe("HomeViewSectionTasks", () => {
"context_module": "actNow",
"context_screen_owner_type": "home",
"destination_path": "/test-link",
"task_id": "one",
"task_id": "<Task-mock-id-1>",
"task_type": "send_wire",
"type": "thumbnail",
},
Expand All @@ -94,29 +133,33 @@ describe("HomeViewSectionTasks", () => {
}),
})

fireEvent.press(screen.getByText("Clear"))
expect(screen.getByText("Task 1")).toBeOnTheScreen()
expect(screen.getByText("Task 2")).toBeOnTheScreen()

// Tap Clear on the first task
fireEvent.press(screen.getAllByText("Clear")[0])

await waitFor(() => {
// mock reslove the mutation
mockResolveLastOperation({})
})

await waitFor(() => {
expect(screen.queryByText("Task 1")).not.toBeOnTheScreen()
})

expect(mockTrackEvent.mock.calls[0]).toMatchInlineSnapshot(`
[
{
"action": "tappedClearTask",
"context_module": "actNow",
"context_screen_owner_type": "home",
"destination_path": "/test-link",
"task_id": "one",
"task_id": "<Task-mock-id-1>",
"task_type": "send_wire",
},
]
`)

await waitFor(() => {
expect(screen.queryByText("Task 1")).not.toBeOnTheScreen()
})
})
})

Expand All @@ -127,11 +170,19 @@ const mockTasks = {
actionLink: "/test-link",
actionMessage: "View",
imageUrl: "https://d2v80f5yrouhh2.cloudfront.net/1/1.jpg",
internalID: "one",
message: "Task Message 1",
title: "Task 1",
taskType: "send_wire",
},
},
{
node: {
actionLink: "/test-link2",
actionMessage: "View",
imageUrl: "https://d2v80f5yrouhh2.cloudfront.net/2/2.jpg",
message: "Task Message 2",
title: "Task 2",
},
},
],
}

0 comments on commit 70c2919

Please sign in to comment.