project-list-context.tsx 14 KB

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