mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
852 lines
40 KiB
TypeScript
852 lines
40 KiB
TypeScript
import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
|
|
import { loaders } from 'kea-loaders'
|
|
|
|
import { IconPlus } from '@posthog/icons'
|
|
|
|
import api from 'lib/api'
|
|
import { GroupsAccessStatus } from 'lib/introductions/groupsAccessLogic'
|
|
import { lemonToast } from 'lib/lemon-ui/LemonToast'
|
|
import { TreeDataItem } from 'lib/lemon-ui/LemonTree/LemonTree'
|
|
import { Spinner } from 'lib/lemon-ui/Spinner'
|
|
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
|
|
import { capitalizeFirstLetter } from 'lib/utils'
|
|
import { getCurrentTeamIdOrNone } from 'lib/utils/getAppContext'
|
|
import { urls } from 'scenes/urls'
|
|
|
|
import { breadcrumbsLogic } from '~/layout/navigation/Breadcrumbs/breadcrumbsLogic'
|
|
import {
|
|
getDefaultTreeData,
|
|
getDefaultTreeNew,
|
|
getDefaultTreePersons,
|
|
getDefaultTreeProducts,
|
|
} from '~/layout/panel-layout/ProjectTree/defaultTree'
|
|
import { RecentResults, SearchResults, projectTreeLogic } from '~/layout/panel-layout/ProjectTree/projectTreeLogic'
|
|
import { FolderState, ProjectTreeAction } from '~/layout/panel-layout/ProjectTree/types'
|
|
import {
|
|
appendResultsToFolders,
|
|
convertFileSystemEntryToTreeDataItem,
|
|
escapePath,
|
|
formatUrlAsName,
|
|
isGroupViewShortcut,
|
|
joinPath,
|
|
sortFilesAndFolders,
|
|
splitPath,
|
|
} from '~/layout/panel-layout/ProjectTree/utils'
|
|
import { FEATURE_FLAGS } from '~/lib/constants'
|
|
import { groupsModel } from '~/models/groupsModel'
|
|
import { FileSystemEntry, FileSystemIconType, FileSystemImport } from '~/queries/schema/schema-general'
|
|
import { UserBasicType } from '~/types'
|
|
|
|
import type { projectTreeDataLogicType } from './projectTreeDataLogicType'
|
|
|
|
const MOVE_ALERT_LIMIT = 50
|
|
const DELETE_ALERT_LIMIT = 0
|
|
export const PAGINATION_LIMIT = 100
|
|
|
|
export const projectTreeDataLogic = kea<projectTreeDataLogicType>([
|
|
path(['layout', 'panel-layout', 'ProjectTree', 'projectTreeDataLogic']),
|
|
connect(() => ({
|
|
values: [
|
|
featureFlagLogic,
|
|
['featureFlags'],
|
|
breadcrumbsLogic,
|
|
['projectTreeRef'],
|
|
groupsModel,
|
|
['aggregationLabel', 'groupTypes', 'groupTypesLoading', 'groupsAccessStatus'],
|
|
],
|
|
})),
|
|
actions({
|
|
loadUnfiledItems: true,
|
|
|
|
loadFolder: (folder: string) => ({ folder }),
|
|
loadFolderIfNotLoaded: (folderId: string) => ({ folderId }),
|
|
loadFolderStart: (folder: string) => ({ folder }),
|
|
loadFolderSuccess: (folder: string, entries: FileSystemEntry[], hasMore: boolean, offsetIncrease: number) => ({
|
|
folder,
|
|
entries,
|
|
hasMore,
|
|
offsetIncrease,
|
|
}),
|
|
loadFolderFailure: (folder: string, error: string) => ({ folder, error }),
|
|
|
|
addLoadedUsers: (users: UserBasicType[]) => ({ users }),
|
|
addLoadedResults: (results: RecentResults | SearchResults) => ({ results }),
|
|
|
|
createSavedItem: (savedItem: FileSystemEntry) => ({ savedItem }),
|
|
deleteSavedItem: (savedItem: FileSystemEntry) => ({ savedItem }),
|
|
deleteItem: (item: FileSystemEntry, projectTreeLogicKey: string) => ({ item, projectTreeLogicKey }),
|
|
linkItem: (oldPath: string, newPath: string, force: boolean, projectTreeLogicKey: string) => ({
|
|
oldPath,
|
|
newPath,
|
|
force,
|
|
projectTreeLogicKey,
|
|
}),
|
|
moveItem: (item: FileSystemEntry, newPath: string, force: boolean, projectTreeLogicKey: string) => ({
|
|
item,
|
|
newPath,
|
|
force,
|
|
projectTreeLogicKey,
|
|
}),
|
|
movedItem: (item: FileSystemEntry, oldPath: string, newPath: string) => ({ item, oldPath, newPath }),
|
|
queueAction: (action: ProjectTreeAction, projectTreeLogicKey: string) => ({ action, projectTreeLogicKey }),
|
|
removeQueuedAction: (action: ProjectTreeAction) => ({ action }),
|
|
|
|
syncTypeAndRef: (type: string, ref: string) => ({ type, ref }),
|
|
deleteTypeAndRef: (type: string, ref: string) => ({ type, ref }),
|
|
|
|
setLastNewFolder: (folder: string | null) => ({ folder }),
|
|
|
|
addShortcutItem: (item: FileSystemEntry) => ({ item }),
|
|
deleteShortcut: (id: FileSystemEntry['id']) => ({ id }),
|
|
loadShortcuts: true,
|
|
}),
|
|
loaders(({ actions, values }) => ({
|
|
unfiledItems: [
|
|
false as boolean,
|
|
{
|
|
loadUnfiledItems: async () => {
|
|
if (!getCurrentTeamIdOrNone()) {
|
|
return false
|
|
}
|
|
const response = await api.fileSystem.unfiled()
|
|
if (response.results?.length > 0) {
|
|
actions.loadFolder('Unfiled')
|
|
for (const folder of Object.keys(values.folders)) {
|
|
if (folder.startsWith('Unfiled/')) {
|
|
actions.loadFolder(folder)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
},
|
|
],
|
|
pendingLoader: [
|
|
false,
|
|
{
|
|
queueAction: async ({ action, projectTreeLogicKey }) => {
|
|
if ((action.type === 'prepare-move' || action.type === 'prepare-link') && action.newPath) {
|
|
const verb = action.type === 'prepare-link' ? 'link' : 'move'
|
|
const verbing = action.type === 'prepare-link' ? 'linking' : 'moving'
|
|
try {
|
|
const response = await api.fileSystem.count(action.item.id)
|
|
actions.removeQueuedAction(action)
|
|
if (response && response.count > MOVE_ALERT_LIMIT) {
|
|
const confirmMessage = `You're about to ${verb} ${response.count} items. Are you sure?`
|
|
if (!confirm(confirmMessage)) {
|
|
return false
|
|
}
|
|
}
|
|
actions.queueAction({ ...action, type: verb }, projectTreeLogicKey)
|
|
} catch (error) {
|
|
console.error(`Error ${verbing} item:`, error)
|
|
lemonToast.error(`Error ${verbing} item: ${error}`)
|
|
actions.removeQueuedAction(action)
|
|
}
|
|
} else if (action.type === 'move' && action.newPath) {
|
|
try {
|
|
const oldPath = action.item.path
|
|
const newPath = action.newPath
|
|
await api.fileSystem.move(action.item.id, newPath)
|
|
actions.removeQueuedAction(action)
|
|
actions.movedItem(action.item, oldPath, newPath)
|
|
lemonToast.success('Item moved successfully', {
|
|
button: {
|
|
label: 'Undo',
|
|
dataAttr: 'undo-project-tree-move',
|
|
action: () => {
|
|
actions.moveItem(
|
|
{ ...action.item, path: newPath },
|
|
oldPath,
|
|
false,
|
|
projectTreeLogicKey
|
|
)
|
|
},
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('Error moving item:', error)
|
|
lemonToast.error(`Error moving item: ${error}`)
|
|
actions.removeQueuedAction(action)
|
|
}
|
|
} else if (action.type === 'link' && action.newPath) {
|
|
try {
|
|
const newPath = action.newPath
|
|
const newItem = await api.fileSystem.link(action.item.id, newPath)
|
|
actions.removeQueuedAction(action)
|
|
if (newItem) {
|
|
actions.createSavedItem(newItem)
|
|
}
|
|
if (action.item.type === 'folder') {
|
|
actions.loadFolder(newPath)
|
|
}
|
|
lemonToast.success('Item linked successfully') // TODO: undo for linking
|
|
} catch (error) {
|
|
console.error('Error linking item:', error)
|
|
lemonToast.error(`Error linking item: ${error}`)
|
|
actions.removeQueuedAction(action)
|
|
}
|
|
} else if (action.type === 'create') {
|
|
try {
|
|
const response = await api.fileSystem.create(action.item)
|
|
actions.removeQueuedAction(action)
|
|
actions.createSavedItem(response)
|
|
lemonToast.success('Folder created successfully', {
|
|
button: {
|
|
label: 'Undo',
|
|
dataAttr: 'undo-project-tree-create-folder',
|
|
action: () => {
|
|
actions.deleteItem(response, projectTreeLogicKey)
|
|
},
|
|
},
|
|
})
|
|
|
|
// Expand in the logic that called this data flow
|
|
projectTreeLogic
|
|
.findMounted({ key: projectTreeLogicKey })
|
|
?.actions.expandProjectFolder(action.item.path)
|
|
} catch (error) {
|
|
console.error('Error creating folder:', error)
|
|
lemonToast.error(`Error creating folder: ${error}`)
|
|
actions.removeQueuedAction(action)
|
|
}
|
|
} else if (action.type === 'prepare-delete' && action.item.id) {
|
|
try {
|
|
const response = await api.fileSystem.count(action.item.id)
|
|
actions.removeQueuedAction(action)
|
|
if (response && response.count > DELETE_ALERT_LIMIT) {
|
|
const confirmMessage = `Delete the folder "${splitPath(
|
|
action.item.path
|
|
).pop()}" and move ${response.count} items back into "Unfiled"?`
|
|
if (!confirm(confirmMessage)) {
|
|
return false
|
|
}
|
|
}
|
|
actions.queueAction({ ...action, type: 'delete' }, projectTreeLogicKey)
|
|
} catch (error) {
|
|
console.error('Error deleting item:', error)
|
|
lemonToast.error(`Error deleting item: ${error}`)
|
|
actions.removeQueuedAction(action)
|
|
}
|
|
} else if (action.type === 'delete' && action.item.id) {
|
|
try {
|
|
await api.fileSystem.delete(action.item.id)
|
|
actions.removeQueuedAction(action)
|
|
actions.deleteSavedItem(action.item)
|
|
lemonToast.success('Item deleted successfully')
|
|
} catch (error) {
|
|
console.error('Error deleting item:', error)
|
|
lemonToast.error(`Error deleting item: ${error}`)
|
|
actions.removeQueuedAction(action)
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
},
|
|
],
|
|
shortcutData: [
|
|
[] as FileSystemEntry[],
|
|
{
|
|
loadShortcuts: async () => {
|
|
if (!getCurrentTeamIdOrNone()) {
|
|
return []
|
|
}
|
|
|
|
const response = await api.fileSystemShortcuts.list()
|
|
return response.results
|
|
},
|
|
addShortcutItem: async ({ item }) => {
|
|
const shortcutItem =
|
|
item.type === 'folder'
|
|
? {
|
|
path: joinPath([splitPath(item.path).pop() ?? 'Unnamed']),
|
|
type: 'folder',
|
|
ref: item.path,
|
|
}
|
|
: {
|
|
path: joinPath([splitPath(item.path).pop() ?? 'Unnamed']),
|
|
type: item.type,
|
|
ref: item.ref,
|
|
href: item.href,
|
|
}
|
|
const response = await api.fileSystemShortcuts.create(shortcutItem)
|
|
return [...values.shortcutData, response]
|
|
},
|
|
deleteShortcut: async ({ id }) => {
|
|
await api.fileSystemShortcuts.delete(id)
|
|
return values.shortcutData.filter((s) => s.id !== id)
|
|
},
|
|
},
|
|
],
|
|
})),
|
|
reducers({
|
|
folders: [
|
|
{} as Record<string, FileSystemEntry[]>,
|
|
{
|
|
loadFolderSuccess: (state, { folder, entries }) => ({ ...state, [folder]: entries }),
|
|
addLoadedResults: (state, { results }) => appendResultsToFolders(results, state),
|
|
createSavedItem: (state, { savedItem }) => {
|
|
const folder = joinPath(splitPath(savedItem.path).slice(0, -1))
|
|
return {
|
|
...state,
|
|
[folder]: (state[folder] || []).find((f) => f.id === savedItem.id)
|
|
? state[folder].map((item) => (item.id === savedItem.id ? savedItem : item))
|
|
: [...(state[folder] || []), savedItem],
|
|
}
|
|
},
|
|
deleteSavedItem: (state, { savedItem }) => {
|
|
const folder = joinPath(splitPath(savedItem.path).slice(0, -1))
|
|
const newState = {
|
|
...state,
|
|
[folder]: state[folder].filter((item) => item.id !== savedItem.id),
|
|
}
|
|
if (savedItem.type === 'folder') {
|
|
for (const folder of Object.keys(newState)) {
|
|
if (folder === savedItem.path || folder.startsWith(savedItem.path + '/')) {
|
|
delete newState[folder]
|
|
}
|
|
}
|
|
}
|
|
return newState
|
|
},
|
|
deleteTypeAndRef: (state, { type, ref }) => {
|
|
const newState = { ...state }
|
|
for (const [folder, files] of Object.entries(newState)) {
|
|
if (
|
|
files.some(
|
|
(file) =>
|
|
(type.endsWith('/') ? file.type?.startsWith(type) : file.type === type) &&
|
|
file.ref === ref
|
|
)
|
|
) {
|
|
newState[folder] = files.filter(
|
|
(file) =>
|
|
(type.endsWith('/') ? !file.type?.startsWith(type) : file.type !== type) ||
|
|
file.ref !== ref
|
|
)
|
|
}
|
|
}
|
|
return newState
|
|
},
|
|
movedItem: (state, { oldPath, newPath, item }) => {
|
|
const newState = { ...state }
|
|
const oldParentFolder = joinPath(splitPath(oldPath).slice(0, -1))
|
|
for (const folder of Object.keys(newState)) {
|
|
if (folder === oldParentFolder) {
|
|
newState[folder] = newState[folder].filter((i) => i.id !== item.id)
|
|
const newParentFolder = joinPath(splitPath(newPath).slice(0, -1))
|
|
newState[newParentFolder] = [
|
|
...(newState[newParentFolder] ?? []),
|
|
{ ...item, path: newPath },
|
|
]
|
|
} else if (folder === oldPath || folder.startsWith(oldPath + '/')) {
|
|
const newFolder = newPath + folder.slice(oldPath.length)
|
|
newState[newFolder] = [
|
|
...(newState[newFolder] ?? []),
|
|
...newState[folder].map((item) => ({
|
|
...item,
|
|
path: newFolder + item.path.slice(folder.length),
|
|
})),
|
|
]
|
|
delete newState[folder]
|
|
}
|
|
}
|
|
return newState
|
|
},
|
|
},
|
|
],
|
|
folderLoadOffset: [
|
|
{} as Record<string, number>,
|
|
{
|
|
loadFolderSuccess: (state, { folder, offsetIncrease }) => {
|
|
return { ...state, [folder]: offsetIncrease + (state[folder] ?? 0) }
|
|
},
|
|
},
|
|
],
|
|
folderStates: [
|
|
{} as Record<string, FolderState>,
|
|
{
|
|
loadFolderStart: (state, { folder }) => ({ ...state, [folder]: 'loading' }),
|
|
loadFolderSuccess: (state, { folder, hasMore }) => ({
|
|
...state,
|
|
[folder]: hasMore ? 'has-more' : 'loaded',
|
|
}),
|
|
loadFolderFailure: (state, { folder }) => ({ ...state, [folder]: 'error' }),
|
|
},
|
|
],
|
|
users: [
|
|
{} as Record<string, UserBasicType>,
|
|
{
|
|
addLoadedUsers: (state, { users }) => {
|
|
if (!users || users.length === 0) {
|
|
return state
|
|
}
|
|
const newState = { ...state }
|
|
for (const user of users) {
|
|
newState[user.id] = user
|
|
}
|
|
return newState
|
|
},
|
|
},
|
|
],
|
|
pendingActions: [
|
|
[] as ProjectTreeAction[],
|
|
{
|
|
queueAction: (state, { action }) => [...state, action],
|
|
removeQueuedAction: (state, { action }) => state.filter((a) => a !== action),
|
|
},
|
|
],
|
|
lastNewFolder: [
|
|
null as string | null,
|
|
{
|
|
setLastNewFolder: (_, { folder }) => {
|
|
return folder ?? null
|
|
},
|
|
},
|
|
],
|
|
shortcutData: [
|
|
[] as FileSystemEntry[],
|
|
{
|
|
deleteTypeAndRef: (state, { type, ref }) => state.filter((s) => s.type !== type || s.ref !== ref),
|
|
addLoadedResults: (state, { results }) => {
|
|
const filesByTypeAndRef = Object.fromEntries(
|
|
results.results.map((file) => [`${file.type}//${file.ref}`, file])
|
|
)
|
|
return state.map((item) => {
|
|
const file = filesByTypeAndRef[`${item.type}//${item.ref}`]
|
|
if (file) {
|
|
return { ...item, path: escapePath(splitPath(file.path).pop() ?? 'Unnamed') }
|
|
}
|
|
return item
|
|
})
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
selectors({
|
|
savedItems: [
|
|
(s) => [s.folders],
|
|
(folders): FileSystemEntry[] =>
|
|
Object.entries(folders).reduce((acc, [_, items]) => acc.concat(items), [] as FileSystemEntry[]),
|
|
],
|
|
savedItemsLoading: [
|
|
(s) => [s.folderStates],
|
|
(folderStates): boolean => Object.values(folderStates).some((state) => state === 'loading'),
|
|
],
|
|
viableItems: [
|
|
// Combine savedItems with pendingActions
|
|
(s) => [s.savedItems, s.pendingActions],
|
|
(savedItems, pendingActions): FileSystemEntry[] => {
|
|
const initialItems = [...savedItems]
|
|
const itemsByPath = initialItems.reduce(
|
|
(acc, item) => {
|
|
acc[item.path] = acc[item.path] ? [...acc[item.path], item] : [item]
|
|
return acc
|
|
},
|
|
{} as Record<string, FileSystemEntry[]>
|
|
)
|
|
|
|
for (const action of pendingActions) {
|
|
if ((action.type === 'move' || action.type === 'prepare-move') && action.newPath) {
|
|
if (!itemsByPath[action.path] || itemsByPath[action.path].length === 0) {
|
|
console.error("Item not found, can't move", action.path)
|
|
continue
|
|
}
|
|
for (const item of itemsByPath[action.path]) {
|
|
const itemTarget = itemsByPath[action.newPath]?.[0]
|
|
if (item.type === 'folder') {
|
|
if (!itemTarget || itemTarget.type === 'folder') {
|
|
for (const path of Object.keys(itemsByPath)) {
|
|
if (path.startsWith(action.path + '/')) {
|
|
for (const loopItem of itemsByPath[path]) {
|
|
const newPath = action.newPath + loopItem.path.slice(action.path.length)
|
|
if (!itemsByPath[newPath]) {
|
|
itemsByPath[newPath] = []
|
|
}
|
|
itemsByPath[newPath] = [
|
|
...itemsByPath[newPath],
|
|
{ ...loopItem, path: newPath, _loading: true },
|
|
]
|
|
}
|
|
delete itemsByPath[path]
|
|
}
|
|
}
|
|
}
|
|
if (!itemTarget) {
|
|
itemsByPath[action.newPath] = [
|
|
...(itemsByPath[action.newPath] ?? []),
|
|
{ ...item, path: action.newPath, _loading: true },
|
|
]
|
|
}
|
|
delete itemsByPath[action.path]
|
|
} else if (item.id === action.item.id) {
|
|
if (!itemsByPath[action.newPath]) {
|
|
itemsByPath[action.newPath] = []
|
|
}
|
|
itemsByPath[action.newPath] = [
|
|
...itemsByPath[action.newPath],
|
|
{ ...item, path: action.newPath, _loading: true },
|
|
]
|
|
if (itemsByPath[action.path].length > 1) {
|
|
itemsByPath[action.path] = itemsByPath[action.path].filter((i) => i.id !== item.id)
|
|
} else {
|
|
delete itemsByPath[action.path]
|
|
}
|
|
}
|
|
}
|
|
} else if (action.type === 'create' && action.newPath) {
|
|
if (!itemsByPath[action.newPath]) {
|
|
itemsByPath[action.newPath] = [
|
|
...(itemsByPath[action.newPath] ?? []),
|
|
{ ...action.item, path: action.newPath, _loading: true },
|
|
]
|
|
} else {
|
|
console.error("Item already exists, can't create", action.item)
|
|
}
|
|
} else if (action.path && itemsByPath[action.path]) {
|
|
itemsByPath[action.path] = itemsByPath[action.path].map((i) => ({ ...i, loading: true }))
|
|
}
|
|
}
|
|
return Object.values(itemsByPath).flatMap((a) => a)
|
|
},
|
|
],
|
|
sortedItems: [
|
|
(s) => [s.viableItems],
|
|
(viableItems): FileSystemEntry[] => [...viableItems].sort(sortFilesAndFolders),
|
|
],
|
|
viableItemsById: [
|
|
(s) => [s.viableItems],
|
|
(viableItems): Record<string, FileSystemEntry> =>
|
|
viableItems.reduce(
|
|
(acc, item) =>
|
|
Object.assign(acc, {
|
|
[item.type === 'folder' ? 'project://' + item.path : 'project/' + item.id]: item,
|
|
}),
|
|
{} as Record<string, FileSystemEntry>
|
|
),
|
|
],
|
|
loadingPaths: [
|
|
// Paths that are currently being loaded
|
|
(s) => [s.unfiledItemsLoading, s.savedItemsLoading, s.pendingLoaderLoading, s.pendingActions],
|
|
(unfiledItemsLoading, savedItemsLoading, pendingLoaderLoading, pendingActions) => {
|
|
const loadingPaths: Record<string, boolean> = {}
|
|
if (unfiledItemsLoading) {
|
|
loadingPaths['Unfiled'] = true
|
|
loadingPaths[''] = true
|
|
}
|
|
if (savedItemsLoading) {
|
|
loadingPaths[''] = true
|
|
}
|
|
if (pendingLoaderLoading && pendingActions.length > 0) {
|
|
loadingPaths[pendingActions[0].newPath || pendingActions[0].path] = true
|
|
}
|
|
return loadingPaths
|
|
},
|
|
],
|
|
projectTreeRefEntry: [
|
|
(s) => [s.projectTreeRef, s.sortedItems],
|
|
(projectTreeRef, sortedItems): FileSystemEntry | null => {
|
|
if (!projectTreeRef || !projectTreeRef.type || !projectTreeRef.ref) {
|
|
return null
|
|
}
|
|
const treeItem = projectTreeRef.type.endsWith('/')
|
|
? sortedItems.find(
|
|
(item) => item.type?.startsWith(projectTreeRef.type) && item.ref === projectTreeRef.ref
|
|
)
|
|
: sortedItems.find((item) => item.type === projectTreeRef.type && item.ref === projectTreeRef.ref)
|
|
return treeItem ?? null
|
|
},
|
|
],
|
|
groupItems: [
|
|
(s) => [s.groupTypes, s.groupsAccessStatus, s.aggregationLabel, s.shortcutData, s.featureFlags],
|
|
(groupTypes, groupsAccessStatus, aggregationLabel, shortcutData, featureFlags): FileSystemImport[] => {
|
|
const showGroupsIntroductionPage = [
|
|
GroupsAccessStatus.HasAccess,
|
|
GroupsAccessStatus.HasGroupTypes,
|
|
GroupsAccessStatus.NoAccess,
|
|
].includes(groupsAccessStatus)
|
|
|
|
const groupItems: FileSystemImport[] = showGroupsIntroductionPage
|
|
? [
|
|
{
|
|
path: 'Groups',
|
|
category: 'Groups',
|
|
iconType: 'group',
|
|
href: urls.groups(0),
|
|
visualOrder: 30,
|
|
},
|
|
]
|
|
: Array.from(groupTypes.values()).map((groupType) => ({
|
|
path: capitalizeFirstLetter(aggregationLabel(groupType.group_type_index).plural),
|
|
category: 'Groups',
|
|
iconType: 'group',
|
|
href: urls.groups(groupType.group_type_index),
|
|
visualOrder: 30 + groupType.group_type_index,
|
|
}))
|
|
|
|
// these are created when users save filtered views
|
|
// from the groups page and should appear in the persons:// tree under "Saved Views"
|
|
const groupFilterShortcuts = featureFlags[FEATURE_FLAGS.CRM_ITERATION_ONE]
|
|
? shortcutData
|
|
.filter((shortcut) => isGroupViewShortcut(shortcut))
|
|
.map((shortcut) => ({
|
|
id: shortcut.id,
|
|
path: shortcut.path,
|
|
type: shortcut.type,
|
|
category: 'Saved Views',
|
|
iconType: 'group' as FileSystemIconType,
|
|
href: shortcut.href || '',
|
|
visualOrder: 100,
|
|
shortcut: true,
|
|
tags: shortcut.tags || [],
|
|
}))
|
|
: []
|
|
|
|
return [...groupItems, ...groupFilterShortcuts]
|
|
},
|
|
],
|
|
getShortcutTreeItems: [
|
|
(s) => [s.shortcutData, s.viableItems, s.folderStates, s.users, s.featureFlags],
|
|
(
|
|
shortcutData,
|
|
viableItems,
|
|
folderStates,
|
|
users,
|
|
featureFlags
|
|
): ((searchTerm: string, onlyFolders: boolean) => TreeDataItem[]) => {
|
|
return function getStaticItems(searchTerm: string, onlyFolders: boolean): TreeDataItem[] {
|
|
const newShortcutData = []
|
|
for (const shortcut of shortcutData.filter(
|
|
// only remove shortcuts that are group view shortcuts when CRM iteration one is enabled
|
|
(shortcut) => !(featureFlags[FEATURE_FLAGS.CRM_ITERATION_ONE] && isGroupViewShortcut(shortcut))
|
|
)) {
|
|
const shortcutTreeItem = convertFileSystemEntryToTreeDataItem({
|
|
root: 'shortcuts://',
|
|
imports: [shortcut],
|
|
checkedItems: {},
|
|
folderStates,
|
|
users,
|
|
foldersFirst: true,
|
|
disabledReason: onlyFolders
|
|
? (item) => (item.type !== 'folder' ? 'Only folders can be selected' : undefined)
|
|
: undefined,
|
|
})[0]
|
|
|
|
if (shortcut.type === 'folder' && shortcut.ref) {
|
|
const allImports = viableItems.filter((item) => item.path.startsWith(shortcut.ref + '/'))
|
|
let converted: TreeDataItem[] = convertFileSystemEntryToTreeDataItem({
|
|
root: 'project://',
|
|
imports: allImports.map((item) => ({ ...item, protocol: 'project://' })),
|
|
checkedItems: {},
|
|
folderStates,
|
|
users,
|
|
foldersFirst: true,
|
|
searchTerm,
|
|
disabledReason: onlyFolders
|
|
? (item) => (item.type !== 'folder' ? 'Only folders can be selected' : undefined)
|
|
: undefined,
|
|
})
|
|
for (let i = 0; i < splitPath(shortcut.ref).length; i++) {
|
|
converted = converted[0]?.children || []
|
|
}
|
|
if (folderStates[shortcut.ref] === 'has-more') {
|
|
converted.push({
|
|
id: `project://-load-more/${shortcut.ref}`,
|
|
name: 'Load more...',
|
|
displayName: <>Load more...</>,
|
|
icon: <IconPlus />,
|
|
disableSelect: true,
|
|
})
|
|
} else if (folderStates[shortcut.ref] === 'loading') {
|
|
converted.push({
|
|
id: `project://-loading/${shortcut.ref}`,
|
|
name: 'Loading...',
|
|
displayName: <>Loading...</>,
|
|
icon: <Spinner />,
|
|
disableSelect: true,
|
|
type: 'loading-indicator',
|
|
})
|
|
}
|
|
|
|
newShortcutData.push({ ...shortcutTreeItem, children: converted })
|
|
} else {
|
|
newShortcutData.push(shortcutTreeItem)
|
|
}
|
|
}
|
|
return newShortcutData
|
|
}
|
|
},
|
|
],
|
|
getStaticTreeItems: [
|
|
(s) => [s.featureFlags, s.getShortcutTreeItems, s.groupItems],
|
|
(
|
|
featureFlags,
|
|
getShortcutTreeItems,
|
|
groupItems
|
|
): ((searchTerm: string, onlyFolders: boolean) => TreeDataItem[]) => {
|
|
const convert = (
|
|
imports: FileSystemImport[],
|
|
protocol: string,
|
|
searchTerm: string | undefined,
|
|
onlyFolders: boolean
|
|
): TreeDataItem[] =>
|
|
convertFileSystemEntryToTreeDataItem({
|
|
root: protocol,
|
|
imports: imports
|
|
.filter((f) => !f.flag || (featureFlags as Record<string, boolean>)[f.flag])
|
|
.map((i) => ({
|
|
...i,
|
|
protocol,
|
|
})),
|
|
checkedItems: {},
|
|
folderStates: {},
|
|
users: {},
|
|
foldersFirst: false,
|
|
searchTerm,
|
|
disabledReason: onlyFolders
|
|
? (item) => (item.type !== 'folder' ? 'Only folders can be selected' : undefined)
|
|
: undefined,
|
|
})
|
|
return function getStaticItems(searchTerm: string, onlyFolders: boolean): TreeDataItem[] {
|
|
const data: [string, FileSystemImport[]][] = [
|
|
['products://', getDefaultTreeProducts()],
|
|
['data://', getDefaultTreeData()],
|
|
['persons://', [...getDefaultTreePersons(), ...groupItems]],
|
|
['new://', getDefaultTreeNew()],
|
|
]
|
|
const staticItems = data.map(([protocol, files]) => ({
|
|
id: protocol,
|
|
name: protocol,
|
|
displayName: <>{formatUrlAsName(protocol)}</>,
|
|
record: { type: 'folder', protocol, path: '' },
|
|
children: convert(files, protocol, searchTerm, onlyFolders),
|
|
}))
|
|
staticItems.push({
|
|
id: 'shortcuts://',
|
|
name: 'Shortcuts',
|
|
displayName: <>Shortcuts</>,
|
|
record: { type: 'folder', protocol: 'shortcuts://', path: '' },
|
|
children: getShortcutTreeItems(searchTerm, onlyFolders),
|
|
})
|
|
return staticItems
|
|
}
|
|
},
|
|
],
|
|
treeItemsNew: [
|
|
(s) => [s.getStaticTreeItems],
|
|
(getStaticTreeItems) => getStaticTreeItems('', false).find((item) => item.id === 'new://')?.children ?? [],
|
|
],
|
|
}),
|
|
listeners(({ actions, values }) => ({
|
|
loadFolder: async ({ folder }) => {
|
|
const currentState = values.folderStates[folder]
|
|
if (currentState === 'loading' || currentState === 'loaded') {
|
|
return
|
|
}
|
|
actions.loadFolderStart(folder)
|
|
try {
|
|
const previousFiles = values.folders[folder] || []
|
|
const offset = values.folderLoadOffset[folder] ?? 0
|
|
const response = await api.fileSystem.list({
|
|
parent: folder,
|
|
depth: splitPath(folder).length + 1,
|
|
limit: PAGINATION_LIMIT + 1,
|
|
offset: offset,
|
|
})
|
|
|
|
let files = response.results
|
|
let hasMore = false
|
|
if (files.length > PAGINATION_LIMIT) {
|
|
files = files.slice(0, PAGINATION_LIMIT)
|
|
hasMore = true
|
|
}
|
|
const fileIds = new Set(files.map((file) => file.id))
|
|
const previousUniqueFiles = previousFiles.filter(
|
|
(prevFile) => !fileIds.has(prevFile.id) && prevFile.path !== folder
|
|
)
|
|
if (response.users?.length > 0) {
|
|
actions.addLoadedUsers(response.users)
|
|
}
|
|
actions.loadFolderSuccess(folder, [...previousUniqueFiles, ...files], hasMore, files.length)
|
|
} catch (error) {
|
|
actions.loadFolderFailure(folder, String(error))
|
|
}
|
|
},
|
|
syncTypeAndRef: async ({ type, ref }) => {
|
|
const items = await (type.endsWith('/')
|
|
? api.fileSystem.list({ type__startswith: type, ref })
|
|
: api.fileSystem.list({ type, ref }))
|
|
if (items.users?.length > 0) {
|
|
actions.addLoadedUsers(items.users)
|
|
}
|
|
actions.addLoadedResults(items as any as SearchResults)
|
|
},
|
|
deleteItem: async ({ item, projectTreeLogicKey }) => {
|
|
if (isGroupViewShortcut(item) && values.featureFlags[FEATURE_FLAGS.CRM_ITERATION_ONE]) {
|
|
actions.deleteShortcut(item?.id)
|
|
return
|
|
}
|
|
|
|
if (!item.id) {
|
|
const response = await api.fileSystem.list({ type: 'folder', path: item.path })
|
|
const items = response.results ?? []
|
|
if (items.length > 0) {
|
|
item = items[0]
|
|
} else {
|
|
lemonToast.error(`Could not find filesystem entry for ${item.path}. Can't delete.`)
|
|
return
|
|
}
|
|
}
|
|
actions.queueAction(
|
|
{ type: item.type === 'folder' ? 'prepare-delete' : 'delete', item, path: item.path },
|
|
projectTreeLogicKey
|
|
)
|
|
},
|
|
moveItem: async ({ item, newPath, force, projectTreeLogicKey }) => {
|
|
if (newPath === item.path) {
|
|
return
|
|
}
|
|
if (!item.id) {
|
|
lemonToast.error("Sorry, can't move an unsaved item (no id)")
|
|
return
|
|
}
|
|
actions.queueAction(
|
|
{
|
|
type: !force && item.type === 'folder' ? 'prepare-move' : 'move',
|
|
item,
|
|
path: item.path,
|
|
newPath: newPath,
|
|
},
|
|
projectTreeLogicKey
|
|
)
|
|
},
|
|
linkItem: async ({ oldPath, newPath, force, projectTreeLogicKey }) => {
|
|
if (newPath === oldPath) {
|
|
lemonToast.error('Cannot link folder into itself')
|
|
return
|
|
}
|
|
const item = values.viableItems.find((item) => item.path === oldPath)
|
|
if (item && item.path === oldPath) {
|
|
if (!item.id) {
|
|
lemonToast.error("Sorry, can't link an unsaved item (no id)")
|
|
return
|
|
}
|
|
actions.queueAction(
|
|
{
|
|
type: !force && item.type === 'folder' ? 'prepare-link' : 'link',
|
|
item,
|
|
path: item.path,
|
|
newPath: newPath + item.path.slice(oldPath.length),
|
|
},
|
|
projectTreeLogicKey
|
|
)
|
|
}
|
|
},
|
|
})),
|
|
afterMount(({ actions }) => {
|
|
actions.loadFolder('')
|
|
actions.loadUnfiledItems()
|
|
actions.loadShortcuts()
|
|
}),
|
|
])
|