From 8f7e098f896f1738f1465a028d5d3537cfe8cd1b Mon Sep 17 00:00:00 2001 From: Felipe Morato Date: Thu, 9 Dec 2021 07:33:24 +0200 Subject: [PATCH] Add possibility to tag objects. This is a continuation to the tagging buckets feature from PR #419. The openApi spec supports tuples in 3.1 with prefixItems. We may want to change the api docs when that comes into use. --- docs/_static/api.yml | 67 ++++++++-- swift_browser_ui/ui/api.py | 47 ++++++- swift_browser_ui/ui/server.py | 2 + swift_browser_ui_frontend/src/common/api.js | 42 +++++- swift_browser_ui_frontend/src/common/conv.js | 36 ++++- swift_browser_ui_frontend/src/common/lang.js | 12 +- .../src/common/router.js | 6 + swift_browser_ui_frontend/src/common/store.js | 123 ++++++++++++------ .../src/components/ObjectDeleteButton.vue | 5 +- .../src/components/ObjectTable.vue | 51 +++++++- swift_browser_ui_frontend/src/entries/main.js | 5 +- .../src/views/AddContainer.vue | 11 +- .../src/views/EditObject.vue | 119 +++++++++++++++++ tests/common/mockups.py | 47 ++++--- tests/cypress/integration/browser.spec.js | 39 +++++- tests/ui_unit/test_api.py | 50 ++++++- 16 files changed, 551 insertions(+), 111 deletions(-) create mode 100644 swift_browser_ui_frontend/src/views/EditObject.vue diff --git a/docs/_static/api.yml b/docs/_static/api.yml index c906c3baa..e9b65613d 100644 --- a/docs/_static/api.yml +++ b/docs/_static/api.yml @@ -718,6 +718,37 @@ paths: description: Unauthorized 404: description: Not Found + post: + tags: + - Frontend + summary: Update user created container metadata. + parameters: + - name: container + in: query + description: The container which metadata will be updated. + schema: + type: string + example: test-container-1 + required: true + requestBody: + description: Bucket metadata as a key-value object. Updates must include all the metadata for the bucket. Omitted keys are removed by the swift backend. + required: true + content: + application/json: + schema: + type: object + properties: + key: + type: string + example: + owner: project-team + responses: + 204: + description: Container metadata was updated. No Content. + 403: + description: Unauthorized + 404: + description: Container was not found /api/bucket/object/meta: get: tags: @@ -750,34 +781,48 @@ paths: post: tags: - Frontend - summary: Update user created container metadata. + summary: Update user created object metadata. parameters: - name: container in: query - description: The container which metadata will be updated. + description: The container which contains the objects to be updated. schema: type: string example: test-container-1 required: true requestBody: - description: Bucket metadata as a key-value object. Updates must include all the metadata for the bucket. Omitted keys are removed by the swift backend. + description: Object metadata as an array of tuples with the object name and a key-value object. Updates must include all the metadata for the object. Omitted keys are removed by the swift backend. required: true content: application/json: schema: - type: object - properties: - key: - type: string + type: array example: - owner: project-team + - - object-name + - key: value + items: + type: array + items: + oneOf: + - type: string + - type: object + minItems: 2 + maxItems: 2 + example: + - object-name + - key: value + example: + - - object-name + - key: value responses: 204: - description: Container metadata was updated. No Content. + description: Object metadata was updated. No Content. + 400: + description: Payload malformed. Bad request. 403: - description: Unauthorized + description: Unauthorized. 404: - description: Container was not found + description: At least one object was not found. /api/shared/objects: get: tags: diff --git a/swift_browser_ui/ui/api.py b/swift_browser_ui/ui/api.py index a5c1328f4..f44d7ff67 100644 --- a/swift_browser_ui/ui/api.py +++ b/swift_browser_ui/ui/api.py @@ -6,7 +6,7 @@ import aiohttp.web from swiftclient.exceptions import ClientException -from swiftclient.service import SwiftError +from swiftclient.service import SwiftError, SwiftPostObject from swiftclient.service import SwiftService, get_conn # for type hints from swiftclient.utils import generate_temp_url @@ -492,7 +492,7 @@ async def get_object_metadata( for i in res ] - # Strip unnecessary specifcations from header names and split open s3 + # Strip unnecessary specifications from header names and split open s3 # information so that it doesn't have to be done in the browser for i in res: i[1] = {k.replace("x-object-meta-", ""): v for k, v in i[1].items()} @@ -549,7 +549,7 @@ async def update_metadata_bucket(request: aiohttp.web.Request) -> aiohttp.web.Re container = request.query.get("container", "") or None meta = await request.json() - meta = [(key, value) for key, value in meta.items()] + meta = [(key, value) for key, value in meta.items() if value] conn = request.app["Sessions"][session]["ST_conn"] ret = conn.post(container=container, options={"meta": meta}) @@ -589,6 +589,47 @@ async def get_metadata_object(request: aiohttp.web.Request) -> aiohttp.web.Respo return aiohttp.web.json_response(await get_object_metadata(conn, meta_cont, meta_obj)) +async def update_metadata_object(request: aiohttp.web.Request) -> aiohttp.web.Response: + """Update metadata for an object.""" + session = api_check(request) + request.app["Log"].info( + "API cal for updating container metadata from " + f"{request.remote}, sess: {session} :: {time.ctime()}" + ) + + # Get required variables from query string + container = request.query.get("container", "") or None + objects = await request.json() + + if not (container or objects): + raise aiohttp.web.HTTPBadRequest + + objects_post = [] + try: + for (name, meta) in objects: + meta = [(key, value) for key, value in meta.items()] + objects_post.append( + SwiftPostObject( + object_name=name, + options={ + "meta": meta, + }, + ) + ) + except ValueError as e: + request.app["Log"].error(f"Payload seems to be malformed: {e}") + raise aiohttp.web.HTTPBadRequest + + conn = request.app["Sessions"][session]["ST_conn"] + ret = conn.post(container=container, objects=objects_post) + + for r in ret: + if not r["success"]: + raise aiohttp.web.HTTPNotFound + + return aiohttp.web.HTTPNoContent() + + async def get_project_metadata(request: aiohttp.web.Request) -> aiohttp.web.Response: """Get the bare minimum required project metadata from OS.""" # The project metadata needs to be filtered for sensitive information, as diff --git a/swift_browser_ui/ui/server.py b/swift_browser_ui/ui/server.py index 5bbfab4a8..04c629be7 100644 --- a/swift_browser_ui/ui/server.py +++ b/swift_browser_ui/ui/server.py @@ -45,6 +45,7 @@ swift_check_object_chunk, swift_replicate_container, update_metadata_bucket, + update_metadata_object, ) from swift_browser_ui.ui.health import handle_health_check from swift_browser_ui.ui.settings import setd @@ -161,6 +162,7 @@ async def servinit() -> aiohttp.web.Application: aiohttp.web.get("/api/bucket/meta", get_metadata_bucket), aiohttp.web.post("/api/bucket/meta", update_metadata_bucket), aiohttp.web.get("/api/bucket/object/meta", get_metadata_object), + aiohttp.web.post("/api/bucket/object/meta", update_metadata_object), aiohttp.web.get("/api/project/meta", get_project_metadata), aiohttp.web.get("/api/project/acl", get_access_control_metadata), aiohttp.web.post("/api/access/{container}", add_project_container_acl), diff --git a/swift_browser_ui_frontend/src/common/api.js b/swift_browser_ui_frontend/src/common/api.js index 6229ba535..5a060e809 100644 --- a/swift_browser_ui_frontend/src/common/api.js +++ b/swift_browser_ui_frontend/src/common/api.js @@ -1,6 +1,9 @@ // API fetch functions. -import { getHumanReadableSize } from "@/common/conv"; +import { + getHumanReadableSize, + makeGetObjectsMetaURL, +} from "@/common/conv"; export async function getUser() { // Function to get the username of the currently displayed user. @@ -131,7 +134,42 @@ export async function getObjects(container) { return objects; } -export async function getSharedObjects( +export async function getObjectsMeta ( + container, + objects, + url, +){ + if (url === undefined) { + url = makeGetObjectsMetaURL(container, objects); + } + + let ret = await fetch( + url, {method: "GET", credentials: "same-origin"}, + ); + return ret.json(); +} + +export async function updateObjectMeta ( + container, + objectMeta, +){ + let url = new URL( + "/api/bucket/object/meta?container=".concat(encodeURI(container)), + document.location.origin, + ); + + let ret = await fetch( + url, + { + method: "POST", + credentials: "same-origin", + body: JSON.stringify([objectMeta]), + }, + ); + return ret; +} + +export async function getSharedObjects ( project, container, url, diff --git a/swift_browser_ui_frontend/src/common/conv.js b/swift_browser_ui_frontend/src/common/conv.js index 263bd1662..1f477e025 100644 --- a/swift_browser_ui_frontend/src/common/conv.js +++ b/swift_browser_ui_frontend/src/common/conv.js @@ -1,6 +1,8 @@ + import { getBucketMeta, getAccessControlMeta, + getObjectsMeta, } from "./api"; export default function getLangCookie() { @@ -153,12 +155,32 @@ export function getHumanReadableSize(val) { return ret; } +function extractTags(meta) { + if ("usertags" in meta[1]) { + return meta[1]["usertags"].split(";"); + } + return []; +} + export async function getTagsForContainer(containerName) { - let tags = []; - await getBucketMeta(containerName).then(meta => { - if ("usertags" in meta[1]) { - tags = meta[1]["usertags"].split(";"); - } - }); - return tags; + let meta = await getBucketMeta(containerName); + return extractTags(meta); } + +export async function getTagsForObjects(containerName, objectList, url) { + let meta = await getObjectsMeta(containerName, objectList, url); + meta.map(item => item[1] = extractTags(item)); + return meta; +} + +export function makeGetObjectsMetaURL(container, objects) { + return new URL( + "/api/bucket/object/meta?container=" + .concat(encodeURI(container)) + .concat("&object=") + .concat(encodeURI(objects.join(","))), + document.location.origin, + ); +} + +export const taginputConfirmKeys = [",", ";", ":", ".", " ", "Tab", "Enter"]; diff --git a/swift_browser_ui_frontend/src/common/lang.js b/swift_browser_ui_frontend/src/common/lang.js index 531b994fd..f2869417b 100644 --- a/swift_browser_ui_frontend/src/common/lang.js +++ b/swift_browser_ui_frontend/src/common/lang.js @@ -192,6 +192,8 @@ let default_translations = { copysuccess: "Started copying the bucket in the background", copyfail: "Failed to copy the bucket", renderFolders: "Render as Folders", + tagName: "Tags", + tagMessage: "Press enter to add.", container_ops: { addContainer: "Add a new bucket", editContainer: "Editing bucket: ", @@ -203,10 +205,10 @@ let default_translations = { containerMessage: "The name of the new bucket", fullDelete: "Deleting a bucket with contents requires deleting " + "all objects inside it first.", - tagName: "Tags", - tagMessage: "Press enter to add.", }, objects: { + objectName: "Object", + editObject: "Editing object: ", deleteConfirm: "Delete Objects", deleteObjects: "Delete Object / Objects", deleteSuccess: "Objects deleted", @@ -422,6 +424,8 @@ let default_translations = { copysuccess: "Aloitettiin säiliön kopiointi taustalla", copyfail: "Säiliön kopiointi epäonnistui", renderFolders: "Näytä kansioina", + tagName: "Tägit", + tagMessage: "Paina 'enter' lisätäksesi.", container_ops: { addContainer: "Luo uusi säiliö", editContainer: "Muokataan säiliötä: ", @@ -431,10 +435,10 @@ let default_translations = { containerName: "Säiliö", containerMessage: "Uuden säiliön nimi", fullDelete: "Säiliön sisältö on poistettava ennen säiliön postamista.", - tagName: "Tägit", - tagMessage: "Paina 'enter' lisätäksesi.", }, objects: { + objectName: "Objekti", + editObject: "Muokataan objekti: ", deleteConfirm: "Poista objektit", deleteObjects: "Poista objekti / objektit", deleteSuccess: "Objektit poistettu", diff --git a/swift_browser_ui_frontend/src/common/router.js b/swift_browser_ui_frontend/src/common/router.js index 6329b1dcd..04cb9f94b 100644 --- a/swift_browser_ui_frontend/src/common/router.js +++ b/swift_browser_ui_frontend/src/common/router.js @@ -3,6 +3,7 @@ import Router from "vue-router"; import DashboardView from "@/views/Dashboard.vue"; import ContainersView from "@/views/Containers.vue"; import ObjectsView from "@/views/Objects.vue"; +import EditObjectView from "@/views/EditObject.vue"; import SharedObjects from "@/views/SharedObjects"; import ShareRequests from "@/views/ShareRequests"; import SharedTo from "@/views/SharedTo"; @@ -89,5 +90,10 @@ export default new Router({ name: "ObjectsView", component: ObjectsView, }, + { + path: "/browse/:user/:project/:container/:object/edit", + name: "EditObjectView", + component: EditObjectView, + }, ], }); diff --git a/swift_browser_ui_frontend/src/common/store.js b/swift_browser_ui_frontend/src/common/store.js index 5239d7f19..513f43706 100644 --- a/swift_browser_ui_frontend/src/common/store.js +++ b/swift_browser_ui_frontend/src/common/store.js @@ -9,6 +9,8 @@ import { } from "./api"; import { getTagsForContainer, + getTagsForObjects, + makeGetObjectsMetaURL, } from "./conv"; Vue.use(Vuex); @@ -22,6 +24,7 @@ const store = new Vuex.Store({ isLoading: false, isFullPage: true, objectCache: [], + objectTagsCache: {}, // {"objectName": ["tag1", "tag2"]} containerCache: [], containerTagsCache: {}, // {"containerName": ["tag1", "tag2"]} langs: [ @@ -44,53 +47,23 @@ const store = new Vuex.Store({ // Update container cache with the new container listing. state.containerCache = payload; }, + updateContainerTags(state, payload) { + state.containerTagsCache = { + ...state.containerTagsCache, + [payload.containerName]: payload.tags, + }; + }, updateObjects ( state, payload, ) { // Update object cache with the new object listing. - let container = payload.route.params.container; - state.isLoading = true; - if (payload.route.name == "SharedObjects") { - state.client.getAccessDetails( - payload.route.params.project, - container, - payload.route.params.owner, - ).then( - (ret) => { - return getSharedObjects( - payload.route.params.owner, - container, - ret.address, - ); - }, - ).then( - (ret) => { - state.isLoading = false; - state.objectCache = ret; - }, - ).catch(() => { - state.objectCache = []; - state.isLoading = false; - }); - } - else { - getObjects(container).then((ret) => { - if (ret.status != 200) { - state.isLoading = false; - } - state.objectCache = ret; - state.isLoading = false; - }).catch(() => { - state.objectCache = []; - state.isLoading = false; - }); - } + state.objectCache = payload; }, - updateContainerTags(state, payload) { - state.containerTagsCache = { - ...state.containerTagsCache, - [payload.containerName]: payload.tags, + updateObjectTags (state, payload) { + state.objectTagsCache = { + ...state.objectTagsCache, + [payload.objectName]: payload.tags, }; }, eraseObjects(state) { @@ -176,6 +149,74 @@ const store = new Vuex.Store({ ); }); }, + updateObjects: async function ({ commit, dispatch, client }, route) { + let container = route.params.container; + commit("loading", true); + if (route.name == "SharedObjects") { + await client.getAccessDetails( + route.params.project, + container, + route.params.owner, + ).then( + (ret) => { + return getSharedObjects( + route.params.owner, + container, + ret.address, + ); + }, + ).then( + (ret) => { + commit("loading", false); + commit("updateObjects", ret); + }, + ).catch(() => { + commit("updateObjects", []); + commit("loading", false); + }); + } else { + await getObjects(container).then((ret) => { + if (ret.status != 200) { + commit("loading", false); + } + commit("updateObjects", ret); + commit("loading", false); + }).catch(() => { + commit("updateObjects", []); + commit("loading", false); + }); + } + dispatch("updateObjectTags", route); + }, + updateObjectTags: async function ({ commit, state }, route) { + if (!state.objectCache.length) { + return; + } + let objectList = []; + for (let i = 0; i < state.objectCache.length; i++) { + // Object names end up in the URL, which has hard length limits. + // The aiohttp backend has a limit of 2048. The maximum size + // for object name is 1024. Set it to a safe enough amount. + // We split the requests to prevent reaching said limits. + objectList.push(state.objectCache[i].name); + const url = makeGetObjectsMetaURL(route.params.container, objectList); + if ( + i === state.objectCache.length - 1 + || url.href.length > 2000 + ) { + getTagsForObjects(route.params.container, objectList, url) + .then(tags => + tags.map(item => { + commit( + "updateObjectTags", + {objectName: item[0], tags: item[1]}, + ); + }), + ); + objectList = []; + } + } + }, }, }); diff --git a/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue b/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue index b8cdc6573..5956e958e 100644 --- a/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue +++ b/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue @@ -53,10 +53,7 @@ export default { this.$route.params.container, to_remove, ).then(() => { - this.$store.commit({ - type: "updateObjects", - route: this.$route, - }); + this.$store.dispatch("updateObjects", this.$route); }); }, }, diff --git a/swift_browser_ui_frontend/src/components/ObjectTable.vue b/swift_browser_ui_frontend/src/components/ObjectTable.vue index 58f23aa12..78b54af22 100644 --- a/swift_browser_ui_frontend/src/components/ObjectTable.vue +++ b/swift_browser_ui_frontend/src/components/ObjectTable.vue @@ -42,6 +42,11 @@ {{ $t('message.renderFolders') }} +
+ + {{ $t('message.table.showTags') }} + +
{{ props.row.name | truncate(100) }} + + + {{ tag }} + +

