refactor(app): placement form section [EE-6386] (#10818)

Co-authored-by: testa113 <testa113>
pull/10908/head
Ali 11 months ago committed by GitHub
parent 2d77e71085
commit 9fc7187e24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -325,7 +325,7 @@ class KubernetesApplicationConverter {
}
if (app.Pods && app.Pods.length) {
KubernetesApplicationHelper.generatePlacementsFormValuesFromAffinity(res, app.Pods[0].Affinity, nodesLabels);
KubernetesApplicationHelper.generatePlacementsFormValuesFromAffinity(res, app.Pods[0].Affinity);
}
return res;

@ -22,7 +22,7 @@ import {
KubernetesApplicationVolumeSecretPayload,
} from 'Kubernetes/models/application/payloads';
import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationPlacementTypes, KubernetesApplicationTypes, HelmApplication } from 'Kubernetes/models/application/models';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes, HelmApplication } from 'Kubernetes/models/application/models';
import { KubernetesPodAffinity, KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models';
import {
KubernetesNodeSelectorRequirementPayload,
@ -429,31 +429,29 @@ class KubernetesApplicationHelper {
/* #endregion */
/* #region PLACEMENTS FV <> AFFINITY */
static generatePlacementsFormValuesFromAffinity(formValues, podAffinity, nodesLabels) {
static generatePlacementsFormValuesFromAffinity(formValues, podAffinity) {
let placements = formValues.Placements;
let type = formValues.PlacementType;
const affinity = podAffinity.nodeAffinity;
if (affinity && affinity.requiredDuringSchedulingIgnoredDuringExecution) {
type = KubernetesApplicationPlacementTypes.MANDATORY;
type = 'mandatory';
_.forEach(affinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms, (term) => {
_.forEach(term.matchExpressions, (exp) => {
const placement = new KubernetesApplicationPlacementFormValue();
const label = _.find(nodesLabels, { Key: exp.key });
placement.Label = label;
placement.Value = exp.values[0];
placement.IsNew = false;
placement.label = exp.key;
placement.value = exp.values[0];
placement.isNew = false;
placements.push(placement);
});
});
} else if (affinity && affinity.preferredDuringSchedulingIgnoredDuringExecution) {
type = KubernetesApplicationPlacementTypes.PREFERRED;
type = 'preferred';
_.forEach(affinity.preferredDuringSchedulingIgnoredDuringExecution, (term) => {
_.forEach(term.preference.matchExpressions, (exp) => {
const placement = new KubernetesApplicationPlacementFormValue();
const label = _.find(nodesLabels, { Key: exp.key });
placement.Label = label;
placement.Value = exp.values[0];
placement.IsNew = false;
placement.label = exp.key;
placement.value = exp.values[0];
placement.isNew = false;
placements.push(placement);
});
});
@ -467,12 +465,12 @@ class KubernetesApplicationHelper {
const placements = formValues.Placements;
const res = new KubernetesPodNodeAffinityPayload();
let expressions = _.map(placements, (p) => {
if (!p.NeedsDeletion) {
if (!p.needsDeletion) {
const exp = new KubernetesNodeSelectorRequirementPayload();
exp.key = p.Label.Key;
if (p.Value) {
exp.key = p.label;
if (p.value) {
exp.operator = KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN;
exp.values = [p.Value];
exp.values = [p.value];
} else {
exp.operator = KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS;
delete exp.values;
@ -482,12 +480,12 @@ class KubernetesApplicationHelper {
});
expressions = _.without(expressions, undefined);
if (expressions.length) {
if (formValues.PlacementType === KubernetesApplicationPlacementTypes.MANDATORY) {
if (formValues.PlacementType === 'mandatory') {
const term = new KubernetesNodeSelectorTermPayload();
term.matchExpressions = expressions;
res.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms.push(term);
delete res.preferredDuringSchedulingIgnoredDuringExecution;
} else if (formValues.PlacementType === KubernetesApplicationPlacementTypes.PREFERRED) {
} else if (formValues.PlacementType === 'preferred') {
const term = new KubernetesPreferredSchedulingTermPayload();
term.preference = new KubernetesNodeSelectorTermPayload();
term.preference.matchExpressions = expressions;

@ -1,5 +1,5 @@
import { PorImageRegistryModel } from '@/docker/models/porImageRegistry';
import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes, KubernetesApplicationPlacementTypes } from './models';
import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes } from './models';
/**
* KubernetesApplicationFormValues Model
@ -25,7 +25,7 @@ export function KubernetesApplicationFormValues() {
this.ConfigMaps = [];
this.Secrets = [];
this.PublishedPorts = []; // KubernetesApplicationPublishedPortFormValue lis;
this.PlacementType = KubernetesApplicationPlacementTypes.PREFERRED;
this.PlacementType = 'preferred';
this.Placements = []; // KubernetesApplicationPlacementFormValue lis;
this.OriginalIngresses = undefined;
}
@ -119,10 +119,10 @@ export function KubernetesApplicationPublishedPortFormValue() {
export function KubernetesApplicationPlacementFormValue() {
return {
Label: {},
Value: '',
NeedsDeletion: false,
IsNew: true,
label: {},
value: '',
needsDeletion: false,
isNew: true,
};
}

@ -30,11 +30,6 @@ export const KubernetesApplicationPublishingTypes = Object.freeze({
LOAD_BALANCER: 3,
});
export const KubernetesApplicationPlacementTypes = Object.freeze({
PREFERRED: 1,
MANDATORY: 2,
});
export const KubernetesApplicationQuotaDefaults = {
CpuLimit: 0.1,
MemoryLimit: 64, // MB

@ -18,6 +18,10 @@ import {
ApplicationEventsDatatable,
} from '@/react/kubernetes/applications/DetailsView';
import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable';
import {
PlacementFormSection,
placementValidation,
} from '@/react/kubernetes/applications/components/PlacementFormSection';
import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
@ -267,3 +271,11 @@ withFormValidation(
['isMetricsEnabled'],
autoScalingValidation
);
withFormValidation(
ngModule,
withUIRouter(withCurrentUser(withReactQuery(PlacementFormSection))),
'placementFormSection',
[],
placementValidation
);

@ -475,105 +475,16 @@
></auto-scaling-form-section>
</div>
<!-- #endregion -->
<div class="mt-4 mb-2" ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.REPLICATED">
<div class="col-sm-12 control-label !mb-2 !p-0 text-left"> Placement preferences and constraints </div>
<!-- #region PLACEMENTS -->
<div class="form-group">
<div class="col-sm-12 small text-muted vertical-center !mb-2" ng-if="ctrl.formValues.Placements.length > 0">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<div> Deploy this application on nodes that respect <b>ALL</b> of the following placement rules. Placement rules are based on node labels. </div>
</div>
<div class="col-sm-12 form-inline">
<div ng-repeat-start="placement in ctrl.formValues.Placements" class="!mb-2">
<div class="col-sm-5 input-group mr-2" ng-class="{ striked: placement.NeedsDeletion }">
<select
class="form-control !rounded"
ng-model="placement.Label"
ng-options="label as (label.Key | kubernetesNodeLabelHumanReadbleText) for label in ctrl.nodesLabels"
ng-change="ctrl.onChangePlacementLabel($index)"
ng-disabled="ctrl.isEditAndNotNewPlacement($index)"
data-cy="k8sAppCreate-placementLabel_{{ $index }}"
>
</select>
</div>
<div class="col-sm-5 input-group mr-2" ng-class="{ striked: placement.NeedsDeletion }">
<select
class="form-control !rounded"
ng-model="placement.Value"
ng-options="value for value in placement.Label.Values"
ng-disabled="ctrl.isEditAndNotNewPlacement($index)"
data-cy="k8sAppCreate-placementName_{{ $index }}"
>
</select>
</div>
<div class="col-sm-1 input-group">
<button
ng-if="!placement.NeedsDeletion"
class="btn btn-md btn-dangerlight btn-only-icon !ml-0"
type="button"
ng-click="ctrl.removePlacement($index)"
data-cy="k8sAppCreate-deletePlacementButton"
>
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
<button
ng-if="placement.NeedsDeletion"
class="btn btn-sm btn-light btn-only-icon !ml-0"
type="button"
ng-click="ctrl.restorePlacement($index)"
data-cy="k8sAppCreate-restorePlacementButton"
>
<pr-icon icon="'rotate-cw'" size="'md'"></pr-icon>
</button>
</div>
</div>
<div ng-repeat-end ng-show="ctrl.state.duplicates.placements.refs[$index] !== undefined">
<div class="col-sm-5 input-group">
<div class="small text-warning" ng-if="ctrl.state.duplicates.placements.refs[$index] !== undefined">
<p class="vertical-center" ng-if="ctrl.state.duplicates.placements.refs[$index] !== undefined">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> This label is already defined.
</p>
</div>
</div>
</div>
</div>
<div class="col-sm-12">
<span class="btn btn-primary btn-sm btn btn-sm btn-light mb-2 !ml-0 mt-2" ng-click="ctrl.addPlacement()">
<pr-icon icon="'plus'" size="'sm'"></pr-icon> Add rule
</span>
</div>
</div>
<div ng-if="ctrl.showPlacementPolicySection()">
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Placement policy</label>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 small text-muted"> Specify the policy associated to the placement rules. </div>
</div>
<box-selector
ng-if="ctrl.formValues.Placements.length"
options="ctrl.placementOptions"
slim="true"
value="ctrl.formValues.PlacementType"
on-change="(ctrl.onChangePlacementType)"
radio-name="'placementType'"
></box-selector>
</div>
</div>
<!-- #endregion -->
</div>
<placement-form-section
ng-if="ctrl.formValues.DeploymentType === ctrl.ApplicationDeploymentTypes.REPLICATED"
values="{placements: ctrl.formValues.Placements, placementType: ctrl.formValues.PlacementType}"
on-change="(ctrl.onChangePlacements)"
></placement-form-section>
<!-- kubernetes services options -->
<div ng-if="ctrl.formValues.ResourcePool">
<div class="mb-8" ng-if="ctrl.formValues.ResourcePool">
<kube-services-form
on-change="(ctrl.onServicesChange)"
values="ctrl.formValues.Services"

@ -13,14 +13,12 @@ import {
KubernetesApplicationPublishingTypes,
KubernetesApplicationQuotaDefaults,
KubernetesApplicationTypes,
KubernetesApplicationPlacementTypes,
KubernetesDeploymentTypes,
} from 'Kubernetes/models/application/models';
import {
KubernetesApplicationEnvironmentVariableFormValue,
KubernetesApplicationFormValues,
KubernetesApplicationPersistedFolderFormValue,
KubernetesApplicationPlacementFormValue,
KubernetesFormValidationReferences,
} from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
@ -36,7 +34,6 @@ import { confirmUpdateAppIngress } from '@/react/kubernetes/applications/CreateV
import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
import { placementOptions } from '@/react/kubernetes/applications/CreateView/placementTypes';
class KubernetesCreateApplicationController {
/* #region CONSTRUCTOR */
@ -80,13 +77,10 @@ class KubernetesCreateApplicationController {
this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
this.ApplicationPublishingTypes = KubernetesApplicationPublishingTypes;
this.ApplicationPlacementTypes = KubernetesApplicationPlacementTypes;
this.ApplicationTypes = KubernetesApplicationTypes;
this.ServiceTypes = KubernetesServiceTypes;
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
this.placementOptions = placementOptions;
this.state = {
appType: this.KubernetesDeploymentTypes.APPLICATION_FORM,
updateWebEditorInProgress: false,
@ -148,7 +142,6 @@ class KubernetesCreateApplicationController {
this.onDataAccessPolicyChange = this.onDataAccessPolicyChange.bind(this);
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
this.supportGlobalDeployment = this.supportGlobalDeployment.bind(this);
this.onChangePlacementType = this.onChangePlacementType.bind(this);
this.onServicesChange = this.onServicesChange.bind(this);
this.onEnvironmentVariableChange = this.onEnvironmentVariableChange.bind(this);
this.onConfigMapsChange = this.onConfigMapsChange.bind(this);
@ -157,12 +150,14 @@ class KubernetesCreateApplicationController {
this.onChangeResourceReservation = this.onChangeResourceReservation.bind(this);
this.onChangeReplicaCount = this.onChangeReplicaCount.bind(this);
this.onAutoScaleChange = this.onAutoScaleChange.bind(this);
this.onChangePlacements = this.onChangePlacements.bind(this);
}
/* #endregion */
onChangePlacementType(value) {
this.$scope.$evalAsync(() => {
this.formValues.PlacementType = value;
onChangePlacements(values) {
return this.$async(async () => {
this.formValues.Placements = values.placements;
this.formValues.PlacementType = values.placementType;
});
}
@ -410,50 +405,6 @@ class KubernetesCreateApplicationController {
}
/* #endregion */
/* #region PLACEMENT UI MANAGEMENT */
addPlacement() {
const placement = new KubernetesApplicationPlacementFormValue();
const label = this.nodesLabels[0];
placement.Label = label;
placement.Value = label.Values[0];
this.formValues.Placements.push(placement);
this.onChangePlacement();
}
restorePlacement(index) {
this.formValues.Placements[index].NeedsDeletion = false;
this.onChangePlacement();
}
removePlacement(index) {
if (this.state.isEdit && !this.formValues.Placements[index].IsNew) {
this.formValues.Placements[index].NeedsDeletion = true;
} else {
this.formValues.Placements.splice(index, 1);
}
this.onChangePlacement();
}
// call all validation functions when a placement is added/removed/restored
onChangePlacement() {
this.onChangePlacementLabelValidate();
}
onChangePlacementLabel(index) {
this.formValues.Placements[index].Value = this.formValues.Placements[index].Label.Values[0];
this.onChangePlacementLabelValidate();
}
onChangePlacementLabelValidate() {
const state = this.state.duplicates.placements;
const source = _.map(this.formValues.Placements, (p) => (p.NeedsDeletion ? undefined : p.Label.Key));
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
}
/* #endregion */
/* #region SERVICES UI MANAGEMENT */
onServicesChange(services) {
return this.$async(async () => {
@ -675,15 +626,6 @@ class KubernetesCreateApplicationController {
}
/* #endregion */
isEditAndNotNewPlacement(index) {
return this.state.isEdit && !this.formValues.Placements[index].IsNew;
}
showPlacementPolicySection() {
const placements = _.filter(this.formValues.Placements, { NeedsDeletion: false });
return placements.length !== 0;
}
isNonScalable() {
const scalable = this.supportScalableReplicaDeployment();
const global = this.supportGlobalDeployment();

@ -188,6 +188,7 @@ export function InputList<T = DefaultType>({
initialItemsCount={initialItemsCount.current}
handleRemoveItem={handleRemoveItem}
handleToggleNeedsDeletion={handleToggleNeedsDeletion}
dataCy={`${deleteButtonDataCy}_${index}`}
/>
)}
</div>
@ -325,6 +326,7 @@ type CanUndoDeleteButtonProps<T> = {
initialItemsCount: number;
handleRemoveItem(key: Key, item: T): void;
handleToggleNeedsDeletion(key: Key, item: T): void;
dataCy: string;
};
function CanUndoDeleteButton<T>({
@ -333,6 +335,7 @@ function CanUndoDeleteButton<T>({
initialItemsCount,
handleRemoveItem,
handleToggleNeedsDeletion,
dataCy,
}: CanUndoDeleteButtonProps<T>) {
return (
<div className="items-start">
@ -343,6 +346,7 @@ function CanUndoDeleteButton<T>({
onClick={handleDeleteClick}
className="vertical-center btn-only-icon"
icon={Trash2}
data-cy={`${dataCy}_delete`}
/>
)}
{item.needsDeletion && (
@ -352,6 +356,7 @@ function CanUndoDeleteButton<T>({
onClick={handleDeleteClick}
className="vertical-center btn-only-icon"
icon={RotateCw}
data-cy={`${dataCy}_undo_delete`}
/>
)}
</div>

@ -57,7 +57,7 @@ export function Select<
isCreatable = false,
size = 'md',
...props
}: Props<Option, IsMulti, Group>) {
}: Props<Option, IsMulti, Group> & AutomationTestingProps) {
const Component = isCreatable ? ReactSelectCreatable : ReactSelect;
return (

@ -1,30 +0,0 @@
import { AlignJustify, Sliders } from 'lucide-react';
import { KubernetesApplicationPlacementTypes } from '@/kubernetes/models/application/models';
import { BoxSelectorOption } from '@@/BoxSelector';
export const placementOptions: ReadonlyArray<BoxSelectorOption<number>> = [
{
id: 'placement_hard',
value: KubernetesApplicationPlacementTypes.MANDATORY,
icon: Sliders,
iconType: 'badge',
label: 'Mandatory',
description: (
<>
Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b>{' '}
Rules
</>
),
},
{
id: 'placement_soft',
value: KubernetesApplicationPlacementTypes.PREFERRED,
icon: AlignJustify,
iconType: 'badge',
label: 'Preferred',
description:
'Schedule this application on nodes that match the rules if possible',
},
] as const;

@ -0,0 +1,140 @@
import { FormikErrors } from 'formik';
import { useMemo } from 'react';
import { useNodesQuery } from '@/react/kubernetes/cluster/HomeView/nodes.service';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { InputList } from '@@/form-components/InputList';
import { PlacementsFormValues, NodeLabels, Placement } from './types';
import { PlacementItem } from './PlacementItem';
import { PlacementTypeBoxSelector } from './PlacementTypeBoxSelector';
type Props = {
values: PlacementsFormValues;
onChange: (values: PlacementsFormValues) => void;
errors?: FormikErrors<PlacementsFormValues>;
};
export function PlacementFormSection({ values, onChange, errors }: Props) {
// node labels are all of the unique node labels across all nodes
const nodesLabels = useNodeLabels();
// available node labels are the node labels that are not already in use by a placement
const availableNodeLabels = useAvailableNodeLabels(
nodesLabels,
values.placements
);
const firstAvailableNodeLabel = Object.keys(availableNodeLabels)[0] || '';
const firstAvailableNodeLabelValue =
availableNodeLabels[firstAvailableNodeLabel]?.[0] || '';
const nonDeletedPlacements = values.placements.filter(
(placement) => !placement.needsDeletion
);
return (
<div className="flex flex-col">
<FormSection
title="Placement preferences and constraints"
titleSize="sm"
titleClassName="control-label !text-[0.9em]"
>
{values.placements?.length > 0 && (
<TextTip color="blue">
Deploy this application on nodes that respect <b>ALL</b> of the
following placement rules. Placement rules are based on node labels.
</TextTip>
)}
<InputList
value={values.placements}
onChange={(placements) => onChange({ ...values, placements })}
renderItem={(item, onChange, index, error) => (
<PlacementItem
item={item}
onChange={onChange}
error={error}
index={index}
nodesLabels={nodesLabels}
availableNodeLabels={availableNodeLabels}
/>
)}
itemBuilder={() => ({
label: firstAvailableNodeLabel,
value: firstAvailableNodeLabelValue,
needsDeletion: false,
})}
errors={errors?.placements}
addLabel="Add rule"
canUndoDelete
deleteButtonDataCy="k8sAppCreate-deletePlacementButton"
disabled={Object.keys(availableNodeLabels).length === 0}
addButtonError={
Object.keys(availableNodeLabels).length === 0
? 'There are no node labels available to add.'
: ''
}
/>
</FormSection>
{nonDeletedPlacements.length >= 1 && (
<FormSection
title="Placement policy"
titleSize="sm"
titleClassName="control-label !text-[0.9em]"
>
<TextTip color="blue">
Specify the policy associated to the placement rules.
</TextTip>
<PlacementTypeBoxSelector
placementType={values.placementType}
onChange={(placementType) => onChange({ ...values, placementType })}
/>
</FormSection>
)}
</div>
);
}
function useAvailableNodeLabels(
nodeLabels: NodeLabels,
placements: Placement[]
): NodeLabels {
return useMemo(() => {
const existingPlacementLabels = placements.map(
(placement) => placement.label
);
const availableNodeLabels = Object.keys(nodeLabels).filter(
(label) => !existingPlacementLabels.includes(label)
);
return availableNodeLabels.reduce((acc, label) => {
acc[label] = nodeLabels[label];
return acc;
}, {} as NodeLabels);
}, [nodeLabels, placements]);
}
function useNodeLabels(): NodeLabels {
const environmentId = useEnvironmentId();
const { data: nodes } = useNodesQuery(environmentId);
// all node label pairs (some might have the same key but different values)
const nodeLabelPairs =
nodes?.flatMap((node) =>
Object.entries(node.metadata?.labels || {}).map(([k, v]) => ({
key: k,
value: v,
}))
) || [];
// get unique node labels with each label's possible values
const uniqueLabels = new Set(nodeLabelPairs.map((pair) => pair.key));
// create a NodeLabels object with each label's possible values
const nodesLabels = Array.from(uniqueLabels).reduce((acc, key) => {
acc[key] = nodeLabelPairs
.filter((pair) => pair.key === key)
.map((pair) => pair.value);
return acc;
}, {} as NodeLabels);
return nodesLabels;
}

@ -0,0 +1,76 @@
import clsx from 'clsx';
import { ItemProps } from '@@/form-components/InputList';
import { Select } from '@@/form-components/ReactSelect';
import { isErrorType } from '@@/form-components/formikUtils';
import { FormError } from '@@/form-components/FormError';
import { NodeLabels, Placement } from './types';
interface PlacementItemProps extends ItemProps<Placement> {
nodesLabels: NodeLabels;
availableNodeLabels: NodeLabels;
}
export function PlacementItem({
onChange,
item,
error,
index,
nodesLabels,
availableNodeLabels,
}: PlacementItemProps) {
const labelOptions = Object.keys(availableNodeLabels).map((label) => ({
label,
value: label,
}));
const valueOptions = nodesLabels[item.label]?.map((value) => ({
label: value,
value,
}));
const placementError = isErrorType(error) ? error : undefined;
return (
<div className="w-full">
<div className="flex w-full gap-2">
<div className="basis-1/2 grow">
<Select
options={labelOptions}
value={{ label: item.label, value: item.label }}
noOptionsMessage={() => 'No available node labels.'}
onChange={(labelOption) => {
const newValues = nodesLabels[labelOption?.value || ''];
onChange({
...item,
value: newValues?.[0] || '',
label: labelOption?.value || '',
});
}}
size="sm"
className={clsx({ striked: !!item.needsDeletion })}
isDisabled={!!item.needsDeletion}
data-cy={`k8sAppCreate-placementLabel_${index}`}
/>
{placementError?.label && (
<FormError>{placementError.label}</FormError>
)}
</div>
<div className="basis-1/2 grow">
<Select
options={valueOptions}
value={valueOptions?.find((option) => option.value === item.value)}
onChange={(valueOption) =>
onChange({ ...item, value: valueOption?.value || '' })
}
size="sm"
className={clsx({ striked: !!item.needsDeletion })}
isDisabled={!!item.needsDeletion}
data-cy={`k8sAppCreate-placementName_${index}`}
/>
{placementError?.value && (
<FormError>{placementError.value}</FormError>
)}
</div>
</div>
</div>
);
}

@ -0,0 +1,48 @@
import { Sliders, AlignJustify } from 'lucide-react';
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
import { PlacementType } from './types';
type Props = {
placementType: PlacementType;
onChange: (placementType: PlacementType) => void;
};
export const placementOptions: ReadonlyArray<BoxSelectorOption<PlacementType>> =
[
{
id: 'placement_hard',
value: 'mandatory',
icon: Sliders,
iconType: 'badge',
label: 'Mandatory',
description: (
<>
Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b>{' '}
Rules
</>
),
},
{
id: 'placement_soft',
value: 'preferred',
icon: AlignJustify,
iconType: 'badge',
label: 'Preferred',
description:
'Schedule this application on nodes that match the rules if possible',
},
] as const;
export function PlacementTypeBoxSelector({ placementType, onChange }: Props) {
return (
<BoxSelector<PlacementType>
value={placementType}
options={placementOptions}
onChange={(placementType) => onChange(placementType)}
radioName="placementType"
slim
/>
);
}

@ -0,0 +1,2 @@
export { PlacementFormSection } from './PlacementFormSection';
export { placementsValidation as placementValidation } from './placementValidation';

@ -0,0 +1,16 @@
import { SchemaOf, array, boolean, mixed, object, string } from 'yup';
import { PlacementsFormValues } from './types';
export function placementsValidation(): SchemaOf<PlacementsFormValues> {
return object({
placementType: mixed().oneOf(['mandatory', 'preferred']).required(),
placements: array(
object({
label: string().required('Node label is required.'),
value: string().required('Node value is required.'),
needsDeletion: boolean(),
}).required()
),
});
}

@ -0,0 +1,14 @@
export type Placement = {
label: string;
value: string;
needsDeletion?: boolean;
};
export type PlacementType = 'mandatory' | 'preferred';
export type PlacementsFormValues = {
placementType: PlacementType;
placements: Placement[];
};
export type NodeLabels = Record<string, string[]>;
Loading…
Cancel
Save