project-list-context.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import {
  2. cloneDeep,
  3. concat,
  4. filter as arrayFilter,
  5. find,
  6. flatten,
  7. uniq,
  8. uniqBy,
  9. without,
  10. } from 'lodash'
  11. import {
  12. createContext,
  13. ReactNode,
  14. useCallback,
  15. useContext,
  16. useEffect,
  17. useMemo,
  18. useRef,
  19. useState,
  20. } from 'react'
  21. import { Tag } from '../../../../../app/src/Features/Tags/types'
  22. import {
  23. GetProjectsResponseBody,
  24. Project,
  25. Sort,
  26. } from '../../../../../types/project/dashboard/api'
  27. import usePersistedState from '../../../shared/hooks/use-persisted-state'
  28. import getMeta from '../../../utils/meta'
  29. import useAsync from '../../../shared/hooks/use-async'
  30. import { getProjects } from '../util/api'
  31. import sortProjects from '../util/sort-projects'
  32. const MAX_PROJECT_PER_PAGE = 20
  33. export type Filter = 'all' | 'owned' | 'shared' | 'archived' | 'trashed'
  34. type FilterMap = {
  35. [key in Filter]: Partial<Project> | ((project: Project) => boolean) // eslint-disable-line no-unused-vars
  36. }
  37. const filters: FilterMap = {
  38. all: {
  39. archived: false,
  40. trashed: false,
  41. },
  42. owned: {
  43. accessLevel: 'owner',
  44. archived: false,
  45. trashed: false,
  46. },
  47. shared: project => {
  48. return (
  49. project.accessLevel !== 'owner' && !project.archived && !project.trashed
  50. )
  51. },
  52. archived: {
  53. archived: true,
  54. trashed: false,
  55. },
  56. trashed: {
  57. trashed: true,
  58. },
  59. }
  60. export const UNCATEGORIZED_KEY = 'uncategorized'
  61. type ProjectListContextValue = {
  62. addClonedProjectToViewData: (project: Project) => void
  63. selectOrUnselectAllProjects: React.Dispatch<React.SetStateAction<boolean>>
  64. visibleProjects: Project[]
  65. totalProjectsCount: number
  66. error: Error | null
  67. isLoading: ReturnType<typeof useAsync>['isLoading']
  68. loadProgress: number
  69. sort: Sort
  70. setSort: React.Dispatch<React.SetStateAction<Sort>>
  71. tags: Tag[]
  72. untaggedProjectsCount: number
  73. filter: Filter
  74. selectFilter: (filter: Filter) => void
  75. selectedTagId?: string | undefined
  76. selectTag: (tagId: string) => void
  77. addTag: (tag: Tag) => void
  78. renameTag: (tagId: string, newTagName: string) => void
  79. deleteTag: (tagId: string) => void
  80. updateProjectViewData: (newProjectData: Project) => void
  81. removeProjectFromView: (project: Project) => void
  82. addProjectToTagInView: (tagId: string, projectId: string) => void
  83. removeProjectFromTagInView: (tagId: string, projectId: string) => void
  84. searchText: string
  85. setSearchText: React.Dispatch<React.SetStateAction<string>>
  86. selectedProjects: Project[]
  87. hiddenProjectsCount: number
  88. loadMoreCount: number
  89. showAllProjects: () => void
  90. loadMoreProjects: () => void
  91. }
  92. export const ProjectListContext = createContext<
  93. ProjectListContextValue | undefined
  94. >(undefined)
  95. type ProjectListProviderProps = {
  96. children: ReactNode
  97. }
  98. export function ProjectListProvider({ children }: ProjectListProviderProps) {
  99. const prefetchedProjectsBlob = getMeta('ol-prefetchedProjectsBlob')
  100. const [loadedProjects, setLoadedProjects] = useState<Project[]>(
  101. prefetchedProjectsBlob?.projects ?? []
  102. )
  103. const [visibleProjects, setVisibleProjects] = useState<Project[]>([])
  104. const [maxVisibleProjects, setMaxVisibleProjects] =
  105. useState(MAX_PROJECT_PER_PAGE)
  106. const [hiddenProjectsCount, setHiddenProjectsCount] = useState(0)
  107. const [loadMoreCount, setLoadMoreCount] = useState<number>(0)
  108. const [loadProgress, setLoadProgress] = useState(
  109. prefetchedProjectsBlob ? 100 : 20
  110. )
  111. const [totalProjectsCount, setTotalProjectsCount] = useState<number>(
  112. prefetchedProjectsBlob?.totalSize ?? 0
  113. )
  114. const [sort, setSort] = useState<Sort>({
  115. by: 'lastUpdated',
  116. order: 'desc',
  117. })
  118. const [filter, setFilter] = usePersistedState<Filter>(
  119. 'project-list-filter',
  120. 'all'
  121. )
  122. const prevSortRef = useRef<Sort>(sort)
  123. const [selectedTagId, setSelectedTagId] = usePersistedState<
  124. string | undefined
  125. >('project-list-selected-tag-id', undefined)
  126. const [tags, setTags] = useState<Tag[]>(getMeta('ol-tags', []) as Tag[])
  127. const [searchText, setSearchText] = useState('')
  128. const {
  129. isLoading: loading,
  130. isIdle,
  131. error,
  132. runAsync,
  133. } = useAsync<GetProjectsResponseBody>({
  134. status: prefetchedProjectsBlob ? 'resolved' : 'pending',
  135. data: prefetchedProjectsBlob,
  136. })
  137. const isLoading = isIdle ? true : loading
  138. useEffect(() => {
  139. if (prefetchedProjectsBlob) return
  140. setLoadProgress(40)
  141. runAsync(getProjects({ by: 'lastUpdated', order: 'desc' }))
  142. .then(data => {
  143. setLoadedProjects(data.projects)
  144. setTotalProjectsCount(data.totalSize)
  145. })
  146. .catch(error => console.error(error))
  147. .finally(() => {
  148. setLoadProgress(100)
  149. })
  150. }, [prefetchedProjectsBlob, runAsync])
  151. useEffect(() => {
  152. let filteredProjects = [...loadedProjects]
  153. if (searchText.length) {
  154. filteredProjects = filteredProjects.filter(project =>
  155. project.name.toLowerCase().includes(searchText.toLowerCase())
  156. )
  157. }
  158. if (selectedTagId !== undefined) {
  159. if (selectedTagId === UNCATEGORIZED_KEY) {
  160. const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids)))
  161. filteredProjects = filteredProjects.filter(
  162. project =>
  163. !project.archived &&
  164. !project.trashed &&
  165. !taggedProjectIds.includes(project.id)
  166. )
  167. } else {
  168. const tag = tags.find(tag => tag._id === selectedTagId)
  169. if (tag) {
  170. filteredProjects = filteredProjects.filter(project =>
  171. tag?.project_ids?.includes(project.id)
  172. )
  173. } else {
  174. setFilter('all')
  175. setSelectedTagId(undefined)
  176. }
  177. }
  178. } else {
  179. filteredProjects = arrayFilter(filteredProjects, filters[filter])
  180. }
  181. if (prevSortRef.current !== sort) {
  182. filteredProjects = sortProjects(filteredProjects, sort)
  183. const loadedProjectsSorted = sortProjects(loadedProjects, sort)
  184. setLoadedProjects(loadedProjectsSorted)
  185. }
  186. if (filteredProjects.length > maxVisibleProjects) {
  187. const visibleFilteredProjects = filteredProjects.slice(
  188. 0,
  189. maxVisibleProjects
  190. )
  191. const hiddenFilteredProjectsCount =
  192. filteredProjects.slice(maxVisibleProjects).length
  193. setVisibleProjects(visibleFilteredProjects)
  194. setHiddenProjectsCount(hiddenFilteredProjectsCount)
  195. if (hiddenFilteredProjectsCount > MAX_PROJECT_PER_PAGE) {
  196. setLoadMoreCount(MAX_PROJECT_PER_PAGE)
  197. } else {
  198. setLoadMoreCount(hiddenFilteredProjectsCount)
  199. }
  200. } else {
  201. setVisibleProjects(filteredProjects)
  202. setLoadMoreCount(0)
  203. setHiddenProjectsCount(0)
  204. }
  205. }, [
  206. loadedProjects,
  207. maxVisibleProjects,
  208. tags,
  209. filter,
  210. setFilter,
  211. selectedTagId,
  212. setSelectedTagId,
  213. searchText,
  214. sort,
  215. ])
  216. useEffect(() => {
  217. prevSortRef.current = sort
  218. }, [sort])
  219. const showAllProjects = useCallback(() => {
  220. setLoadMoreCount(0)
  221. setHiddenProjectsCount(0)
  222. setMaxVisibleProjects(maxVisibleProjects + hiddenProjectsCount)
  223. }, [hiddenProjectsCount, maxVisibleProjects])
  224. const loadMoreProjects = useCallback(() => {
  225. setMaxVisibleProjects(maxVisibleProjects + loadMoreCount)
  226. }, [maxVisibleProjects, loadMoreCount])
  227. const selectedProjects = useMemo(() => {
  228. return visibleProjects.filter(project => project.selected)
  229. }, [visibleProjects])
  230. const selectOrUnselectAllProjects = useCallback(
  231. checked => {
  232. const visibleProjectIds = visibleProjects.map(p => p.id)
  233. setLoadedProjects(loadedProjects =>
  234. loadedProjects.map(p => {
  235. if (visibleProjectIds.includes(p.id)) {
  236. return { ...p, selected: checked }
  237. } else {
  238. return p
  239. }
  240. })
  241. )
  242. },
  243. [visibleProjects]
  244. )
  245. const untaggedProjectsCount = useMemo(() => {
  246. const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids)))
  247. return loadedProjects.filter(
  248. project =>
  249. !project.archived &&
  250. !project.trashed &&
  251. !taggedProjectIds.includes(project.id)
  252. ).length
  253. }, [tags, loadedProjects])
  254. const selectFilter = useCallback(
  255. (filter: Filter) => {
  256. setFilter(filter)
  257. setSelectedTagId(undefined)
  258. const selected = false
  259. selectOrUnselectAllProjects(selected)
  260. },
  261. [selectOrUnselectAllProjects, setFilter, setSelectedTagId]
  262. )
  263. const selectTag = useCallback(
  264. (tagId: string) => {
  265. setSelectedTagId(tagId)
  266. },
  267. [setSelectedTagId]
  268. )
  269. const addTag = useCallback((tag: Tag) => {
  270. setTags(tags => uniqBy(concat(tags, [tag]), '_id'))
  271. }, [])
  272. const renameTag = useCallback((tagId: string, newTagName: string) => {
  273. setTags(tags => {
  274. const newTags = cloneDeep(tags)
  275. const tag = find(newTags, ['_id', tagId])
  276. if (tag) {
  277. tag.name = newTagName
  278. }
  279. return newTags
  280. })
  281. }, [])
  282. const deleteTag = useCallback(
  283. (tagId: string | null) => {
  284. setTags(tags => tags.filter(tag => tag._id !== tagId))
  285. },
  286. [setTags]
  287. )
  288. const addProjectToTagInView = useCallback(
  289. (tagId: string, projectId: string) => {
  290. setTags(tags => {
  291. const updatedTags = [...tags]
  292. for (const tag of updatedTags) {
  293. if (tag._id === tagId) {
  294. tag.project_ids = uniq([...(tag.project_ids || []), projectId])
  295. }
  296. }
  297. return updatedTags
  298. })
  299. },
  300. [setTags]
  301. )
  302. const removeProjectFromTagInView = useCallback(
  303. (tagId: string, projectId: string) => {
  304. setTags(tags => {
  305. const updatedTags = [...tags]
  306. for (const tag of updatedTags) {
  307. if (tag._id === tagId) {
  308. tag.project_ids = without(tag.project_ids || [], projectId)
  309. }
  310. }
  311. return updatedTags
  312. })
  313. },
  314. [setTags]
  315. )
  316. const addClonedProjectToViewData = useCallback(
  317. project => {
  318. // clone API not using camelCase and does not return all data
  319. const owner = {
  320. id: project.owner?._id,
  321. email: project.owner?.email,
  322. firstName: project.owner?.first_name,
  323. lastName: project.owner?.last_name,
  324. }
  325. const clonedProject = {
  326. ...project,
  327. id: project.project_id,
  328. owner,
  329. lastUpdatedBy: owner,
  330. source: 'owner',
  331. trashed: false,
  332. archived: false,
  333. accessLevel: 'owner',
  334. }
  335. setLoadedProjects(loadedProjects => {
  336. return sortProjects([...loadedProjects, clonedProject], sort)
  337. })
  338. },
  339. [sort]
  340. )
  341. const updateProjectViewData = useCallback((newProjectData: Project) => {
  342. setLoadedProjects(loadedProjects => {
  343. return loadedProjects.map((p: Project) =>
  344. p.id === newProjectData.id ? { ...newProjectData } : p
  345. )
  346. })
  347. }, [])
  348. const removeProjectFromView = useCallback(
  349. (project: Project) => {
  350. const projects = loadedProjects.filter(
  351. (p: Project) => p.id !== project.id
  352. )
  353. setLoadedProjects(projects)
  354. },
  355. [loadedProjects]
  356. )
  357. const value = useMemo<ProjectListContextValue>(
  358. () => ({
  359. addTag,
  360. addClonedProjectToViewData,
  361. addProjectToTagInView,
  362. deleteTag,
  363. error,
  364. filter,
  365. hiddenProjectsCount,
  366. isLoading,
  367. loadMoreCount,
  368. loadMoreProjects,
  369. loadProgress,
  370. removeProjectFromTagInView,
  371. removeProjectFromView,
  372. renameTag,
  373. selectedTagId,
  374. selectFilter,
  375. selectedProjects,
  376. selectOrUnselectAllProjects,
  377. selectTag,
  378. searchText,
  379. setSearchText,
  380. setSort,
  381. showAllProjects,
  382. sort,
  383. tags,
  384. totalProjectsCount,
  385. untaggedProjectsCount,
  386. updateProjectViewData,
  387. visibleProjects,
  388. }),
  389. [
  390. addTag,
  391. addClonedProjectToViewData,
  392. addProjectToTagInView,
  393. deleteTag,
  394. error,
  395. filter,
  396. hiddenProjectsCount,
  397. isLoading,
  398. loadMoreCount,
  399. loadMoreProjects,
  400. loadProgress,
  401. removeProjectFromTagInView,
  402. removeProjectFromView,
  403. renameTag,
  404. selectedTagId,
  405. selectFilter,
  406. selectedProjects,
  407. selectOrUnselectAllProjects,
  408. selectTag,
  409. searchText,
  410. setSearchText,
  411. setSort,
  412. showAllProjects,
  413. sort,
  414. tags,
  415. totalProjectsCount,
  416. untaggedProjectsCount,
  417. updateProjectViewData,
  418. visibleProjects,
  419. ]
  420. )
  421. return (
  422. <ProjectListContext.Provider value={value}>
  423. {children}
  424. </ProjectListContext.Provider>
  425. )
  426. }
  427. export function useProjectListContext() {
  428. const context = useContext(ProjectListContext)
  429. if (!context) {
  430. throw new Error(
  431. 'ProjectListContext is only available inside ProjectListProvider'
  432. )
  433. }
  434. return context
  435. }