+

+ + {{ $t('message.edit') }} + +

@@ -317,9 +352,11 @@ export default { data: function () { return { oList: [], + tags: {}, selected: undefined, isPaginated: true, renderFolders: false, + showTags: true, perPage: 15, defaultSortDirection: "asc", searchQuery: "", @@ -334,9 +371,15 @@ export default { queryPage () { return this.$route.query.page || 1; }, + container () { + return this.$route.params.container; + }, objects () { return this.$store.state.objectCache; }, + objectTags() { + return this.$store.state.objectTagsCache; + }, }, watch: { searchQuery: function () { @@ -358,6 +401,9 @@ export default { } this.checkedRows = []; }, + objectTags: function () { + this.tags = this.objectTags; // {"objectName": ["tag1", "tag2"]} + }, prefix: function () { if (this.renderFolders) { this.oList = this.getFolderContents(); @@ -382,10 +428,7 @@ export default { methods: { updateObjects: function () { // Update current object listing in Vuex if length is too little - this.$store.commit({ - type: "updateObjects", - route: this.$route, - }); + this.$store.dispatch("updateObjects", this.$route); }, isRowCheckable: function (row) { return this.renderFolders ? this.isFile(row.name) : true; diff --git a/swift_browser_ui_frontend/src/entries/main.js b/swift_browser_ui_frontend/src/entries/main.js index 48a49c46a..8557688c9 100644 --- a/swift_browser_ui_frontend/src/entries/main.js +++ b/swift_browser_ui_frontend/src/entries/main.js @@ -170,10 +170,7 @@ new Vue({ type: "is-success", }); if (this.$route.params.container != undefined) { - this.$store.commit({ - type: "updateObjects", - route: this.$route, - }); + this.$store.dispatch("updateObjects", this.$route); } }, fileFailureToast: function (file) { diff --git a/swift_browser_ui_frontend/src/views/AddContainer.vue b/swift_browser_ui_frontend/src/views/AddContainer.vue index e54996195..e16cc1f66 100644 --- a/swift_browser_ui_frontend/src/views/AddContainer.vue +++ b/swift_browser_ui_frontend/src/views/AddContainer.vue @@ -12,8 +12,8 @@ +
+

+ {{ $t('message.objects.editObject') + object }} +

+ + + + + + + + +

+ + {{ $t('message.save') }} + +

+
+
+ + + + + diff --git a/tests/common/mockups.py b/tests/common/mockups.py index 966d32428..d405eef3f 100644 --- a/tests/common/mockups.py +++ b/tests/common/mockups.py @@ -361,18 +361,13 @@ def stat(self, *args): def ret_obj_stat(self, args): """Return object stats from stat query.""" ret = [] - for i in args[1]: - to_add = {} - to_add["headers"] = {} - if "Obj_example" in self.obj_meta[args[0]][i].keys(): - to_add["headers"]["x-object-meta-obj-example"] = "example" - if "Obj_S3_example" in self.obj_meta[args[0]][i].keys(): - to_add["headers"]["x-object-meta-s3cmd-attrs"] = self.obj_meta[args[0]][ - i - ]["Obj_S3_example"] - to_add["success"] = True + for obj in args[1]: + to_add = { + "headers": self.obj_meta[args[0]][obj], + "success": True, + "object": obj, + } to_add["headers"]["content-type"] = "binary/octet-stream" - to_add["object"] = i ret.append(to_add) return ret @@ -386,14 +381,26 @@ def ret_cont_stat(self, args): ret["success"] = True return ret - def post(self, container=None, options=None): + def post(self, container=None, objects=None, options=None): """Mock the post call of SwiftService.""" - if container: - for (meta, value) in options.get("meta", []): + + def update_meta(target, headers, meta): + for (meta, value) in meta: if not value: - del self.cont_meta[container][f"x-container-meta-{meta}"] + del headers[f"x-{target}-meta-{meta}"] continue - self.cont_meta[container][f"x-container-meta-{meta}"] = value + headers[f"x-{target}-meta-{meta}"] = value + + if container and objects: + for obj in objects: + update_meta( + "object", + self.obj_meta[container][obj.object_name], + obj.options.get("meta", []), + ) + return [{"success": True} for _ in objects] + elif container: + update_meta("container", self.cont_meta[container], options.get("meta", [])) else: # Get the URL key 2 key = options["meta"][0].split(":")[1] @@ -412,14 +419,16 @@ def set_swift_meta_container(self, container): def set_swift_meta_object(self, container, obj): """Generate test swift metadata for an object.""" - self.obj_meta[container][obj] = {} - self.obj_meta[container][obj]["Obj_example"] = "example" + self.obj_meta[container][obj] = { + "x-object-meta-usertags": "objects;with;tags", + "x-object-meta-obj-example": "example", + } def set_s3_meta_object(self, container, obj): """Generate test s3 metadata for an object.""" self.obj_meta[container][obj] = {} self.obj_meta[container][obj][ - "Obj_S3_example" + "x-object-meta-s3cmd-attrs" ] = "atime:1536648772/ctime:1536648921/gid:101/gname:example" diff --git a/tests/cypress/integration/browser.spec.js b/tests/cypress/integration/browser.spec.js index 702a62578..887c88560 100644 --- a/tests/cypress/integration/browser.spec.js +++ b/tests/cypress/integration/browser.spec.js @@ -38,7 +38,7 @@ describe("Browse buckets and test operations", function () { .within(() => { cy.get('td').eq(0).click() cy.get('td').eq(1).then(($elem) => { - expect($elem.get(0).innerText.trim()).to.have.lengthOf(40) + expect($elem.get(0).innerText.split('\n')[0].trim()).to.have.lengthOf(40) }) }) }) @@ -64,19 +64,19 @@ describe("Browse buckets and test operations", function () { }) - it("should display, add, remove tags", () => { + it("should display, add, remove container tags", () => { // container list loads with tags cy.get('tbody .tags .tag').should('have.length', 40) cy.get('tbody tr .tags').first().children('.tag').should('have.length', 4) - // // remove one tag + // remove one tag cy.get('tbody tr').contains('Edit').click() cy.get('h1').should('contain', 'Editing bucket') cy.get('.delete').first().click() cy.get('button').contains('Save').click() cy.get('tbody tr .tags').first().children('.tag').should('have.length', 3) - // // add few tags + // add few tags cy.get('tbody tr').contains('Edit').click() cy.get('.taginput input').type('adding.couple more') cy.get('button').contains('Save').click() @@ -87,10 +87,39 @@ describe("Browse buckets and test operations", function () { cy.get('.taginput-container').children('span').should('have.length', 6) cy.get('.delete').each(el => { cy.get('.delete').first().click() - cy.wait(100) }); cy.get('.taginput-container').children('span').should('have.length', 0) cy.get('button').contains('Save').click() cy.get('tbody .tags .tag').should('have.length', 36) }) + + it("should display, add, remove object tags", () => { + cy.get('tbody tr').first().dblclick() + + // object list loads with tags + cy.get('tbody tr .tags').first().children('.tag').should('have.length', 3) + + // remove one tag + cy.get('tbody tr').contains('Edit').click() + cy.get('h1').should('contain', 'Editing object') + cy.get('.delete').first().click() + cy.get('button').contains('Save').click() + cy.get('tbody tr .tags').first().children('.tag').should('have.length', 2) + + // add few tags + cy.get('tbody tr').contains('Edit').click() + cy.get('.taginput input').type('adding.couple more') + cy.get('button').contains('Save').click() + cy.get('tbody tr .tags').first().children('.tag').should('have.length', 5) + + // remove all tags from an object + cy.get('tbody tr').contains('Edit').click() + cy.get('.taginput-container').children('span').should('have.length', 5) + cy.get('.delete').each(el => { + cy.get('.delete').first().click() + }); + cy.get('.taginput-container').children('span').should('have.length', 0) + cy.get('button').contains('Save').click() + cy.get('tbody tr .tags').first().children('.tag').should('have.length', 0) + }) }) diff --git a/tests/ui_unit/test_api.py b/tests/ui_unit/test_api.py index 179be07ec..f391c812f 100644 --- a/tests/ui_unit/test_api.py +++ b/tests/ui_unit/test_api.py @@ -16,6 +16,7 @@ get_os_user, os_list_projects, update_metadata_bucket, + update_metadata_object, ) from swift_browser_ui.ui.api import swift_list_buckets, swift_list_objects from swift_browser_ui.ui.api import swift_download_object @@ -298,7 +299,49 @@ async def test_get_object_meta_swift(self): resp = await get_metadata_object(self.request) resp = json.loads(resp.text) - expected = [[objkey, {"obj-example": "example"}]] + expected = [[objkey, {"obj-example": "example", "usertags": "objects;with;tags"}]] + self.assertEqual(resp, expected) + + async def test_set_object_meta_swift(self): + """Test metadata API endpoint with container metadata.""" + req_sessions = self.request.app["Sessions"] + req_sessions[self.cookie]["ST_conn"].init_with_data( + containers=1, + object_range=(1, 1), + size_range=(252144, 252144), + ) + container = "test-container-0" + req_sessions[self.cookie]["ST_conn"].set_swift_meta_container(container) + for obj in req_sessions[self.cookie]["ST_conn"].obj_meta.keys(): + req_sessions[self.cookie]["ST_conn"].set_swift_meta_object(container, obj) + + obj = list(req_sessions[self.cookie]["ST_conn"].obj_meta[container])[0] + self.request.query["container"] = container + self.request.set_post( + json.dumps( + [ + [ + obj, + {"usertags": "tags;for;testing"}, + ] + ] + ) + ) + + post_resp = await update_metadata_object(self.request) + self.assertEqual(post_resp.status, 204) + + self.request.set_post(None) + self.request.query["object"] = obj + resp = await get_metadata_object(self.request) + resp = json.loads(resp.text) + + expected = [ + [ + obj, + {"obj-example": "example", "usertags": "tags;for;testing"}, + ] + ] self.assertEqual(resp, expected) async def test_get_object_meta_s3(self): @@ -372,7 +415,10 @@ async def test_get_object_meta_swift_whole(self): resp = await get_metadata_object(self.request) resp = json.loads(resp.text) - comp = [[i, {"obj-example": "example"}] for i in [j["name"] for j in objs]] + comp = [ + [i, {"usertags": "objects;with;tags", "obj-example": "example"}] + for i in [j["name"] for j in objs] + ] self.assertEqual(resp, comp)