@spinnaker/core 2026.3.0 → 2026.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,7 @@ export declare class PipelineGraph extends React.Component<IPipelineGraphProps,
28
28
  private element;
29
29
  private graphResizeObserver;
30
30
  private graphStatusHash;
31
+ private graphStructure;
31
32
  private graphVerticalPadding;
32
33
  private lastElementWidth;
33
34
  private layoutRetryFrame;
@@ -76,6 +77,7 @@ export declare class PipelineGraph extends React.Component<IPipelineGraphProps,
76
77
  componentDidMount(): void;
77
78
  private refCallback;
78
79
  private observeElementResize;
80
+ private getGraphStructure;
79
81
  private validatePipeline;
80
82
  componentWillReceiveProps(nextProps: IPipelineGraphProps): void;
81
83
  componentDidUpdate(_prevProps: IPipelineGraphProps, prevState: IPipelineGraphState): void;
@@ -6,5 +6,5 @@ export interface IEvaluatedVariable {
6
6
  key: string;
7
7
  value: string;
8
8
  }
9
- export declare function validateEvaluateVariablesStage(stage: IStage): any;
9
+ export declare function validateEvaluateVariablesStage(stage: IStage, dashedIdentifiersEnabled?: boolean): any;
10
10
  export declare function EvaluateVariablesStageConfig(props: IStageConfigProps): JSX.Element;
@@ -0,0 +1,8 @@
1
+ export interface IExpressionCapabilities {
2
+ functions: unknown[];
3
+ spelEvaluators: unknown[];
4
+ dashedIdentifiersEnabled: boolean;
5
+ }
6
+ export declare class ExpressionCapabilitiesReader {
7
+ static getExpressionCapabilities(): Promise<IExpressionCapabilities>;
8
+ }
@@ -7,6 +7,5 @@ export interface ITriggersProps {
7
7
  pipelineConfig?: IPipeline;
8
8
  fieldUpdated: () => void;
9
9
  updatePipelineConfig: (changes: Partial<IPipeline>) => void;
10
- revertCount: number;
11
10
  }
12
11
  export declare function Triggers(props: ITriggersProps): JSX.Element;
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "https://github.com/spinnaker/spinnaker.git"
6
6
  },
7
7
  "license": "Apache-2.0",
8
- "version": "2026.3.0",
8
+ "version": "2026.3.2",
9
9
  "module": "dist/index.js",
10
10
  "typings": "dist/index.d.ts",
11
11
  "publishConfig": {
@@ -115,5 +115,5 @@
115
115
  "shx": "0.3.3",
116
116
  "typescript": "5.0.4"
117
117
  },
118
- "gitHead": "0f7d75f5d4b42c1675e2ecc69a53f8eb73090eb9"
118
+ "gitHead": "d4601ebf41a79d3f529e3aa399d5ce4f6d38cd8b"
119
119
  }
@@ -69,7 +69,6 @@ interface IPipelineConfigViewState {
69
69
  section: string;
70
70
  stageIndex: number;
71
71
  loading?: boolean;
72
- revertCount: number;
73
72
  original?: string;
74
73
  originalRenderablePipeline?: string;
75
74
  isDirty?: boolean;
@@ -152,13 +151,24 @@ function getInitialViewState(
152
151
  section,
153
152
  stageIndex: hasStage ? stageIndex : 0,
154
153
  loading: false,
155
- revertCount: 0,
156
154
  original: toJson(pipeline),
157
155
  originalRenderablePipeline: toJson(renderablePipeline),
158
156
  isDirty: false,
159
157
  };
160
158
  }
161
159
 
