Skip to content

Commit e231975

Browse files
authored
Merge pull request #105 from timoa/fix/75-improve-matrix-strategy
fix(job): add custom matrix strategy + update predefined version #75
2 parents 476c866 + 83ce5d1 commit e231975

10 files changed

Lines changed: 349 additions & 129 deletions

File tree

media/main.css

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

media/main.css.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

media/main.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

media/main.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/JobPropertyPanel.tsx

Lines changed: 90 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getMatrixVariableValues,
77
isCommonMatrixVariable,
88
} from '@/lib/matrixOptions'
9+
import { mergeMatrixEntry } from '@/lib/matrixUtils'
910
import { RUNNER_OPTIONS } from '@/lib/runnerConfig'
1011
import { SiUbuntu, SiApple } from 'react-icons/si'
1112
import { FaWindows } from 'react-icons/fa'
@@ -132,8 +133,11 @@ export function JobPropertyPanel({
132133
}
133134

134135
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
136+
const [addPredefinedOpen, setAddPredefinedOpen] = useState(false)
135137
const [matrixVariableDropdowns, setMatrixVariableDropdowns] = useState<Record<string, boolean>>({})
136138
const [matrixValueDropdowns, setMatrixValueDropdowns] = useState<Record<string, boolean>>({})
139+
const [customMatrixName, setCustomMatrixName] = useState('')
140+
const [customMatrixValues, setCustomMatrixValues] = useState('')
137141
const dropdownRef = useRef<HTMLDivElement>(null)
138142

139143
useEffect(() => {
@@ -146,17 +150,18 @@ export function JobPropertyPanel({
146150
if (!(target instanceof Element)) return
147151
const isInsideMatrixDropdown = target.closest('[data-matrix-dropdown]')
148152
if (!isInsideMatrixDropdown) {
153+
setAddPredefinedOpen(false)
149154
setMatrixVariableDropdowns({})
150155
setMatrixValueDropdowns({})
151156
}
152157
}
153-
if (isDropdownOpen || Object.keys(matrixVariableDropdowns).length > 0 || Object.keys(matrixValueDropdowns).length > 0) {
158+
if (isDropdownOpen || addPredefinedOpen || Object.keys(matrixVariableDropdowns).length > 0 || Object.keys(matrixValueDropdowns).length > 0) {
154159
document.addEventListener('mousedown', handleClickOutside)
155160
}
156161
return () => {
157162
document.removeEventListener('mousedown', handleClickOutside)
158163
}
159-
}, [isDropdownOpen, matrixVariableDropdowns, matrixValueDropdowns])
164+
}, [isDropdownOpen, addPredefinedOpen, matrixVariableDropdowns, matrixValueDropdowns])
160165

161166
const handleRunsOnChange = useCallback(
162167
(value: string) => {
@@ -768,68 +773,94 @@ export function JobPropertyPanel({
768773
</div>
769774
)
770775
})}
771-
<div className="relative">
772-
<button
773-
type="button"
774-
onClick={() =>
775-
setMatrixVariableDropdowns((prev) => ({
776-
...prev,
777-
'__new__': !prev['__new__'],
778-
}))
779-
}
780-
className="w-full rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-2 py-1 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600 flex items-center justify-between"
781-
>
782-
<span>+ Add matrix variable</span>
783-
<span className="text-slate-400 dark:text-slate-500"></span>
784-
</button>
785-
{matrixVariableDropdowns['__new__'] && (
786-
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded shadow-lg max-h-60 overflow-auto">
787-
{COMMON_MATRIX_VARIABLES.map((option) => (
788-
<button
789-
key={option.name}
790-
type="button"
791-
onClick={() => {
792-
const newMatrix = { ...job.strategy!.matrix! }
793-
newMatrix[option.name] = [option.values[0]]
776+
<div className="space-y-2">
777+
<div>
778+
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Predefined (language/version)</label>
779+
<div className="relative">
780+
<button
781+
type="button"
782+
onClick={() => setAddPredefinedOpen((prev) => !prev)}
783+
className="w-full rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-2 py-1 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600 flex items-center justify-between"
784+
>
785+
<span>+ Add predefined variable</span>
786+
<span className="text-slate-400 dark:text-slate-500"></span>
787+
</button>
788+
{addPredefinedOpen && (
789+
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded shadow-lg max-h-60 overflow-auto">
790+
{COMMON_MATRIX_VARIABLES.map((option) => (
791+
<button
792+
key={option.name}
793+
type="button"
794+
onClick={() => {
795+
const newMatrix = mergeMatrixEntry(
796+
job.strategy!.matrix!,
797+
option.name,
798+
[option.values[0]]
799+
)
800+
setJobField('strategy', {
801+
...job.strategy,
802+
matrix: newMatrix,
803+
})
804+
setAddPredefinedOpen(false)
805+
}}
806+
className="w-full px-2 py-1.5 text-sm text-left hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-900 dark:text-slate-200"
807+
>
808+
{option.label}
809+
</button>
810+
))}
811+
</div>
812+
)}
813+
</div>
814+
</div>
815+
<div>
816+
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Custom variables</label>
817+
<div className="flex gap-2">
818+
<input
819+
type="text"
820+
value={customMatrixName}
821+
onChange={(e) => setCustomMatrixName(e.target.value)}
822+
className="flex-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-200 px-2 py-1 text-sm"
823+
placeholder="Variable name"
824+
/>
825+
<input
826+
type="text"
827+
value={customMatrixValues}
828+
onChange={(e) => setCustomMatrixValues(e.target.value)}
829+
className="flex-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-200 px-2 py-1 text-sm"
830+
placeholder="value1, value2, value3"
831+
/>
832+
<button
833+
type="button"
834+
onClick={() => {
835+
const name = customMatrixName.trim()
836+
const values = customMatrixValues
837+
.split(',')
838+
.map((v) => v.trim())
839+
.filter(Boolean)
840+
if (name && values.length > 0) {
841+
const newMatrix = mergeMatrixEntry(
842+
job.strategy!.matrix!,
843+
name,
844+
values
845+
)
794846
setJobField('strategy', {
795847
...job.strategy,
796848
matrix: newMatrix,
797849
})
798-
setMatrixVariableDropdowns((prev) => ({
799-
...prev,
800-
'__new__': false,
801-
}))
802-
}}
803-
className="w-full px-2 py-1.5 text-sm text-left hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-900 dark:text-slate-200"
804-
>
805-
{option.label}
806-
</button>
807-
))}
808-
<div className="border-t border-slate-200 dark:border-slate-700 px-2 py-1">
809-
<input
810-
type="text"
811-
onKeyDown={(e) => {
812-
if (e.key === 'Enter') {
813-
const input = e.currentTarget
814-
const newMatrix = { ...job.strategy!.matrix!, [input.value]: [''] }
815-
setJobField('strategy', {
816-
...job.strategy,
817-
matrix: newMatrix,
818-
})
819-
setMatrixVariableDropdowns((prev) => ({
820-
...prev,
821-
'__new__': false,
822-
}))
823-
input.value = ''
824-
}
825-
}}
826-
className="w-full rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-200 px-2 py-1 text-sm"
827-
placeholder="Custom variable name (Enter)"
828-
onClick={(e) => e.stopPropagation()}
829-
/>
830-
</div>
850+
setCustomMatrixName('')
851+
setCustomMatrixValues('')
852+
}
853+
}}
854+
disabled={
855+
!customMatrixName.trim() ||
856+
customMatrixValues.split(',').map((v) => v.trim()).filter(Boolean).length === 0
857+
}
858+
className="rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-2 py-1 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
859+
>
860+
Add
861+
</button>
831862
</div>
832-
)}
863+
</div>
833864
</div>
834865
</div>
835866
<div className="flex items-center gap-4 border-t border-slate-200 dark:border-slate-700 pt-2">

src/lib/matrixOptions.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('COMMON_MATRIX_VARIABLES', () => {
2525

2626
describe('getMatrixVariableValues', () => {
2727
it('returns values for known variable name', () => {
28-
expect(getMatrixVariableValues('node')).toEqual(['16', '18', '20', '22'])
28+
expect(getMatrixVariableValues('node')).toEqual(['20', '22', '24', '25'])
2929
expect(getMatrixVariableValues('os')).toContain('ubuntu-latest')
3030
expect(getMatrixVariableValues('python')).toContain('3.12')
3131
})

src/lib/matrixOptions.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ export const COMMON_MATRIX_VARIABLES: MatrixVariableOption[] = [
1313
{
1414
name: 'node',
1515
label: 'Node.js',
16-
values: ['16', '18', '20', '22'],
16+
values: ['20', '22', '24', '25'],
1717
},
1818
{
1919
name: 'python',
2020
label: 'Python',
21-
values: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'],
21+
values: ['3.10', '3.11', '3.12', '3.13', '3.14'],
2222
},
2323
{
2424
name: 'os',
@@ -46,27 +46,27 @@ export const COMMON_MATRIX_VARIABLES: MatrixVariableOption[] = [
4646
{
4747
name: 'java',
4848
label: 'Java',
49-
values: ['8', '11', '17', '21'],
49+
values: ['17', '21', '25'],
5050
},
5151
{
5252
name: 'go',
5353
label: 'Go',
54-
values: ['1.19', '1.20', '1.21', '1.22', '1.23'],
54+
values: ['1.25', '1.26'],
5555
},
5656
{
5757
name: 'ruby',
5858
label: 'Ruby',
59-
values: ['3.0', '3.1', '3.2', '3.3'],
59+
values: ['3.2', '3.3', '3.4'],
6060
},
6161
{
6262
name: 'php',
6363
label: 'PHP',
64-
values: ['8.0', '8.1', '8.2', '8.3'],
64+
values: ['8.2', '8.3', '8.4', '8.5'],
6565
},
6666
{
6767
name: 'dotnet',
6868
label: '.NET',
69-
values: ['6.0', '7.0', '8.0'],
69+
values: ['8.0', '9.0', '10.0'],
7070
},
7171
]
7272

src/lib/matrixUtils.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { mergeMatrixEntry, type MatrixRecord } from './matrixUtils'
3+
4+
describe('mergeMatrixEntry', () => {
5+
it('returns unchanged matrix when incoming values are empty after trim', () => {
6+
const matrix: MatrixRecord = { node: ['20', '22'] }
7+
const result = mergeMatrixEntry(matrix, 'node', [])
8+
expect(result).toEqual({ node: ['20', '22'] })
9+
expect(result).not.toBe(matrix)
10+
})
11+
12+
it('returns unchanged matrix when incoming values are only whitespace', () => {
13+
const matrix: MatrixRecord = { node: ['20'] }
14+
const result = mergeMatrixEntry(matrix, 'node', [' ', '', ' '])
15+
expect(result).toEqual({ node: ['20'] })
16+
})
17+
18+
it('creates new string entry when key does not exist', () => {
19+
const matrix: MatrixRecord = {}
20+
const result = mergeMatrixEntry(matrix, 'node', ['20', '22'])
21+
expect(result).toEqual({ node: ['20', '22'] })
22+
})
23+
24+
it('creates new string entry when existing value is not an array', () => {
25+
const matrix = { node: 'invalid' } as unknown as MatrixRecord
26+
const result = mergeMatrixEntry(matrix, 'node', ['20'])
27+
expect(result).toEqual({ node: ['20'] })
28+
})
29+
30+
it('merges into existing string array and dedupes by string equality', () => {
31+
const matrix: MatrixRecord = { node: ['20', '22'] }
32+
const result = mergeMatrixEntry(matrix, 'node', ['22', '24'])
33+
expect(result).toEqual({ node: ['20', '22', '24'] })
34+
})
35+
36+
it('does not add duplicate when value already exists as string', () => {
37+
const matrix: MatrixRecord = { node: ['20'] }
38+
const result = mergeMatrixEntry(matrix, 'node', ['20'])
39+
expect(result).toEqual({ node: ['20'] })
40+
})
41+
42+
it('merges into existing number array and coerces incoming values', () => {
43+
const matrix: MatrixRecord = { node: [20, 22] }
44+
const result = mergeMatrixEntry(matrix, 'node', ['24', '25'])
45+
expect(result).toEqual({ node: [20, 22, 24, 25] })
46+
})
47+
48+
it('filters out NaN when merging into number array', () => {
49+
const matrix: MatrixRecord = { node: [20, 22] }
50+
const result = mergeMatrixEntry(matrix, 'node', ['abc', '24', 'x'])
51+
expect(result).toEqual({ node: [20, 22, 24] })
52+
})
53+
54+
it('dedupes when merging into number array (string "20" matches existing 20)', () => {
55+
const matrix: MatrixRecord = { node: [20, 22] }
56+
const result = mergeMatrixEntry(matrix, 'node', ['20', '24'])
57+
expect(result).toEqual({ node: [20, 22, 24] })
58+
})
59+
60+
it('trims whitespace from incoming values', () => {
61+
const matrix: MatrixRecord = {}
62+
const result = mergeMatrixEntry(matrix, 'node', [' 20 ', ' 22 ', '24'])
63+
expect(result).toEqual({ node: ['20', '22', '24'] })
64+
})
65+
66+
it('does not mutate the input matrix', () => {
67+
const matrix: MatrixRecord = { node: ['20'] }
68+
const result = mergeMatrixEntry(matrix, 'node', ['22'])
69+
expect(matrix).toEqual({ node: ['20'] })
70+
expect(result).toEqual({ node: ['20', '22'] })
71+
})
72+
73+
it('preserves other matrix keys when merging', () => {
74+
const matrix: MatrixRecord = { node: ['20'], os: ['ubuntu-latest'] }
75+
const result = mergeMatrixEntry(matrix, 'node', ['22'])
76+
expect(result).toEqual({ node: ['20', '22'], os: ['ubuntu-latest'] })
77+
})
78+
79+
it('handles empty existing array by adding as strings', () => {
80+
const matrix: MatrixRecord = { node: [] }
81+
const result = mergeMatrixEntry(matrix, 'node', ['20', '22'])
82+
expect(result).toEqual({ node: ['20', '22'] })
83+
})
84+
85+
it('dedupes within incoming batch before adding to new key', () => {
86+
const matrix: MatrixRecord = {}
87+
const result = mergeMatrixEntry(matrix, 'node', ['20', '22', '20', '24'])
88+
expect(result).toEqual({ node: ['20', '22', '24'] })
89+
})
90+
91+
it('dedupes within incoming batch before adding to existing array', () => {
92+
const matrix: MatrixRecord = { node: ['20'] }
93+
const result = mergeMatrixEntry(matrix, 'node', ['22', '24', '22'])
94+
expect(result).toEqual({ node: ['20', '22', '24'] })
95+
})
96+
97+
it('preserves version string semantics (3.10 is not 3.1)', () => {
98+
const matrix: MatrixRecord = { python: ['3.1', '3.9'] }
99+
const result = mergeMatrixEntry(matrix, 'python', ['3.10', '3.11'])
100+
expect(result).toEqual({ python: ['3.1', '3.9', '3.10', '3.11'] })
101+
})
102+
})

0 commit comments

Comments
 (0)