diff --git a/forge/ee/routes/staticAssets/index.js b/forge/ee/routes/staticAssets/index.js index 74cc56e1eb..6d1024c9ef 100644 --- a/forge/ee/routes/staticAssets/index.js +++ b/forge/ee/routes/staticAssets/index.js @@ -61,6 +61,7 @@ module.exports = async function (app) { try { const sharingConfig = await request.project.getSetting(KEY_SHARED_ASSETS) || {} const result = await app.containers.listFiles(request.project, filePath) + result.files.forEach(file => { if (file.type === 'directory') { const absolutePath = filePath + (filePath.length > 0 ? '/' : '') + file.name @@ -69,6 +70,23 @@ module.exports = async function (app) { } } }) + + const parentDirectoryName = filePath.split('/').pop() + const parentDirectoryPath = filePath.split('/').slice(0, -1).join('/') + + const parentFiles = await app.containers.listFiles( + request.project, parentDirectoryPath + ) + const currentDirectory = parentFiles.files.filter(file => file.name === parentDirectoryName).shift() + + if (currentDirectory) { + result.folder = currentDirectory + const absolutePath = parentDirectoryPath + (parentDirectoryPath.length > 0 ? '/' : '') + currentDirectory.name + if (sharingConfig[absolutePath]) { + result.folder.share = sharingConfig[absolutePath] + } + } else result.folder = null + reply.send(result) } catch (err) { if (err.statusCode === 404) { diff --git a/frontend/src/api/assets.js b/frontend/src/api/assets.js index 2dc97b5a50..ac019cf548 100644 --- a/frontend/src/api/assets.js +++ b/frontend/src/api/assets.js @@ -4,7 +4,10 @@ const getFiles = function (instanceId, path) { // remove leading / from path path = path.replace(/^\//, '') return client.get(`/api/v1/projects/${instanceId}/files/_/${encodeURIComponent(path || '')}`).then(res => { - return res.data.files + return { + files: res.data.files, + folder: res.data.folder + } }) } @@ -30,6 +33,14 @@ const updateFolder = function (instanceId, pwd, oldName, newName) { }) } +const updateVisibility = function (instanceId, pwd, visibility, staticPath = '') { + // remove leading / from path + pwd = pwd.replace(/^\//, '') + return client.put(`/api/v1/projects/${instanceId}/files/_/${encodeURIComponent(pwd)}`, { + share: visibility === 'public' ? { root: staticPath } : {} + }) +} + const deleteItem = function (instanceId, path) { // remove leading / from path path = path.replace(/^\//, '') @@ -52,6 +63,7 @@ export default { getFiles, createFolder, updateFolder, + updateVisibility, deleteItem, uploadFile } diff --git a/frontend/src/components/TextCopier.vue b/frontend/src/components/TextCopier.vue new file mode 100644 index 0000000000..b793d0e57a --- /dev/null +++ b/frontend/src/components/TextCopier.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/components/file-browser/FileBrowser.vue b/frontend/src/components/file-browser/FileBrowser.vue index 38b5d3b799..eb9ac06582 100644 --- a/frontend/src/components/file-browser/FileBrowser.vue +++ b/frontend/src/components/file-browser/FileBrowser.vue @@ -119,13 +119,9 @@ export default { required: true, type: Object }, - folder: { - required: true, - type: Object - }, breadcrumbs: { required: true, - type: Object + type: Array }, disabled: { required: false, @@ -136,6 +132,10 @@ export default { required: false, default: '', type: String + }, + instance: { + required: true, + type: Object } }, emits: ['change-directory', 'items-updated'], @@ -154,15 +154,36 @@ export default { } }, computed: { + folder () { + return [...this.breadcrumbs].pop() + }, + isPublicFolder () { + if (!this.folder) { + return false + } + + return Object.prototype.hasOwnProperty.call(this.folder, 'share') && + Object.prototype.hasOwnProperty.call(this.folder.share, 'root') + }, + publicFolderPath () { + if (!this.isPublicFolder) { + return null + } + + return this.folder.share.root + }, instanceId () { return this.$route.params.id }, pwd () { - return [...this.breadcrumbs, this.folder.name].filter(b => b).join('/').replace('//', '/') + return [...this.breadcrumbs.map(crumb => crumb.name)] + .filter(b => b) + .join('/') + .replace('//', '/') }, baseURI () { // clear null values - const breadcrumbs = this.breadcrumbs.filter(n => n) + const breadcrumbs = this.breadcrumbs.map(crumb => crumb.name).filter(n => n) return breadcrumbs.join('/').replace('//', '/') }, columns () { @@ -195,10 +216,25 @@ export default { is: markRaw(ItemFilePath), extraProps: { breadcrumbs: this.breadcrumbs, - folder: this.folder.name || '' + folder: this.folder?.name || '' } } }, + { + key: 'url', + label: 'URL', + sortable: true, + component: { + is: markRaw(ItemFilePath), + extraProps: { + baseURL: this.instance?.url, + breadcrumbs: this.breadcrumbs, + prepend: this.publicFolderPath, + isNotAvailable: !this.isPublicFolder + } + }, + hidden: true + }, { key: 'lastModified', label: 'Last Modified', @@ -207,7 +243,7 @@ export default { ] }, noDataMessages () { - return this.noDataMessage.length ? this.noDataMessage : `No files in '${this.folder.name || 'Storage'}'` + return this.noDataMessage.length ? this.noDataMessage : `No files in '${this.folder?.name || 'Storage'}'` } }, methods: { @@ -215,9 +251,8 @@ export default { this.$refs[dialog].show() }, createFolder () { - const pwd = this.baseURI + '/' + (this.folder.name || '') this.loading = true - AssetsAPI.createFolder(this.instanceId, pwd, this.forms.newFolder.name) + AssetsAPI.createFolder(this.instanceId, this.baseURI, this.forms.newFolder.name) .then(() => this.$emit('items-updated')) .catch(error => { console.error(error) @@ -292,7 +327,7 @@ export default { }) }, uploadFile () { - const pwd = this.baseURI + '/' + (this.folder.name || '') + const pwd = this.baseURI + '/' const filename = this.forms.file.name this.loading = true AssetsAPI.uploadFile(this.instanceId, pwd, filename, this.forms.file) diff --git a/frontend/src/components/file-browser/VisibilitySelector.vue b/frontend/src/components/file-browser/VisibilitySelector.vue new file mode 100644 index 0000000000..f96640bde6 --- /dev/null +++ b/frontend/src/components/file-browser/VisibilitySelector.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/frontend/src/components/file-browser/cells/FilePath.vue b/frontend/src/components/file-browser/cells/FilePath.vue index d2ae30073e..19c03d1b8a 100644 --- a/frontend/src/components/file-browser/cells/FilePath.vue +++ b/frontend/src/components/file-browser/cells/FilePath.vue @@ -1,44 +1,69 @@ diff --git a/frontend/src/composables/String.js b/frontend/src/composables/String.js index 04f41b4739..c0a3edf7fb 100644 --- a/frontend/src/composables/String.js +++ b/frontend/src/composables/String.js @@ -20,3 +20,14 @@ export const dateToSlug = (date) => { return `${year}-${month}-${day}_${hours}-${minutes}` } + +// utility function to remove leading and trailing slashes +export const removeSlashes = (str, leading = true, trailing = true) => { + if (leading && str.startsWith('/')) { + str = str.slice(1) + } + if (trailing && str.endsWith('/')) { + str = str.slice(0, -1) + } + return str +} diff --git a/frontend/src/pages/instance/Assets.vue b/frontend/src/pages/instance/Assets.vue index 042bb4c6b6..fa5ed234de 100644 --- a/frontend/src/pages/instance/Assets.vue +++ b/frontend/src/pages/instance/Assets.vue @@ -3,24 +3,22 @@ -
- - - / - - / - +
+ 2.8.0 in order to use this feature.', instanceSuspendedMessage: 'The instance must be running to access its assets.' } }, computed: { + currentDirectory () { + if (this.breadcrumbs.length) { + return this.breadcrumbs[this.breadcrumbs.length - 1] + } + return null + }, launcherSatisfiesVersion () { if (!this.isInstanceRunning) { return true @@ -85,15 +87,26 @@ export default { }, isInstanceRunning () { return this.instance?.meta?.state === 'running' + }, + sortedFiles () { + const files = this.files.filter(file => file.type === 'file').sort() + const folders = this.files.filter(file => file.type === 'directory').sort() + + return [...folders, ...files] } }, watch: { isInstanceRunning (newState, oldState) { if (newState && !oldState) { - this.loadContents() + this.loadContents(this.breadcrumbs, true) } else { this.files = [] } + }, + currentDirectory (currentDirectory, previousDirectory) { + if (currentDirectory?.name !== previousDirectory?.name) { + this.loadContents() + } } }, mounted () { @@ -104,13 +117,19 @@ export default { this.loadContents() }, methods: { - loadContents () { + loadContents (breadcrumbs = [], reloadDirectory = false) { if (this.isFeatureEnabled) { - const breadcrumbs = this.breadcrumbs.join('/') - const path = breadcrumbs + (breadcrumbs.length > 0 ? '/' : '') + (this.currentDirectory.name || '') - AssetsAPI.getFiles(this.instance.id, path) - .then(files => { - this.files = files + if (breadcrumbs.length === 0) { + breadcrumbs = this.breadcrumbs + } + + const filepath = breadcrumbs.map(crumb => crumb.name).join('/') + return AssetsAPI.getFiles(this.instance.id, filepath) + .then(payload => { + this.files = payload.files + if (payload.folder && reloadDirectory) { + this.breadcrumbs[this.breadcrumbs.length - 1] = payload.folder + } }) .catch(error => { console.error(error) @@ -118,21 +137,40 @@ export default { } }, changeDirectory (dir) { - this.breadcrumbs.push(this.currentDirectory.name || '') - this.currentDirectory.name = dir.name - this.loadContents() + if (dir.name) { + this.breadcrumbs.push(dir) + } }, - breadcrumbClicked ($index) { - this.currentDirectory.name = this.breadcrumbs[$index] || '' - this.breadcrumbs = this.breadcrumbs.slice(0, $index) - this.loadContents() + goBack () { + this.breadcrumbs.pop() + if (this.breadcrumbs.length === 0) { + this.changeDirectory(null) + } else { + this.changeDirectory(this.breadcrumbs.pop()) + } + }, + onVisibilitySelected (payload) { + const pwd = this.breadcrumbs + .map(crumb => crumb.name) + .join('/') + .replace('//', '/') + + AssetsAPI.updateVisibility( + this.instance.id, + pwd, + payload.visibility, + payload.path + ) + .then((res) => this.loadContents()) + .then((res) => Alerts.emit('Instance settings successfully updated. Restart the instance to apply the changes.', 'confirmation', 6000)) + .catch(err => console.warn(err)) } } } diff --git a/frontend/src/pages/instance/components/DashboardLink.vue b/frontend/src/pages/instance/components/DashboardLink.vue index 6b26f1547e..9e9cff8cb8 100644 --- a/frontend/src/pages/instance/components/DashboardLink.vue +++ b/frontend/src/pages/instance/components/DashboardLink.vue @@ -25,16 +25,7 @@ import { ChartPieIcon } from '@heroicons/vue/outline' -// utility function to remove leading and trailing slashes -const removeSlashes = (str, leading = true, trailing = true) => { - if (leading && str.startsWith('/')) { - str = str.slice(1) - } - if (trailing && str.endsWith('/')) { - str = str.slice(0, -1) - } - return str -} +import { removeSlashes } from '../../../composables/String.js' export default { name: 'DashboardLink', diff --git a/frontend/src/pages/instance/components/FolderBreadcrumbs.vue b/frontend/src/pages/instance/components/FolderBreadcrumbs.vue new file mode 100644 index 0000000000..cdf7a032cb --- /dev/null +++ b/frontend/src/pages/instance/components/FolderBreadcrumbs.vue @@ -0,0 +1,175 @@ + + + diff --git a/test/e2e/frontend/cypress/tests-ee/instances/assets.spec.js b/test/e2e/frontend/cypress/tests-ee/instances/assets.spec.js index 47d195a4af..df356859f5 100644 --- a/test/e2e/frontend/cypress/tests-ee/instances/assets.spec.js +++ b/test/e2e/frontend/cypress/tests-ee/instances/assets.spec.js @@ -15,7 +15,7 @@ function interceptTeamFeature () { .as('getTeam') } -function interceptInstanceMeta () { +function interceptInstanceMeta (body = {}) { cy.intercept('GET', '/api/*/projects/*', (req) => req.reply(res => { // this.instance?.meta?.versions?.launcher res.body = { @@ -26,7 +26,8 @@ function interceptInstanceMeta () { launcher: '2.8.0' } } - } + }, + ...body } return res })).as('getInstanceStatus') @@ -340,9 +341,237 @@ describe('FlowForge - Instance - Assets', () => { cy.get('[data-el="folder-breadcrumbs"]').contains('hello_world') - cy.get('[data-el="folder-breadcrumbs"]').contains('Storage').click() + cy.get('[data-action="navigate-back"]').click() cy.get('[data-el="folder-breadcrumbs"]').should('not.contain', 'hello_world') + + cy.get('[data-el="files-table"] table tbody').contains('hello_world') + }) + }) + + describe('the Folder Breadcrumbs', () => { + beforeEach(() => { + interceptTeamFeature() + interceptInstanceMeta() + + navigateToProject('BTeam', 'instance-2-1', 'assets') + }) + + it('displays a disabled visibility selector on the default root folder and correct folder path', () => { + interceptFiles([ + { name: 'hello_world', type: 'directory', lastModified: new Date() } + ]) + cy.wait('@getFiles') + + cy.intercept('GET', '/api/*/projects/*/files/_/hello_world', ({ + meta: { }, + files: [], + count: 0 + })).as('getNestedFiles') + + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"]') + .click() + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"] .ff-dropdown-options') + .should('not.be.visible') + cy.get('[data-el="folder-breadcrumbs"]').contains('Storage') + cy.get('[data-el="folder-breadcrumbs"] [data-el="ff-data-cell"]:nth-child(4) .path').should('be.empty') + }) + + it('displays an enabled visibility selector on nested folders and correct folder path', () => { + interceptFiles([ + { name: 'hello_world', type: 'directory', lastModified: new Date() } + ]) + cy.intercept('GET', '/api/*/projects/*/files/_/hello_world', ({ + meta: { }, + files: [], + count: 0 + })).as('getNestedFiles') + cy.wait('@getFiles') + + cy.intercept('GET', '/api/*/projects/*/files/_/hello_world', ({ + meta: { }, + files: [], + count: 0 + })).as('getNestedFiles') + + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"]') + .click() + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"] .ff-dropdown-options') + .should('not.be.visible') + + cy.get('[data-el="folder-breadcrumbs"]').contains('Storage') + cy.get('[data-el="folder-breadcrumbs"] [data-el="ff-data-cell"]:nth-child(4) .path').should('be.empty') + + cy.get('[data-el="files-table"] table tbody tr').contains('hello_world').click() + cy.wait('@getNestedFiles') + + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"]') + .click() + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"] .ff-dropdown-options') + .should('be.visible') + + cy.get('[data-el="folder-breadcrumbs"] [data-el="ff-data-cell"]:nth-child(2)').contains('hello_world') + cy.get('[data-el="folder-breadcrumbs"] [data-el="ff-data-cell"]:nth-child(4)').contains('hello_world/') + cy.get('[data-el="folder-breadcrumbs"] [data-el="ff-data-cell"]:nth-child(5)').contains('Not Available') + }) + + it('prevents users to set private visibility when the folder is private', () => { + const spy = cy.spy().as('updateVisibilityCall') + cy.intercept('PUT', '/api/*/projects/*/files/_/hello_world', spy) + interceptFiles([ + { name: 'hello_world', type: 'directory', lastModified: new Date() } + ]) + cy.intercept('GET', '/api/*/projects/*/files/_/hello_world', ({ + meta: { }, + files: [], + count: 0 + })).as('getNestedFiles') + cy.wait('@getFiles') + + cy.get('[data-el="files-table"] table tbody tr').contains('hello_world').click() + cy.wait('@getNestedFiles') + + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"]') + .click() + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"] .ff-dropdown-options') + .should('be.visible') + .within(() => { + cy.get('[data-action="select-private"]').click() + cy.get('@updateVisibilityCall').should('not.have.been.called') + }) + }) + + it('allows users to set private visibility when the folder is public', () => { + const spy = cy.spy().as('updateVisibilityCall') + cy.intercept('PUT', '/api/*/projects/*/files/_/hello_world', spy) + interceptFiles([ + { + name: 'hello_world', + type: 'directory', + lastModified: new Date(), + share: { + root: 'hello_public' + } + } + ]) + cy.intercept('GET', '/api/*/projects/*/files/_/hello_world', ({ + meta: { }, + files: [], + count: 0 + })).as('getNestedFiles') + cy.wait('@getFiles') + + cy.get('[data-el="files-table"] table tbody tr').contains('hello_world').click() + cy.wait('@getNestedFiles') + + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"]') + .click() + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"] .ff-dropdown-options') + .should('be.visible') + .within(() => { + cy.get('[data-action="select-private"]').click() + cy.get('@updateVisibilityCall').should('have.been.called', 1) + }) + }) + + it('allows users to set public visibility when the directory is private', () => { + cy.intercept('PUT', '/api/*/projects/*/files/_/hello_world', { }).as('updateVisibility') + interceptFiles([ + { + name: 'hello_world', + type: 'directory', + lastModified: new Date() + } + ]) + cy.intercept('GET', '/api/*/projects/*/files/_/hello_world', ({ + meta: { }, + files: [], + count: 0 + })).as('getNestedFiles') + cy.wait('@getFiles') + + cy.get('[data-el="files-table"] table tbody tr').contains('hello_world').click() + cy.wait('@getNestedFiles') + + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"]') + .click() + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"] .ff-dropdown-options') + .should('be.visible') + .within(() => { + cy.get('[data-action="select-public"]').click() + }) + + cy.get('[data-el="select-static-path-dialog"]') + .should('be.visible') + cy.get('[data-el="select-static-path-dialog"] input').type('public_path') + cy.get('[data-el="select-static-path-dialog"]').within(() => { + cy.get('[data-action="dialog-confirm"]').click() + }) + + cy.wait('@updateVisibility') + cy.get('[data-el="notification-alert"]').should('be.visible') + cy.get('[data-el="notification-alert"]').contains('Instance settings successfully updated. Restart the instance to apply the changes.') + }) + + it('allows users to set public visibility when the directory is public', () => { + cy.intercept('PUT', '/api/*/projects/*/files/_/hello_world', { }).as('updateVisibility') + interceptFiles([ + { + name: 'hello_world', + type: 'directory', + lastModified: new Date(), + share: { + root: 'public-path' + } + } + ]) + cy.intercept('GET', '/api/*/projects/*/files/_/hello_world', ({ + meta: { }, + files: [], + count: 0 + })).as('getNestedFiles') + cy.wait('@getFiles') + + cy.get('[data-el="files-table"] table tbody tr').contains('hello_world').click() + cy.wait('@getNestedFiles') + + cy.get('[data-el="folder-breadcrumbs"]').contains('public-path/') + + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"]') + .click() + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"] .ff-dropdown-options') + .should('be.visible') + .within(() => { + cy.get('[data-action="select-public"]').click() + }) + + cy.get('[data-el="select-static-path-dialog"]') + .should('be.visible') + cy.get('[data-el="select-static-path-dialog"] input').type('public_path') + cy.get('[data-el="select-static-path-dialog"]').within(() => { + cy.get('[data-action="dialog-confirm"]').click() + }) + + cy.wait('@updateVisibility') + cy.get('[data-el="notification-alert"]').should('be.visible') + cy.get('[data-el="notification-alert"]').contains('Instance settings successfully updated. Restart the instance to apply the changes.') + }) + + it('is disabled when the instance state is not running', () => { + interceptInstanceMeta({ meta: { state: 'suspended' } }) + + cy.get('[data-el="status-badge-suspended"]').should('exist') + cy.get('[data-el="files-table"]').contains('The instance must be running to access its assets.') + + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"]').click() + cy.get('[data-el="folder-breadcrumbs"] [data-el="visibility-selector"] .ff-dropdown-options') + .should('not.be.visible') + + // checking folder path + cy.get('[data-el="folder-breadcrumbs"] [data-el="ff-data-cell"]:nth-child(4)').contains('Not Available') + + // checking Base URL + cy.get('[data-el="folder-breadcrumbs"] [data-el="ff-data-cell"]:nth-child(5)').contains('Not Available') }) }) })