From a2df55f740cf4b53b0242b032d6b238358d12fa7 Mon Sep 17 00:00:00 2001 From: dtlehrer <19999194+dtlehrer@users.noreply.github.com> Date: Mon, 12 Aug 2024 10:12:12 -0500 Subject: [PATCH 1/7] prevent scene from disappearing when double-clicking on a HierarchyTreeNode (#10932) --- .../editor/panels/Hierarchy/container/index.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx b/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx index ae728106b9..09252525c2 100644 --- a/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx +++ b/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx @@ -23,7 +23,12 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -import { getComponent, getMutableComponent, useOptionalComponent } from '@etherealengine/ecs/src/ComponentFunctions' +import { + getComponent, + getMutableComponent, + getOptionalComponent, + useOptionalComponent +} from '@etherealengine/ecs/src/ComponentFunctions' import { AllFileTypes } from '@etherealengine/engine/src/assets/constants/fileTypes' import { getMutableState, getState, none, useHookstate, useMutableState } from '@etherealengine/hyperflux' import { NameComponent } from '@etherealengine/spatial/src/common/NameComponent' @@ -276,9 +281,11 @@ function HierarchyPanelContents(props: { sceneURL: string; rootEntity: Entity; i } setPrevClickedNode(entity) } else if (e.detail === 2) { - const editorCameraState = getMutableComponent(Engine.instance.cameraEntity, CameraOrbitComponent) - editorCameraState.focusedEntities.set([entity]) - editorCameraState.refocus.set(true) + if (entity && getOptionalComponent(entity, CameraOrbitComponent)) { + const editorCameraState = getMutableComponent(Engine.instance.cameraEntity, CameraOrbitComponent) + editorCameraState.focusedEntities.set([entity]) + editorCameraState.refocus.set(true) + } } }, [prevClickedNode, entityHierarchy] From b45db2accba362e4125185b58c3ff545f3020fae Mon Sep 17 00:00:00 2001 From: dtlehrer <19999194+dtlehrer@users.noreply.github.com> Date: Mon, 12 Aug 2024 13:32:28 -0500 Subject: [PATCH 2/7] [IR-3324] studio: drag-n-drop folders (#10737) * enable movement of folders via drag n drop * update handleBrowserItemClick & include folders in multi-select * pass tests? * revert * update moveFolderRecursively * Update file-browser.class.ts * fix s3.storage.isDirectory * change s3.storage.isDirectory command prefix * fix moveObject key - to work with directories and files * add test cases for isDirectory, moveObject, and getIncrementalName * add test case for moveFolderRecursively * Update FileUtil.test.ts * Update FileUtil.test.ts * Update storageprovider.test.ts * Update storageprovider.test.ts * Update storageprovider.test.ts * pass ui check * Update index.tsx * double-click always navigates to folder * select folders on single click and navigate into them on double click * don't allow folders to be dropped into self * make local.storage.ts moveObject function work with directories --------- Co-authored-by: Rahul Ghosh Co-authored-by: Daniel Belmes <3631206+DanielBelmes@users.noreply.github.com> --- .../server-core/src/media/FileUtil.test.ts | 34 ++++++++ packages/server-core/src/media/FileUtil.ts | 9 ++- .../media/file-browser/file-browser.class.ts | 37 ++++++++- .../media/storageprovider/local.storage.ts | 11 ++- .../src/media/storageprovider/s3.storage.ts | 11 ++- .../storageprovider/storageprovider.test.ts | 77 ++++++++++++++++--- .../editor/panels/Files/browserGrid/index.tsx | 8 +- .../editor/panels/Files/container/index.tsx | 26 +++---- 8 files changed, 171 insertions(+), 42 deletions(-) diff --git a/packages/server-core/src/media/FileUtil.test.ts b/packages/server-core/src/media/FileUtil.test.ts index 2effa93ac9..f77b319aff 100644 --- a/packages/server-core/src/media/FileUtil.test.ts +++ b/packages/server-core/src/media/FileUtil.test.ts @@ -157,6 +157,40 @@ describe('FileUtil functions', () => { fs.rmdirSync(path.join(STORAGE_PATH, dirName)) fs.rmdirSync(path.join(STORAGE_PATH, dirName_1)) }) + + it('should handle singular and plural directory names correctly', async () => { + const singularDirName = 'testdir' + const pluralDirName = 'testdirs' + + // ensure directories don't exist before starting + if (fs.existsSync(path.join(STORAGE_PATH, singularDirName))) { + fs.rmdirSync(path.join(STORAGE_PATH, singularDirName)) + } + if (fs.existsSync(path.join(STORAGE_PATH, pluralDirName))) { + fs.rmdirSync(path.join(STORAGE_PATH, pluralDirName)) + } + + // create 'testdirs' directory + fs.mkdirSync(path.join(STORAGE_PATH, pluralDirName)) + + // try to create 'testdir' directory + let name = await getIncrementalName(singularDirName, TEST_DIR, store, true) + assert.equal(name, singularDirName, "Should return 'testdir' as it doesn't exist") + + // create 'testdir' directory + fs.mkdirSync(path.join(STORAGE_PATH, singularDirName)) + + // try to create another 'testdir' directory + name = await getIncrementalName(singularDirName, TEST_DIR, store, true) + assert.equal(name, `${singularDirName}(1)`, "Should return 'testdir(1)' as 'testdir' already exists") + + // try to create 'testdirs' directory + name = await getIncrementalName(pluralDirName, TEST_DIR, store, true) + assert.equal(name, `${pluralDirName}(1)`, "Should return 'testdirs(1)' as 'testdirs' already exists") + + fs.rmdirSync(path.join(STORAGE_PATH, singularDirName)) + fs.rmdirSync(path.join(STORAGE_PATH, pluralDirName)) + }) }) after(() => { diff --git a/packages/server-core/src/media/FileUtil.ts b/packages/server-core/src/media/FileUtil.ts index 535cf90366..e82cc7032a 100644 --- a/packages/server-core/src/media/FileUtil.ts +++ b/packages/server-core/src/media/FileUtil.ts @@ -50,22 +50,23 @@ export const getIncrementalName = async function ( let filename = name if (!(await store.doesExist(filename, directoryPath))) return filename + if (isDirectory && !(await store.isDirectory(filename, directoryPath))) return filename let count = 1 if (isDirectory) { - do { + while (await store.isDirectory(filename, directoryPath)) { filename = `${name}(${count})` count++ - } while (await store.doesExist(filename, directoryPath)) + } } else { const extension = path.extname(name) const baseName = path.basename(name, extension) - do { + while (await store.doesExist(filename, directoryPath)) { filename = `${baseName}(${count})${extension}` count++ - } while (await store.doesExist(filename, directoryPath)) + } } return filename diff --git a/packages/server-core/src/media/file-browser/file-browser.class.ts b/packages/server-core/src/media/file-browser/file-browser.class.ts index bcf3ca6902..f196369c3b 100755 --- a/packages/server-core/src/media/file-browser/file-browser.class.ts +++ b/packages/server-core/src/media/file-browser/file-browser.class.ts @@ -51,7 +51,7 @@ import config from '../../appconfig' import { getContentType } from '../../util/fileUtils' import { getIncrementalName } from '../FileUtil' import { getStorageProvider } from '../storageprovider/storageprovider' -import { StorageObjectInterface } from '../storageprovider/storageprovider.interface' +import { StorageObjectInterface, StorageProviderInterface } from '../storageprovider/storageprovider.interface' import { uploadStaticResource } from './file-helper' export const projectsRootFolder = path.join(appRootPath.path, 'packages/projects') @@ -221,7 +221,16 @@ export class FileBrowserService const isDirectory = await storageProvider.isDirectory(oldName, oldDirectory) const fileName = await getIncrementalName(newName, newDirectory, storageProvider, isDirectory) - await storageProvider.moveObject(oldName, fileName, oldDirectory, newDirectory, data.isCopy) + + if (isDirectory) { + await this.moveFolderRecursively( + storageProvider, + path.join(oldDirectory, oldName), + path.join(newDirectory, fileName) + ) + } else { + await storageProvider.moveObject(oldName, fileName, oldDirectory, newDirectory, data.isCopy) + } const staticResources = (await this.app.service(staticResourcePath).find({ query: { @@ -265,6 +274,30 @@ export class FileBrowserService return results } + private async moveFolderRecursively(storageProvider: StorageProviderInterface, oldPath: string, newPath: string) { + const items = await storageProvider.listFolderContent(oldPath + '/') + + for (const item of items) { + const oldItemPath = path.join(oldPath, item.name) + const newItemPath = path.join(newPath, item.name) + + if (item.type === 'directory') { + await this.moveFolderRecursively(storageProvider, oldItemPath, newItemPath) + } else { + await storageProvider.moveObject(item.name, item.name, oldPath, newPath, false) + } + } + + // move the folder itself + await storageProvider.moveObject( + path.basename(oldPath), + path.basename(newPath), + path.dirname(oldPath), + path.dirname(newPath), + false + ) + } + /** * Upload file */ diff --git a/packages/server-core/src/media/storageprovider/local.storage.ts b/packages/server-core/src/media/storageprovider/local.storage.ts index cd335fd873..7e1859b462 100755 --- a/packages/server-core/src/media/storageprovider/local.storage.ts +++ b/packages/server-core/src/media/storageprovider/local.storage.ts @@ -39,7 +39,6 @@ import config from '../../appconfig' import logger from '../../ServerLogger' import { ServerMode, ServerState } from '../../ServerState' import { getContentType } from '../../util/fileUtils' -import { copyRecursiveSync } from '../FileUtil' import { BlobStore, PutObjectParams, @@ -423,7 +422,15 @@ export class LocalStorage implements StorageProviderInterface { if (!fs.existsSync(path.dirname(newFilePath))) fs.mkdirSync(path.dirname(newFilePath), { recursive: true }) try { - isCopy ? copyRecursiveSync(oldFilePath, newFilePath) : fs.renameSync(oldFilePath, newFilePath) + if (isCopy) { + if (fs.lstatSync(oldFilePath).isDirectory()) { + fs.mkdirSync(newFilePath) + } else { + fs.copyFileSync(oldFilePath, newFilePath) + } + } else { + fs.renameSync(oldFilePath, newFilePath) + } } catch (err) { return false } diff --git a/packages/server-core/src/media/storageprovider/s3.storage.ts b/packages/server-core/src/media/storageprovider/s3.storage.ts index deecc2dfd8..11e34b9024 100755 --- a/packages/server-core/src/media/storageprovider/s3.storage.ts +++ b/packages/server-core/src/media/storageprovider/s3.storage.ts @@ -260,12 +260,12 @@ export class S3Provider implements StorageProviderInterface { // https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-folders.htmlhow to const command = new ListObjectsV2Command({ Bucket: this.bucket, - Prefix: path.join(directoryPath, fileName), + Prefix: path.join(directoryPath, fileName, '/'), MaxKeys: 1 }) try { const response = await this.provider.send(command) - return response?.Contents?.[0]?.Key?.endsWith('/') || false + return (response.Contents && response.Contents.length > 0) || false } catch { return false } @@ -740,13 +740,16 @@ export class S3Provider implements StorageProviderInterface { * @param isCopy If true it will create a copy of object. */ async moveObject(oldName: string, newName: string, oldPath: string, newPath: string, isCopy = false) { + const isDirectory = await this.isDirectory(oldName, oldPath) const oldFilePath = path.join(oldPath, oldName) const newFilePath = path.join(newPath, newName) - const listResponse = await this.listObjects(oldFilePath, true) + const listResponse = await this.listObjects(oldFilePath + (isDirectory ? '/' : ''), false) const result = await Promise.all([ ...listResponse.Contents.map(async (file) => { - const key = path.join(newFilePath, file.Key.replace(oldFilePath, '')) + const relativePath = file.Key.replace(oldFilePath, '') + const key = newFilePath + relativePath + const input = { Bucket: this.bucket, CopySource: `/${this.bucket}/${file.Key}`, diff --git a/packages/server-core/tests/storageprovider/storageprovider.test.ts b/packages/server-core/tests/storageprovider/storageprovider.test.ts index bee09620d7..12100a0a54 100644 --- a/packages/server-core/tests/storageprovider/storageprovider.test.ts +++ b/packages/server-core/tests/storageprovider/storageprovider.test.ts @@ -58,9 +58,20 @@ describe('storageprovider', () => { storageProviders.forEach((providerType) => { describe(`tests for ${providerType.name}`, () => { let provider + let testRootPath + + const createTestDirectories = async () => { + testRootPath = path.join(process.cwd(), 'packages', 'server', 'upload', testFolderName) + await fs.ensureDir(testRootPath) + await fs.ensureDir(path.join(testRootPath, 'temp')) + await fs.ensureDir(path.join(testRootPath, 'temp2')) + await fs.ensureDir(path.join(testRootPath, 'testDirectory')) + } + before(async function () { createEngine() provider = new providerType() + await createTestDirectories() await providerBeforeTest(provider, testFolderName, folderKeyTemp, folderKeyTemp2) }) @@ -165,19 +176,27 @@ describe('storageprovider', () => { }) it(`should put over 1000 objects in ${providerType.name}`, async function () { - const promises: any[] = [] - for (let i = 0; i < 1010; i++) { - const fileKey = path.join(testFolderName, `${i}-${testFileName}`) - const data = Buffer.from([]) - promises.push( - provider.putObject({ - Body: data, - Key: fileKey, - ContentType: getContentType(fileKey) - }) - ) + this.timeout(30000) // increase timeout to 30 seconds + + const batchSize = 100 + const totalObjects = 1010 + + for (let i = 0; i < totalObjects; i += batchSize) { + const promises: any[] = [] + for (let j = i; j < Math.min(i + batchSize, totalObjects); j++) { + const fileKey = path.join(testFolderName, `${j}-${testFileName}`) + const data = Buffer.from([]) + promises.push( + provider.putObject({ + Body: data, + Key: fileKey, + ContentType: getContentType(fileKey) + }) + ) + } + await Promise.all(promises) + await new Promise((resolve) => setTimeout(resolve, 100)) // Add a small delay between batches } - await Promise.all(promises) }) it(`should list over 1000 objects in ${providerType.name}`, async function () { @@ -185,9 +204,43 @@ describe('storageprovider', () => { assert(res.length > 1000) }) + it(`isDirectory: should correctly identify directories in ${providerType.name}`, async function () { + const dirName = 'testDirectory' + const dirPath = path.join(testRootPath, dirName) + const fileName = `testFile-${uuidv4()}.txt` + const filePath = path.join(dirPath, fileName) + + // create a directory + await provider.putObject( + { + Key: dirPath, + Body: Buffer.from(''), + ContentType: 'application/x-directory' + }, + { isDirectory: true } + ) + + // create a file inside the directory + await provider.putObject({ + Body: Buffer.from('test content'), + Key: filePath, + ContentType: 'text/plain' + }) + + // test isDirectory + assert(await provider.isDirectory(dirName, testRootPath), 'Should identify directory') + assert(!(await provider.isDirectory(fileName, filePath)), 'Should not identify file as directory') + assert( + !(await provider.isDirectory('nonexistent', testFolderName)), + 'Should not identify non-existent path as directory' + ) + }) + after(async function () { await destroyEngine() await providerAfterTest(provider, testFolderName) + // clean up the test directory + await fs.remove(testRootPath) }) }) }) diff --git a/packages/ui/src/components/editor/panels/Files/browserGrid/index.tsx b/packages/ui/src/components/editor/panels/Files/browserGrid/index.tsx index 70c60946f2..a0bfc3f0be 100644 --- a/packages/ui/src/components/editor/panels/Files/browserGrid/index.tsx +++ b/packages/ui/src/components/editor/panels/Files/browserGrid/index.tsx @@ -212,6 +212,7 @@ type FileBrowserItemType = { staticResourceModifiedDates: Record isSelected: boolean refreshDirectory: () => Promise + selectedFileKeys: string[] } function fileConsistsOfContentType(file: FileDataType, contentType: string): boolean { @@ -240,7 +241,8 @@ export function FileBrowserItem({ isListView, staticResourceModifiedDates, isSelected, - refreshDirectory + refreshDirectory, + selectedFileKeys }: FileBrowserItemType) { const { t } = useTranslation() const [anchorEvent, setAnchorEvent] = React.useState>(undefined) @@ -322,7 +324,9 @@ export function FileBrowserItem({ accept: [...SupportedFileTypes], drop: (dropItem) => handleDropItemsOnPanel(dropItem, item), canDrop: (dropItem: Record) => - item.isFolder && ('key' in dropItem || canDropItemOverFolder(item.key)), + item.isFolder && + ('key' in dropItem || canDropItemOverFolder(item.key)) && + !selectedFileKeys.includes(item.key), collect: (monitor) => ({ isOver: monitor.canDrop() && monitor.isOver() }) diff --git a/packages/ui/src/components/editor/panels/Files/container/index.tsx b/packages/ui/src/components/editor/panels/Files/container/index.tsx index 3008bb79bc..3d34986aca 100644 --- a/packages/ui/src/components/editor/panels/Files/container/index.tsx +++ b/packages/ui/src/components/editor/panels/Files/container/index.tsx @@ -297,7 +297,10 @@ const FileBrowserContentPanel: React.FC = (props) } const onSelect = (event, params: FileDataType) => { - if (params.type !== 'folder') { + if (params.isFolder && event.detail === 2) { + const newPath = `${selectedDirectory.value}${params.name}/` + changeDirectoryByPath(newPath) + } else { props.onSelectionChanged({ resourceUrl: params.url, name: params.name, @@ -306,11 +309,6 @@ const FileBrowserContentPanel: React.FC = (props) }) ClickPlacementState.setSelectedAsset(params.url) - } else { - if (event.detail === 2) { - const newPath = `${selectedDirectory.value}${params.name}/` - changeDirectoryByPath(newPath) - } } } @@ -324,7 +322,6 @@ const FileBrowserContentPanel: React.FC = (props) selectedFileKeys?: string[] ) => { if (isLoading) return - const destinationPath = dropOn?.isFolder ? `${dropOn.key}/` : selectedDirectory.value if (selectedFileKeys && selectedFileKeys.length > 0) { @@ -332,18 +329,14 @@ const FileBrowserContentPanel: React.FC = (props) selectedFileKeys.map(async (fileKey) => { const file = files.find((f) => f.key === fileKey) if (file) { - if (file.isFolder) { - await fileService.create(`${destinationPath}${file.name}`) - } else { - const newName = `${file.name}${file.type ? '.' + file.type : ''}` - await moveContent(file.fullName, newName, file.path, destinationPath, false) - } + const newName = file.isFolder ? file.name : `${file.name}${file.type ? '.' + file.type : ''}` + await moveContent(file.fullName, newName, file.path, destinationPath, false) } }) ) } else if (isFileDataType(data)) { if (dropOn?.isFolder) { - const newName = `${data.name}${data.type ? '.' + data.type : ''}` + const newName = data.isFolder ? data.name : `${data.name}${data.type ? '.' + data.type : ''}` await moveContent(data.fullName, newName, data.path, destinationPath, false) } } else { @@ -571,8 +564,8 @@ const FileBrowserContentPanel: React.FC = (props) item={file} disableDnD={props.disableDnD} projectName={projectName} - onClick={(event, currentFile) => { - handleFileBrowserItemClick(event, currentFile) + onClick={(event) => { + handleFileBrowserItemClick(event, file) onSelect(event, file) }} onContextMenu={(event, currentFile) => { @@ -637,6 +630,7 @@ const FileBrowserContentPanel: React.FC = (props) staticResourceModifiedDates={staticResourceModifiedDates.value} isSelected={fileProperties.value.some(({ key }) => key === file.key)} refreshDirectory={refreshDirectory} + selectedFileKeys={fileProperties.value.map((file) => file.key)} /> ))} From 85de0d82c0e083d53e001fa94f6dcaa71c937d71 Mon Sep 17 00:00:00 2001 From: Michael Estes Date: Mon, 12 Aug 2024 14:04:34 -0700 Subject: [PATCH 3/7] Component dependencies (#10916) * WIP component dependencies for loading * naming * naming * Race condition with model.scene not being reactive * Missed check * Logic update * Scene loaded hooks * use scene loaded hooks instead of progress * Remove component dependencies * remove * UUID typing * Don't need to do this in a use effect anymore * remove console log --- .../src/networking/AvatarSpawnSystem.tsx | 3 +- .../src/systems/LoadingUISystem.tsx | 4 +- .../hierarchy/HierarchyTreeWalker.ts | 6 +- .../src/systems/ClickPlacementSystem.tsx | 7 +- packages/engine/src/gltf/GLTFComponent.tsx | 156 +++++++++++++++--- .../components/ParticleSystemComponent.ts | 6 +- .../components/VisualScriptComponent.tsx | 3 +- .../common/functions/OnBeforeCompilePlugin.ts | 1 - .../panels/Viewport/container/index.tsx | 6 +- 9 files changed, 154 insertions(+), 38 deletions(-) diff --git a/packages/client-core/src/networking/AvatarSpawnSystem.tsx b/packages/client-core/src/networking/AvatarSpawnSystem.tsx index 11a04785da..da6513dacd 100644 --- a/packages/client-core/src/networking/AvatarSpawnSystem.tsx +++ b/packages/client-core/src/networking/AvatarSpawnSystem.tsx @@ -34,7 +34,6 @@ import { getComponent, getOptionalComponent, PresentationSystemGroup, - useComponent, useQuery, UUIDComponent } from '@etherealengine/ecs' @@ -56,7 +55,7 @@ import { AuthState } from '../user/services/AuthService' export const AvatarSpawnReactor = (props: { sceneEntity: Entity }) => { if (!isClient) return null const { sceneEntity } = props - const gltfLoaded = useComponent(sceneEntity, GLTFComponent).progress.value === 100 + const gltfLoaded = GLTFComponent.useSceneLoaded(sceneEntity) const searchParams = useMutableState(SearchParamState) const spawnAvatar = useHookstate(false) diff --git a/packages/client-core/src/systems/LoadingUISystem.tsx b/packages/client-core/src/systems/LoadingUISystem.tsx index d306f00d61..e44b4442f9 100755 --- a/packages/client-core/src/systems/LoadingUISystem.tsx +++ b/packages/client-core/src/systems/LoadingUISystem.tsx @@ -140,9 +140,9 @@ export const LoadingUISystemState = defineState({ const LoadingReactor = (props: { sceneEntity: Entity }) => { const { sceneEntity } = props - const gltfComponent = useComponent(props.sceneEntity, GLTFComponent) + const gltfComponent = useComponent(sceneEntity, GLTFComponent) const loadingProgress = gltfComponent.progress.value - const sceneLoaded = loadingProgress === 100 + const sceneLoaded = GLTFComponent.useSceneLoaded(sceneEntity) const locationState = useMutableState(LocationState) const state = useMutableState(LoadingUISystemState) diff --git a/packages/editor/src/components/hierarchy/HierarchyTreeWalker.ts b/packages/editor/src/components/hierarchy/HierarchyTreeWalker.ts index 29b0d85a9d..307507df45 100644 --- a/packages/editor/src/components/hierarchy/HierarchyTreeWalker.ts +++ b/packages/editor/src/components/hierarchy/HierarchyTreeWalker.ts @@ -23,8 +23,8 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20 Ethereal Engine. All Rights Reserved. */ -import { ComponentType, getComponent, hasComponent } from '@etherealengine/ecs/src/ComponentFunctions' -import { Entity } from '@etherealengine/ecs/src/Entity' +import { getComponent, hasComponent } from '@etherealengine/ecs/src/ComponentFunctions' +import { Entity, EntityUUID } from '@etherealengine/ecs/src/Entity' import { entityExists } from '@etherealengine/ecs/src/EntityFunctions' import { SourceComponent } from '@etherealengine/engine/src/scene/components/SourceComponent' import { getState } from '@etherealengine/hyperflux' @@ -68,7 +68,7 @@ function buildHierarchyTree( sceneID: string, showModelChildren: boolean ) { - const uuid = node.extensions && (node.extensions[UUIDComponent.jsonID] as ComponentType) + const uuid = node.extensions && (node.extensions[UUIDComponent.jsonID] as EntityUUID) const entity = UUIDComponent.getEntityByUUID(uuid!) if (!entity || !entityExists(entity)) return diff --git a/packages/editor/src/systems/ClickPlacementSystem.tsx b/packages/editor/src/systems/ClickPlacementSystem.tsx index 475a25b9a5..db23567ede 100644 --- a/packages/editor/src/systems/ClickPlacementSystem.tsx +++ b/packages/editor/src/systems/ClickPlacementSystem.tsx @@ -36,7 +36,6 @@ import { getOptionalComponent, removeComponent, setComponent, - useComponent, useOptionalComponent } from '@etherealengine/ecs' import { GLTFComponent } from '@etherealengine/engine/src/gltf/GLTFComponent' @@ -115,7 +114,7 @@ const ClickPlacementReactor = (props: { parentEntity: Entity }) => { const { parentEntity } = props const clickState = useState(getMutableState(ClickPlacementState)) const editorState = useState(getMutableState(EditorHelperState)) - const gltfComponent = useComponent(parentEntity, GLTFComponent) + const sceneLoaded = GLTFComponent.useSceneLoaded(parentEntity) const errors = useEntityErrors(clickState.placementEntity.value, ModelComponent) // const renderers = defineQuery([RendererComponent]) @@ -132,7 +131,7 @@ const ClickPlacementReactor = (props: { parentEntity: Entity }) => { // }, [editorState.placementMode]) useEffect(() => { - if (gltfComponent.progress.value < 100) return + if (!sceneLoaded) return if (editorState.placementMode.value === PlacementMode.CLICK) { SelectionState.updateSelection([]) if (clickState.placementEntity.value) return @@ -146,7 +145,7 @@ const ClickPlacementReactor = (props: { parentEntity: Entity }) => { clickState.placementEntity.set(UndefinedEntity) SelectionState.updateSelection(selectedEntities) } - }, [editorState.placementMode, gltfComponent.progress]) + }, [editorState.placementMode, sceneLoaded]) useEffect(() => { if (!clickState.placementEntity.value) return diff --git a/packages/engine/src/gltf/GLTFComponent.tsx b/packages/engine/src/gltf/GLTFComponent.tsx index c2803003e4..af991ac3ac 100644 --- a/packages/engine/src/gltf/GLTFComponent.tsx +++ b/packages/engine/src/gltf/GLTFComponent.tsx @@ -28,6 +28,8 @@ import React, { useEffect } from 'react' import { parseStorageProviderURLs } from '@etherealengine/common/src/utils/parseSceneJSON' import { + Component, + ComponentJSONIDMap, defineComponent, Entity, EntityUUID, @@ -44,13 +46,36 @@ import { dispatchAction, getState, useHookstate } from '@etherealengine/hyperflu import { FileLoader } from '../assets/loaders/base/FileLoader' import { BINARY_EXTENSION_HEADER_MAGIC, EXTENSIONS, GLTFBinaryExtension } from '../assets/loaders/gltf/GLTFExtensions' -import { ModelComponent } from '../scene/components/ModelComponent' import { SourceComponent } from '../scene/components/SourceComponent' import { SceneJsonType } from '../scene/types/SceneTypes' import { migrateSceneJSONToGLTF } from './convertJsonToGLTF' import { GLTFDocumentState, GLTFSnapshotAction } from './GLTFDocumentState' import { ResourcePendingComponent } from './ResourcePendingComponent' +const loadDependencies = { + ['EE_model']: ['scene'] +} as Record + +type ComponentDependencies = Record + +const buildComponentDependencies = (json: GLTF.IGLTF) => { + const dependencies = {} as ComponentDependencies + if (!json.nodes) return dependencies + for (const node of json.nodes) { + if (!node.extensions || !node.extensions[UUIDComponent.jsonID]) continue + const uuid = node.extensions[UUIDComponent.jsonID] as EntityUUID + const extensions = Object.keys(node.extensions) + for (const extension of extensions) { + if (loadDependencies[extension]) { + if (!dependencies[uuid]) dependencies[uuid] = [] + dependencies[uuid].push(ComponentJSONIDMap.get(extension)!) + } + } + } + + return dependencies +} + export const GLTFComponent = defineComponent({ name: 'GLTFComponent', @@ -59,7 +84,8 @@ export const GLTFComponent = defineComponent({ src: '', // internals extensions: {}, - progress: 0 + progress: 0, + dependencies: undefined as ComponentDependencies | undefined } }, @@ -67,41 +93,61 @@ export const GLTFComponent = defineComponent({ if (typeof json?.src === 'string') component.src.set(json.src) }, + useDependenciesLoaded(entity: Entity) { + const dependencies = useComponent(entity, GLTFComponent).dependencies + return !!(dependencies.value && !dependencies.keys?.length) + }, + + useSceneLoaded(entity: Entity) { + const gltfComponent = useComponent(entity, GLTFComponent) + const dependencies = gltfComponent.dependencies + const progress = gltfComponent.progress.value + return !!(dependencies.value && !dependencies.keys?.length) && progress === 100 + }, + + isSceneLoaded(entity: Entity) { + const gltfComponent = getComponent(entity, GLTFComponent) + const dependencies = gltfComponent.dependencies + const progress = gltfComponent.progress + return !!(dependencies && !Object.keys(dependencies).length) && progress === 100 + }, + reactor: () => { const entity = useEntityContext() const gltfComponent = useComponent(entity, GLTFComponent) + const dependencies = gltfComponent.dependencies useGLTFDocument(gltfComponent.src.value, entity) const documentID = useComponent(entity, SourceComponent).value - return + return ( + <> + + {dependencies.value && dependencies.keys?.length ? ( + + ) : null} + + ) } }) const ResourceReactor = (props: { documentID: string; entity: Entity }) => { + const dependenciesLoaded = GLTFComponent.useDependenciesLoaded(props.entity) const resourceQuery = useQuery([SourceComponent, ResourcePendingComponent]) const sourceEntities = useHookstate(SourceComponent.entitiesBySourceState[props.documentID]) useEffect(() => { if (getComponent(props.entity, GLTFComponent).progress === 100) return if (!getState(GLTFDocumentState)[props.documentID]) return - const document = getState(GLTFDocumentState)[props.documentID] - const modelNodes = document.nodes?.filter((node) => !!node.extensions?.[ModelComponent.jsonID]) - if (modelNodes) { - for (const node of modelNodes) { - //check if an entity exists for this node, and has a model component - const uuid = node.extensions![UUIDComponent.jsonID] as EntityUUID - if (!UUIDComponent.entitiesByUUIDState[uuid]) return - const entity = UUIDComponent.entitiesByUUIDState[uuid].value - const model = getOptionalComponent(entity, ModelComponent) - //ensure that model contents have been loaded into the scene - if (!model?.scene) return - } - } + const entities = resourceQuery.filter((e) => getComponent(e, SourceComponent) === props.documentID) if (!entities.length) { - getMutableComponent(props.entity, GLTFComponent).progress.set(100) + if (dependenciesLoaded) getMutableComponent(props.entity, GLTFComponent).progress.set(100) return } @@ -121,14 +167,83 @@ const ResourceReactor = (props: { documentID: string; entity: Entity }) => { const progress = resources.reduce((acc, resource) => acc + resource.progress, 0) const total = resources.reduce((acc, resource) => acc + resource.total, 0) + if (!total) return - const percentage = total === 0 ? 100 : (progress / total) * 100 + const percentage = Math.floor(Math.min((progress / total) * 100, dependenciesLoaded ? 100 : 99)) getMutableComponent(props.entity, GLTFComponent).progress.set(percentage) - }, [resourceQuery, sourceEntities]) + }, [resourceQuery, sourceEntities, dependenciesLoaded]) return null } +const ComponentReactor = (props: { gltfComponentEntity: Entity; entity: Entity; component: Component }) => { + const { gltfComponentEntity, entity, component } = props + const dependencies = loadDependencies[component.jsonID!] + const comp = useComponent(entity, component) + + useEffect(() => { + const compValue = comp.value + for (const key of dependencies) { + if (!compValue[key]) return + } + + // console.log(`All dependencies loaded for entity: ${entity} on component: ${component.jsonID}`) + + const gltfComponent = getMutableComponent(gltfComponentEntity, GLTFComponent) + const uuid = getComponent(entity, UUIDComponent) + gltfComponent.dependencies.set((prev) => { + const dependencyArr = prev![uuid] as Component[] + const index = dependencyArr.findIndex((compItem) => compItem.jsonID === component.jsonID) + dependencyArr.splice(index, 1) + if (!dependencyArr.length) { + delete prev![uuid] + } + return prev + }) + }, [...dependencies.map((key) => comp[key])]) + + return null +} + +const DependencyEntryReactor = (props: { gltfComponentEntity: Entity; uuid: string; components: Component[] }) => { + const { gltfComponentEntity, uuid, components } = props + const entity = UUIDComponent.useEntityByUUID(uuid as EntityUUID) as Entity | undefined + return entity ? ( + <> + {components.map((component) => { + return ( + + ) + })} + + ) : null +} + +const DependencyReactor = (props: { gltfComponentEntity: Entity; dependencies: ComponentDependencies }) => { + const { gltfComponentEntity, dependencies } = props + const entries = Object.entries(dependencies) + + return ( + <> + {entries.map(([uuid, components]) => { + return ( + + ) + })} + + ) +} + const onError = (error: ErrorEvent) => { // console.error(error) } @@ -187,6 +302,9 @@ const useGLTFDocument = (url: string, entity: Entity) => { json = migrateSceneJSONToGLTF(json) } + const dependencies = buildComponentDependencies(json) + state.dependencies.set(dependencies) + dispatchAction( GLTFSnapshotAction.createSnapshot({ source: getComponent(entity, SourceComponent), diff --git a/packages/engine/src/scene/components/ParticleSystemComponent.ts b/packages/engine/src/scene/components/ParticleSystemComponent.ts index 374bffde30..d267efa9a1 100644 --- a/packages/engine/src/scene/components/ParticleSystemComponent.ts +++ b/packages/engine/src/scene/components/ParticleSystemComponent.ts @@ -868,7 +868,7 @@ export const ParticleSystemComponent = defineComponent({ const metadata = useHookstate({ textures: {}, geometries: {}, materials: {} } as ParticleSystemMetadata) const sceneID = useOptionalComponent(entity, SourceComponent)?.value const rootEntity = useHookstate(getMutableState(GLTFSourceState))[sceneID ?? ''].value - const rootGLTF = useOptionalComponent(rootEntity, GLTFComponent) + const sceneLoaded = GLTFComponent.useSceneLoaded(rootEntity) const refreshed = useHookstate(false) const [geoDependency] = useGLTF(componentState.value.systemParameters.instancingGeometry!, entity, (url) => { @@ -890,7 +890,7 @@ export const ParticleSystemComponent = defineComponent({ }) //@todo: this is a hack to make trail rendering mode work correctly. We need to find out why an additional snapshot is needed useEffect(() => { - if (rootGLTF?.value?.progress !== 100) return + if (!sceneLoaded) return if (refreshed.value) return //if (componentState.systemParameters.renderMode.value === RenderMode.Trail) { @@ -898,7 +898,7 @@ export const ParticleSystemComponent = defineComponent({ dispatchAction(GLTFSnapshotAction.createSnapshot(snapshot)) //} refreshed.set(true) - }, [rootGLTF?.value?.progress]) + }, [sceneLoaded]) useEffect(() => { //add dud material diff --git a/packages/engine/src/visualscript/components/VisualScriptComponent.tsx b/packages/engine/src/visualscript/components/VisualScriptComponent.tsx index e82d5c73e3..4189e15dc6 100644 --- a/packages/engine/src/visualscript/components/VisualScriptComponent.tsx +++ b/packages/engine/src/visualscript/components/VisualScriptComponent.tsx @@ -111,8 +111,7 @@ export const VisualScriptComponent = defineComponent({ }) const LoadReactor = (props: { entity: Entity; gltfAncestor: Entity }) => { - const gltfComponent = useComponent(props.gltfAncestor, GLTFComponent) - const loaded = gltfComponent.progress.value === 100 + const loaded = GLTFComponent.useSceneLoaded(props.gltfAncestor) useEffect(() => { setComponent(props.entity, VisualScriptComponent, { run: true }) diff --git a/packages/spatial/src/common/functions/OnBeforeCompilePlugin.ts b/packages/spatial/src/common/functions/OnBeforeCompilePlugin.ts index 20e8dd1758..179f8d4aa5 100644 --- a/packages/spatial/src/common/functions/OnBeforeCompilePlugin.ts +++ b/packages/spatial/src/common/functions/OnBeforeCompilePlugin.ts @@ -58,7 +58,6 @@ export type PluginType = PluginObjectType | typeof Material.prototype.onBeforeCo /**@deprecated Use setPlugin instead */ export function addOBCPlugin(material: Material, plugin: PluginType): void { material.onBeforeCompile = plugin as any - console.log(material.onBeforeCompile) material.needsUpdate = true } diff --git a/packages/ui/src/components/editor/panels/Viewport/container/index.tsx b/packages/ui/src/components/editor/panels/Viewport/container/index.tsx index e744cfb1b6..580bd920c3 100644 --- a/packages/ui/src/components/editor/panels/Viewport/container/index.tsx +++ b/packages/ui/src/components/editor/panels/Viewport/container/index.tsx @@ -123,6 +123,7 @@ const ViewportDnD = ({ children }: { children: React.ReactNode }) => { const SceneLoadingProgress = ({ rootEntity }) => { const { t } = useTranslation() const progress = useComponent(rootEntity, GLTFComponent).progress.value + const loaded = GLTFComponent.useSceneLoaded(rootEntity) const resourcePendingQuery = useQuery([ResourcePendingComponent]) const root = getComponent(rootEntity, SourceComponent) const sceneModified = useHookstate(getMutableState(GLTFModifiedState)[root]).value @@ -142,12 +143,13 @@ const SceneLoadingProgress = ({ rootEntity }) => { } }, [sceneModified]) - if (progress === 100) return null + if (loaded) return null return ( ) @@ -183,8 +185,8 @@ const ViewPortPanelContainer = () => { {sceneName.value ? : null} {sceneName.value ? ( <> - {rootEntity.value && }
+ {rootEntity.value && } ) : (
From 5755786b9cc5633739373b970eb11a9a21a19593 Mon Sep 17 00:00:00 2001 From: Appaji <52322531+CITIZENDOT@users.noreply.github.com> Date: Tue, 13 Aug 2024 12:12:56 +0530 Subject: [PATCH 4/7] IR-3053: Add project-history service (#10736) * Add project-history service * Add license * Add hooks to project service * Pass actionIdentifier & fix resolver & hooks order Import ProjectHistoryService in services.ts * replace `isSuperAdmin` hook * Fix errors * Implement base UI * Update table structure And Add history hooks to remaining services * add license * remove userId in query * Add User Interface for Project-History service - Update AvatarImage's label parsing logic * Parse URLs * Add `updatedBy` column in `static-resource` table * Add stored procedure and trigger for static-resource * Fix typo * Fix typo * remove hook * Add new line at the end * `dropForeign` column in migration * revert prop types in Text * move schema and path to common files * Move schema and path to common package * Add `static-resource` & `location` triggers and stored procedures Added triggers for all the operations (CREATE, UPDATE, DELETE) Also added `updatedBy` columns to the both tables * revert tsconfig * Add `project` triggers and stored procedures * Add `project-permission` triggers + stored procedures + updatedBy column * fix CI/CD error * fix CI/CD error * fix errors * move strings from multitenancy.json * remove individual action types * typo * allow multipleStatements in knexfile * improve ui * Fixed enum * fix tooltip value for permission actions set actionDetail type to json * change `CONCAT` to `JSON_OBJECT` to construct json strings * remove unnecessary checks * Revert "Fixed enum" This reverts commit 7c0f76afc104bfca496172581596a5d446f8f9ab. * add newline * check user permissions before find query * revert tsconfig * Changed permissions * permit only owner scope (and project:read) * join avatarimage labels without comma * move delete log operations to API write * Fix scene & resource classification --------- Co-authored-by: Liam Broza Co-authored-by: hanzlamateen --- packages/client-core/i18n/en/admin.json | 8 +- .../components/project/ProjectHistory.tsx | 302 ++++++++++++++++++ .../project/ProjectHistoryModal.tsx | 45 +++ .../admin/components/project/ProjectTable.tsx | 12 + .../src/social/services/LocationService.ts | 1 + packages/common/src/schema.type.module.ts | 4 + .../schemas/media/static-resource.schema.ts | 3 + .../projects/project-history.schema.ts | 110 +++++++ .../projects/project-permission.schema.ts | 3 + .../src/schemas/projects/project.schema.ts | 6 +- .../src/schemas/social/location.schema.ts | 4 + packages/server-core/knexfile.ts | 10 +- .../migrations/20240731173405_updatedBy.ts | 59 ++++ .../static-resource/static-resource.hooks.ts | 33 +- .../static-resource.resolvers.ts | 10 +- packages/server-core/src/mysql.ts | 3 +- .../20240730102300_project-history.ts | 86 +++++ ...20240731180000_static_resource_triggers.ts | 51 +++ .../20240806175210_location_triggers.ts | 51 +++ .../20240806191009_project_triggers.ts | 48 +++ ...40806192128_project-permission_triggers.ts | 51 +++ .../migrations/location_triggers.sql | 108 +++++++ .../project-permission_triggers.sql | 120 +++++++ .../migrations/project-triggers.sql | 46 +++ .../migrations/static_resource_triggers.sql | 142 ++++++++ .../project-history/project-history.class.ts | 43 +++ .../project-history/project-history.docs.ts | 44 +++ .../project-history/project-history.hooks.ts | 163 ++++++++++ .../project-history.resolvers.ts | 81 +++++ .../project-history/project-history.ts | 59 ++++ .../migrations/20240806192034_updatedBy.ts | 58 ++++ .../project-permission.hooks.ts | 27 +- .../project-permission.resolvers.ts | 6 + .../migrations/20240806170758_updatedBy.ts | 58 ++++ .../src/projects/project/project.resolvers.ts | 6 + packages/server-core/src/projects/services.ts | 4 +- .../src/social/location/location.hooks.ts | 24 +- .../src/social/location/location.resolvers.ts | 6 + .../migrations/20240806175038_updatedBy.ts | 58 ++++ .../editor/panels/Files/container/index.tsx | 1 + .../primitives/tailwind/AvatarImage/index.tsx | 18 +- .../ui/src/primitives/tailwind/Text/index.tsx | 6 +- 42 files changed, 1961 insertions(+), 17 deletions(-) create mode 100644 packages/client-core/src/admin/components/project/ProjectHistory.tsx create mode 100644 packages/client-core/src/admin/components/project/ProjectHistoryModal.tsx create mode 100644 packages/common/src/schemas/projects/project-history.schema.ts create mode 100644 packages/server-core/src/media/static-resource/migrations/20240731173405_updatedBy.ts create mode 100644 packages/server-core/src/projects/project-history/migrations/20240730102300_project-history.ts create mode 100644 packages/server-core/src/projects/project-history/migrations/20240731180000_static_resource_triggers.ts create mode 100644 packages/server-core/src/projects/project-history/migrations/20240806175210_location_triggers.ts create mode 100644 packages/server-core/src/projects/project-history/migrations/20240806191009_project_triggers.ts create mode 100644 packages/server-core/src/projects/project-history/migrations/20240806192128_project-permission_triggers.ts create mode 100644 packages/server-core/src/projects/project-history/migrations/location_triggers.sql create mode 100644 packages/server-core/src/projects/project-history/migrations/project-permission_triggers.sql create mode 100644 packages/server-core/src/projects/project-history/migrations/project-triggers.sql create mode 100644 packages/server-core/src/projects/project-history/migrations/static_resource_triggers.sql create mode 100644 packages/server-core/src/projects/project-history/project-history.class.ts create mode 100644 packages/server-core/src/projects/project-history/project-history.docs.ts create mode 100644 packages/server-core/src/projects/project-history/project-history.hooks.ts create mode 100644 packages/server-core/src/projects/project-history/project-history.resolvers.ts create mode 100644 packages/server-core/src/projects/project-history/project-history.ts create mode 100644 packages/server-core/src/projects/project-permission/migrations/20240806192034_updatedBy.ts create mode 100644 packages/server-core/src/projects/project/migrations/20240806170758_updatedBy.ts create mode 100644 packages/server-core/src/social/location/migrations/20240806175038_updatedBy.ts diff --git a/packages/client-core/i18n/en/admin.json b/packages/client-core/i18n/en/admin.json index 98fef2a4d5..d40aa031d7 100755 --- a/packages/client-core/i18n/en/admin.json +++ b/packages/client-core/i18n/en/admin.json @@ -86,7 +86,9 @@ "lastUpdatedBy": "Last updated by user id: {{userId}} on {{updatedAt}}", "fillRequiredFields": "Please fill all required field", "fixErrorFields": "Please fix all errors", - "logOut": "Log Out" + "logOut": "Log Out", + "newestFirst": "Newest First", + "oldestFirst": "Oldest First" }, "analytics": { "loading": "Loading analytics...", @@ -214,8 +216,10 @@ "repo": "Repo", "access": "Access", "invalidateCache": "Invalidate Cache", - "update": "Update" + "update": "Update", + "history": "History" }, + "projectHistory": "Project History", "addProject": "Add Project", "updateProject": "Update Project", "downloadProject": "Download Project", diff --git a/packages/client-core/src/admin/components/project/ProjectHistory.tsx b/packages/client-core/src/admin/components/project/ProjectHistory.tsx new file mode 100644 index 0000000000..0dc57f958f --- /dev/null +++ b/packages/client-core/src/admin/components/project/ProjectHistory.tsx @@ -0,0 +1,302 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { projectHistoryPath } from '@etherealengine/common/src/schema.type.module' +import { ProjectHistoryType } from '@etherealengine/common/src/schemas/projects/project-history.schema' +import { useFind } from '@etherealengine/spatial/src/common/functions/FeathersHooks' + +import { toDisplayDateTime } from '@etherealengine/common/src/utils/datetime-sql' +import AvatarImage from '@etherealengine/ui/src/primitives/tailwind/AvatarImage' +import Button from '@etherealengine/ui/src/primitives/tailwind/Button' +import { TablePagination } from '@etherealengine/ui/src/primitives/tailwind/Table' +import Text from '@etherealengine/ui/src/primitives/tailwind/Text' +import Tooltip from '@etherealengine/ui/src/primitives/tailwind/Tooltip' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { FaSortAmountDown, FaSortAmountUpAlt } from 'react-icons/fa' + +const PROJECT_HISTORY_PAGE_LIMIT = 10 + +const getRelativeURLFromProject = (projectName: string, url: string) => { + const prefix = `projects/${projectName}/` + if (url.startsWith(prefix)) { + return url.replace(prefix, '') + } + return url +} + +const getResourceURL = (projectName: string, url: string, resourceType: 'resource' | 'scene') => { + const relativeURL = getRelativeURLFromProject(projectName, url) + const resourceURL = + resourceType === 'resource' + ? `/projects/${projectName}/${relativeURL}` + : `/studio?project=${projectName}&scenePath=${url}` + return { + relativeURL, + resourceURL + } +} + +function HistoryLog({ projectHistory, projectName }: { projectHistory: ProjectHistoryType; projectName: string }) { + const { t } = useTranslation() + + const RenderAction = () => { + if (projectHistory.action === 'LOCATION_PUBLISHED' || projectHistory.action === 'LOCATION_UNPUBLISHED') { + const actionDetail = JSON.parse(projectHistory.actionDetail) as { + locationName: string + sceneURL: string + sceneId: string + } + + const verb = projectHistory.action === 'LOCATION_PUBLISHED' ? 'published' : 'unpublished' + + const { relativeURL, resourceURL } = getResourceURL(projectName, actionDetail.sceneURL, 'scene') + + return ( + <> + {verb} the location + + {verb === 'published' ? ( + + + {actionDetail.locationName} + + + ) : ( + + {actionDetail.locationName} + + )} + + from the scene + + + {relativeURL}. + + + ) + } else if (projectHistory.action === 'LOCATION_MODIFIED') { + const actionDetail = JSON.parse(projectHistory.actionDetail) as { + locationName: string + } + + return ( + <> + modified the location + + + + {actionDetail.locationName} + + + + ) + } else if (projectHistory.action === 'PERMISSION_CREATED' || projectHistory.action === 'PERMISSION_REMOVED') { + const actionDetail = JSON.parse(projectHistory.actionDetail) as { + userName: string + userId: string + permissionType: string + } + + const verb = projectHistory.action === 'PERMISSION_CREATED' ? 'added' : 'removed' + + return ( + <> + {verb} the + {actionDetail.permissionType} + + access to + + + {actionDetail.userName} + + + ) + } else if (projectHistory.action === 'PERMISSION_MODIFIED') { + const actionDetail = JSON.parse(projectHistory.actionDetail) as { + userName: string + userId: string + oldPermissionType: string + newPermissionType: string + } + + return ( + <> + updated the permission of the user + + {actionDetail.userName} + + from + {actionDetail.oldPermissionType} + to + {actionDetail.newPermissionType} + + ) + } else if (projectHistory.action === 'PROJECT_CREATED') { + return created the project + } else if ( + projectHistory.action === 'RESOURCE_CREATED' || + projectHistory.action === 'RESOURCE_REMOVED' || + projectHistory.action === 'SCENE_CREATED' || + projectHistory.action === 'SCENE_REMOVED' + ) { + const verb = + projectHistory.action === 'RESOURCE_CREATED' || projectHistory.action === 'SCENE_CREATED' + ? 'created' + : 'removed' + const object = + projectHistory.action === 'RESOURCE_CREATED' || projectHistory.action === 'RESOURCE_REMOVED' + ? 'resource' + : 'scene' + + const actionDetail = JSON.parse(projectHistory.actionDetail) as { + url: string + } + + const { relativeURL, resourceURL } = getResourceURL(projectName, actionDetail.url, object) + + return ( + <> + + {verb} the {object} + + + {relativeURL} + + + ) + } else if (projectHistory.action === 'RESOURCE_RENAMED' || projectHistory.action === 'SCENE_RENAMED') { + const object = projectHistory.action === 'RESOURCE_RENAMED' ? 'resource' : 'scene' + const actionDetail = JSON.parse(projectHistory.actionDetail) as { + oldURL: string + newURL: string + } + + const { relativeURL: oldRelativeURL } = getResourceURL(projectName, actionDetail.oldURL, object) + const { relativeURL: newRelativeURL, resourceURL: newResourceURL } = getResourceURL( + projectName, + actionDetail.newURL, + object + ) + + return ( + <> + renamed a {object} from + + {oldRelativeURL} + to + + {getRelativeURLFromProject(projectName, newRelativeURL)} + + + ) + } else if (projectHistory.action === 'RESOURCE_MODIFIED' || projectHistory.action === 'SCENE_MODIFIED') { + const object = projectHistory.action === 'RESOURCE_MODIFIED' ? 'resource' : 'scene' + const actionDetail = JSON.parse(projectHistory.actionDetail) as { + url: string + } + + const { relativeURL, resourceURL } = getResourceURL(projectName, actionDetail.url, object) + + return ( + <> + modified the {object} + + {relativeURL} + + + ) + } + + return null + } + + return ( +
+
+ + + {projectHistory.userName} + + +
+ + {toDisplayDateTime(projectHistory.createdAt)} +
+ ) +} + +export const ProjectHistory = ({ projectId, projectName }: { projectId: string; projectName: string }) => { + const { t } = useTranslation() + const projectHistoryQuery = useFind(projectHistoryPath, { + query: { + projectId: projectId, + $sort: { + createdAt: -1 + }, + $limit: PROJECT_HISTORY_PAGE_LIMIT + } + }) + + const sortOrder = projectHistoryQuery.sort.createdAt + + const toggleSortOrder = () => { + projectHistoryQuery.setSort({ + createdAt: sortOrder === -1 ? 1 : -1 + }) + } + + return ( +
+ + + {projectHistoryQuery.data && + projectHistoryQuery.data.map((projectHistory, index) => ( + + ))} + + projectHistoryQuery.setPage(newPage)} + /> +
+ ) +} diff --git a/packages/client-core/src/admin/components/project/ProjectHistoryModal.tsx b/packages/client-core/src/admin/components/project/ProjectHistoryModal.tsx new file mode 100644 index 0000000000..033242d548 --- /dev/null +++ b/packages/client-core/src/admin/components/project/ProjectHistoryModal.tsx @@ -0,0 +1,45 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import Modal from '@etherealengine/ui/src/primitives/tailwind/Modal' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { PopoverState } from '../../../common/services/PopoverState' +import { ProjectHistory } from './ProjectHistory' + +export const ProjectHistoryModal = ({ projectId, projectName }: { projectId: string; projectName: string }) => { + const { t } = useTranslation() + return ( + { + PopoverState.hidePopupover() + }} + > + + + ) +} diff --git a/packages/client-core/src/admin/components/project/ProjectTable.tsx b/packages/client-core/src/admin/components/project/ProjectTable.tsx index a664aeff37..f27d9e058b 100644 --- a/packages/client-core/src/admin/components/project/ProjectTable.tsx +++ b/packages/client-core/src/admin/components/project/ProjectTable.tsx @@ -28,6 +28,7 @@ import { useTranslation } from 'react-i18next' import { GrGithub } from 'react-icons/gr' import { HiOutlineArrowPath, + HiOutlineClock, HiOutlineCommandLine, HiOutlineExclamationCircle, HiOutlineFolder, @@ -55,6 +56,7 @@ import { ProjectRowType, projectsColumns } from '../../common/constants/project' import { ProjectUpdateState } from '../../services/ProjectUpdateService' import AddEditProjectModal from './AddEditProjectModal' import ManageUserPermissionModal from './ManageUserPermissionModal' +import { ProjectHistoryModal } from './ProjectHistoryModal' const logger = multiLogger.child({ component: 'client-core:ProjectTable' }) @@ -186,6 +188,16 @@ export default function ProjectTable(props: { search: string }) { > {t('admin:components.common.view')} +