@cat-factory/app 0.267.0 → 0.268.0
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/app/components/layout/AccountRiskPolicySettings.vue +120 -0
- package/app/components/settings/AccountSettingsPanel.vue +17 -2
- package/app/components/settings/RiskPolicyCreateForm.vue +164 -0
- package/app/components/settings/RiskPolicyEditorRow.vue +315 -0
- package/app/components/settings/RiskPolicyInheritedRow.vue +69 -0
- package/app/components/settings/RiskPolicyPanel.vue +176 -603
- package/app/composables/api/presets.ts +50 -13
- package/app/composables/usePipelineErrorToast.ts +11 -0
- package/app/composables/useRiskPolicyHealth.ts +14 -1
- package/app/composables/useRunStart.spec.ts +9 -5
- package/app/stores/riskPolicies.ts +123 -13
- package/app/types/merge.ts +5 -0
- package/app/utils/riskPolicy.spec.ts +28 -0
- package/app/utils/riskPolicy.ts +24 -1
- package/app/utils/riskPolicyDraft.ts +203 -0
- package/i18n/locales/de.json +45 -4
- package/i18n/locales/en.json +45 -4
- package/i18n/locales/es.json +45 -4
- package/i18n/locales/fr.json +45 -4
- package/i18n/locales/he.json +45 -4
- package/i18n/locales/it.json +45 -4
- package/i18n/locales/ja.json +45 -4
- package/i18n/locales/pl.json +45 -4
- package/i18n/locales/tr.json +45 -4
- package/i18n/locales/uk.json +45 -4
- package/package.json +2 -2
|
@@ -1,325 +1,116 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Workspace settings: the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import type { StepGating } from '@cat-factory/contracts'
|
|
17
|
-
import {
|
|
18
|
-
RISK_POLICY_AXES,
|
|
19
|
-
RISK_POLICY_CEILING_FIELD,
|
|
20
|
-
type RiskPolicyAxis,
|
|
21
|
-
} from '~/utils/riskPolicy'
|
|
22
|
-
import MergeClassRulesEditor from '~/components/settings/MergeClassRulesEditor.vue'
|
|
23
|
-
import MergeRolePolicyEditor from '~/components/settings/MergeRolePolicyEditor.vue'
|
|
2
|
+
// Workspace settings: the risk policy library a task picks its auto-merge policy from (the `merger`
|
|
3
|
+
// step compares a PR's assessment against the resolved policy).
|
|
4
|
+
//
|
|
5
|
+
// Since ADR 0055 the library has two tiers. The board's OWN policies are fully editable here; the
|
|
6
|
+
// ones it INHERITS from its account are read-only, with the two actions a board has over a row it
|
|
7
|
+
// does not own (clone it in, or hide it). A third section lists what is currently hidden, because a
|
|
8
|
+
// hidden policy is by construction absent from the library above and would otherwise be a one-way
|
|
9
|
+
// door.
|
|
10
|
+
import { computed, onMounted, ref } from 'vue'
|
|
11
|
+
import type { RiskPolicyLibraryEntry, UpdateRiskPolicyInput } from '~/types/merge'
|
|
12
|
+
import { riskPolicyCopyName } from '~/utils/riskPolicy'
|
|
13
|
+
import RiskPolicyCreateForm from '~/components/settings/RiskPolicyCreateForm.vue'
|
|
14
|
+
import RiskPolicyEditorRow from '~/components/settings/RiskPolicyEditorRow.vue'
|
|
15
|
+
import RiskPolicyInheritedRow from '~/components/settings/RiskPolicyInheritedRow.vue'
|
|
24
16
|
|
|
25
17
|
const { t } = useI18n()
|
|
26
18
|
|
|
27
|
-
// Both axis groups below iterate the SHARED presentation order rather than hard-coding one,
|
|
28
|
-
// so this editor can't drift from the order the picker's preview and the inspector's summary
|
|
29
|
-
// line use (it used to read complexity-first while they read risk-first).
|
|
30
|
-
const CEILING_LABEL_KEYS: Record<RiskPolicyAxis, string> = {
|
|
31
|
-
risk: 'settings.riskPolicy.field.maxRisk',
|
|
32
|
-
impact: 'settings.riskPolicy.field.maxImpact',
|
|
33
|
-
complexity: 'settings.riskPolicy.field.maxComplexity',
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// The fork-decision group is the same three axes read as FLOORS (how big an estimate has to
|
|
37
|
-
// be before the coder stops to propose implementations), so it shares the order but not the
|
|
38
|
-
// fields.
|
|
39
|
-
const FORK_FLOOR_FIELD: Record<
|
|
40
|
-
RiskPolicyAxis,
|
|
41
|
-
'forkMinRisk' | 'forkMinImpact' | 'forkMinComplexity'
|
|
42
|
-
> = {
|
|
43
|
-
risk: 'forkMinRisk',
|
|
44
|
-
impact: 'forkMinImpact',
|
|
45
|
-
complexity: 'forkMinComplexity',
|
|
46
|
-
}
|
|
47
|
-
const FORK_FLOOR_LABEL_KEYS: Record<RiskPolicyAxis, string> = {
|
|
48
|
-
risk: 'settings.riskPolicy.forkDecision.minRisk',
|
|
49
|
-
impact: 'settings.riskPolicy.forkDecision.minImpact',
|
|
50
|
-
complexity: 'settings.riskPolicy.forkDecision.minComplexity',
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Per-concern-level label. An exhaustive Record keyed off the union (a missing member fails
|
|
54
|
-
// the typecheck); each value is a LITERAL catalog key so the typed-message-keys check sees
|
|
55
|
-
// it. Leaf keys mirror the enum value verbatim.
|
|
56
|
-
const CONCERN_LABEL_KEYS: Record<RequirementConcernLevel, string> = {
|
|
57
|
-
none: 'settings.riskPolicy.concern.none',
|
|
58
|
-
low: 'settings.riskPolicy.concern.low',
|
|
59
|
-
medium: 'settings.riskPolicy.concern.medium',
|
|
60
|
-
high: 'settings.riskPolicy.concern.high',
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Concern-level options for the requirements auto-pass threshold (none < low < medium < high).
|
|
64
|
-
const CONCERN_LEVELS = computed<{ value: RequirementConcernLevel; label: string }[]>(() => [
|
|
65
|
-
{ value: 'none', label: t(CONCERN_LABEL_KEYS.none) },
|
|
66
|
-
{ value: 'low', label: t(CONCERN_LABEL_KEYS.low) },
|
|
67
|
-
{ value: 'medium', label: t(CONCERN_LABEL_KEYS.medium) },
|
|
68
|
-
{ value: 'high', label: t(CONCERN_LABEL_KEYS.high) },
|
|
69
|
-
])
|
|
70
|
-
|
|
71
19
|
const store = useRiskPoliciesStore()
|
|
72
20
|
const toast = useToast()
|
|
73
21
|
const { present } = usePipelineErrorToast()
|
|
74
22
|
const { confirm } = useConfirm()
|
|
75
23
|
|
|
76
|
-
// Local editable copy per preset, kept in sync with the store. Percentages are
|
|
77
|
-
// edited 0..100 and stored 0..1.
|
|
78
|
-
interface Draft {
|
|
79
|
-
name: string
|
|
80
|
-
maxComplexity: number
|
|
81
|
-
maxRisk: number
|
|
82
|
-
maxImpact: number
|
|
83
|
-
ciMaxAttempts: number
|
|
84
|
-
maxRequirementIterations: number
|
|
85
|
-
maxRequirementConcernAllowed: RequirementConcernLevel
|
|
86
|
-
autoMergeEnabled: boolean
|
|
87
|
-
// Whether a run under this policy answers the parks its own automatic loops raise when they give
|
|
88
|
-
// up, rather than stopping for a person. Edited as a switch because the vocabulary is two-valued
|
|
89
|
-
// and the OFF state is the historical behaviour.
|
|
90
|
-
unattended: boolean
|
|
91
|
-
// The confidence floor an unattended run's auto-answered requirements finding must clear, as a
|
|
92
|
-
// PERCENT (the numbers above are edited the same way). Only read while `unattended` is on, which
|
|
93
|
-
// is why the field is rendered inside that block rather than beside the other budgets.
|
|
94
|
-
minAutoAnswerConfidence: number
|
|
95
|
-
// Per-change-class auto-merge rules. An OMITTED class means "use the score ceilings above",
|
|
96
|
-
// so `{}` is the identity — the editor stores `thresholds` as an omission for that reason.
|
|
97
|
-
classRules: MergeClassRules
|
|
98
|
-
// The ROLE layer over those rules: per-role narrowing (narrow-only, so `{}` is the identity)
|
|
99
|
-
// and the roles whose runs are sandboxed. Both replace the stored value wholesale on save,
|
|
100
|
-
// which is why clearing one in the editor is a plain omission.
|
|
101
|
-
classRulesByRole: ClassRulesByRole
|
|
102
|
-
dryRunRoles: DryRunRoles
|
|
103
|
-
// Which classes each role may LAND at all. A role with no entry is unrestricted, so `{}` is the
|
|
104
|
-
// identity; an entry with no classes is the different policy that the role lands nothing.
|
|
105
|
-
submissionClassesByRole: SubmissionClassesByRole
|
|
106
|
-
// Implementation-fork decision gating (edited 0..100, stored 0..1); disabled ⇒ off in `auto`.
|
|
107
|
-
forkEnabled: boolean
|
|
108
|
-
forkMinComplexity: number
|
|
109
|
-
forkMinRisk: number
|
|
110
|
-
forkMinImpact: number
|
|
111
|
-
forkOnMissing: 'run' | 'skip'
|
|
112
|
-
}
|
|
113
|
-
const drafts = reactive<Record<string, Draft>>({})
|
|
114
|
-
|
|
115
|
-
// On-missing-estimate options for the fork gating group (fail toward asking / skipping).
|
|
116
|
-
const ON_MISSING_OPTIONS = computed<{ value: 'run' | 'skip'; label: string }[]>(() => [
|
|
117
|
-
{ value: 'run', label: t('settings.riskPolicy.forkDecision.onMissing.run') },
|
|
118
|
-
{ value: 'skip', label: t('settings.riskPolicy.forkDecision.onMissing.skip') },
|
|
119
|
-
])
|
|
120
|
-
|
|
121
|
-
/** Build the `StepGating` payload for the fork-decision gate from a draft (or null when off). */
|
|
122
|
-
function forkGating(d: Draft): StepGating {
|
|
123
|
-
return {
|
|
124
|
-
enabled: d.forkEnabled,
|
|
125
|
-
minComplexity: d.forkMinComplexity / 100,
|
|
126
|
-
minRisk: d.forkMinRisk / 100,
|
|
127
|
-
minImpact: d.forkMinImpact / 100,
|
|
128
|
-
onMissingEstimate: d.forkOnMissing,
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function toDraft(p: RiskPolicy): Draft {
|
|
133
|
-
return {
|
|
134
|
-
name: p.name,
|
|
135
|
-
maxComplexity: Math.round(p.maxComplexity * 100),
|
|
136
|
-
maxRisk: Math.round(p.maxRisk * 100),
|
|
137
|
-
maxImpact: Math.round(p.maxImpact * 100),
|
|
138
|
-
ciMaxAttempts: p.ciMaxAttempts,
|
|
139
|
-
maxRequirementIterations: p.maxRequirementIterations,
|
|
140
|
-
maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
|
|
141
|
-
autoMergeEnabled: p.autoMergeEnabled,
|
|
142
|
-
unattended: p.autonomy === 'unattended',
|
|
143
|
-
minAutoAnswerConfidence: Math.round(p.minAutoAnswerConfidence * 100),
|
|
144
|
-
classRules: { ...p.classRules },
|
|
145
|
-
classRulesByRole: { ...p.classRulesByRole },
|
|
146
|
-
dryRunRoles: [...p.dryRunRoles],
|
|
147
|
-
submissionClassesByRole: { ...p.submissionClassesByRole },
|
|
148
|
-
forkEnabled: p.forkDecision?.enabled ?? false,
|
|
149
|
-
forkMinComplexity: Math.round((p.forkDecision?.minComplexity ?? 0.5) * 100),
|
|
150
|
-
forkMinRisk: Math.round((p.forkDecision?.minRisk ?? 0.4) * 100),
|
|
151
|
-
forkMinImpact: Math.round((p.forkDecision?.minImpact ?? 0.4) * 100),
|
|
152
|
-
forkOnMissing: p.forkDecision?.onMissingEstimate ?? 'run',
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
watch(
|
|
157
|
-
() => store.presets,
|
|
158
|
-
(presets) => {
|
|
159
|
-
for (const p of presets) if (!drafts[p.id]) drafts[p.id] = toDraft(p)
|
|
160
|
-
for (const id of Object.keys(drafts)) if (!presets.some((p) => p.id === id)) delete drafts[id]
|
|
161
|
-
},
|
|
162
|
-
{ immediate: true, deep: false },
|
|
163
|
-
)
|
|
164
|
-
|
|
165
24
|
/**
|
|
166
25
|
* Which single control is mid-request, keyed `<policyId>[:<action>]`.
|
|
167
26
|
*
|
|
168
|
-
* Per ACTION and not merely per policy, because a row
|
|
169
|
-
*
|
|
170
|
-
*
|
|
27
|
+
* Per ACTION and not merely per policy, because a row carries several independent buttons: keyed by
|
|
28
|
+
* policy alone, promoting the in-app default spun the unattended button too and told the operator a
|
|
29
|
+
* change they had not asked for was in flight.
|
|
171
30
|
*/
|
|
172
31
|
const busy = ref<string | null>(null)
|
|
32
|
+
const creating = ref(false)
|
|
33
|
+
|
|
34
|
+
const own = computed(() => store.presets.filter((p) => p.tier === 'workspace'))
|
|
35
|
+
const inherited = computed(() => store.presets.filter((p) => p.tier === 'account'))
|
|
173
36
|
|
|
174
37
|
/**
|
|
175
|
-
*
|
|
38
|
+
* Whether the hidden-policy read FAILED, as opposed to answering nothing.
|
|
176
39
|
*
|
|
177
|
-
* The two
|
|
178
|
-
*
|
|
179
|
-
*
|
|
40
|
+
* The two must not render the same. An empty list means the board hides nothing; a failed read means
|
|
41
|
+
* it may be hiding policies this panel cannot show, and silence there would leave an operator who
|
|
42
|
+
* just hid one looking for it in a section that claims to be complete. Not a toast, because this
|
|
43
|
+
* fires on open and only powers an undo affordance: a line in the section itself is the honest place.
|
|
180
44
|
*/
|
|
181
|
-
|
|
182
|
-
if (p.isDefault && p.isUnattendedDefault)
|
|
183
|
-
return t('settings.riskPolicy.deleteBothDefaultsBlocked')
|
|
184
|
-
if (p.isDefault) return t('settings.riskPolicy.deleteDefaultBlocked')
|
|
185
|
-
if (p.isUnattendedDefault) return t('settings.riskPolicy.deleteUnattendedDefaultBlocked')
|
|
186
|
-
return t('settings.riskPolicy.deletePreset')
|
|
187
|
-
}
|
|
45
|
+
const suppressionsFailed = ref(false)
|
|
188
46
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
47
|
+
// What the board hides is not in the snapshot (a hidden policy is absent from the library by
|
|
48
|
+
// construction), so it is read when the panel opens.
|
|
49
|
+
onMounted(() => {
|
|
50
|
+
void store
|
|
51
|
+
.loadSuppressions()
|
|
52
|
+
.then(() => (suppressionsFailed.value = false))
|
|
53
|
+
.catch(() => (suppressionsFailed.value = true))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/** Run one guarded action under a busy key, reporting a failure through the shared funnel. */
|
|
57
|
+
async function run(key: string, titleKey: string, action: () => Promise<unknown>) {
|
|
58
|
+
busy.value = key
|
|
193
59
|
try {
|
|
194
|
-
await
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
classRules: d.classRules,
|
|
206
|
-
classRulesByRole: d.classRulesByRole,
|
|
207
|
-
dryRunRoles: d.dryRunRoles,
|
|
208
|
-
submissionClassesByRole: d.submissionClassesByRole,
|
|
209
|
-
forkDecision: forkGating(d),
|
|
210
|
-
})
|
|
60
|
+
await action()
|
|
61
|
+
} catch (e) {
|
|
62
|
+
present(e, titleKey)
|
|
63
|
+
} finally {
|
|
64
|
+
busy.value = null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function save(policy: RiskPolicyLibraryEntry, patch: UpdateRiskPolicyInput) {
|
|
69
|
+
await run(policy.id, 'settings.riskPolicy.toast.saveFailed', async () => {
|
|
70
|
+
await store.update(policy.id, patch)
|
|
211
71
|
toast.add({
|
|
212
72
|
title: t('settings.riskPolicy.toast.saved'),
|
|
213
73
|
icon: 'i-lucide-check',
|
|
214
74
|
color: 'success',
|
|
215
75
|
})
|
|
216
|
-
}
|
|
217
|
-
present(e, 'settings.riskPolicy.toast.saveFailed')
|
|
218
|
-
} finally {
|
|
219
|
-
busy.value = null
|
|
220
|
-
}
|
|
76
|
+
})
|
|
221
77
|
}
|
|
222
78
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
} catch (e) {
|
|
228
|
-
present(e, 'settings.riskPolicy.toast.defaultFailed')
|
|
229
|
-
} finally {
|
|
230
|
-
busy.value = null
|
|
231
|
-
}
|
|
79
|
+
function makeDefault(policy: RiskPolicyLibraryEntry) {
|
|
80
|
+
return run(`${policy.id}:default`, 'settings.riskPolicy.toast.defaultFailed', () =>
|
|
81
|
+
store.update(policy.id, { isDefault: true }),
|
|
82
|
+
)
|
|
232
83
|
}
|
|
233
84
|
|
|
234
85
|
/**
|
|
235
|
-
* Promote this policy to the UNATTENDED default: the one a task that pins none resolves when
|
|
236
|
-
*
|
|
86
|
+
* Promote this policy to the UNATTENDED default: the one a task that pins none resolves when nothing
|
|
87
|
+
* is watching the run (a start over the public API, a tracker dispatch, a schedule fire).
|
|
237
88
|
*
|
|
238
89
|
* Its own action rather than a second meaning for the button above, because the two defaults are
|
|
239
|
-
* independent: a board can run one posture in the app and another for the work it never sees
|
|
240
|
-
* flagging one policy both ways is a deliberate choice rather than the only option.
|
|
90
|
+
* independent: a board can run one posture in the app and another for the work it never sees.
|
|
241
91
|
*/
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
} catch (e) {
|
|
247
|
-
present(e, 'settings.riskPolicy.toast.defaultFailed')
|
|
248
|
-
} finally {
|
|
249
|
-
busy.value = null
|
|
250
|
-
}
|
|
92
|
+
function makeUnattendedDefault(policy: RiskPolicyLibraryEntry) {
|
|
93
|
+
return run(`${policy.id}:unattended`, 'settings.riskPolicy.toast.defaultFailed', () =>
|
|
94
|
+
store.update(policy.id, { isUnattendedDefault: true }),
|
|
95
|
+
)
|
|
251
96
|
}
|
|
252
97
|
|
|
253
|
-
async function remove(
|
|
98
|
+
async function remove(policy: RiskPolicyLibraryEntry) {
|
|
254
99
|
const ok = await confirm({
|
|
255
100
|
title: t('settings.riskPolicy.confirmDelete.title'),
|
|
256
|
-
description: t('settings.riskPolicy.confirmDelete.body', { name:
|
|
101
|
+
description: t('settings.riskPolicy.confirmDelete.body', { name: policy.name }),
|
|
257
102
|
variant: 'destructive',
|
|
258
103
|
confirmLabel: t('common.delete'),
|
|
259
104
|
icon: 'i-lucide-trash-2',
|
|
260
105
|
})
|
|
261
106
|
if (!ok) return
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
await store.remove(p.id)
|
|
265
|
-
} catch (e) {
|
|
266
|
-
present(e, 'settings.riskPolicy.toast.deleteFailed')
|
|
267
|
-
} finally {
|
|
268
|
-
busy.value = null
|
|
269
|
-
}
|
|
107
|
+
await run(policy.id, 'settings.riskPolicy.toast.deleteFailed', () => store.remove(policy.id))
|
|
270
108
|
}
|
|
271
109
|
|
|
272
|
-
|
|
273
|
-
const creating = ref(false)
|
|
274
|
-
const draft = reactive<Draft>({
|
|
275
|
-
name: '',
|
|
276
|
-
maxComplexity: 50,
|
|
277
|
-
maxRisk: 40,
|
|
278
|
-
maxImpact: 50,
|
|
279
|
-
ciMaxAttempts: 10,
|
|
280
|
-
maxRequirementIterations: 6,
|
|
281
|
-
maxRequirementConcernAllowed: 'none',
|
|
282
|
-
autoMergeEnabled: true,
|
|
283
|
-
// A new policy parks on its own caps, matching every built-in but the unattended default: a
|
|
284
|
-
// licence to answer them is a posture somebody grants, never one a blank form assumes.
|
|
285
|
-
unattended: false,
|
|
286
|
-
minAutoAnswerConfidence: 80,
|
|
287
|
-
// The create row authors the numbers only. Class and role rules start at their identity and
|
|
288
|
-
// are edited on the saved preset, where each rule can be shown beside the base rule (and the
|
|
289
|
-
// track record) it narrows — neither reads as anything on a policy that does not exist yet.
|
|
290
|
-
classRules: {},
|
|
291
|
-
classRulesByRole: {},
|
|
292
|
-
dryRunRoles: [],
|
|
293
|
-
submissionClassesByRole: {},
|
|
294
|
-
forkEnabled: false,
|
|
295
|
-
forkMinComplexity: 50,
|
|
296
|
-
forkMinRisk: 40,
|
|
297
|
-
forkMinImpact: 40,
|
|
298
|
-
forkOnMissing: 'run',
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
async function create() {
|
|
302
|
-
if (!draft.name.trim()) return
|
|
110
|
+
async function create(input: Parameters<typeof store.create>[0]) {
|
|
303
111
|
creating.value = true
|
|
304
112
|
try {
|
|
305
|
-
await store.create(
|
|
306
|
-
name: draft.name.trim(),
|
|
307
|
-
maxComplexity: draft.maxComplexity / 100,
|
|
308
|
-
maxRisk: draft.maxRisk / 100,
|
|
309
|
-
maxImpact: draft.maxImpact / 100,
|
|
310
|
-
ciMaxAttempts: draft.ciMaxAttempts,
|
|
311
|
-
maxRequirementIterations: draft.maxRequirementIterations,
|
|
312
|
-
maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
|
|
313
|
-
autoMergeEnabled: draft.autoMergeEnabled,
|
|
314
|
-
autonomy: draft.unattended ? 'unattended' : 'attended',
|
|
315
|
-
minAutoAnswerConfidence: draft.minAutoAnswerConfidence / 100,
|
|
316
|
-
classRules: draft.classRules,
|
|
317
|
-
forkDecision: forkGating(draft),
|
|
318
|
-
})
|
|
319
|
-
draft.name = ''
|
|
320
|
-
draft.autoMergeEnabled = true
|
|
321
|
-
draft.unattended = false
|
|
322
|
-
draft.classRules = {}
|
|
113
|
+
await store.create(input)
|
|
323
114
|
toast.add({
|
|
324
115
|
title: t('settings.riskPolicy.toast.created'),
|
|
325
116
|
icon: 'i-lucide-check',
|
|
@@ -331,6 +122,50 @@ async function create() {
|
|
|
331
122
|
creating.value = false
|
|
332
123
|
}
|
|
333
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Clone an inherited policy into the board. The copy's NAME is composed here because the backend
|
|
128
|
+
* does not localize prose: it defaults to the source's name, and this states in the reader's own
|
|
129
|
+
* language that the row is a copy. `riskPolicyCopyName` holds it inside the contract's length limit,
|
|
130
|
+
* which the marker would otherwise push a long source name past.
|
|
131
|
+
*/
|
|
132
|
+
function clone(policy: RiskPolicyLibraryEntry) {
|
|
133
|
+
return run(`${policy.id}:clone`, 'settings.riskPolicy.toast.cloneFailed', async () => {
|
|
134
|
+
const name = riskPolicyCopyName(policy.name, (source) =>
|
|
135
|
+
t('settings.riskPolicy.inherited.copyName', { name: source }),
|
|
136
|
+
)
|
|
137
|
+
await store.clone(policy.id, name)
|
|
138
|
+
toast.add({
|
|
139
|
+
title: t('settings.riskPolicy.toast.cloned'),
|
|
140
|
+
icon: 'i-lucide-check',
|
|
141
|
+
color: 'success',
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Hide an inherited policy. Confirmed, because a task that already pinned it falls back to the
|
|
148
|
+
* board's default for its scope — the same thing a deleted local policy does — and that is a change
|
|
149
|
+
* of merge posture on existing work rather than a change to a list.
|
|
150
|
+
*/
|
|
151
|
+
async function hide(policy: RiskPolicyLibraryEntry) {
|
|
152
|
+
const ok = await confirm({
|
|
153
|
+
title: t('settings.riskPolicy.confirmHide.title'),
|
|
154
|
+
description: t('settings.riskPolicy.confirmHide.body', { name: policy.name }),
|
|
155
|
+
confirmLabel: t('settings.riskPolicy.inherited.hide'),
|
|
156
|
+
icon: 'i-lucide-eye-off',
|
|
157
|
+
})
|
|
158
|
+
if (!ok) return
|
|
159
|
+
await run(`${policy.id}:hide`, 'settings.riskPolicy.toast.hideFailed', () =>
|
|
160
|
+
store.hide(policy.id),
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function unhide(presetId: string) {
|
|
165
|
+
return run(`${presetId}:unhide`, 'settings.riskPolicy.toast.unhideFailed', () =>
|
|
166
|
+
store.unhide(presetId),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
334
169
|
</script>
|
|
335
170
|
|
|
336
171
|
<template>
|
|
@@ -346,340 +181,78 @@ async function create() {
|
|
|
346
181
|
</template>
|
|
347
182
|
</i18n-t>
|
|
348
183
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
class="
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
<
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
</UBadge>
|
|
366
|
-
<!--
|
|
367
|
-
Visibly labelled, like its `makeDefault` sibling below. `title` is a tooltip, NOT an
|
|
368
|
-
accessible name: icon-only, this was announced as an unlabelled button, and a sighted
|
|
369
|
-
user had to hover a bare glyph to discover it re-points which policy governs every
|
|
370
|
-
unwatched run. The short text is the accessible name (so it is not one of those buttons
|
|
371
|
-
whose spoken name and printed label disagree) and the longer `title` still explains
|
|
372
|
-
which runs those are. `busy` is compared to a per-BUTTON key so promoting one default
|
|
373
|
-
does not spin the other's button too.
|
|
374
|
-
-->
|
|
375
|
-
<UButton
|
|
376
|
-
v-else
|
|
377
|
-
color="neutral"
|
|
378
|
-
variant="ghost"
|
|
379
|
-
size="xs"
|
|
380
|
-
icon="i-lucide-bot"
|
|
381
|
-
:loading="busy === `${p.id}:unattended`"
|
|
382
|
-
:title="t('settings.riskPolicy.makeUnattendedDefault')"
|
|
383
|
-
@click="makeUnattendedDefault(p)"
|
|
384
|
-
>
|
|
385
|
-
{{ t('settings.riskPolicy.makeUnattendedDefaultShort') }}
|
|
386
|
-
</UButton>
|
|
387
|
-
<UBadge v-if="p.isDefault" color="primary" variant="subtle" size="sm">
|
|
388
|
-
{{ t('settings.riskPolicy.default') }}
|
|
389
|
-
</UBadge>
|
|
390
|
-
<UButton
|
|
391
|
-
v-else
|
|
392
|
-
color="neutral"
|
|
393
|
-
variant="ghost"
|
|
394
|
-
size="xs"
|
|
395
|
-
icon="i-lucide-star"
|
|
396
|
-
:loading="busy === `${p.id}:default`"
|
|
397
|
-
@click="makeDefault(p)"
|
|
398
|
-
>
|
|
399
|
-
{{ t('settings.riskPolicy.makeDefault') }}
|
|
400
|
-
</UButton>
|
|
401
|
-
<UButton
|
|
402
|
-
color="error"
|
|
403
|
-
variant="ghost"
|
|
404
|
-
size="xs"
|
|
405
|
-
icon="i-lucide-trash-2"
|
|
406
|
-
:disabled="p.isDefault || p.isUnattendedDefault || busy?.startsWith(p.id)"
|
|
407
|
-
:title="deleteBlockedReason(p)"
|
|
408
|
-
@click="remove(p)"
|
|
409
|
-
/>
|
|
410
|
-
</div>
|
|
411
|
-
|
|
412
|
-
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
413
|
-
<label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
|
|
414
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
415
|
-
{{ t(CEILING_LABEL_KEYS[axis]) }}
|
|
416
|
-
</span>
|
|
417
|
-
<UInput
|
|
418
|
-
v-model.number="drafts[p.id]![RISK_POLICY_CEILING_FIELD[axis]]"
|
|
419
|
-
type="number"
|
|
420
|
-
:min="0"
|
|
421
|
-
:max="100"
|
|
422
|
-
size="sm"
|
|
423
|
-
/>
|
|
424
|
-
</label>
|
|
425
|
-
<label class="block">
|
|
426
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
427
|
-
{{ t('settings.riskPolicy.field.ciMaxAttempts') }}
|
|
428
|
-
</span>
|
|
429
|
-
<UInput
|
|
430
|
-
v-model.number="drafts[p.id]!.ciMaxAttempts"
|
|
431
|
-
type="number"
|
|
432
|
-
:min="0"
|
|
433
|
-
:max="50"
|
|
434
|
-
size="sm"
|
|
435
|
-
/>
|
|
436
|
-
</label>
|
|
437
|
-
<label class="block">
|
|
438
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
439
|
-
{{ t('settings.riskPolicy.field.maxRequirementIterations') }}
|
|
440
|
-
</span>
|
|
441
|
-
<UInput
|
|
442
|
-
v-model.number="drafts[p.id]!.maxRequirementIterations"
|
|
443
|
-
type="number"
|
|
444
|
-
:min="1"
|
|
445
|
-
:max="20"
|
|
446
|
-
size="sm"
|
|
447
|
-
/>
|
|
448
|
-
</label>
|
|
449
|
-
<label class="block">
|
|
450
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
451
|
-
{{ t('settings.riskPolicy.field.maxRequirementConcernAllowed') }}
|
|
452
|
-
</span>
|
|
453
|
-
<USelect
|
|
454
|
-
v-model="drafts[p.id]!.maxRequirementConcernAllowed"
|
|
455
|
-
:items="CONCERN_LEVELS"
|
|
456
|
-
value-key="value"
|
|
457
|
-
size="sm"
|
|
458
|
-
/>
|
|
459
|
-
</label>
|
|
460
|
-
</div>
|
|
461
|
-
|
|
462
|
-
<!-- Per-change-class auto-merge rules, each shown beside that class's accumulated track
|
|
463
|
-
record — the number that justifies widening the rule. -->
|
|
464
|
-
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
465
|
-
<MergeClassRulesEditor
|
|
466
|
-
v-model="drafts[p.id]!.classRules"
|
|
467
|
-
:auto-merge-enabled="drafts[p.id]!.autoMergeEnabled"
|
|
468
|
-
:disabled="busy === p.id"
|
|
469
|
-
/>
|
|
470
|
-
</div>
|
|
471
|
-
|
|
472
|
-
<!-- The role layer over those rules: what a run may do depending on WHO started it, up to
|
|
473
|
-
and including a full sandbox. Directly under the base rules it narrows, since a role
|
|
474
|
-
rule is only readable against the rule it applies to. -->
|
|
475
|
-
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
476
|
-
<MergeRolePolicyEditor
|
|
477
|
-
v-model:class-rules-by-role="drafts[p.id]!.classRulesByRole"
|
|
478
|
-
v-model:dry-run-roles="drafts[p.id]!.dryRunRoles"
|
|
479
|
-
v-model:submission-classes-by-role="drafts[p.id]!.submissionClassesByRole"
|
|
480
|
-
:class-rules="drafts[p.id]!.classRules"
|
|
481
|
-
:auto-merge-enabled="drafts[p.id]!.autoMergeEnabled"
|
|
482
|
-
:disabled="busy === p.id"
|
|
483
|
-
/>
|
|
484
|
-
</div>
|
|
485
|
-
|
|
486
|
-
<!-- Implementation-fork decision gate: propose materially different approaches before the
|
|
487
|
-
Coder writes code (in `auto` tri-state, gated on the task estimate). -->
|
|
488
|
-
<div class="mt-3 rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
489
|
-
<USwitch
|
|
490
|
-
v-model="drafts[p.id]!.forkEnabled"
|
|
491
|
-
size="sm"
|
|
492
|
-
:label="t('settings.riskPolicy.forkDecision.label')"
|
|
493
|
-
:description="t('settings.riskPolicy.forkDecision.hint')"
|
|
494
|
-
/>
|
|
495
|
-
<div v-if="drafts[p.id]!.forkEnabled" class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
496
|
-
<label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
|
|
497
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
498
|
-
{{ t(FORK_FLOOR_LABEL_KEYS[axis]) }}
|
|
499
|
-
</span>
|
|
500
|
-
<UInput
|
|
501
|
-
v-model.number="drafts[p.id]![FORK_FLOOR_FIELD[axis]]"
|
|
502
|
-
type="number"
|
|
503
|
-
size="sm"
|
|
504
|
-
:min="0"
|
|
505
|
-
:max="100"
|
|
506
|
-
/>
|
|
507
|
-
</label>
|
|
508
|
-
<label class="block">
|
|
509
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
510
|
-
{{ t('settings.riskPolicy.forkDecision.onMissingLabel') }}
|
|
511
|
-
</span>
|
|
512
|
-
<USelect v-model="drafts[p.id]!.forkOnMissing" :items="ON_MISSING_OPTIONS" size="sm" />
|
|
513
|
-
</label>
|
|
514
|
-
</div>
|
|
515
|
-
</div>
|
|
184
|
+
<!-- Inherited FIRST: they are the org's posture, and a board reading its own list wants to see
|
|
185
|
+
what it is working from before what it has changed. -->
|
|
186
|
+
<section v-if="inherited.length > 0" class="space-y-2">
|
|
187
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
188
|
+
{{ t('settings.riskPolicy.inherited.heading') }}
|
|
189
|
+
</p>
|
|
190
|
+
<p class="text-[11px] text-slate-500">{{ t('settings.riskPolicy.inherited.hint') }}</p>
|
|
191
|
+
<RiskPolicyInheritedRow
|
|
192
|
+
v-for="policy in inherited"
|
|
193
|
+
:key="policy.id"
|
|
194
|
+
:policy="policy"
|
|
195
|
+
:busy="busy"
|
|
196
|
+
@clone="clone(policy)"
|
|
197
|
+
@hide="hide(policy)"
|
|
198
|
+
/>
|
|
199
|
+
</section>
|
|
516
200
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
{{ t('settings.riskPolicy.autoAnswer.label') }}
|
|
537
|
-
</span>
|
|
538
|
-
<UInput
|
|
539
|
-
v-model.number="drafts[p.id]!.minAutoAnswerConfidence"
|
|
540
|
-
type="number"
|
|
541
|
-
min="0"
|
|
542
|
-
max="100"
|
|
543
|
-
size="sm"
|
|
544
|
-
data-testid="risk-policy-auto-answer-floor"
|
|
545
|
-
/>
|
|
546
|
-
<span class="mt-1 block text-[11px] text-slate-500">
|
|
547
|
-
{{ t('settings.riskPolicy.autoAnswer.hint') }}
|
|
548
|
-
</span>
|
|
549
|
-
</label>
|
|
550
|
-
</div>
|
|
201
|
+
<section class="space-y-4">
|
|
202
|
+
<p
|
|
203
|
+
v-if="inherited.length > 0"
|
|
204
|
+
class="text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
205
|
+
>
|
|
206
|
+
{{ t('settings.riskPolicy.own.heading') }}
|
|
207
|
+
</p>
|
|
208
|
+
<RiskPolicyEditorRow
|
|
209
|
+
v-for="policy in own"
|
|
210
|
+
:key="policy.id"
|
|
211
|
+
:policy="policy"
|
|
212
|
+
:busy="busy"
|
|
213
|
+
:show-defaults="true"
|
|
214
|
+
@save="save(policy, $event)"
|
|
215
|
+
@promote="makeDefault(policy)"
|
|
216
|
+
@promote-unattended="makeUnattendedDefault(policy)"
|
|
217
|
+
@remove="remove(policy)"
|
|
218
|
+
/>
|
|
219
|
+
</section>
|
|
551
220
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
221
|
+
<!-- What this board is hiding. Rendered only when there IS something, and each entry says
|
|
222
|
+
whether it still shadows an account policy: one whose policy the account has since withdrawn
|
|
223
|
+
withholds nothing, and reading it as a live opt-out would misstate what the board is doing. -->
|
|
224
|
+
<section v-if="store.suppressions.length > 0 || suppressionsFailed" class="space-y-2">
|
|
225
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
226
|
+
{{ t('settings.riskPolicy.hidden.heading') }}
|
|
227
|
+
</p>
|
|
228
|
+
<p v-if="suppressionsFailed" class="text-[11px] text-amber-400">
|
|
229
|
+
{{ t('settings.riskPolicy.hidden.loadFailed') }}
|
|
230
|
+
</p>
|
|
231
|
+
<div
|
|
232
|
+
v-for="entry in store.suppressions"
|
|
233
|
+
:key="entry.id"
|
|
234
|
+
class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/40 px-3 py-2"
|
|
235
|
+
data-testid="risk-policy-hidden-row"
|
|
236
|
+
:data-policy-id="entry.id"
|
|
237
|
+
>
|
|
238
|
+
<span class="flex-1 truncate text-[12px] text-slate-300">{{ entry.name }}</span>
|
|
239
|
+
<span v-if="!entry.inherited" class="text-[11px] text-slate-500">
|
|
240
|
+
{{ t('settings.riskPolicy.hidden.withdrawn') }}
|
|
241
|
+
</span>
|
|
563
242
|
<UButton
|
|
564
|
-
color="
|
|
565
|
-
variant="
|
|
243
|
+
color="neutral"
|
|
244
|
+
variant="ghost"
|
|
566
245
|
size="xs"
|
|
567
|
-
icon="i-lucide-
|
|
568
|
-
:loading="busy ===
|
|
569
|
-
|
|
246
|
+
icon="i-lucide-eye"
|
|
247
|
+
:loading="busy === `${entry.id}:unhide`"
|
|
248
|
+
data-testid="risk-policy-unhide"
|
|
249
|
+
@click="unhide(entry.id)"
|
|
570
250
|
>
|
|
571
|
-
{{ t('
|
|
251
|
+
{{ t('settings.riskPolicy.hidden.restore') }}
|
|
572
252
|
</UButton>
|
|
573
253
|
</div>
|
|
574
|
-
</
|
|
254
|
+
</section>
|
|
575
255
|
|
|
576
|
-
|
|
577
|
-
<div class="rounded-lg border border-dashed border-slate-700 p-3">
|
|
578
|
-
<p class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
579
|
-
{{ t('settings.riskPolicy.newPreset') }}
|
|
580
|
-
</p>
|
|
581
|
-
<div class="flex flex-wrap items-end gap-3">
|
|
582
|
-
<label class="block min-w-40 flex-1">
|
|
583
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
584
|
-
{{ t('settings.riskPolicy.create.name') }}
|
|
585
|
-
</span>
|
|
586
|
-
<UInput
|
|
587
|
-
v-model="draft.name"
|
|
588
|
-
size="sm"
|
|
589
|
-
:placeholder="t('settings.riskPolicy.create.namePlaceholder')"
|
|
590
|
-
data-testid="risk-policy-create-name"
|
|
591
|
-
/>
|
|
592
|
-
</label>
|
|
593
|
-
<label class="block w-20">
|
|
594
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
595
|
-
{{ t('settings.riskPolicy.create.complexity') }}
|
|
596
|
-
</span>
|
|
597
|
-
<UInput
|
|
598
|
-
v-model.number="draft.maxComplexity"
|
|
599
|
-
type="number"
|
|
600
|
-
:min="0"
|
|
601
|
-
:max="100"
|
|
602
|
-
size="sm"
|
|
603
|
-
data-testid="risk-policy-create-complexity"
|
|
604
|
-
/>
|
|
605
|
-
</label>
|
|
606
|
-
<label class="block w-20">
|
|
607
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
608
|
-
{{ t('settings.riskPolicy.create.risk') }}
|
|
609
|
-
</span>
|
|
610
|
-
<UInput
|
|
611
|
-
v-model.number="draft.maxRisk"
|
|
612
|
-
type="number"
|
|
613
|
-
:min="0"
|
|
614
|
-
:max="100"
|
|
615
|
-
size="sm"
|
|
616
|
-
data-testid="risk-policy-create-risk"
|
|
617
|
-
/>
|
|
618
|
-
</label>
|
|
619
|
-
<label class="block w-20">
|
|
620
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
621
|
-
{{ t('settings.riskPolicy.create.impact') }}
|
|
622
|
-
</span>
|
|
623
|
-
<UInput
|
|
624
|
-
v-model.number="draft.maxImpact"
|
|
625
|
-
type="number"
|
|
626
|
-
:min="0"
|
|
627
|
-
:max="100"
|
|
628
|
-
size="sm"
|
|
629
|
-
data-testid="risk-policy-create-impact"
|
|
630
|
-
/>
|
|
631
|
-
</label>
|
|
632
|
-
<label class="block w-20">
|
|
633
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
634
|
-
{{ t('settings.riskPolicy.create.ciFix') }}
|
|
635
|
-
</span>
|
|
636
|
-
<UInput v-model.number="draft.ciMaxAttempts" type="number" :min="0" :max="50" size="sm" />
|
|
637
|
-
</label>
|
|
638
|
-
<label class="block w-20">
|
|
639
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
640
|
-
{{ t('settings.riskPolicy.create.reqIter') }}
|
|
641
|
-
</span>
|
|
642
|
-
<UInput
|
|
643
|
-
v-model.number="draft.maxRequirementIterations"
|
|
644
|
-
type="number"
|
|
645
|
-
:min="1"
|
|
646
|
-
:max="20"
|
|
647
|
-
size="sm"
|
|
648
|
-
/>
|
|
649
|
-
</label>
|
|
650
|
-
<label class="block w-32">
|
|
651
|
-
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
652
|
-
{{ t('settings.riskPolicy.create.autoPass') }}
|
|
653
|
-
</span>
|
|
654
|
-
<USelect
|
|
655
|
-
v-model="draft.maxRequirementConcernAllowed"
|
|
656
|
-
:items="CONCERN_LEVELS"
|
|
657
|
-
value-key="value"
|
|
658
|
-
size="sm"
|
|
659
|
-
/>
|
|
660
|
-
</label>
|
|
661
|
-
<USwitch
|
|
662
|
-
v-model="draft.autoMergeEnabled"
|
|
663
|
-
size="sm"
|
|
664
|
-
:label="t('settings.riskPolicy.field.autoMerge')"
|
|
665
|
-
/>
|
|
666
|
-
<USwitch
|
|
667
|
-
v-model="draft.forkEnabled"
|
|
668
|
-
size="sm"
|
|
669
|
-
:label="t('settings.riskPolicy.forkDecision.label')"
|
|
670
|
-
/>
|
|
671
|
-
<UButton
|
|
672
|
-
color="primary"
|
|
673
|
-
size="sm"
|
|
674
|
-
icon="i-lucide-plus"
|
|
675
|
-
:loading="creating"
|
|
676
|
-
:disabled="!draft.name.trim()"
|
|
677
|
-
data-testid="risk-policy-create-submit"
|
|
678
|
-
@click="create"
|
|
679
|
-
>
|
|
680
|
-
{{ t('settings.riskPolicy.add') }}
|
|
681
|
-
</UButton>
|
|
682
|
-
</div>
|
|
683
|
-
</div>
|
|
256
|
+
<RiskPolicyCreateForm :busy="creating" @create="create($event)" />
|
|
684
257
|
</div>
|
|
685
258
|
</template>
|