160
+ // Keeps the stage selection in range after the stages are replaced
161
+ function selectionInRange(
162
+ viewState: IPipelineConfigViewState,
163
+ renderablePipeline: IPipeline,
164
+ ): Pick<IPipelineConfigViewState, 'section' | 'stageIndex'> {
165
+ const lastStageIndex = (renderablePipeline.stages || []).length - 1;
166
+ return {
167
+ section: viewState.section === 'stage' && lastStageIndex < 0 ? 'triggers' : viewState.section,
168
+ stageIndex: Math.max(0, Math.min(viewState.stageIndex, lastStageIndex)),
169
+ };
170
+ }
171
+
162
172
  function getLatestConfig(application: Application, pipeline: IPipeline): IPipeline {
163
173
  const dataSource = pipeline.strategy ? application.strategyConfigs : application.pipelineConfigs;
164
174
  return dataSource?.data?.find((config: IPipeline) => config.id === pipeline.id);
@@ -314,9 +324,9 @@ interface IPipelineStageConfigProps {
314
324
  application: Application;
315
325
  pipeline: IPipeline;
316
326
  stage: IStage;
317
- updateStageDependencies: (dependencies: string[]) => void;
318
327
  updateStageField: (changes: Partial<IStage>) => void;
319
328
  stageFieldUpdated: () => void;
329
+ replaceStage: (stage: IStage) => void;
320
330
  removeStage: (stage: IStage) => void;
321
331
  }
322
332
 
@@ -328,24 +338,35 @@ function PipelineStageConfig({
328
338
  removeStage,
329
339
  stage,
330
340
  stageFieldUpdated,
331
- updateStageDependencies,
341
+ replaceStage,
332
342
  updateStageField,
333
343
  }: IPipelineStageConfigProps) {
334
344
  const [stageRoleOptions, setStageRoleOptions] = React.useState<any[]>([]);
335
345
  const lastAppliedDefaultsKey = React.useRef<string>();
336
346
  const canResolveStageTypes = accounts !== null && accounts.length > 0 && !accountLoadError;
337
- const configurableStageTypes = canResolveStageTypes
338
- ? Registry.pipeline
339
- .getConfigurableStageTypes(accounts)
340
- .filter((type) => !pipeline.strategy || type.strategy)
341
- .sort((a, b) => a.label.localeCompare(b.label))
342
- : [];
343
- const providerConfigs = stage.type ? Registry.pipeline.getProvidersFor(stage.type) : [];
347
+ // Registry lookups copy every stage type, so only repeat them when their inputs change
348
+ const configurableStageTypes = React.useMemo(
349
+ () =>
350
+ canResolveStageTypes
351
+ ? Registry.pipeline
352
+ .getConfigurableStageTypes(accounts)
353
+ .filter((type) => !pipeline.strategy || type.strategy)
354
+ .sort((a, b) => a.label.localeCompare(b.label))
355
+ : [],
356
+ [accounts, canResolveStageTypes, pipeline.strategy],
357
+ );
358
+ const providerConfigs = React.useMemo(() => (stage.type ? Registry.pipeline.getProvidersFor(stage.type) : []), [
359
+ stage.type,
360
+ ]);
344
361
  const baseStageType = providerConfigs[0]?.provides || stage.type;
345
362
  const selectedStageType = configurableStageTypes.find((type) => type.key === baseStageType);
346
- const baseStageConfig = Registry.pipeline
347
- .getStageTypes()
348
- .find((type) => type.key === baseStageType && type.useBaseProvider && !type.provides);
363
+ const baseStageConfig = React.useMemo(
364
+ () =>
365
+ Registry.pipeline
366
+ .getStageTypes()
367
+ .find((type) => type.key === baseStageType && type.useBaseProvider && !type.provides),
368
+ [baseStageType],
369
+ );
349
370
  const availableProviders = selectedStageType?.cloudProviders || [];
350
371
  const selectedProvider = stage.cloudProvider || stage.cloudProviderType;
351
372
  const providerFieldsIncoherent =
@@ -501,7 +522,7 @@ function PipelineStageConfig({
501
522
 
502
523
  const editStageJson = () => {
503
524
  ReactModal.show(EditStageJsonModal, { stage } as any, { dialogClassName: 'modal-lg modal-fullscreen' })
504
- .then(stageFieldUpdated)
525
+ .then(() => replaceStage(stage))
505
526
  .catch(() => {});
506
527
  };
507
528
 
@@ -628,7 +649,7 @@ function PipelineStageConfig({
628
649
  inputClassName="pipeline-stage-dependency-select"
629
650
  multi={true}
630
651
  name="requisiteStageRefIds"
631
- onChange={(event) => updateStageDependencies(event.target.value || [])}
652
+ onChange={(event) => updateReservedStageField({ requisiteStageRefIds: event.target.value || [] })}
632
653
  options={stageDependencyOptions}
633
654
  value={(stage.requisiteStageRefIds || []).map(String)}
634
655
  valueRenderer={(option: any) => option.name}
@@ -780,6 +801,9 @@ export function PipelineConfigPageComponent({
780
801
  const currentPipelineId = React.useRef(pipelineId);
781
802
  const pipelineLoadGeneration = React.useRef(0);
782
803
  const templateModalInvocation = React.useRef(0);
804
+ const [stageEditorKeys] = React.useState(() => ({ ids: new WeakMap<IStage, number>(), next: 0 }));
805
+ const latestModel = React.useRef(model);
806
+ latestModel.current = model;
783
807
  currentPipelineId.current = pipelineId;
784
808
 
785
809
  React.useEffect(
@@ -829,7 +853,12 @@ export function PipelineConfigPageComponent({
829
853
  renderablePipeline: { ...current.renderablePipeline, ...cloneDeep(plan) },
830
854
  };
831
855
  });
832
- setViewState((current) => (isCurrentInvocation() ? { ...current, isDirty: true, loading: false } : current));
856
+ const nextRenderablePipeline = { ...latestModel.current.renderablePipeline, ...plan };
857
+ setViewState((current) =>
858
+ isCurrentInvocation()
859
+ ? { ...current, ...selectionInRange(current, nextRenderablePipeline), isDirty: true, loading: false }
860
+ : current,
861
+ );
833
862
  })
834
863
  .catch(() => {})
835
864
  .finally(() => {
@@ -972,25 +1001,48 @@ export function PipelineConfigPageComponent({
972
1001
  };
973
1002
 
974
1003
  const updateModel = (nextModel: IPipelineConfigModel, shouldMarkDirty = true) => {
1004
+ // Callbacks that run before the next render read this model
1005
+ latestModel.current = nextModel;
975
1006
  setModel(nextModel);
976
1007
  if (shouldMarkDirty) {
977
1008
  markDirty(nextModel.pipeline);
978
1009
  }
979
1010
  };
980
1011
 
1012
+ // Each stage object gets its own editor, so replacing a stage remounts it with the new values
1013
+ const getStageEditorKey = (stage: IStage) => {
1014
+ if (!stageEditorKeys.ids.has(stage)) {
1015
+ stageEditorKeys.ids.set(stage, stageEditorKeys.next++);
1016
+ }
1017
+ return stageEditorKeys.ids.get(stage);
1018
+ };
1019
+
1020
+ // Stage edits are made in place, so the stage objects and their editors are kept
981
1021
  const stageFieldUpdated = () => {
982
1022
  setRevision((current) => current + 1);
983
- const nextRenderablePipeline = cloneDeep(model.renderablePipeline);
1023
+ const nextRenderablePipeline = { ...model.renderablePipeline };
984
1024
  const nextPipeline = model.isTemplatedPipeline ? model.pipeline : nextRenderablePipeline;
985
1025
  updateModel({ ...model, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline });
986
1026
  };
987
1027
 
1028
+ // Reads the latest model, which may be newer than this render's
1029
+ const replaceStage = (stageToReplace: IStage) => {
1030
+ const latest = latestModel.current;
1031
+ setRevision((current) => current + 1);
1032
+ const nextRenderablePipeline = {
1033
+ ...latest.renderablePipeline,
1034
+ stages: latest.renderablePipeline.stages.map((stage) => (stage === stageToReplace ? { ...stage } : stage)),
1035
+ };
1036
+ const nextPipeline = latest.isTemplatedPipeline ? latest.pipeline : nextRenderablePipeline;
1037
+ updateModel({ ...latest, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline });
1038
+ };
1039
+
1040
+ // Shallow copy, so unchanged objects keep their identity
988
1041
  const updatePipelineConfig = (changes: Partial<IPipeline>) => {
989
- const nextPipeline = cloneDeep(model.pipeline);
990
1042
  const nextChanges = model.isV2TemplatedPipeline
991
1043
  ? (PipelineTemplateV2Service.filterInheritedConfig(cloneDeep(changes)) as Partial<IPipeline>)
992
1044
  : changes;
993
- Object.assign(nextPipeline, nextChanges);
1045
+ const nextPipeline = { ...model.pipeline, ...nextChanges };
994
1046
 
995
1047
  let nextRenderablePipeline = model.renderablePipeline;
996
1048
  if (model.isV2TemplatedPipeline) {
@@ -1019,7 +1071,9 @@ export function PipelineConfigPageComponent({
1019
1071
  };
1020
1072
 
1021
1073
  const addStage = (newStage: Partial<IStage> = { isNew: true }) => {
1022
- const nextRenderablePipeline = cloneDeep(model.renderablePipeline);
1074
+ // Reads the latest model, which may be newer than this render's
1075
+ const latest = latestModel.current;
1076
+ const nextRenderablePipeline = cloneDeep(latest.renderablePipeline);
1023
1077
  const stages = nextRenderablePipeline.stages || [];
1024
1078
  const stageToAdd = newStage as IStage;
1025
1079
  stageToAdd.refId = String(Math.max(0, ...stages.map((stage) => Number(stage.refId) || 0)) + 1);
@@ -1031,8 +1085,8 @@ export function PipelineConfigPageComponent({
1031
1085
  }
1032
1086
  stages.push(stageToAdd);
1033
1087
  nextRenderablePipeline.stages = stages;
1034
- const nextPipeline = model.isTemplatedPipeline ? model.pipeline : nextRenderablePipeline;
1035
- updateModel({ ...model, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline });
1088
+ const nextPipeline = latest.isTemplatedPipeline ? latest.pipeline : nextRenderablePipeline;
1089
+ updateModel({ ...latest, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline });
1036
1090
  setViewState((current) => ({ ...current, section: 'stage', stageIndex: stages.length - 1 }));
1037
1091
  };
1038
1092
 
@@ -1060,12 +1114,7 @@ export function PipelineConfigPageComponent({
1060
1114
  });
1061
1115
  const nextPipeline = model.isTemplatedPipeline ? model.pipeline : nextRenderablePipeline;
1062
1116
  updateModel({ ...model, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline });
1063
- const lastStageIndex = nextRenderablePipeline.stages.length - 1;
1064
- setViewState((current) => ({
1065
- ...current,
1066
- section: lastStageIndex < 0 ? 'triggers' : current.section,
1067
- stageIndex: Math.max(0, Math.min(current.stageIndex, lastStageIndex)),
1068
- }));
1117
+ setViewState((current) => ({ ...current, ...selectionInRange(current, nextRenderablePipeline) }));
1069
1118
  };
1070
1119
 
1071
1120
  const updateStageField = (stage: IStage, changes: Partial<IStage>) => {
@@ -1081,32 +1130,12 @@ export function PipelineConfigPageComponent({
1081
1130
  }
1082
1131
  };
1083
1132
 
1084
- const updateStageDependencies = (stageToUpdate: IStage, dependencies: string[]) => {
1085
- const nextRenderablePipeline = cloneDeep(model.renderablePipeline);
1086
- const nextStage = nextRenderablePipeline.stages.find((stage) => stage.refId === stageToUpdate.refId);
1087
- if (!nextStage) {
1088
- return;
1089
- }
1090
- nextStage.requisiteStageRefIds = dependencies;
1091
- const nextPipeline = model.isTemplatedPipeline ? model.pipeline : nextRenderablePipeline;
1092
- updateModel({ ...model, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline });
1093
- };
1094
-
1095
1133
  const revertPipelineChanges = () => {
1096
1134
  const original = fromJson<IPipeline>(viewState.original);
1097
1135
  const originalRenderablePipeline = fromJson<IPipeline>(viewState.originalRenderablePipeline);
1098
1136
  const nextRenderablePipeline = model.isTemplatedPipeline ? originalRenderablePipeline : original;
1099
1137
  updateModel({ ...model, pipeline: original, renderablePipeline: nextRenderablePipeline }, false);
1100
- setViewState((current) => {
1101
- const lastStageIndex = nextRenderablePipeline.stages.length - 1;
1102
- return {
1103
- ...current,
1104
- section: current.section === 'stage' && lastStageIndex < 0 ? 'triggers' : current.section,
1105
- stageIndex: Math.max(0, Math.min(current.stageIndex, lastStageIndex)),
1106
- revertCount: (current.revertCount || 0) + 1,
1107
- isDirty: false,
1108
- };
1109
- });
1138
+ setViewState((current) => ({ ...current, ...selectionInRange(current, nextRenderablePipeline), isDirty: false }));
1110
1139
  };
1111
1140
 
1112
1141
  const applyUpdateTs = (toSave: IPipeline) => {
@@ -1115,9 +1144,16 @@ export function PipelineConfigPageComponent({
1115
1144
  if (latestFromServer?.updateTs) {
1116
1145
  toSave.updateTs = latestFromServer.updateTs;
1117
1146
  }
1118
- const nextRenderablePipeline = model.isTemplatedPipeline ? model.renderablePipeline : toSave;
1119
- updateModel({ ...model, pipeline: toSave, renderablePipeline: nextRenderablePipeline }, false);
1120
- setOriginal(toSave, nextRenderablePipeline);
1147
+ // Keep the current stage objects, so their editors stay mounted and edits made during the save are kept
1148
+ const current = latestModel.current;
1149
+ const pipeline = { ...current.pipeline };
1150
+ if (latestFromServer?.updateTs) {
1151
+ pipeline.updateTs = latestFromServer.updateTs;
1152
+ }
1153
+ const renderablePipeline = current.isTemplatedPipeline ? current.renderablePipeline : pipeline;
1154
+ updateModel({ ...current, pipeline, renderablePipeline }, false);
1155
+ setOriginal(toSave, current.isTemplatedPipeline ? current.renderablePipeline : toSave);
1156
+ markDirty(pipeline);
1121
1157
  });
1122
1158
  };
1123
1159
 
@@ -1142,27 +1178,33 @@ export function PipelineConfigPageComponent({
1142
1178
  const renamePipeline = () => {
1143
1179
  ReactModal.show(RenamePipelineModal, { pipeline: model.pipeline, application: app } as any)
1144
1180
  .then((pipelineName: string) => {
1145
- const nextPipeline = { ...model.pipeline, name: pipelineName };
1146
- const nextRenderablePipeline = model.isTemplatedPipeline ? model.renderablePipeline : nextPipeline;
1147
- updateModel({ ...model, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline }, false);
1148
- return applyUpdateTs(nextPipeline);
1181
+ const latest = latestModel.current;
1182
+ const nextPipeline = { ...latest.pipeline, name: pipelineName };
1183
+ const nextRenderablePipeline = latest.isTemplatedPipeline ? latest.renderablePipeline : nextPipeline;
1184
+ updateModel({ ...latest, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline }, false);
1185
+ return applyUpdateTs(cloneDeep(nextPipeline));
1149
1186
  })
1150
1187
  .catch(() => {});
1151
1188
  };
1152
1189
 
1153
1190
  const editPipelineJson = () => {
1191
+ const editedPipeline = model.pipeline;
1192
+ const openedUpdateTs = editedPipeline.updateTs;
1154
1193
  ReactModal.show(
1155
1194
  EditPipelineJsonModal,
1156
- { pipeline: model.pipeline, plan: model.isTemplatedPipeline ? model.renderablePipeline : null } as any,
1195
+ { pipeline: editedPipeline, plan: model.isTemplatedPipeline ? model.renderablePipeline : null } as any,
1157
1196
  { dialogClassName: 'modal-lg modal-fullscreen' },
1158
1197
  )
1159
1198
  .then(() => {
1160
- const nextPipeline = cloneDeep(model.pipeline);
1161
- updateModel({
1162
- ...model,
1163
- pipeline: nextPipeline,
1164
- renderablePipeline: model.isTemplatedPipeline ? model.renderablePipeline : nextPipeline,
1165
- });
1199
+ // The modal edits the pipeline it was given, but a save may have finished while it was open
1200
+ const latest = latestModel.current;
1201
+ const nextPipeline = cloneDeep(editedPipeline);
1202
+ if (nextPipeline.updateTs === openedUpdateTs) {
1203
+ nextPipeline.updateTs = latest.pipeline.updateTs;
1204
+ }
1205
+ const nextRenderablePipeline = latest.isTemplatedPipeline ? latest.renderablePipeline : nextPipeline;
1206
+ updateModel({ ...latest, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline });
1207
+ setViewState((current) => ({ ...current, ...selectionInRange(current, nextRenderablePipeline) }));
1166
1208
  })
1167
1209
  .catch(() => {});
1168
1210
  };
@@ -1171,34 +1213,38 @@ export function PipelineConfigPageComponent({
1171
1213
  ReactModal.show(DeletePipelineModal, { pipeline: model.pipeline, application: app } as any).catch(() => {});
1172
1214
  };
1173
1215
 
1174
- const disableToggled = (isDisabled: boolean) => {
1175
- const nextPipeline = { ...model.pipeline, disabled: isDisabled };
1176
- const nextRenderablePipeline = model.isTemplatedPipeline ? model.renderablePipeline : nextPipeline;
1177
- updateModel({ ...model, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline }, false);
1178
- const original = fromJson<IPipeline>(viewState.original);
1179
- original.disabled = isDisabled;
1180
- setOriginal(original, nextRenderablePipeline);
1216
+ // Only the disabled flag was saved, so other pending changes stay unsaved
1217
+ const disableToggled = (isDisabled: boolean, original: IPipeline) => {
1218
+ const latest = latestModel.current;
1219
+ const nextPipeline = { ...latest.pipeline, disabled: isDisabled };
1220
+ const nextRenderablePipeline = latest.isTemplatedPipeline ? latest.renderablePipeline : nextPipeline;
1221
+ updateModel({ ...latest, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline }, false);
1222
+ return applyUpdateTs({ ...original, disabled: isDisabled });
1181
1223
  };
1182
1224
 
1183
1225
  const enablePipeline = () => {
1184
- ReactModal.show(EnablePipelineModal, { pipeline: fromJson<IPipeline>(viewState.original) } as any)
1185
- .then(() => disableToggled(false))
1226
+ const original = fromJson<IPipeline>(viewState.original);
1227
+ ReactModal.show(EnablePipelineModal, { pipeline: original } as any)
1228
+ .then(() => disableToggled(false, original))
1186
1229
  .catch(() => {});
1187
1230
  };
1188
1231
 
1189
1232
  const disablePipeline = () => {
1190
- ReactModal.show(DisablePipelineModal, { pipeline: fromJson<IPipeline>(viewState.original) } as any)
1191
- .then(() => disableToggled(true))
1233
+ const original = fromJson<IPipeline>(viewState.original);
1234
+ ReactModal.show(DisablePipelineModal, { pipeline: original } as any)
1235
+ .then(() => disableToggled(true, original))
1192
1236
  .catch(() => {});
1193
1237
  };
1194
1238
 
1195
1239
  const lockPipeline = () => {
1196
1240
  ReactModal.show(LockPipelineModal, { pipeline: model.pipeline } as any)
1197
1241
  .then((pipeline: IPipeline) => {
1198
- const nextPipeline = { ...model.pipeline, locked: pipeline.locked };
1199
- const nextRenderablePipeline = model.isTemplatedPipeline ? model.renderablePipeline : nextPipeline;
1200
- updateModel({ ...model, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline }, false);
1201
- setOriginal(nextPipeline, nextRenderablePipeline);
1242
+ const latest = latestModel.current;
1243
+ const nextPipeline = { ...latest.pipeline, locked: pipeline.locked };
1244
+ const nextRenderablePipeline = latest.isTemplatedPipeline ? latest.renderablePipeline : nextPipeline;
1245
+ updateModel({ ...latest, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline }, false);
1246
+ // The modal saved the pipeline
1247
+ return applyUpdateTs(cloneDeep(nextPipeline));
1202
1248
  })
1203
1249
  .catch(() => {});
1204
1250
  };
@@ -1206,11 +1252,13 @@ export function PipelineConfigPageComponent({
1206
1252
  const unlockPipeline = () => {
1207
1253
  ReactModal.show(UnlockPipelineModal, { pipeline: model.pipeline } as any)
1208
1254
  .then(() => {
1209
- const nextPipeline = cloneDeep(model.pipeline);
1255
+ const latest = latestModel.current;
1256
+ const nextPipeline = { ...latest.pipeline };
1210
1257
  delete nextPipeline.locked;
1211
- const nextRenderablePipeline = model.isTemplatedPipeline ? model.renderablePipeline : nextPipeline;
1212
- updateModel({ ...model, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline }, false);
1213
- setOriginal(nextPipeline, nextRenderablePipeline);
1258
+ const nextRenderablePipeline = latest.isTemplatedPipeline ? latest.renderablePipeline : nextPipeline;
1259
+ updateModel({ ...latest, pipeline: nextPipeline, renderablePipeline: nextRenderablePipeline }, false);
1260
+ // The modal saved the pipeline
1261
+ return applyUpdateTs(cloneDeep(nextPipeline));
1214
1262
  })
1215
1263
  .catch(() => {});
1216
1264
  };
@@ -1222,7 +1270,8 @@ export function PipelineConfigPageComponent({
1222
1270
  currentConfig: viewState.isDirty ? cloneDeep(model.pipeline) : null,
1223
1271
  } as any)
1224
1272
  .then((newConfig: IPipeline) => {
1225
- updateModel({ ...model, pipeline: newConfig, renderablePipeline: newConfig });
1273
+ updateModel({ ...latestModel.current, pipeline: newConfig, renderablePipeline: newConfig });
1274
+ setViewState((current) => ({ ...current, ...selectionInRange(current, newConfig) }));
1226
1275
  savePipeline(newConfig);
1227
1276
  })
1228
1277
  .catch(() => {});
@@ -1379,18 +1428,18 @@ export function PipelineConfigPageComponent({
1379
1428
  pipeline={renderablePipeline}
1380
1429
  fieldUpdated={stageFieldUpdated}
1381
1430
  updatePipelineConfig={updatePipelineConfig}
1382
- revertCount={viewState.revertCount}
1383
1431
  />
1384
1432
  )}
1385
1433
  {viewState.section === 'stage' && selectedStage && (
1386
1434
  <PipelineStageConfig
1435
+ key={getStageEditorKey(selectedStage)}
1387
1436
  accounts={accounts}
1388
1437
  accountLoadError={accountLoadError}
1389
1438
  application={app}
1390
1439
  pipeline={renderablePipeline}
1391
1440
  stage={selectedStage}
1392
1441
  stageFieldUpdated={stageFieldUpdated}
1393
- updateStageDependencies={(dependencies) => updateStageDependencies(selectedStage, dependencies)}
1442
+ replaceStage={replaceStage}
1394
1443
  updateStageField={(changes) => updateStageField(selectedStage, changes)}
1395
1444
  removeStage={removeStage}
1396
1445
  />
@@ -0,0 +1,55 @@
1
+ import { mount } from 'enzyme';
2
+ import React from 'react';
3
+
4
+ import { EditPipelineJsonModal } from './EditPipelineJsonModal';
5
+ import type { IPipeline } from '../../../../domain';
6
+
7
+ describe('EditPipelineJsonModal', () => {
8
+ const pipeline = (): IPipeline => ({
9
+ application: 'app',
10
+ id: 'pipeline-id',
11
+ name: 'Pipeline',
12
+ stages: [],
13
+ triggers: [],
14
+ parameterConfig: [],
15
+ notifications: [],
16
+ limitConcurrent: true,
17
+ keepWaitingPipelines: false,
18
+ });
19
+
20
+ const updatePipeline = (edited: any) => {
21
+ const original = pipeline();
22
+ const closeModal = jasmine.createSpy('closeModal');
23
+ const wrapper = mount(
24
+ <EditPipelineJsonModal pipeline={original} plan={null} closeModal={closeModal} dismissModal={() => {}} />,
25
+ );
26
+ wrapper.setState({ pipelineJSON: JSON.stringify(edited) });
27
+ (wrapper.instance() as any).updatePipeline();
28
+ return { closeModal, original, errorMessage: (wrapper.state() as any).errorMessage as string };
29
+ };
30
+
31
+ it('rejects triggers that are not objects', () => {
32
+ const { closeModal, original, errorMessage } = updatePipeline({ ...pipeline(), triggers: ['x'] });
33
+
34
+ expect(errorMessage).toBe('The triggers property must be a list of objects.');
35
+ expect(closeModal).not.toHaveBeenCalled();
36
+ expect(original.triggers).toEqual([]);
37
+ });
38
+
39
+ it('rejects stages that are not a list', () => {
40
+ const { closeModal, original, errorMessage } = updatePipeline({ ...pipeline(), stages: 'x' });
41
+
42
+ expect(errorMessage).toBe('The stages property must be a list of objects.');
43
+ expect(closeModal).not.toHaveBeenCalled();
44
+ expect(original.stages).toEqual([]);
45
+ });
46
+
47
+ it('accepts stages and triggers that are lists of objects', () => {
48
+ const edited = { ...pipeline(), triggers: [{ type: 'cron' }] };
49
+ const { closeModal, original, errorMessage } = updatePipeline(edited);
50
+
51
+ expect(errorMessage).toBeFalsy();
52
+ expect(closeModal).toHaveBeenCalled();
53
+ expect(original.triggers).toEqual([{ type: 'cron' } as any]);
54
+ });
55
+ });
@@ -1,4 +1,5 @@
1
1
  import 'brace/mode/json';
2
+ import { isPlainObject } from 'lodash';
2
3
  import React from 'react';
3
4
  import { Modal } from 'react-bootstrap';
4
5
 
@@ -54,6 +55,13 @@ export class EditPipelineJsonModal extends React.Component<IEditPipelineJsonModa
54
55
  }
55
56
 
56
57
  private validatePipeline(pipeline: IPipeline): void {
58
+ ['stages', 'triggers'].forEach((field) => {
59
+ const items = (pipeline as any)[field];
60
+ if (items != null && (!Array.isArray(items) || !items.every((item) => isPlainObject(item)))) {
61
+ throw new Error(`The ${field} property must be a list of objects.`);
62
+ }
63
+ });
64
+
57
65
  const refIds = new Set<string | number>();
58
66
  const badIds = new Set<string | number>();
59
67
  if (pipeline.stages) {
@@ -59,6 +59,7 @@ export class PipelineGraph extends React.Component<IPipelineGraphProps, IPipelin
59
59
  private element: JQuery;
60
60
  private graphResizeObserver: ResizeObserver;
61
61
  private graphStatusHash: string;
62
+ private graphStructure: string;
62
63
  private graphVerticalPadding = 11;
63
64
  private lastElementWidth = 0;
64
65
  private layoutRetryFrame: number | null = null;
@@ -438,6 +439,7 @@ export class PipelineGraph extends React.Component<IPipelineGraphProps, IPipelin
438
439
  newState = clone(this.defaultState);
439
440
  newState.showGraph = false;
440
441
  } else if (!statesOnly) {
442
+ this.graphStructure = this.getGraphStructure(props.pipeline);
441
443
  newState = clone(this.defaultState);
442
444
  const groupedNodes = this.applyPhasesAndLink(props, nodes, newState);
443
445
  this.applyPhaseWidth(props, newState);
@@ -504,6 +506,19 @@ export class PipelineGraph extends React.Component<IPipelineGraphProps, IPipelin
504
506
  this.graphResizeObserver.observe(this.element[0]);
505
507
  }
506
508
 
509
+ // The stage fields the configuration graph draws. Layout measures each node in the DOM, so skip it when these
510
+ // are unchanged.
511
+ private getGraphStructure(pipeline: IPipeline): string {
512
+ return JSON.stringify(
513
+ (pipeline?.stages ?? []).map((stage) => [
514
+ stage.refId,
515
+ stage.name,
516
+ stage.requisiteStageRefIds,
517
+ stage.graphRowOverride,
518
+ ]),
519
+ );
520
+ }
521
+
507
522
  @Debounce(300)
508
523
  private validatePipeline(pipeline: IPipeline): void {
509
524
  PipelineConfigValidator.validatePipeline(pipeline).catch(() => {});
@@ -524,7 +539,9 @@ export class PipelineGraph extends React.Component<IPipelineGraphProps, IPipelin
524
539
 
525
540
  if (nextProps.pipeline !== this.props.pipeline) {
526
541
  updateGraph = true;
527
- stateOnly = false;
542
+ if (this.getGraphStructure(nextProps.pipeline) !== this.graphStructure) {
543
+ stateOnly = false;
544
+ }
528
545
  if (this.props.shouldValidate) {
529
546
  this.validatePipeline(nextProps.pipeline);
530
547
  }
@@ -0,0 +1,62 @@
1
+ import type { FormikProps } from 'formik';
2
+ import { mount } from 'enzyme';
3
+ import React from 'react';
4
+
5
+ import { FormikStageConfig } from './FormikStageConfig';
6
+ import type { IStage } from '../../../domain';
7
+
8
+ describe('FormikStageConfig', () => {
9
+ const mountConfig = (stage: IStage, onChange: (values: IStage) => void) => {
10
+ let formik: FormikProps<IStage>;
11
+ const wrapper = mount(
12
+ <FormikStageConfig
13
+ application={{} as any}
14
+ pipeline={{} as any}
15
+ stage={stage}
16
+ onChange={onChange}
17
+ render={(props) => {
18
+ formik = props.formik;
19
+ return null;
20
+ }}
21
+ />,
22
+ );
23
+ return { wrapper, setField: (name: string, value: any) => formik.setFieldValue(name, value) };
24
+ };
25
+
26
+ const stageWith = (extra: Partial<IStage> = {}): IStage => ({
27
+ refId: '1',
28
+ name: 'Script',
29
+ type: 'script',
30
+ requisiteStageRefIds: [],
31
+ command: 'a',
32
+ failPipeline: true,
33
+ ...extra,
34
+ });
35
+
36
+ it('keeps stage fields changed outside the form', (done) => {
37
+ const stage = stageWith();
38
+ const onChange = (values: IStage) => {
39
+ expect(values.command).toBe('b');
40
+ expect(values.failPipeline).toBe(false);
41
+ done();
42
+ };
43
+ const { wrapper, setField } = mountConfig(stage, onChange);
44
+
45
+ // Like Execution Options, which edits the stage outside the form
46
+ stage.failPipeline = false;
47
+ wrapper.setProps({ stage });
48
+ setField('command', 'b');
49
+ });
50
+
51
+ it('removes fields the form removed', (done) => {
52
+ const stage = stageWith();
53
+ const onChange = (values: IStage) => {
54
+ expect(values.command).toBeUndefined();
55
+ expect(values.failPipeline).toBe(true);
56
+ done();
57
+ };
58
+ const { setField } = mountConfig(stage, onChange);
59
+
60
+ setField('command', undefined);
61
+ });
62
+ });