@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.
@@ -32,6 +32,17 @@ const decorate = (validate: IContextualValidator, props: IFormikStageConfigProps
32
32
  return (values: IStage) => validate(values, { application, pipeline });
33
33
  };
34
34
 
35
+ // Applies only the fields the form changed, so stage edits made outside the form are kept
36
+ const applyChangedValues = (stage: IStage, values: IStage, prevValues: IStage): IStage => {
37
+ const changes: Partial<IStage> = {};
38
+ new Set([...Object.keys(prevValues), ...Object.keys(values)]).forEach((key) => {
39
+ if (values[key] !== prevValues[key]) {
40
+ changes[key] = values[key];
41
+ }
42
+ });
43
+ return { ...stage, ...changes };
44
+ };
45
+
35
46
  export class FormikStageConfig extends React.Component<IFormikStageConfigProps> {
36
47
  public render() {
37
48
  const { render, onChange, stage, validate, application, pipeline } = this.props;
@@ -42,7 +53,13 @@ export class FormikStageConfig extends React.Component<IFormikStageConfigProps>
42
53
  onSubmit={() => {}}
43
54
  render={(formik) => (
44
55
  <LayoutProvider value={ResponsiveFieldLayout}>
45
- <WatchValue onChange={onChange} value={formik.values} />
56
+ <WatchValue
57
+ onChange={
58
+ onChange &&
59
+ ((values: IStage, prevValues: IStage) => onChange(applyChangedValues(stage, values, prevValues)))
60
+ }
61
+ value={formik.values}
62
+ />
46
63
  {render({ application, pipeline, formik })}
47
64
  </LayoutProvider>
48
65
  )}
@@ -0,0 +1,70 @@
1
+ import { validateEvaluateVariablesStage } from './EvaluateVariablesStageConfig';
2
+ import type { IStage } from '../../../../domain';
3
+
4
+ function buildStage(variables: Array<{ key: string; value: string }>): IStage {
5
+ return ({ variables } as unknown) as IStage;
6
+ }
7
+
8
+ describe('validateEvaluateVariablesStage', () => {
9
+ describe('when dashed identifier support is disabled (default)', () => {
10
+ it('rejects a variable name containing a hyphen', () => {
11
+ const stage = buildStage([{ key: 'my-container-name', value: '${1}' }]);
12
+
13
+ const result = validateEvaluateVariablesStage(stage);
14
+
15
+ expect(result.variables[0].key).toEqual(
16
+ 'Error: Variable Name should consist only of letters, numbers, or underscore',
17
+ );
18
+ });
19
+
20
+ it('accepts a variable name using only letters, numbers, and underscore', () => {
21
+ const stage = buildStage([{ key: 'my_container_name1', value: '${1}' }]);
22
+
23
+ const result = validateEvaluateVariablesStage(stage);
24
+
25
+ expect(result.variables).toBeUndefined();
26
+ });
27
+ });
28
+
29
+ describe('when dashed identifier support is enabled', () => {
30
+ it('accepts a variable name containing a hyphen', () => {
31
+ const stage = buildStage([{ key: 'my-container-name', value: '${1}' }]);
32
+
33
+ const result = validateEvaluateVariablesStage(stage, true);
34
+
35
+ expect(result.variables).toBeUndefined();
36
+ });
37
+
38
+ it('still rejects a variable name starting with a digit', () => {
39
+ const stage = buildStage([{ key: '1-my-container-name', value: '${1}' }]);
40
+
41
+ const result = validateEvaluateVariablesStage(stage, true);
42
+
43
+ expect(result.variables[0].key).toEqual(
44
+ 'Error: Variable Name should consist only of letters, numbers, underscore, or hyphen',
45
+ );
46
+ });
47
+
48
+ it('still rejects a variable name containing a space or other invalid character', () => {
49
+ const stage = buildStage([{ key: 'my container name', value: '${1}' }]);
50
+
51
+ const result = validateEvaluateVariablesStage(stage, true);
52
+
53
+ expect(result.variables[0].key).toEqual(
54
+ 'Error: Variable Name should consist only of letters, numbers, underscore, or hyphen',
55
+ );
56
+ });
57
+ });
58
+
59
+ it('still enforces the duplicate key validator regardless of dashed identifier support', () => {
60
+ const stage = buildStage([
61
+ { key: 'my-name', value: '${1}' },
62
+ { key: 'my-name', value: '${2}' },
63
+ ]);
64
+
65
+ const result = validateEvaluateVariablesStage(stage, true);
66
+
67
+ expect(result.variables[0].key).toEqual("Duplicate key 'my-name'");
68
+ expect(result.variables[1].key).toEqual("Duplicate key 'my-name'");
69
+ });
70
+ });
@@ -4,6 +4,7 @@ import React from 'react';
4
4
 
5
5
  import type { IExecutionAndStagePickerProps } from './ExecutionAndStagePicker';
6
6
  import { ExecutionAndStagePicker } from './ExecutionAndStagePicker';
7
+ import { ExpressionCapabilitiesReader } from './ExpressionCapabilitiesReader';
7
8
  import type { IFormikStageConfigInjectedProps } from '../FormikStageConfig';
8
9
  import { FormikStageConfig } from '../FormikStageConfig';
9
10
  import type { IStageConfigProps } from '../common';
@@ -31,9 +32,14 @@ export interface IEvaluatedVariable {
31
32
  value: string;
32
33
  }
33
34
 
34
- const variableNameValidator: IValidator = (val: string, label: string) =>
35
- !val.match(/^[a-zA-Z_][a-zA-Z0-9_]*$/) &&
36
- errorMessage(`${label} should consist only of letters, numbers, or underscore`);
35
+ const createVariableNameValidator = (dashedIdentifiersEnabled: boolean): IValidator => {
36
+ const pattern = dashedIdentifiersEnabled ? /^[a-zA-Z_][a-zA-Z0-9_-]*$/ : /^[a-zA-Z_][a-zA-Z0-9_]*$/;
37
+ const allowedCharacters = dashedIdentifiersEnabled
38
+ ? 'letters, numbers, underscore, or hyphen'
39
+ : 'letters, numbers, or underscore';
40
+ return (val: string, label: string) =>
41
+ !val.match(pattern) && errorMessage(`${label} should consist only of ${allowedCharacters}`);
42
+ };
37
43
 
38
44
  const duplicateKeyValidatorFactory = (variables: IEvaluatedVariable[] = []) => {
39
45
  const keyCounts = countBy(
@@ -43,9 +49,10 @@ const duplicateKeyValidatorFactory = (variables: IEvaluatedVariable[] = []) => {
43
49
  return (key: string) => keyCounts[key] > 1 && `Duplicate key '${key}'`;
44
50
  };
45
51
 
46
- export function validateEvaluateVariablesStage(stage: IStage) {
52
+ export function validateEvaluateVariablesStage(stage: IStage, dashedIdentifiersEnabled = false) {
47
53
  const formValidator = new FormValidator(stage);
48
54
  const duplicateKeyValidator = duplicateKeyValidatorFactory(stage.variables);
55
+ const variableNameValidator = createVariableNameValidator(dashedIdentifiersEnabled);
49
56
  formValidator.field('variables').withValidators(
50
57
  formValidator.arrayForEach((item) => {
51
58
  item.field('key', 'Variable Name').required().withValidators(variableNameValidator, duplicateKeyValidator);
@@ -75,6 +82,13 @@ function PreviewConfiguration(props: IExecutionAndStagePickerProps) {
75
82
  export function EvaluateVariablesStageConfig(props: IStageConfigProps) {
76
83
  const { application, stage, pipeline, updateStage } = props;
77
84
  const [chosenStage, setChosenStage] = React.useState({} as IStageForSpelPreview);
85
+ const [dashedIdentifiersEnabled, setDashedIdentifiersEnabled] = React.useState(false);
86
+
87
+ React.useEffect(() => {
88
+ ExpressionCapabilitiesReader.getExpressionCapabilities()
89
+ .then((capabilities) => setDashedIdentifiersEnabled(capabilities.dashedIdentifiersEnabled))
90
+ .catch(() => setDashedIdentifiersEnabled(false));
91
+ }, []);
78
92
 
79
93
  const helpMessage =
80
94
  'Define one or more variables by assigning a **name** _(string)_ and a **value** ' +
@@ -86,7 +100,9 @@ export function EvaluateVariablesStageConfig(props: IStageConfigProps) {
86
100
  application={application}
87
101
  stage={stage}
88
102
  pipeline={pipeline}
89
- validate={validateEvaluateVariablesStage}
103
+ validate={(stageToValidate) =>
104
+ validateEvaluateVariablesStage(stageToValidate as IStage, dashedIdentifiersEnabled)
105
+ }
90
106
  onChange={updateStage}
91
107
  render={(renderProps) => {
92
108
  return (
@@ -0,0 +1,13 @@
1
+ import { REST } from '../../../../api';
2
+
3
+ export interface IExpressionCapabilities {
4
+ functions: unknown[];
5
+ spelEvaluators: unknown[];
6
+ dashedIdentifiersEnabled: boolean;
7
+ }
8
+
9
+ export class ExpressionCapabilitiesReader {
10
+ public static getExpressionCapabilities(): Promise<IExpressionCapabilities> {
11
+ return REST('/capabilities/expressions').useCache(true).get();
12
+ }
13
+ }
@@ -11,9 +11,16 @@ export interface IExecutionOptionsPageContentProps {
11
11
 
12
12
  export function ExecutionOptionsPageContent(props: IExecutionOptionsPageContentProps) {
13
13
  const { pipeline, updatePipelineConfig } = props;
14
- const [currentMaxConcurrent, setCurrentMaxConcurrent] = useState(pipeline.maxConcurrentExecutions || 0);
14
+ const maxConcurrent = pipeline.maxConcurrentExecutions || 0;
15
+ const [currentMaxConcurrent, setCurrentMaxConcurrent] = useState(maxConcurrent);
16
+ const [lastPipeline, setLastPipeline] = useState(pipeline);
17
+ // Show the pipeline's value when the pipeline changes, such as after a revert or pipeline JSON edit
18
+ if (pipeline !== lastPipeline) {
19
+ setLastPipeline(pipeline);
20
+ setCurrentMaxConcurrent(maxConcurrent);
21
+ }
15
22
  const handleMaxConcurrentChange = (changeEvent: any) => {
16
- const value = Number.parseInt(changeEvent.target.value);
23
+ const value = Number.parseInt(changeEvent.target.value) || 0;
17
24
  setCurrentMaxConcurrent(value);
18
25
  updatePipelineConfig({ maxConcurrentExecutions: value });
19
26
  };
@@ -29,11 +36,11 @@ export function ExecutionOptionsPageContent(props: IExecutionOptionsPageContentP
29
36
  />
30
37
  )}
31
38
  onChange={() => {
32
- updatePipelineConfig({ limitConcurrent: !pipeline.limitConcurrent });
39
+ const changes: Partial<IPipeline> = { limitConcurrent: !pipeline.limitConcurrent };
33
40
  if (!pipeline.limitConcurrent && !pipeline.keepWaitingPipelines) {
34
- pipeline.keepWaitingPipelines = true;
35
- updatePipelineConfig({ keepWaitingPipelines: true });
41
+ changes.keepWaitingPipelines = true;
36
42
  }
43
+ updatePipelineConfig(changes);
37
44
  }}
38
45
  value={pipeline.limitConcurrent}
39
46
  />
@@ -31,7 +31,6 @@ describe('<Triggers />', () => {
31
31
  application: ApplicationModelBuilder.createApplicationForTests('products'),
32
32
  fieldUpdated: jasmine.createSpy('fieldUpdated'),
33
33
  updatePipelineConfig: jasmine.createSpy('updatePipelineConfig'),
34
- revertCount: 0,
35
34
  };
36
35
  });
37
36
 
@@ -15,14 +15,10 @@ export interface ITriggersProps {
15
15
  pipelineConfig?: IPipeline;
16
16
  fieldUpdated: () => void;
17
17
  updatePipelineConfig: (changes: Partial<IPipeline>) => void;
18
- revertCount: number;
19
18
  }
20
19
 
21
20
  export function Triggers(props: ITriggersProps) {
22
21
  const pipeline = props.pipeline;
23
- // KLUDGE: This value is used as a React key when rendering the Triggers.
24
- // Whenever the pipeline is reverted, this causes the Triggers to remount and reset formik state.
25
- const revertCountKLUDGE = props.revertCount;
26
22
 
27
23
  return (
28
24
  <PageNavigator scrollableContainer=".pipeline-config-page">
@@ -35,7 +31,7 @@ export function Triggers(props: ITriggersProps) {
35
31
  badge={pipeline.triggers ? pipeline.triggers.length.toString() : '0'}
36
32
  noWrapper={true}
37
33
  >
38
- <TriggersPageContent {...props} key={revertCountKLUDGE} />
34
+ <TriggersPageContent {...props} />
39
35
  </PageSection>
40
36
  <PageSection
41
37
  pageKey="parameters"
@@ -57,7 +53,6 @@ export function Triggers(props: ITriggersProps) {
57
53
  <MetadataPageContent
58
54
  pipeline={props.pipelineConfig || props.pipeline}
59
55
  updatePipelineConfig={props.updatePipelineConfig}
60
- key={revertCountKLUDGE}
61
56
  />
62
57
  </PageSection>
63
58
  </PageNavigator>
@@ -24,12 +24,15 @@ export function TriggersPageContent(props: ITriggersPageContentProps) {
24
24
  const { pipeline, application, updatePipelineConfig } = props;
25
25
  const expectedArtifacts = pipeline?.expectedArtifacts ?? [];
26
26
  const triggers = pipeline?.triggers ?? [];
27
- // KLUDGE: because we don't have a stable identifier to use for each trigger object, we need to reset the keys
28
- // used for each trigger in the list when a delete happens (because the array of triggers shifts by one).
29
- // For now, we do this by incrementing a counter every time a delete happens, and sticking that number
30
- // into the key of each trigger before its index value. Ideally, we should generate stable UUIDs for
31
- // each trigger object that we can use instead of relying on index keys.
32
- const [deleteCount, setDeleteCount] = useState(0);
27
+ // Each trigger object gets its own form, so replacing a trigger remounts it.
28
+ // The key follows a trigger through its form's own edits.
29
+ const [triggerKeys] = useState(() => ({ ids: new WeakMap<ITrigger, number>(), next: 0 }));
30
+ const getTriggerKey = (trigger: ITrigger) => {
31
+ if (!triggerKeys.ids.has(trigger)) {
32
+ triggerKeys.ids.set(trigger, triggerKeys.next++);
33
+ }
34
+ return triggerKeys.ids.get(trigger);
35
+ };
33
36
 
34
37
  function updateRoles(roles: any[]): void {
35
38
  updatePipelineConfig({ roles });
@@ -72,7 +75,6 @@ export function TriggersPageContent(props: ITriggersPageContentProps) {
72
75
  }
73
76
 
74
77
  updatePipelineConfig(pipelineUpdate);
75
- setDeleteCount(deleteCount + 1);
76
78
  }
77
79
 
78
80
  /**
@@ -82,6 +84,7 @@ export function TriggersPageContent(props: ITriggersPageContentProps) {
82
84
  * @param updatedTrigger The updated trigger
83
85
  */
84
86
  function updateTrigger(indexToUpdate: number, updatedTrigger: ITrigger): void {
87
+ triggerKeys.ids.set(updatedTrigger, getTriggerKey(triggers[indexToUpdate]));
85
88
  PipelineConfigValidator.validatePipeline(pipeline);
86
89
  updatePipelineConfig({
87
90
  triggers: triggers.map((trigger, index) => {
@@ -181,7 +184,7 @@ export function TriggersPageContent(props: ITriggersPageContentProps) {
181
184
  </div>
182
185
  )}
183
186
  {triggers.map((trigger, index) => (
184
- <div className="trigger-config" key={`${deleteCount}:${index}`}>
187
+ <div className="trigger-config" key={getTriggerKey(trigger)}>
185
188
  <Trigger
186
189
  application={application}
187
190
  index={index}