import { cloneDeep, concat, filter as arrayFilter, find, flatten, uniq, uniqBy, without, } from 'lodash' import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react' import { Tag } from '../../../../../app/src/Features/Tags/types' import { ClonedProject, GetProjectsResponseBody, Project, Sort, } from '../../../../../types/project/dashboard/api' import usePersistedState from '../../../shared/hooks/use-persisted-state' import getMeta from '../../../utils/meta' import useAsync from '../../../shared/hooks/use-async' import { getProjects } from '../util/api' import sortProjects from '../util/sort-projects' import { isArchivedOrTrashed, isDeletableProject, isLeavableProject, } from '../util/project' import { debugConsole } from '@/utils/debugging' const MAX_PROJECT_PER_PAGE = 20 export type Filter = 'all' | 'owned' | 'shared' | 'archived' | 'trashed' type FilterMap = { [key in Filter]: Partial | ((project: Project) => boolean) // eslint-disable-line no-unused-vars } const filters: FilterMap = { all: { archived: false, trashed: false, }, owned: { accessLevel: 'owner', archived: false, trashed: false, }, shared: project => { return ( project.accessLevel !== 'owner' && !project.archived && !project.trashed ) }, archived: { archived: true, trashed: false, }, trashed: { trashed: true, }, } export const UNCATEGORIZED_KEY = 'uncategorized' export type ProjectListContextValue = { addClonedProjectToViewData: (project: ClonedProject) => void selectOrUnselectAllProjects: React.Dispatch> visibleProjects: Project[] totalProjectsCount: number error: Error | null isLoading: ReturnType['isLoading'] loadProgress: number sort: Sort setSort: React.Dispatch> tags: Tag[] untaggedProjectsCount: number projectsPerTag: Record filter: Filter selectFilter: (filter: Filter) => void selectedTagId?: string | undefined selectTag: (tagId: string) => void addTag: (tag: Tag) => void updateTag: (tagId: string, newTagName: string, newTagColor?: string) => void deleteTag: (tagId: string) => void updateProjectViewData: (newProjectData: Project) => void removeProjectFromView: (project: Project) => void addProjectToTagInView: (tagId: string, projectId: string) => void removeProjectFromTagInView: (tagId: string, projectId: string) => void searchText: string setSearchText: React.Dispatch> selectedProjects: Project[] selectedProjectIds: Set setSelectedProjectIds: React.Dispatch>> toggleSelectedProject: (projectId: string, selected?: boolean) => void hiddenProjectsCount: number loadMoreCount: number showAllProjects: () => void loadMoreProjects: () => void hasLeavableProjectsSelected: boolean hasDeletableProjectsSelected: boolean } export const ProjectListContext = createContext< ProjectListContextValue | undefined >(undefined) type ProjectListProviderProps = { children: ReactNode } export function ProjectListProvider({ children }: ProjectListProviderProps) { const prefetchedProjectsBlob = getMeta('ol-prefetchedProjectsBlob') const [loadedProjects, setLoadedProjects] = useState( prefetchedProjectsBlob?.projects ?? [] ) const [visibleProjects, setVisibleProjects] = useState([]) const [maxVisibleProjects, setMaxVisibleProjects] = useState(MAX_PROJECT_PER_PAGE) const [hiddenProjectsCount, setHiddenProjectsCount] = useState(0) const [loadMoreCount, setLoadMoreCount] = useState(0) const [loadProgress, setLoadProgress] = useState( prefetchedProjectsBlob ? 100 : 20 ) const [totalProjectsCount, setTotalProjectsCount] = useState( prefetchedProjectsBlob?.totalSize ?? 0 ) const [sort, setSort] = useState({ by: 'lastUpdated', order: 'desc', }) const [filter, setFilter] = usePersistedState( 'project-list-filter', 'all' ) const prevSortRef = useRef(sort) const [selectedTagId, setSelectedTagId] = usePersistedState< string | undefined >('project-list-selected-tag-id', undefined) const [showCustomPicker, setShowCustomPicker] = useState(false) const olTags = getMeta('ol-tags') || [] const [tags, setTags] = useState(() => // `tag.name` data may be null for some old users olTags.map(tag => ({ ...tag, name: tag.name ?? '' })) ) const [searchText, setSearchText] = useState('') const { isLoading: loading, isIdle, error, runAsync, } = useAsync({ status: prefetchedProjectsBlob ? 'resolved' : 'pending', data: prefetchedProjectsBlob, }) const isLoading = isIdle ? true : loading useEffect(() => { if (prefetchedProjectsBlob) return setLoadProgress(40) runAsync(getProjects({ by: 'lastUpdated', order: 'desc' })) .then(data => { setLoadedProjects(data.projects) setTotalProjectsCount(data.totalSize) }) .catch(debugConsole.error) .finally(() => { setLoadProgress(100) }) }, [prefetchedProjectsBlob, runAsync]) useEffect(() => { let filteredProjects = [...loadedProjects] if (searchText.length) { filteredProjects = filteredProjects.filter(project => project.name.toLowerCase().includes(searchText.toLowerCase()) ) } if (selectedTagId !== undefined) { if (selectedTagId === UNCATEGORIZED_KEY) { const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids))) filteredProjects = filteredProjects.filter( project => !project.archived && !project.trashed && !taggedProjectIds.includes(project.id) ) } else { const tag = tags.find(tag => tag._id === selectedTagId) if (tag) { filteredProjects = filteredProjects.filter( p => !isArchivedOrTrashed(p) && tag?.project_ids?.includes(p.id) ) } else { setSelectedTagId(undefined) } } } else { filteredProjects = arrayFilter(filteredProjects, filters[filter]) } if (prevSortRef.current !== sort) { filteredProjects = sortProjects(filteredProjects, sort) const loadedProjectsSorted = sortProjects(loadedProjects, sort) setLoadedProjects(loadedProjectsSorted) } if (filteredProjects.length > maxVisibleProjects) { const visibleFilteredProjects = filteredProjects.slice( 0, maxVisibleProjects ) const hiddenFilteredProjectsCount = filteredProjects.slice(maxVisibleProjects).length setVisibleProjects(visibleFilteredProjects) setHiddenProjectsCount(hiddenFilteredProjectsCount) if (hiddenFilteredProjectsCount > MAX_PROJECT_PER_PAGE) { setLoadMoreCount(MAX_PROJECT_PER_PAGE) } else { setLoadMoreCount(hiddenFilteredProjectsCount) } } else { setVisibleProjects(filteredProjects) setLoadMoreCount(0) setHiddenProjectsCount(0) } }, [ loadedProjects, maxVisibleProjects, tags, filter, setFilter, selectedTagId, setSelectedTagId, searchText, sort, ]) useEffect(() => { prevSortRef.current = sort }, [sort]) const showAllProjects = useCallback(() => { setLoadMoreCount(0) setHiddenProjectsCount(0) setMaxVisibleProjects(maxVisibleProjects + hiddenProjectsCount) }, [hiddenProjectsCount, maxVisibleProjects]) const loadMoreProjects = useCallback(() => { setMaxVisibleProjects(maxVisibleProjects + loadMoreCount) }, [maxVisibleProjects, loadMoreCount]) const [selectedProjectIds, setSelectedProjectIds] = useState( () => new Set() ) const toggleSelectedProject = useCallback( (projectId: string, selected?: boolean) => { setSelectedProjectIds(prevSelectedProjectIds => { const selectedProjectIds = new Set(prevSelectedProjectIds) if (selected === true) { selectedProjectIds.add(projectId) } else if (selected === false) { selectedProjectIds.delete(projectId) } else if (selectedProjectIds.has(projectId)) { selectedProjectIds.delete(projectId) } else { selectedProjectIds.add(projectId) } return selectedProjectIds }) }, [] ) const selectedProjects = useMemo(() => { return visibleProjects.filter(project => selectedProjectIds.has(project.id)) }, [selectedProjectIds, visibleProjects]) const selectOrUnselectAllProjects = useCallback( (checked: any) => { setSelectedProjectIds(prevSelectedProjectIds => { const selectedProjectIds = new Set(prevSelectedProjectIds) for (const project of visibleProjects) { if (checked) { selectedProjectIds.add(project.id) } else { selectedProjectIds.delete(project.id) } } return selectedProjectIds }) }, [visibleProjects] ) const untaggedProjectsCount = useMemo(() => { const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids))) return loadedProjects.filter( project => !project.archived && !project.trashed && !taggedProjectIds.includes(project.id) ).length }, [tags, loadedProjects]) const projectsPerTag = useMemo(() => { return tags.reduce>((prev, curTag) => { const tagProjects = loadedProjects.filter(p => { return !isArchivedOrTrashed(p) && curTag.project_ids?.includes(p.id) }) return { ...prev, [curTag._id]: tagProjects } }, {}) }, [tags, loadedProjects]) const selectFilter = useCallback( (filter: Filter) => { setFilter(filter) setSelectedTagId(undefined) const selected = false selectOrUnselectAllProjects(selected) }, [selectOrUnselectAllProjects, setFilter, setSelectedTagId] ) const selectTag = useCallback( (tagId: string) => { setFilter('all') setSelectedTagId(tagId) }, [setSelectedTagId, setFilter] ) const addTag = useCallback((tag: Tag) => { setTags(tags => uniqBy(concat(tags, [tag]), '_id')) }, []) const updateTag = useCallback( (tagId: string, newTagName: string, newTagColor?: string) => { setTags(tags => { const newTags = cloneDeep(tags) const tag = find(newTags, ['_id', tagId]) if (tag) { tag.name = newTagName tag.color = newTagColor } return newTags }) }, [] ) const deleteTag = useCallback( (tagId: string | null) => { setTags(tags => tags.filter(tag => tag._id !== tagId)) }, [setTags] ) const addProjectToTagInView = useCallback( (tagId: string, projectId: string) => { setTags(tags => { const updatedTags = [...tags] for (const tag of updatedTags) { if (tag._id === tagId) { tag.project_ids = uniq([...(tag.project_ids || []), projectId]) } } return updatedTags }) }, [setTags] ) const removeProjectFromTagInView = useCallback( (tagId: string, projectId: string) => { setTags(tags => { const updatedTags = [...tags] for (const tag of updatedTags) { if (tag._id === tagId) { tag.project_ids = without(tag.project_ids || [], projectId) } } return updatedTags }) }, [setTags] ) const addClonedProjectToViewData = useCallback( (project: ClonedProject) => { // clone API not using camelCase and does not return all data const owner = { id: project.owner?._id, email: project.owner?.email, firstName: project.owner?.first_name, lastName: project.owner?.last_name, } const clonedProject: Project = { ...project, id: project.project_id, owner, lastUpdatedBy: owner, source: 'owner', trashed: false, archived: false, accessLevel: 'owner', } setLoadedProjects(loadedProjects => { return sortProjects([...loadedProjects, clonedProject], sort) }) }, [sort] ) const updateProjectViewData = useCallback((newProjectData: Project) => { setLoadedProjects(loadedProjects => { return loadedProjects.map(p => p.id === newProjectData.id ? { ...newProjectData } : p ) }) }, []) const removeProjectFromView = useCallback((project: Project) => { setLoadedProjects(loadedProjects => { return loadedProjects.filter(p => p.id !== project.id) }) }, []) const hasLeavableProjectsSelected = useMemo( () => selectedProjects.some(isLeavableProject), [selectedProjects] ) const hasDeletableProjectsSelected = useMemo( () => selectedProjects.some(isDeletableProject), [selectedProjects] ) const value = useMemo( () => ({ addTag, addClonedProjectToViewData, addProjectToTagInView, deleteTag, error, filter, hasLeavableProjectsSelected, hasDeletableProjectsSelected, hiddenProjectsCount, isLoading, loadMoreCount, loadMoreProjects, loadProgress, removeProjectFromTagInView, removeProjectFromView, selectedTagId, selectFilter, selectedProjects, selectedProjectIds, selectOrUnselectAllProjects, selectTag, searchText, setSearchText, setSelectedProjectIds, setShowCustomPicker, setSort, showAllProjects, showCustomPicker, sort, tags, toggleSelectedProject, totalProjectsCount, untaggedProjectsCount, updateProjectViewData, updateTag, projectsPerTag, visibleProjects, }), [ addTag, addClonedProjectToViewData, addProjectToTagInView, deleteTag, error, filter, hasLeavableProjectsSelected, hasDeletableProjectsSelected, hiddenProjectsCount, isLoading, loadMoreCount, loadMoreProjects, loadProgress, removeProjectFromTagInView, removeProjectFromView, selectedTagId, selectFilter, selectedProjectIds, selectedProjects, selectOrUnselectAllProjects, selectTag, searchText, setSearchText, setSelectedProjectIds, setShowCustomPicker, setSort, showAllProjects, showCustomPicker, sort, tags, toggleSelectedProject, totalProjectsCount, untaggedProjectsCount, updateProjectViewData, updateTag, projectsPerTag, visibleProjects, ] ) return ( {children} ) } export function useProjectListContext() { const context = useContext(ProjectListContext) if (!context) { throw new Error( 'ProjectListContext is only available inside ProjectListProvider' ) } return context }