Skip to content

Commit da95b7e

Browse files
ymh6315431minghao.yang
andauthored
Feat(application_spaces): Enhance env editor with responsiveness and features. (#1236)
* Draft MR * Feat(application_spaces): Enhance env editor with responsiveness and features. --------- Co-authored-by: minghao.yang <[email protected]>
1 parent f87598e commit da95b7e

File tree

10 files changed

+406
-48
lines changed

10 files changed

+406
-48
lines changed
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

frontend/src/components/__tests__/application_spaces/ApplicationSpaceSettings.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const createWrapper = (props = {}) => {
5757
},
5858
global: {
5959
mocks: {
60-
$t: (key) => key
60+
$t: (key, params) => key === 'application_spaces.edit.updateSuccess' ? 'Success' : key
6161
},
6262
provide: {
6363
fetchRepoDetail: mockFetchRepoDetail
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
<template>
2+
<div class="flex justify-between items-center">
3+
<div
4+
class="text-sm text-gray-700"
5+
:class="hideTitle ? 'opacity-0' : ''"
6+
>
7+
{{ $t('application_spaces.env.title') }}
8+
</div>
9+
<el-tooltip
10+
v-if="envList.length >= 5"
11+
:content="$t('application_spaces.env.placeholder')"
12+
placement="top"
13+
>
14+
<div class="flex gap-1 btn btn-link-color btn-sm text-gray-400 cursor-not-allowed">
15+
<SvgIcon name="space-env-plus" />{{
16+
$t('application_spaces.env.addEnv')
17+
}}
18+
</div>
19+
</el-tooltip>
20+
21+
<div
22+
v-else
23+
class="flex gap-1 btn btn-link-color btn-sm"
24+
@click="openAdd"
25+
>
26+
<SvgIcon name="space-env-plus" />{{ $t('application_spaces.env.addEnv') }}
27+
</div>
28+
</div>
29+
30+
<div v-if="envList.length">
31+
<div
32+
v-for="(item, idx) in envList"
33+
:key="item.key"
34+
class="flex justify-between items-center px-6 py-2 mb-2 border border-gray-200 bg-gray-50 rounded-xl"
35+
>
36+
<div class="flex font-medium text-gray-700 text-sm truncate">
37+
<SvgIcon
38+
name="space-env-icon"
39+
class="mr-3"
40+
/>{{ item.key }}
41+
</div>
42+
<div class="flex space-x-2">
43+
<CsgButton
44+
:name="$t('application_spaces.env.edit')"
45+
class="btn btn-secondary-gray btn-sm"
46+
@click="openEdit(idx)"
47+
/>
48+
<CsgButton
49+
:name="$t('application_spaces.env.delete')"
50+
class="btn btn-secondary-gray btn-sm"
51+
@click="remove(idx)"
52+
/>
53+
</div>
54+
</div>
55+
</div>
56+
<div
57+
v-else
58+
class="py-4 border border-gray-200 border-dashed flex justify-center text-gray-600 text-sm font-light text-center rounded-lg"
59+
>
60+
<SvgIcon
61+
name="space-env-icon"
62+
class="mr-1"
63+
/>
64+
{{ $t('application_spaces.env.empty') }}
65+
</div>
66+
67+
<el-dialog
68+
v-model="dialogVisible"
69+
:width="dialogWidth"
70+
:close-on-click-modal="false"
71+
>
72+
<template #header="{ close }">
73+
<div class="flex justify-between">
74+
<img
75+
src="/images/collection_half_cirle.png"
76+
class="w-[50%] absolute top-0 left-0"
77+
/>
78+
</div>
79+
</template>
80+
<div class="w-full relative">
81+
<div class="text-lg font-medium text-gray-900">{{ dialogTitle }}</div>
82+
<div class="text-sm font-light text-gray-600 mb-5">
83+
{{ $t('application_spaces.env.placeholder2') }}
84+
</div>
85+
<el-form>
86+
<el-form-item
87+
:error="nameError"
88+
class="mb-9"
89+
>
90+
<div class="mb-1 text-sm text-gray-700">
91+
{{ $t('application_spaces.env.name') }}
92+
</div>
93+
<el-input
94+
v-model="form.key"
95+
@input="validateName"
96+
:placeholder="$t('application_spaces.env.name')"
97+
/>
98+
</el-form-item>
99+
100+
<el-form-item :error="valueError">
101+
<div class="mb-1 text-sm text-gray-700">
102+
{{ $t('application_spaces.env.value') }}
103+
</div>
104+
<el-input
105+
v-model="form.value"
106+
type="textarea"
107+
rows="3"
108+
@input="validateValue"
109+
:placeholder="$t('application_spaces.env.value')"
110+
/>
111+
</el-form-item>
112+
</el-form>
113+
</div>
114+
115+
<template #footer>
116+
<div class="flex w-full justify-between gap-3 px-5">
117+
<div class="w-1/2">
118+
<CsgButton
119+
:name="$t('application_spaces.env.cancel')"
120+
class="btn btn-secondary-gray btn-lg w-full"
121+
@click="dialogVisible = false"
122+
/>
123+
</div>
124+
<div class="w-1/2">
125+
<CsgButton
126+
:name="$t('application_spaces.env.save')"
127+
class="btn btn-primary btn-lg w-full"
128+
@click="save"
129+
/>
130+
</div>
131+
</div>
132+
</template>
133+
</el-dialog>
134+
</template>
135+
136+
<script setup>
137+
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
138+
import { useI18n } from 'vue-i18n'
139+
140+
const { t } = useI18n()
141+
142+
const props = defineProps({
143+
modelValue: String,
144+
hideTitle: {
145+
type: Boolean,
146+
default: false
147+
}
148+
})
149+
const emit = defineEmits(['update:modelValue'])
150+
151+
const envList = ref([])
152+
function fromString(str) {
153+
try {
154+
const obj = JSON.parse(str || '{}')
155+
envList.value = Object.entries(obj).map(([k, v]) => ({
156+
key: k,
157+
value: String(v)
158+
}))
159+
} catch {
160+
envList.value = []
161+
}
162+
}
163+
fromString(props.modelValue)
164+
watch(() => props.modelValue, fromString)
165+
166+
function syncToParent() {
167+
const obj = {}
168+
envList.value.forEach((i) => (obj[i.key] = i.value))
169+
emit('update:modelValue', JSON.stringify(obj))
170+
}
171+
172+
function remove(idx) {
173+
envList.value.splice(idx, 1)
174+
syncToParent()
175+
}
176+
177+
const isMobile = ref(window.innerWidth < 450)
178+
const dialogWidth = computed(() => (isMobile.value ? '95%' : '450'))
179+
const dialogVisible = ref(false)
180+
const dialogTitle = ref(t('application_spaces.env.addEnv'))
181+
const editing = ref(false)
182+
const editIndex = ref(-1)
183+
const form = ref({ key: '', value: '' })
184+
185+
const nameError = ref('')
186+
const valueError = ref('')
187+
const reservedNames = [
188+
'PATH',
189+
'HOME',
190+
'USER',
191+
'USERNAME',
192+
'PWD',
193+
'TMP',
194+
'TEMP',
195+
'HOSTNAME',
196+
'SHELL',
197+
'LOGNAME',
198+
'DISPLAY',
199+
'LANG',
200+
'TERM',
201+
'EDITOR',
202+
'NODE_ENV',
203+
'JAVA_HOME',
204+
'PYTHONPATH',
205+
'GOPATH',
206+
'HTTP_PROXY',
207+
'HTTPS_PROXY',
208+
'NO_PROXY',
209+
'CI',
210+
'ENV',
211+
'SHLVL',
212+
'PORT',
213+
'BUILD_ID',
214+
'BRANCH',
215+
'CLASSPATH'
216+
]
217+
218+
function validateName() {
219+
const key = form.value.key.trim()
220+
nameError.value = ''
221+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
222+
nameError.value = t('application_spaces.env.validatePlaceholder')
223+
return
224+
}
225+
if (reservedNames.includes(key.toUpperCase())) {
226+
nameError.value = t('application_spaces.env.validatePlaceholder2')
227+
return
228+
}
229+
const duplicated = envList.value.some(
230+
(item, idx) =>
231+
item.key === key && idx !== (editing.value ? editIndex.value : -1)
232+
)
233+
if (duplicated)
234+
nameError.value = t('application_spaces.env.validatePlaceholder3')
235+
}
236+
237+
function validateValue() {
238+
const val = form.value.value ?? ''
239+
valueError.value = /[\x00-\x1F\x7F]/.test(val)
240+
? t('application_spaces.env.validatePlaceholder4')
241+
: ''
242+
}
243+
244+
function openAdd() {
245+
dialogTitle.value = t('application_spaces.env.addEnv')
246+
editing.value = false
247+
form.value = { key: '', value: '' }
248+
nameError.value = ''
249+
valueError.value = ''
250+
dialogVisible.value = true
251+
}
252+
function openEdit(idx) {
253+
dialogTitle.value = t('application_spaces.env.editEnv')
254+
editing.value = true
255+
editIndex.value = idx
256+
form.value = { ...envList.value[idx] }
257+
nameError.value = ''
258+
valueError.value = ''
259+
dialogVisible.value = true
260+
}
261+
262+
function save() {
263+
validateName()
264+
validateValue()
265+
if (nameError.value || valueError.value) return
266+
267+
if (editing.value) envList.value[editIndex.value] = { ...form.value }
268+
else envList.value.push({ ...form.value })
269+
270+
dialogVisible.value = false
271+
syncToParent()
272+
}
273+
function onResize() {
274+
isMobile.value = window.innerWidth < 450
275+
}
276+
277+
onMounted(() => window.addEventListener('resize', onResize))
278+
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
279+
</script>
280+
281+
<style scoped>
282+
.truncate {
283+
white-space: nowrap;
284+
overflow: hidden;
285+
text-overflow: ellipsis;
286+
}
287+
</style>

0 commit comments

Comments
 (0)