project-list-context.tsx 13 KB

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