mirror of https://github.com/rancher/dashboard.git
580 lines
14 KiB
Vue
580 lines
14 KiB
Vue
<script>
|
|
import { computed, ref, toRef, watch } from 'vue';
|
|
import { mapActions, useStore } from 'vuex';
|
|
|
|
import { get, set } from '@shell/utils/object';
|
|
import { sortBy } from '@shell/utils/sort';
|
|
import { NAMESPACE } from '@shell/config/types';
|
|
import { DESCRIPTION } from '@shell/config/labels-annotations';
|
|
import { _VIEW, _EDIT, _CREATE } from '@shell/config/query-params';
|
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
|
import { normalizeName } from '@shell/utils/kube';
|
|
import { useI18n } from '@shell/composables/useI18n';
|
|
|
|
export default {
|
|
name: 'NameNsDescription',
|
|
|
|
emits: ['update:value', 'isNamespaceNew'],
|
|
|
|
components: {
|
|
LabeledInput,
|
|
LabeledSelect,
|
|
},
|
|
|
|
props: {
|
|
value: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
mode: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
nameHidden: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
nameNsHidden: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
descriptionHidden: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
extraColumns: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
nameLabel: {
|
|
type: String,
|
|
default: 'nameNsDescription.name.label',
|
|
},
|
|
nameEditable: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
namePlaceholder: {
|
|
type: String,
|
|
default: 'nameNsDescription.name.placeholder',
|
|
},
|
|
nameDisabled: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
nameRequired: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
nameNormalized: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
namespaced: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
namespaceFilter: { type: Function, default: null },
|
|
namespaceMapper: { type: Function, default: null },
|
|
namespaceType: {
|
|
type: String,
|
|
default: NAMESPACE,
|
|
},
|
|
namespaceLabel: {
|
|
type: String,
|
|
default: 'nameNsDescription.namespace.label',
|
|
},
|
|
namespacePlaceholder: {
|
|
type: String,
|
|
default: 'nameNsDescription.namespace.placeholder',
|
|
},
|
|
namespaceDisabled: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
namespaceNewAllowed: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
noDefaultNamespace: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/**
|
|
* Use these objects instead of namespaces
|
|
*/
|
|
namespacesOverride: {
|
|
type: Array,
|
|
default: null,
|
|
},
|
|
/**
|
|
* User these namespaces instead of determining list within component
|
|
*/
|
|
namespaceOptions: {
|
|
type: Array,
|
|
default: null,
|
|
},
|
|
createNamespaceOverride: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
descriptionLabel: {
|
|
type: String,
|
|
default: 'nameNsDescription.description.label',
|
|
},
|
|
descriptionPlaceholder: {
|
|
type: String,
|
|
default: 'nameNsDescription.description.placeholder',
|
|
},
|
|
descriptionDisabled: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
// Use specific fields on the value instead of the normal metadata locations
|
|
nameKey: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
namespaceKey: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
descriptionKey: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
forceNamespace: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
showSpacer: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
horizontal: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
rules: {
|
|
default: () => ({
|
|
namespace: [],
|
|
name: [],
|
|
description: []
|
|
}),
|
|
type: Object,
|
|
},
|
|
|
|
/**
|
|
* Inherited global identifier prefix for tests
|
|
* Define a term based on the parent component to avoid conflicts on multiple components
|
|
*/
|
|
componentTestid: {
|
|
type: String,
|
|
default: 'name-ns-description'
|
|
}
|
|
},
|
|
|
|
data() {
|
|
return { createNamespace: false };
|
|
},
|
|
|
|
setup(props, { emit }) {
|
|
const v = toRef(props.value);
|
|
const metadata = v.value.metadata;
|
|
const namespace = ref(null);
|
|
const name = ref(null);
|
|
const description = ref(null);
|
|
|
|
watch(name, (val) => {
|
|
if (props.normalizeName) {
|
|
val = normalizeName(val);
|
|
}
|
|
|
|
if (props.nameKey) {
|
|
set(props.value, props.nameKey, val);
|
|
} else {
|
|
props.value.metadata['name'] = val;
|
|
}
|
|
emit('update:value', props.value);
|
|
});
|
|
|
|
if (props.nameKey) {
|
|
name.value = get(v.value, props.nameKey);
|
|
} else {
|
|
name.value = metadata?.name || '';
|
|
}
|
|
|
|
const isCreate = computed(() => {
|
|
return props.mode === _CREATE;
|
|
});
|
|
|
|
const store = useStore();
|
|
const { t } = useI18n(store);
|
|
const allowedNamespaces = computed(() => store.getters.allowedNamespaces());
|
|
const storeNamespaces = computed(() => store.getters.namespaces());
|
|
const currentCluster = computed(() => store.getters.currentCluster);
|
|
|
|
const inStore = computed(() => {
|
|
return store.getters['currentStore']();
|
|
});
|
|
|
|
const nsSchema = computed(() => {
|
|
return store.getters[`${ inStore.value }/schemaFor`](NAMESPACE);
|
|
});
|
|
|
|
const canCreateNamespace = computed(() => {
|
|
// Check if user can push to namespaces... and as the ns is outside of a project restrict to admins and cluster owners
|
|
return (nsSchema.value?.collectionMethods || []).includes('POST') && currentCluster.value?.canUpdate;
|
|
});
|
|
|
|
/**
|
|
* Map namespaces from the store to options, adding divider and create button
|
|
*/
|
|
const options = computed(() => {
|
|
let namespaces;
|
|
|
|
if (props.namespacesOverride) {
|
|
// Use the resources provided
|
|
namespaces = props.namespacesOverride;
|
|
} else {
|
|
if (props.namespaceOptions) {
|
|
// Use the namespaces provided
|
|
namespaces = (props.namespaceOptions.map((ns) => ns.name) || []).sort();
|
|
} else {
|
|
// Determine the namespaces
|
|
const namespaceObjs = isCreate.value ? allowedNamespaces.value : storeNamespaces.value;
|
|
|
|
namespaces = Object.keys(namespaceObjs);
|
|
}
|
|
}
|
|
|
|
const options = namespaces
|
|
.map((namespace) => ({ nameDisplay: namespace, id: namespace }))
|
|
.map(props.namespaceMapper || ((obj) => ({
|
|
label: obj.nameDisplay,
|
|
value: obj.id,
|
|
})));
|
|
|
|
const sortedByLabel = sortBy(options, 'label');
|
|
|
|
if (props.forceNamespace) {
|
|
sortedByLabel.unshift({
|
|
label: props.forceNamespace,
|
|
value: props.forceNamespace,
|
|
});
|
|
}
|
|
|
|
const createButton = {
|
|
label: t('namespace.createNamespace'),
|
|
value: '',
|
|
kind: 'highlighted'
|
|
};
|
|
const divider = {
|
|
label: 'divider',
|
|
disabled: true,
|
|
kind: 'divider'
|
|
};
|
|
|
|
const createOverhead = canCreateNamespace.value || props.createNamespaceOverride ? [createButton, divider] : [];
|
|
|
|
return [
|
|
...createOverhead,
|
|
...sortedByLabel
|
|
];
|
|
});
|
|
|
|
const updateNamespace = (val) => {
|
|
if (props.forceNamespace) {
|
|
val = props.forceNamespace;
|
|
}
|
|
|
|
if (props.namespaced) {
|
|
emit('isNamespaceNew', !val || (options.value && !options.value.find((n) => n.value === val)));
|
|
}
|
|
|
|
if (props.namespaceKey) {
|
|
set(props.value, props.namespaceKey, val);
|
|
} else {
|
|
props.value.metadata.namespace = val;
|
|
}
|
|
};
|
|
|
|
if (props.namespaced) {
|
|
if (props.forceNamespace) {
|
|
namespace.value = toRef(props.forceNamespace);
|
|
updateNamespace(namespace);
|
|
} else if (props.namespaceKey) {
|
|
namespace.value = get(v, props.namespaceKey);
|
|
} else {
|
|
namespace.value = metadata?.namespace;
|
|
}
|
|
|
|
if (!namespace.value && !props.noDefaultNamespace) {
|
|
namespace.value = store.getters['defaultNamespace'];
|
|
if (metadata) {
|
|
metadata.namespace = namespace;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (props.descriptionKey) {
|
|
description.value = get(v.value, props.descriptionKey);
|
|
} else {
|
|
description.value = metadata?.annotations?.[DESCRIPTION];
|
|
}
|
|
|
|
return {
|
|
namespace,
|
|
name,
|
|
description,
|
|
isCreate,
|
|
options,
|
|
updateNamespace,
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
...mapActions('cru-resource', ['setCreateNamespace']),
|
|
namespaceReallyDisabled() {
|
|
return (
|
|
!!this.forceNamespace || this.namespaceDisabled || this.mode === _EDIT
|
|
); // namespace is never editable
|
|
},
|
|
|
|
nameReallyDisabled() {
|
|
return this.nameDisabled || (this.mode === _EDIT && !this.nameEditable);
|
|
},
|
|
|
|
isView() {
|
|
return this.mode === _VIEW;
|
|
},
|
|
|
|
showCustomize() {
|
|
return this.mode === _CREATE && this.name && this.name.length > 0;
|
|
},
|
|
|
|
colSpan() {
|
|
if (!this.horizontal) {
|
|
return `span-8`;
|
|
}
|
|
// Name and namespace take up two columns.
|
|
let cols = (this.nameNsHidden ? 0 : 2) + (this.descriptionHidden ? 0 : 1) + this.extraColumns.length;
|
|
|
|
cols = Math.max(2, cols); // If there's only one column, make it render half-width as if there were two
|
|
const span = 12 / cols; // If there's 5, 7, or more columns this will break; don't do that.
|
|
|
|
return `span-${ span }`;
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
namespace(val) {
|
|
this.updateNamespace(val);
|
|
this.$emit('update:value', this.value);
|
|
},
|
|
|
|
description(val) {
|
|
if (this.descriptionKey) {
|
|
set(this.value, this.descriptionKey, val);
|
|
} else {
|
|
this.value.setAnnotation(DESCRIPTION, val);
|
|
}
|
|
this.$emit('update:value', this.value);
|
|
},
|
|
},
|
|
|
|
mounted() {
|
|
this.$nextTick(() => {
|
|
if (this.$refs.nameInput) {
|
|
this.$refs.nameInput.focus();
|
|
}
|
|
});
|
|
},
|
|
|
|
methods: {
|
|
changeNameAndNamespace(e) {
|
|
this.name = (e.text || '').toLowerCase();
|
|
this.namespace = e.selected;
|
|
},
|
|
|
|
cancelCreateNamespace(e) {
|
|
this.createNamespace = false;
|
|
this.$parent.$emit('createNamespace', false);
|
|
// In practice we should always have a defaultNamespace... unless we're in non-kube extension world, so fall back on options
|
|
this.namespace = this.$store.getters['defaultNamespace'] || this.options.find((o) => !!o.value)?.value;
|
|
},
|
|
|
|
selectNamespace(e) {
|
|
if (!e || e.value === '') { // The blank value in the dropdown is labeled "Create a New Namespace"
|
|
this.createNamespace = true;
|
|
this.$store.dispatch(
|
|
'cru-resource/setCreateNamespace',
|
|
true,
|
|
);
|
|
this.$emit('isNamespaceNew', true);
|
|
this.$nextTick(() => this.$refs.namespaceInput.focus());
|
|
} else {
|
|
this.createNamespace = false;
|
|
this.$store.dispatch(
|
|
'cru-resource/setCreateNamespace',
|
|
false,
|
|
);
|
|
this.$emit('isNamespaceNew', false);
|
|
}
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="row mb-20">
|
|
<slot name="project-selector" />
|
|
<div
|
|
v-if="namespaced && !nameNsHidden && createNamespace"
|
|
:data-testid="componentTestid + '-namespace-create'"
|
|
class="col span-3"
|
|
>
|
|
<LabeledInput
|
|
ref="namespaceInput"
|
|
v-model:value="namespace"
|
|
:label="t('namespace.label')"
|
|
:placeholder="t('namespace.createNamespace')"
|
|
:disabled="namespaceReallyDisabled"
|
|
:mode="mode"
|
|
:min-height="30"
|
|
:required="nameRequired"
|
|
:rules="rules.namespace"
|
|
/>
|
|
<button
|
|
:aria-label="t('namespace.cancelCreateAriaLabel')"
|
|
@click="cancelCreateNamespace"
|
|
>
|
|
<i
|
|
v-clean-tooltip="t('generic.cancel')"
|
|
class="icon icon-close align-value"
|
|
/>
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="namespaced && !nameNsHidden && !createNamespace"
|
|
:data-testid="componentTestid + '-namespace'"
|
|
class="col span-3"
|
|
>
|
|
<LabeledSelect
|
|
v-show="!createNamespace"
|
|
v-model:value="namespace"
|
|
:clearable="true"
|
|
:options="options"
|
|
:disabled="namespaceReallyDisabled"
|
|
:searchable="true"
|
|
:mode="mode"
|
|
:multiple="false"
|
|
:label="t('namespace.label')"
|
|
:placeholder="t('namespace.selectOrCreate')"
|
|
:rules="rules.namespace"
|
|
required
|
|
@selecting="selectNamespace"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="!nameHidden && !nameNsHidden"
|
|
:data-testid="componentTestid + '-name'"
|
|
class="col span-3"
|
|
>
|
|
<LabeledInput
|
|
ref="nameInput"
|
|
key="name"
|
|
v-model:value="name"
|
|
data-testid="NameNsDescriptionNameInput"
|
|
:label="t(nameLabel)"
|
|
:placeholder="t(namePlaceholder)"
|
|
:disabled="nameReallyDisabled"
|
|
:mode="mode"
|
|
:min-height="30"
|
|
:required="nameRequired"
|
|
:rules="rules.name"
|
|
/>
|
|
</div>
|
|
|
|
<slot name="customize" />
|
|
<div
|
|
v-show="!descriptionHidden"
|
|
:data-testid="componentTestid + '-description'"
|
|
:class="['col', extraColumns.length > 0 ? 'span-3' : 'span-6']"
|
|
>
|
|
<LabeledInput
|
|
key="description"
|
|
v-model:value="description"
|
|
:mode="mode"
|
|
:disabled="descriptionDisabled"
|
|
:label="t(descriptionLabel)"
|
|
:placeholder="t(descriptionPlaceholder)"
|
|
:min-height="30"
|
|
:rules="rules.description"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-for="(slot, i) in extraColumns"
|
|
:key="i"
|
|
:class="{ col: true, [colSpan]: true }"
|
|
>
|
|
<slot :name="slot" />
|
|
</div>
|
|
<div
|
|
v-if="showSpacer"
|
|
class="spacer"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
button {
|
|
all: unset;
|
|
height: 0;
|
|
position: relative;
|
|
top: -35px;
|
|
float: right;
|
|
margin-right: 7px;
|
|
|
|
cursor: pointer;
|
|
|
|
.align-value {
|
|
padding-top: 7px;
|
|
}
|
|
}
|
|
|
|
.row {
|
|
&.name-ns-description {
|
|
max-height: $input-height;
|
|
}
|
|
|
|
.namespace-select :deep() {
|
|
.labeled-select {
|
|
min-width: 40%;
|
|
|
|
.v-select.inline {
|
|
&.vs--single {
|
|
padding-bottom: 2px;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
&.flip-direction {
|
|
flex-direction: column;
|
|
|
|
&.name-ns-description {
|
|
max-height: initial;
|
|
}
|
|
|
|
&>div>* {
|
|
margin-bottom: 20px;
|
|
}
|
|
}
|
|
|
|
}
|
|
</style>
|