From 9fc7187e249e4d7b158f143482ccb22d95dcde30 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Wed, 3 Jan 2024 11:00:50 +1300 Subject: [PATCH] refactor(app): placement form section [EE-6386] (#10818) Co-authored-by: testa113 --- app/kubernetes/converters/application.js | 2 +- app/kubernetes/helpers/application/index.js | 34 ++--- .../models/application/formValues.js | 12 +- .../models/application/models/constants.js | 5 - app/kubernetes/react/components/index.ts | 12 ++ .../create/createApplication.html | 103 +------------ .../create/createApplicationController.js | 68 +-------- .../form-components/InputList/InputList.tsx | 5 + .../form-components/ReactSelect.tsx | 2 +- .../CreateView/placementTypes.tsx | 30 ---- .../PlacementFormSection.tsx | 140 ++++++++++++++++++ .../PlacementFormSection/PlacementItem.tsx | 76 ++++++++++ .../PlacementTypeBoxSelector.tsx | 48 ++++++ .../components/PlacementFormSection/index.ts | 2 + .../placementValidation.ts | 16 ++ .../components/PlacementFormSection/types.ts | 14 ++ 16 files changed, 349 insertions(+), 220 deletions(-) delete mode 100644 app/react/kubernetes/applications/CreateView/placementTypes.tsx create mode 100644 app/react/kubernetes/applications/components/PlacementFormSection/PlacementFormSection.tsx create mode 100644 app/react/kubernetes/applications/components/PlacementFormSection/PlacementItem.tsx create mode 100644 app/react/kubernetes/applications/components/PlacementFormSection/PlacementTypeBoxSelector.tsx create mode 100644 app/react/kubernetes/applications/components/PlacementFormSection/index.ts create mode 100644 app/react/kubernetes/applications/components/PlacementFormSection/placementValidation.ts create mode 100644 app/react/kubernetes/applications/components/PlacementFormSection/types.ts diff --git a/app/kubernetes/converters/application.js b/app/kubernetes/converters/application.js index 7555c66b3..d3161b410 100644 --- a/app/kubernetes/converters/application.js +++ b/app/kubernetes/converters/application.js @@ -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; diff --git a/app/kubernetes/helpers/application/index.js b/app/kubernetes/helpers/application/index.js index 46d780776..115f5208f 100644 --- a/app/kubernetes/helpers/application/index.js +++ b/app/kubernetes/helpers/application/index.js @@ -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; diff --git a/app/kubernetes/models/application/formValues.js b/app/kubernetes/models/application/formValues.js index aa4f50563..57eefcade 100644 --- a/app/kubernetes/models/application/formValues.js +++ b/app/kubernetes/models/application/formValues.js @@ -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, }; } diff --git a/app/kubernetes/models/application/models/constants.js b/app/kubernetes/models/application/models/constants.js index dead6e7f3..aa06f7f25 100644 --- a/app/kubernetes/models/application/models/constants.js +++ b/app/kubernetes/models/application/models/constants.js @@ -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 diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index e9f986fbc..c4473c3b8 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -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 +); diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index a2afda66c..90093a1f8 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -475,105 +475,16 @@ > - -
-
Placement preferences and constraints
- - -
-
- -
Deploy this application on nodes that respect ALL of the following placement rules. Placement rules are based on node labels.
-
- -
-
-
- -
-
- -
- -
- - -
-
-
-
-
-

- This label is already defined. -

-
-
-
-
- -
- - Add rule - -
-
-
-
-
- -
-
- -
-
Specify the policy associated to the placement rules.
-
- - -
-
- + + -
+
{ - 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(); diff --git a/app/react/components/form-components/InputList/InputList.tsx b/app/react/components/form-components/InputList/InputList.tsx index dce0818d0..eef2d42ac 100644 --- a/app/react/components/form-components/InputList/InputList.tsx +++ b/app/react/components/form-components/InputList/InputList.tsx @@ -188,6 +188,7 @@ export function InputList({ initialItemsCount={initialItemsCount.current} handleRemoveItem={handleRemoveItem} handleToggleNeedsDeletion={handleToggleNeedsDeletion} + dataCy={`${deleteButtonDataCy}_${index}`} /> )}
@@ -325,6 +326,7 @@ type CanUndoDeleteButtonProps = { initialItemsCount: number; handleRemoveItem(key: Key, item: T): void; handleToggleNeedsDeletion(key: Key, item: T): void; + dataCy: string; }; function CanUndoDeleteButton({ @@ -333,6 +335,7 @@ function CanUndoDeleteButton({ initialItemsCount, handleRemoveItem, handleToggleNeedsDeletion, + dataCy, }: CanUndoDeleteButtonProps) { return (
@@ -343,6 +346,7 @@ function CanUndoDeleteButton({ onClick={handleDeleteClick} className="vertical-center btn-only-icon" icon={Trash2} + data-cy={`${dataCy}_delete`} /> )} {item.needsDeletion && ( @@ -352,6 +356,7 @@ function CanUndoDeleteButton({ onClick={handleDeleteClick} className="vertical-center btn-only-icon" icon={RotateCw} + data-cy={`${dataCy}_undo_delete`} /> )}
diff --git a/app/react/components/form-components/ReactSelect.tsx b/app/react/components/form-components/ReactSelect.tsx index bb460e141..8b12edbb5 100644 --- a/app/react/components/form-components/ReactSelect.tsx +++ b/app/react/components/form-components/ReactSelect.tsx @@ -57,7 +57,7 @@ export function Select< isCreatable = false, size = 'md', ...props -}: Props) { +}: Props & AutomationTestingProps) { const Component = isCreatable ? ReactSelectCreatable : ReactSelect; return ( diff --git a/app/react/kubernetes/applications/CreateView/placementTypes.tsx b/app/react/kubernetes/applications/CreateView/placementTypes.tsx deleted file mode 100644 index 665a553ba..000000000 --- a/app/react/kubernetes/applications/CreateView/placementTypes.tsx +++ /dev/null @@ -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> = [ - { - id: 'placement_hard', - value: KubernetesApplicationPlacementTypes.MANDATORY, - icon: Sliders, - iconType: 'badge', - label: 'Mandatory', - description: ( - <> - Schedule this application ONLY on nodes that match ALL{' '} - 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; diff --git a/app/react/kubernetes/applications/components/PlacementFormSection/PlacementFormSection.tsx b/app/react/kubernetes/applications/components/PlacementFormSection/PlacementFormSection.tsx new file mode 100644 index 000000000..8f2bf0580 --- /dev/null +++ b/app/react/kubernetes/applications/components/PlacementFormSection/PlacementFormSection.tsx @@ -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; +}; + +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 ( +
+ + {values.placements?.length > 0 && ( + + Deploy this application on nodes that respect ALL of the + following placement rules. Placement rules are based on node labels. + + )} + onChange({ ...values, placements })} + renderItem={(item, onChange, index, error) => ( + + )} + 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.' + : '' + } + /> + + {nonDeletedPlacements.length >= 1 && ( + + + Specify the policy associated to the placement rules. + + onChange({ ...values, placementType })} + /> + + )} +
+ ); +} + +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; +} diff --git a/app/react/kubernetes/applications/components/PlacementFormSection/PlacementItem.tsx b/app/react/kubernetes/applications/components/PlacementFormSection/PlacementItem.tsx new file mode 100644 index 000000000..e683e1390 --- /dev/null +++ b/app/react/kubernetes/applications/components/PlacementFormSection/PlacementItem.tsx @@ -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 { + 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 ( +
+
+
+ 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 && ( + {placementError.value} + )} +
+
+
+ ); +} diff --git a/app/react/kubernetes/applications/components/PlacementFormSection/PlacementTypeBoxSelector.tsx b/app/react/kubernetes/applications/components/PlacementFormSection/PlacementTypeBoxSelector.tsx new file mode 100644 index 000000000..381e5fd13 --- /dev/null +++ b/app/react/kubernetes/applications/components/PlacementFormSection/PlacementTypeBoxSelector.tsx @@ -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> = + [ + { + id: 'placement_hard', + value: 'mandatory', + icon: Sliders, + iconType: 'badge', + label: 'Mandatory', + description: ( + <> + Schedule this application ONLY on nodes that match ALL{' '} + 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 ( + + value={placementType} + options={placementOptions} + onChange={(placementType) => onChange(placementType)} + radioName="placementType" + slim + /> + ); +} diff --git a/app/react/kubernetes/applications/components/PlacementFormSection/index.ts b/app/react/kubernetes/applications/components/PlacementFormSection/index.ts new file mode 100644 index 000000000..fd6356649 --- /dev/null +++ b/app/react/kubernetes/applications/components/PlacementFormSection/index.ts @@ -0,0 +1,2 @@ +export { PlacementFormSection } from './PlacementFormSection'; +export { placementsValidation as placementValidation } from './placementValidation'; diff --git a/app/react/kubernetes/applications/components/PlacementFormSection/placementValidation.ts b/app/react/kubernetes/applications/components/PlacementFormSection/placementValidation.ts new file mode 100644 index 000000000..52e98ed96 --- /dev/null +++ b/app/react/kubernetes/applications/components/PlacementFormSection/placementValidation.ts @@ -0,0 +1,16 @@ +import { SchemaOf, array, boolean, mixed, object, string } from 'yup'; + +import { PlacementsFormValues } from './types'; + +export function placementsValidation(): SchemaOf { + 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() + ), + }); +} diff --git a/app/react/kubernetes/applications/components/PlacementFormSection/types.ts b/app/react/kubernetes/applications/components/PlacementFormSection/types.ts new file mode 100644 index 000000000..ef8ab0f0a --- /dev/null +++ b/app/react/kubernetes/applications/components/PlacementFormSection/types.ts @@ -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;