Skip to content

Commit 68be79f

Browse files
authored
Merge pull request #19841 from ahmedhamidawan/fix_directory_form_element_encoding
[24.2] Decode/encode FormDirectory paths to allow spaces (and other characters)
2 parents ff95e7e + c0762f7 commit 68be79f

File tree

3 files changed

+80
-29
lines changed

3 files changed

+80
-29
lines changed

client/src/components/Form/Elements/FormDirectory.test.js

+43-11
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,28 @@ jest.mock("app");
1414

1515
const { server, http } = useServerMock();
1616

17+
async function init(wrapper, data) {
18+
// the file dialog modal should exist
19+
const filesDialogComponent = wrapper.findComponent(FilesDialog);
20+
expect(filesDialogComponent.exists()).toBe(true);
21+
filesDialogComponent.vm.callback({ url: data.url });
22+
// HACK to avoid https://github.com/facebook/jest/issues/2549 (URL implementation is not the same as global node)
23+
wrapper.vm.pathChunks = data.pathChunks;
24+
await flushPromises();
25+
await validateLatestEmittedPath(wrapper, data.url);
26+
}
27+
28+
async function validateLatestEmittedPath(wrapper, expectedPath) {
29+
const latestEmitIndex = wrapper.emitted()["input"].length - 1;
30+
const latestPath = wrapper.emitted()["input"][latestEmitIndex][0];
31+
expect(latestPath).toBe(expectedPath);
32+
33+
// also manually change prop value to be able to test the value being displayed
34+
await wrapper.setProps({ value: latestPath });
35+
const fullPathDisplayed = wrapper.find("[data-description='directory full path']");
36+
expect(fullPathDisplayed.text()).toBe(`Directory Path: ${expectedPath}`);
37+
}
38+
1739
describe("DirectoryPathEditableBreadcrumb", () => {
1840
let wrapper;
1941
let spyOnUrlSet;
@@ -29,6 +51,11 @@ describe("DirectoryPathEditableBreadcrumb", () => {
2951
const validPath = "validpath";
3052
const invalidPath = "./];";
3153

54+
const testingDataWithSpecialChars = {
55+
url: "gxfiles://directory/sub directory/with%20percent",
56+
pathChunks: [{ pathChunk: "directory" }, { pathChunk: "sub%20directory" }, { pathChunk: "with%2520percent" }],
57+
};
58+
3259
const saveNewChunk = async (path) => {
3360
// enter a new path chunk
3461
const input = wrapper.find("#path-input-breadcrumb");
@@ -38,15 +65,6 @@ describe("DirectoryPathEditableBreadcrumb", () => {
3865
input.trigger("keyup.enter");
3966
return input;
4067
};
41-
const init = async () => {
42-
// the file dialog modal should exist
43-
const filesDialogComponent = wrapper.findComponent(FilesDialog);
44-
expect(filesDialogComponent.exists()).toBe(true);
45-
filesDialogComponent.vm.callback({ url: testingData.url });
46-
// HACK to avoid https://github.com/facebook/jest/issues/2549 (URL implementation is not the same as global node)
47-
wrapper.vm.pathChunks = testingData.pathChunks;
48-
await flushPromises();
49-
};
5068

5169
beforeEach(async () => {
5270
spyOnUrlSet = jest.spyOn(FormDirectory.methods, "setUrl");
@@ -67,13 +85,12 @@ describe("DirectoryPathEditableBreadcrumb", () => {
6785

6886
wrapper = mount(FormDirectory, {
6987
propsData: {
70-
callback: () => {},
88+
value: null,
7189
},
7290
localVue: localVue,
7391
pinia,
7492
});
7593
await flushPromises();
76-
await init();
7794
});
7895
afterEach(async () => {
7996
if (wrapper) {
@@ -83,6 +100,7 @@ describe("DirectoryPathEditableBreadcrumb", () => {
83100
});
84101

85102
it("should render Breadcrumb", async () => {
103+
await init(wrapper, testingData);
86104
// after initial folder is chosen, setUrl() should be called and modal disappear
87105
expect(spyOnUrlSet).toHaveBeenCalled();
88106
expect(wrapper.findComponent(FilesDialog).exists()).toBe(false);
@@ -107,6 +125,7 @@ describe("DirectoryPathEditableBreadcrumb", () => {
107125
});
108126

109127
it("should prevent invalid Paths", async () => {
128+
await init(wrapper, testingData);
110129
// enter a new path chunk
111130
const input = await saveNewChunk(invalidPath);
112131
await flushPromises();
@@ -117,6 +136,7 @@ describe("DirectoryPathEditableBreadcrumb", () => {
117136
});
118137

119138
it("should save and remove new Paths", async () => {
139+
await init(wrapper, testingData);
120140
// enter a new path chunk
121141
const input = await saveNewChunk(validPath);
122142

@@ -129,19 +149,31 @@ describe("DirectoryPathEditableBreadcrumb", () => {
129149
expect(wrapper.findAll("li.breadcrumb-item").length).toBe(testingData.expectedNumberOfPaths + 1);
130150
// find newly added chunk
131151
const addedChunk = wrapper.findAll("li.breadcrumb-item button").wrappers.find((e) => e.text() === validPath);
152+
153+
await validateLatestEmittedPath(wrapper, `${testingData.url}/${validPath}`);
154+
132155
// remove chunk from the path
133156
await addedChunk.trigger("click");
134157
await flushPromises();
135158
// number of elements should be the same again
136159
expect(wrapper.findAll("li.breadcrumb-item").length).toBe(testingData.expectedNumberOfPaths);
160+
161+
await validateLatestEmittedPath(wrapper, testingData.url);
137162
});
138163

139164
it("should update new path", async () => {
165+
await init(wrapper, testingData);
140166
// enter a new path chunk
141167
expect(spyOnUpdateURL).toHaveBeenCalled();
142168
await saveNewChunk(validPath);
143169
await flushPromises();
144170

145171
expect(spyOnUpdateURL).toHaveBeenCalled();
146172
});
173+
174+
it("should retain special characters in path", async () => {
175+
// the init function itself validates that the emits and path display
176+
// retain special characters in the url
177+
await init(wrapper, testingDataWithSpecialChars);
178+
});
147179
});

client/src/components/Form/Elements/FormDirectory.vue

+36-12
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
:require-writable="true"
1212
:is-open="isModalShown" />
1313
</div>
14-
<b-breadcrumb v-if="url">
14+
<b-breadcrumb v-if="url" class="mb-0">
1515
<b-breadcrumb-item title="Select another folder" class="align-items-center" @click="reset">
1616
<b-button class="pathname" variant="primary">
1717
<FontAwesomeIcon icon="folder-open" /> {{ url.protocol }}</b-button
@@ -22,8 +22,8 @@
2222
:key="index"
2323
class="existent-url-path align-items-center">
2424
<b-button class="regular-path-chunk" :disabled="!editable" variant="dark" @click="removePath(index)">
25-
{{ pathChunk }}</b-button
26-
>
25+
{{ decodeURIComponent(pathChunk) }}
26+
</b-button>
2727
</b-breadcrumb-item>
2828
<b-breadcrumb-item class="directory-input-field align-items-center">
2929
<b-input
@@ -38,6 +38,11 @@
3838
@keydown.8.capture="removeLastPath" />
3939
</b-breadcrumb-item>
4040
</b-breadcrumb>
41+
42+
<div v-if="value" class="px-2" data-description="directory full path">
43+
<span v-localize>Directory Path:</span>
44+
<code>{{ value }}</code>
45+
</div>
4146
</div>
4247
</template>
4348

@@ -46,8 +51,11 @@ import { library } from "@fortawesome/fontawesome-svg-core";
4651
import { faFolder, faFolderOpen } from "@fortawesome/free-solid-svg-icons";
4752
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
4853
import { FilesDialog } from "components/FilesDialog";
54+
import { Toast } from "composables/toast";
4955
import _l from "utils/localization";
5056
57+
import { errorMessageAsString } from "@/utils/simple-error";
58+
5159
library.add(faFolder, faFolderOpen);
5260
5361
const getDefaultValues = () => ({
@@ -62,6 +70,12 @@ export default {
6270
FontAwesomeIcon,
6371
FilesDialog,
6472
},
73+
props: {
74+
value: {
75+
type: String,
76+
default: null,
77+
},
78+
},
6579
data() {
6680
return { ...getDefaultValues(), modalKey: 0, selectText: _l("Select") };
6781
},
@@ -80,6 +94,7 @@ export default {
8094
methods: {
8195
removePath(index) {
8296
this.pathChunks = this.pathChunks.slice(0, index);
97+
this.updateURL();
8398
},
8499
reset() {
85100
const data = getDefaultValues();
@@ -102,15 +117,19 @@ export default {
102117
}
103118
},
104119
setUrl({ url }) {
105-
this.url = new URL(url);
106-
// split path and keep only valid entries
107-
this.pathChunks = this.url.href
108-
.split(/[/\\]/)
109-
.splice(2)
110-
.map((x) => ({ pathChunk: x, editable: false }));
120+
try {
121+
this.url = new URL(encodeURI(url));
122+
// split path and keep only valid entries
123+
this.pathChunks = this.url.href
124+
.split(/[/\\]/)
125+
.splice(2)
126+
.map((x) => ({ pathChunk: x, editable: false }));
111127
112-
if (url) {
113-
this.updateURL();
128+
if (url) {
129+
this.updateURL();
130+
}
131+
} catch (error) {
132+
Toast.error(errorMessageAsString(error), "Invalid directory path");
114133
}
115134
},
116135
addPath({ key }) {
@@ -125,7 +144,12 @@ export default {
125144
let url = undefined;
126145
if (!isReset) {
127146
// create an string of path chunks separated by `/`
128-
url = encodeURI(`${this.url.protocol}//${this.pathChunks.map(({ pathChunk }) => pathChunk).join("/")}`);
147+
url = encodeURI(
148+
`${this.url.protocol}//${this.pathChunks
149+
.map(({ pathChunk }) => decodeURIComponent(pathChunk))
150+
.join("/")}`
151+
);
152+
url = decodeURI(url);
129153
}
130154
this.$emit("input", url);
131155
},

client/src/components/Form/FormElement.vue

+1-6
Original file line numberDiff line numberDiff line change
@@ -297,12 +297,7 @@ function onAlert(value: string | undefined) {
297297
:options="attrs.options"
298298
:optional="attrs.optional"
299299
:multiple="attrs.multiple" />
300-
<FormDataUri
301-
v-else-if="isUriDataField"
302-
:id="id"
303-
v-model="currentValue"
304-
:value="attrs.value"
305-
:multiple="attrs.multiple" />
300+
<FormDataUri v-else-if="isUriDataField" :id="id" v-model="currentValue" :multiple="attrs.multiple" />
306301
<FormData
307302
v-else-if="['data', 'data_collection'].includes(props.type)"
308303
:id="id"

0 commit comments

Comments
 (0)