main
vue 200 lines 6.02 KB
Raw
1 <template>
2 <n-form ref="formRef" :model="form" :rules label-placement="top">
3 <n-form-item label="Display name" path="display_name">
4 <n-input
5 v-model:value="form.display_name"
6 placeholder="e.g. Acme Production Shuffle"
7 :maxlength="128"
8 show-count
9 />
10 </n-form-item>
11
12 <!--
13 Shuffle Org picker. Phase 3a: dropdown of orgs the deployment's
14 admin Bearer can see, populated from /api/notifications/shuffle/orgs.
15 Manual entry stays as a fallback for offline use, restricted
16 networks, or when an org is too new to appear in the listing yet.
17 -->
18 <n-form-item label="Shuffle org" path="shuffle_org_id">
19 <div class="flex w-full flex-col gap-2">
20 <n-select
21 v-model:value="form.shuffle_org_id"
22 :options="orgOptions"
23 :loading="loadingOrgs"
24 :disabled="manualEntry"
25 filterable
26 placeholder="Pick a Shuffle org"
27 @update:value="onOrgPicked"
28 />
29
30 <n-checkbox v-model:checked="manualEntry" size="small">
31 Don't see your org? Enter the ID manually
32 </n-checkbox>
33
34 <n-collapse-transition :show="manualEntry">
35 <n-input
36 v-model:value="form.shuffle_org_id"
37 placeholder="6b6f65a4-d8f8-48ef-b02f-23a4a5f73e4a"
38 :maxlength="64"
39 />
40 </n-collapse-transition>
41 </div>
42 <template v-if="!fieldErrors.shuffle_org_id" #feedback>
43 Sent as the
44 <code>Org-Id</code>
45 header on every dispatch — scopes the Shuffle call to the right org's authenticated apps.
46 </template>
47 </n-form-item>
48
49 <n-form-item>
50 <n-checkbox v-model:checked="form.enabled">Enabled</n-checkbox>
51 </n-form-item>
52
53 <div class="flex justify-end gap-2">
54 <n-button @click="$emit('close')">Cancel</n-button>
55 <n-button type="primary" :loading="submitting" @click="submit">
56 {{ editing ? "Save changes" : "Add integration" }}
57 </n-button>
58 </div>
59 </n-form>
60 </template>
61
62 <script setup lang="ts">
63 import type { FormInst, FormRules } from "naive-ui"
64 import type { ApiError } from "@/types/common"
65 import type { ShuffleIntegration, ShuffleIntegrationPayload, ShuffleOrg } from "@/types/notifications.d"
66 import { NButton, NCheckbox, NCollapseTransition, NForm, NFormItem, NInput, NSelect, useMessage } from "naive-ui"
67 import { computed, onBeforeMount, reactive, ref } from "vue"
68 import Api from "@/api"
69 import { getApiErrorMessage } from "@/utils"
70
71 const props = defineProps<{
72 customerCode: string
73 editingIntegration: ShuffleIntegration | null
74 }>()
75
76 const emit = defineEmits<{
77 (e: "submitted"): void
78 (e: "close"): void
79 }>()
80
81 const message = useMessage()
82 const formRef = ref<FormInst | null>(null)
83 const submitting = ref(false)
84
85 const editing = computed(() => props.editingIntegration !== null)
86 type FeedbackField = "shuffle_org_id"
87
88 const fieldErrors = reactive<Partial<Record<FeedbackField, string>>>({})
89
90 const form = reactive<ShuffleIntegrationPayload>({
91 display_name: props.editingIntegration?.display_name ?? "",
92 shuffle_org_id: props.editingIntegration?.shuffle_org_id ?? "",
93 enabled: props.editingIntegration?.enabled ?? true
94 })
95
96 // Org-picker state. We default to dropdown mode; manual entry is a
97 // one-checkbox escape hatch for cases where the Shuffle listing call
98 // fails or the desired org doesn't appear in the list.
99 const orgs = ref<ShuffleOrg[]>([])
100 const loadingOrgs = ref(false)
101 const manualEntry = ref(false)
102
103 const orgOptions = computed(() =>
104 orgs.value.map(o => {
105 // Show the name with a short Org-Id suffix so admins can disambiguate
106 // when two orgs share a display name. Sub-orgs get an extra hint so
107 // it's obvious which rows are children of the parent (typical Shuffle
108 // pattern: one parent org per MSP, one sub-org per customer).
109 const idHint = `(${o.id.slice(0, 8)}…)`
110 const subOrgHint = o.creator_org ? " · sub-org" : ""
111 return {
112 label: `${o.name} ${idHint}${subOrgHint}`,
113 value: o.id
114 }
115 })
116 )
117
118 function clearFieldError(field: FeedbackField) {
119 delete fieldErrors[field]
120 }
121
122 function createFieldError(field: FeedbackField, message: string) {
123 fieldErrors[field] = message
124 return new Error(message)
125 }
126
127 async function loadOrgs() {
128 if (loadingOrgs.value) return
129
130 loadingOrgs.value = true
131
132 try {
133 const res = await Api.notifications.listShuffleOrgs()
134 if (res.data.success) {
135 orgs.value = res.data.orgs
136 // If we're editing and the existing org_id isn't in the list,
137 // fall through to manual entry so the form stays usable.
138 if (editing.value && form.shuffle_org_id && !orgs.value.some(o => o.id === form.shuffle_org_id)) {
139 manualEntry.value = true
140 }
141 } else {
142 message.warning(res.data.message || "Failed to load Shuffle orgs")
143 manualEntry.value = true
144 }
145 } catch (err) {
146 message.error(getApiErrorMessage(err as ApiError) || "Failed to load Shuffle orgs")
147 manualEntry.value = true
148 } finally {
149 loadingOrgs.value = false
150 }
151 }
152
153 function onOrgPicked(_orgId: string | null) {
154 // No-op for now — kept as a hook in case Phase 3b wants to chain
155 // the picker into automatic display-name population.
156 }
157
158 const rules: FormRules = {
159 display_name: { required: true, message: "Name is required", trigger: ["input", "blur"] },
160 shuffle_org_id: {
161 required: true,
162 validator: (_rule, value: string | null) => {
163 if (!value) return createFieldError("shuffle_org_id", "Pick a Shuffle org or enter an Org-Id manually")
164 clearFieldError("shuffle_org_id")
165 return true
166 },
167 trigger: ["input", "change", "blur"]
168 }
169 }
170
171 async function submit() {
172 try {
173 await formRef.value?.validate()
174 } catch {
175 return
176 }
177
178 submitting.value = true
179 try {
180 const res = props.editingIntegration
181 ? await Api.notifications.updateShuffleIntegration(props.customerCode, props.editingIntegration.id, form)
182 : await Api.notifications.createShuffleIntegration(props.customerCode, form)
183
184 if (res.data.success) {
185 message.success(editing.value ? "Integration updated" : "Integration added")
186 emit("submitted")
187 } else {
188 message.warning(res.data.message || "Failed to save integration")
189 }
190 } catch (err: unknown) {
191 message.error(getApiErrorMessage(err as never) || "Failed to save integration")
192 } finally {
193 submitting.value = false
194 }
195 }
196
197 onBeforeMount(() => {
198 loadOrgs()
199 })
200 </script>