project-list-context.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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. updateTag: (tagId: string, newTagName: string, newTagColor?: 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 [showCustomPicker, setShowCustomPicker] = useState(false)
  135. const olTags: Tag[] = getMeta('ol-tags', [])
  136. const [tags, setTags] = useState<Tag[]>(() =>
  137. // `tag.name` data may be null for some old users
  138. olTags.map(tag => ({ ...tag, name: tag.name ?? '' }))
  139. )
  140. const [searchText, setSearchText] = useState('')
  141. const {
  142. isLoading: loading,
  143. isIdle,
  144. error,
  145. runAsync,
  146. } = useAsync<GetProjectsResponseBody>({
  147. status: prefetchedProjectsBlob ? 'resolved' : 'pending',
  148. data: prefetchedProjectsBlob,
  149. })
  150. const isLoading = isIdle ? true : loading
  151. useEffect(() => {
  152. if (prefetchedProjectsBlob) return
  153. setLoadProgress(40)
  154. runAsync(getProjects({ by: 'lastUpdated', order: 'desc' }))
  155. .then(data => {
  156. setLoadedProjects(data.projects)
  157. setTotalProjectsCount(data.totalSize)
  158. })
  159. .catch(error => console.error(error))
  160. .finally(() => {
  161. setLoadProgress(100)
  162. })
  163. }, [prefetchedProjectsBlob, runAsync])
  164. useEffect(() => {
  165. let filteredProjects = [...loadedProjects]
  166. if (searchText.length) {
  167. filteredProjects = filteredProjects.filter(project =>
  168. project.name.toLowerCase().includes(searchText.toLowerCase())
  169. )
  170. }
  171. if (selectedTagId !== undefined) {
  172. if (selectedTagId === UNCATEGORIZED_KEY) {
  173. const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids)))
  174. filteredProjects = filteredProjects.filter(
  175. project =>
  176. !project.archived &&
  177. !project.trashed &&
  178. !taggedProjectIds.includes(project.id)
  179. )
  180. } else {
  181. const tag = tags.find(tag => tag._id === selectedTagId)
  182. if (tag) {
  183. filteredProjects = filteredProjects.filter(
  184. p => !isArchivedOrTrashed(p) && tag?.project_ids?.includes(p.id)
  185. )
  186. } else {
  187. setSelectedTagId(undefined)
  188. }
  189. }
  190. } else {
  191. filteredProjects = arrayFilter(filteredProjects, filters[filter])
  192. }
  193. if (prevSortRef.current !== sort) {
  194. filteredProjects = sortProjects(filteredProjects, sort)
  195. const loadedProjectsSorted = sortProjects(loadedProjects, sort)
  196. setLoadedProjects(loadedProjectsSorted)
  197. }
  198. if (filteredProjects.length > maxVisibleProjects) {
  199. const visibleFilteredProjects = filteredProjects.slice(
  200. 0,
  201. maxVisibleProjects
  202. )
  203. const hiddenFilteredProjectsCount =
  204. filteredProjects.slice(maxVisibleProjects).length
  205. setVisibleProjects(visibleFilteredProjects)
  206. setHiddenProjectsCount(hiddenFilteredProjectsCount)
  207. if (hiddenFilteredProjectsCount > MAX_PROJECT_PER_PAGE) {
  208. setLoadMoreCount(MAX_PROJECT_PER_PAGE)
  209. } else {
  210. setLoadMoreCount(hiddenFilteredProjectsCount)
  211. }
  212. } else {
  213. setVisibleProjects(filteredProjects)
  214. setLoadMoreCount(0)
  215. setHiddenProjectsCount(0)
  216. }
  217. }, [
  218. loadedProjects,
  219. maxVisibleProjects,
  220. tags,
  221. filter,
  222. setFilter,
  223. selectedTagId,
  224. setSelectedTagId,
  225. searchText,
  226. sort,
  227. ])
  228. useEffect(() => {
  229. prevSortRef.current = sort
  230. }, [sort])
  231. const showAllProjects = useCallback(() => {
  232. setLoadMoreCount(0)
  233. setHiddenProjectsCount(0)
  234. setMaxVisibleProjects(maxVisibleProjects + hiddenProjectsCount)
  235. }, [hiddenProjectsCount, maxVisibleProjects])
  236. const loadMoreProjects = useCallback(() => {
  237. setMaxVisibleProjects(maxVisibleProjects + loadMoreCount)
  238. }, [maxVisibleProjects, loadMoreCount])
  239. const selectedProjects = useMemo(() => {
  240. return visibleProjects.filter(project => project.selected)
  241. }, [visibleProjects])
  242. const selectOrUnselectAllProjects = useCallback(
  243. checked => {
  244. const visibleProjectIds = visibleProjects.map(p => p.id)
  245. setLoadedProjects(loadedProjects =>
  246. loadedProjects.map(p => {
  247. if (visibleProjectIds.includes(p.id)) {
  248. return { ...p, selected: checked }
  249. } else {
  250. return p
  251. }
  252. })
  253. )
  254. },
  255. [visibleProjects]
  256. )
  257. const untaggedProjectsCount = useMemo(() => {
  258. const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids)))
  259. return loadedProjects.filter(
  260. project =>
  261. !project.archived &&
  262. !project.trashed &&
  263. !taggedProjectIds.includes(project.id)
  264. ).length
  265. }, [tags, loadedProjects])
  266. const projectsPerTag = useMemo(() => {
  267. return tags.reduce<Record<Tag['_id'], Project[]>>((prev, curTag) => {
  268. const tagProjects = loadedProjects.filter(p => {
  269. return !isArchivedOrTrashed(p) && curTag.project_ids?.includes(p.id)
  270. })
  271. return { ...prev, [curTag._id]: tagProjects }
  272. }, {})
  273. }, [tags, loadedProjects])
  274. const selectFilter = useCallback(
  275. (filter: Filter) => {
  276. setFilter(filter)
  277. setSelectedTagId(undefined)
  278. const selected = false
  279. selectOrUnselectAllProjects(selected)
  280. },
  281. [selectOrUnselectAllProjects, setFilter, setSelectedTagId]
  282. )
  283. const selectTag = useCallback(
  284. (tagId: string) => {
  285. setFilter('all')
  286. setSelectedTagId(tagId)
  287. },
  288. [setSelectedTagId, setFilter]
  289. )
  290. const addTag = useCallback((tag: Tag) => {
  291. setTags(tags => uniqBy(concat(tags, [tag]), '_id'))
  292. }, [])
  293. const updateTag = useCallback(
  294. (tagId: string, newTagName: string, newTagColor?: string) => {
  295. setTags(tags => {
  296. const newTags = cloneDeep(tags)
  297. const tag = find(newTags, ['_id', tagId])
  298. if (tag) {
  299. tag.name = newTagName
  300. tag.color = newTagColor
  301. }
  302. return newTags
  303. })
  304. },
  305. []
  306. )
  307. const deleteTag = useCallback(
  308. (tagId: string | null) => {
  309. setTags(tags => tags.filter(tag => tag._id !== tagId))
  310. },
  311. [setTags]
  312. )
  313. const addProjectToTagInView = useCallback(
  314. (tagId: string, projectId: string) => {
  315. setTags(tags => {
  316. const updatedTags = [...tags]
  317. for (const tag of updatedTags) {
  318. if (tag._id === tagId) {
  319. tag.project_ids = uniq([...(tag.project_ids || []), projectId])
  320. }
  321. }
  322. return updatedTags
  323. })
  324. },
  325. [setTags]
  326. )
  327. const removeProjectFromTagInView = useCallback(
  328. (tagId: string, projectId: string) => {
  329. setTags(tags => {
  330. const updatedTags = [...tags]
  331. for (const tag of updatedTags) {
  332. if (tag._id === tagId) {
  333. tag.project_ids = without(tag.project_ids || [], projectId)
  334. }
  335. }
  336. return updatedTags
  337. })
  338. },
  339. [setTags]
  340. )
  341. const addClonedProjectToViewData = useCallback(
  342. project => {
  343. // clone API not using camelCase and does not return all data
  344. const owner = {
  345. id: project.owner?._id,
  346. email: project.owner?.email,
  347. firstName: project.owner?.first_name,
  348. lastName: project.owner?.last_name,
  349. }
  350. const clonedProject = {
  351. ...project,
  352. id: project.project_id,
  353. owner,
  354. lastUpdatedBy: owner,
  355. source: 'owner',
  356. trashed: false,
  357. archived: false,
  358. accessLevel: 'owner',
  359. }
  360. setLoadedProjects(loadedProjects => {
  361. return sortProjects([...loadedProjects, clonedProject], sort)
  362. })
  363. },
  364. [sort]
  365. )
  366. const updateProjectViewData = useCallback((newProjectData: Project) => {
  367. setLoadedProjects(loadedProjects => {
  368. return loadedProjects.map(p =>
  369. p.id === newProjectData.id ? { ...newProjectData } : p
  370. )
  371. })
  372. }, [])
  373. const removeProjectFromView = useCallback((project: Project) => {
  374. setLoadedProjects(loadedProjects => {
  375. return loadedProjects.filter(p => p.id !== project.id)
  376. })
  377. }, [])
  378. const hasLeavableProjectsSelected = useMemo(
  379. () => selectedProjects.some(isLeavableProject),
  380. [selectedProjects]
  381. )
  382. const hasDeletableProjectsSelected = useMemo(
  383. () => selectedProjects.some(isDeletableProject),
  384. [selectedProjects]
  385. )
  386. const value = useMemo<ProjectListContextValue>(
  387. () => ({
  388. addTag,
  389. addClonedProjectToViewData,
  390. addProjectToTagInView,
  391. deleteTag,
  392. error,
  393. filter,
  394. hasLeavableProjectsSelected,
  395. hasDeletableProjectsSelected,
  396. hiddenProjectsCount,
  397. isLoading,
  398. loadMoreCount,
  399. loadMoreProjects,
  400. loadProgress,
  401. removeProjectFromTagInView,
  402. removeProjectFromView,
  403. selectedTagId,
  404. selectFilter,
  405. selectedProjects,
  406. selectOrUnselectAllProjects,
  407. selectTag,
  408. searchText,
  409. setSearchText,
  410. setShowCustomPicker,
  411. setSort,
  412. showAllProjects,
  413. showCustomPicker,
  414. sort,
  415. tags,
  416. totalProjectsCount,
  417. untaggedProjectsCount,
  418. updateProjectViewData,
  419. updateTag,
  420. projectsPerTag,
  421. visibleProjects,
  422. }),
  423. [
  424. addTag,
  425. addClonedProjectToViewData,
  426. addProjectToTagInView,
  427. deleteTag,
  428. error,
  429. filter,
  430. hasLeavableProjectsSelected,
  431. hasDeletableProjectsSelected,
  432. hiddenProjectsCount,
  433. isLoading,
  434. loadMoreCount,
  435. loadMoreProjects,
  436. loadProgress,
  437. removeProjectFromTagInView,
  438. removeProjectFromView,
  439. selectedTagId,
  440. selectFilter,
  441. selectedProjects,
  442. selectOrUnselectAllProjects,
  443. selectTag,
  444. searchText,
  445. setSearchText,
  446. setShowCustomPicker,
  447. setSort,
  448. showAllProjects,
  449. showCustomPicker,
  450. sort,
  451. tags,
  452. totalProjectsCount,
  453. untaggedProjectsCount,
  454. updateProjectViewData,
  455. updateTag,
  456. projectsPerTag,
  457. visibleProjects,
  458. ]
  459. )
  460. return (
  461. <ProjectListContext.Provider value={value}>
  462. {children}
  463. </ProjectListContext.Provider>
  464. )
  465. }
  466. export function useProjectListContext() {
  467. const context = useContext(ProjectListContext)
  468. if (!context) {
  469. throw new Error(
  470. 'ProjectListContext is only available inside ProjectListProvider'
  471. )
  472. }
  473. return context
  474. }