project-list-context.tsx 15 KB

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