@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.
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/pipeline/config/graph/PipelineGraph.d.ts +2 -0
- package/dist/pipeline/config/stages/evaluateVariables/EvaluateVariablesStageConfig.d.ts +1 -1
- package/dist/pipeline/config/stages/evaluateVariables/ExpressionCapabilitiesReader.d.ts +8 -0
- package/dist/pipeline/config/triggers/Triggers.d.ts +0 -1
- package/package.json +2 -2
- package/src/pipeline/config/PipelineConfigPage.tsx +135 -86
- package/src/pipeline/config/actions/pipelineJson/EditPipelineJsonModal.spec.tsx +55 -0
- package/src/pipeline/config/actions/pipelineJson/EditPipelineJsonModal.tsx +8 -0
- package/src/pipeline/config/graph/PipelineGraph.tsx +18 -1
- package/src/pipeline/config/stages/FormikStageConfig.spec.tsx +62 -0
- package/src/pipeline/config/stages/FormikStageConfig.tsx +18 -1
- package/src/pipeline/config/stages/evaluateVariables/EvaluateVariablesStageConfig.spec.ts +70 -0
- package/src/pipeline/config/stages/evaluateVariables/EvaluateVariablesStageConfig.tsx +21 -5
- package/src/pipeline/config/stages/evaluateVariables/ExpressionCapabilitiesReader.ts +13 -0
- package/src/pipeline/config/triggers/ExecutionOptionsPageContent.tsx +12 -5
- package/src/pipeline/config/triggers/Triggers.spec.tsx +0 -1
- package/src/pipeline/config/triggers/Triggers.tsx +1 -6
- package/src/pipeline/config/triggers/TriggersPageContent.tsx +11 -8
|
@@ -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
|
+
}
|
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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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 =
|
|
347
|
-
|
|
348
|
-
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1035
|
-
updateModel({ ...
|
|
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
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
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:
|
|
1195
|
+
{ pipeline: editedPipeline, plan: model.isTemplatedPipeline ? model.renderablePipeline : null } as any,
|
|
1157
1196
|
{ dialogClassName: 'modal-lg modal-fullscreen' },
|
|
1158
1197
|
)
|
|
1159
1198
|
.then(() => {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
const
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
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
|
|
1199
|
-
const
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
|
1255
|
+
const latest = latestModel.current;
|
|
1256
|
+
const nextPipeline = { ...latest.pipeline };
|
|
1210
1257
|
delete nextPipeline.locked;
|
|
1211
|
-
const nextRenderablePipeline =
|
|
1212
|
-
updateModel({ ...
|
|
1213
|
-
|
|
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({ ...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|