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