@cat-factory/app 0.248.0 → 0.249.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/board/AddTaskModal.vue +1 -0
- package/app/components/board/nodes/BlockNode.vue +24 -0
- package/app/components/context/ContextAttachmentFields.vue +102 -0
- package/app/components/context/pastedLinkOffer.logic.spec.ts +35 -0
- package/app/components/context/pastedLinkOffer.logic.ts +50 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +50 -0
- package/app/components/documents/SpawnPreviewModal.vue +92 -16
- package/app/components/documents/StartFromDesignModal.vue +237 -0
- package/app/components/layout/AccountDeploymentSettings.vue +122 -0
- package/app/composables/api/documents.ts +17 -6
- package/app/composables/useDocumentSourceConnect.ts +88 -0
- package/app/modular/external-tools.spec.ts +1 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +10 -0
- package/app/modular/nav-gates.ts +4 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -0
- package/app/modular/tutorial-tours.ts +77 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/documents.spec.ts +60 -0
- package/app/stores/documents.ts +52 -24
- package/app/stores/ui/modals.ts +15 -0
- package/i18n/locales/de.json +82 -6
- package/i18n/locales/en.json +82 -6
- package/i18n/locales/es.json +82 -6
- package/i18n/locales/fr.json +82 -6
- package/i18n/locales/he.json +82 -6
- package/i18n/locales/it.json +82 -6
- package/i18n/locales/ja.json +82 -6
- package/i18n/locales/pl.json +82 -6
- package/i18n/locales/tr.json +82 -6
- package/i18n/locales/uk.json +82 -6
- package/package.json +2 -2
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Start a task from a design link: paste a Figma/Zeplin URL on a service frame, and the resolved
|
|
3
|
+
// reference is staged onto the add-task form as context. One affordance, three steps that used to
|
|
4
|
+
// be four separate surfaces (Integrations hub → import modal → board → add task → attach).
|
|
5
|
+
//
|
|
6
|
+
// Three rules this surface exists to hold, each of which the obvious version gets wrong.
|
|
7
|
+
//
|
|
8
|
+
// - It resolves the reference BEFORE anything is created, through the same
|
|
9
|
+
// `POST /document-sources/:source/resolve-ref` the attach picker uses, so a share link's title
|
|
10
|
+
// segment and tracking params are trimmed where the person who pasted them can still see it,
|
|
11
|
+
// and a link the source cannot read is a correction rather than a toast over a task that
|
|
12
|
+
// already exists without its design.
|
|
13
|
+
// - It only asks the CONNECTED DESIGN sources, and it asks them in claim-confidence order
|
|
14
|
+
// (host-pinned first), because a host-blind prose parser will happily claim a Figma URL whose
|
|
15
|
+
// file key carries a UUID-shaped run. `connectedDesignSources` is already in registry order
|
|
16
|
+
// and every design source is host-pinned, so the order is the store's.
|
|
17
|
+
// - A WIDENED reference is stated separately from a trimmed one. Figma's own Copy link emits a
|
|
18
|
+
// complex instance id for any component instance, which the parser cannot read, so it falls
|
|
19
|
+
// back to the whole file: "I attached this frame" and "I attached the entire design" otherwise
|
|
20
|
+
// render identically. For a designer that widening IS the defect, not a detail.
|
|
21
|
+
import { refRowFor, classifyRefFailure, type RefState } from './ContextDocumentPicker.logic'
|
|
22
|
+
import type { DocumentSourceKind, ResolvedDocumentRef } from '~/types/domain'
|
|
23
|
+
|
|
24
|
+
const { t } = useI18n()
|
|
25
|
+
const ui = useUiStore()
|
|
26
|
+
const documents = useDocumentsStore()
|
|
27
|
+
|
|
28
|
+
const open = computed({
|
|
29
|
+
get: () => ui.startFromDesign !== null,
|
|
30
|
+
set: (v: boolean) => {
|
|
31
|
+
if (!v) ui.closeStartFromDesign()
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const pasted = ref('')
|
|
36
|
+
const state = ref<RefState>({ status: 'none' })
|
|
37
|
+
/** The source that claimed the paste, held beside the verdict so the staged item names it. */
|
|
38
|
+
const claimant = ref<DocumentSourceKind | null>(null)
|
|
39
|
+
/**
|
|
40
|
+
* The exact text {@link state} is the verdict FOR, so the same text is never re-resolved.
|
|
41
|
+
*
|
|
42
|
+
* This is what keeps Continue clickable. The input resolves on blur, and clicking Continue blurs
|
|
43
|
+
* it, so a person who resolved with Enter and then reached for the button re-entered `resolve()`
|
|
44
|
+
* on mousedown: the state fell back to `checking`, `row` went null, and Vue disabled the button
|
|
45
|
+
* before mouseup, so the click was never dispatched. The button came back enabled a moment later
|
|
46
|
+
* having done nothing, which reads as a dead button rather than as a race. (The `start-from-design`
|
|
47
|
+
* tour hit the same edge, its `advanceOn: 'target-click'` step waiting on a click that never fired.)
|
|
48
|
+
*
|
|
49
|
+
* Guarding on the TEXT rather than suppressing the blur is what makes it a fix instead of a
|
|
50
|
+
* workaround: re-resolving input that already has a verdict spends one HTTP call per connected
|
|
51
|
+
* design source to arrive back where it started, whoever caused it.
|
|
52
|
+
*/
|
|
53
|
+
const resolvedFor = ref<string | null>(null)
|
|
54
|
+
|
|
55
|
+
watch(open, (isOpen) => {
|
|
56
|
+
if (!isOpen) return
|
|
57
|
+
pasted.value = ''
|
|
58
|
+
state.value = { status: 'none' }
|
|
59
|
+
claimant.value = null
|
|
60
|
+
resolvedFor.value = null
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const row = computed(() => refRowFor(state.value, pasted.value))
|
|
64
|
+
const sources = computed(() => documents.connectedDesignSources)
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The source the staged reference will be attached to.
|
|
68
|
+
*
|
|
69
|
+
* An UNCHECKED paste (the pre-flight itself failed: offline, a 502, a proxy's error page) is not
|
|
70
|
+
* a refusal, so it stays stageable with the import as the backstop it always was — but only when
|
|
71
|
+
* ONE design source is connected, because that is the only case where nothing has to be guessed.
|
|
72
|
+
* With two, the source is exactly what the resolve was going to tell us, and picking the
|
|
73
|
+
* first-registered one would attach a Zeplin screen to Figma's key space. Then the honest answer
|
|
74
|
+
* is to say the check could not be made and let the person retry.
|
|
75
|
+
*/
|
|
76
|
+
const target = computed<DocumentSourceKind | null>(() => {
|
|
77
|
+
if (claimant.value) return claimant.value
|
|
78
|
+
if (state.value.status !== 'unchecked') return null
|
|
79
|
+
return sources.value.length === 1 ? (sources.value[0] ?? null) : null
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ask each connected design source in turn and keep the FIRST that claims the paste.
|
|
84
|
+
*
|
|
85
|
+
* Sequential rather than parallel: the sources are ordered by how much a claim over a URL is
|
|
86
|
+
* worth, and running them together would make the winner whichever answered first. A refusal from
|
|
87
|
+
* one source is not a refusal of the paste, so only the LAST one is surfaced when nobody claims
|
|
88
|
+
* it — that is the state where the person genuinely has to change something.
|
|
89
|
+
*/
|
|
90
|
+
async function resolve() {
|
|
91
|
+
const text = pasted.value.trim()
|
|
92
|
+
// Already judged, so there is nothing to learn and a verdict to lose (see `resolvedFor`).
|
|
93
|
+
if (text === resolvedFor.value) return
|
|
94
|
+
claimant.value = null
|
|
95
|
+
if (!text) {
|
|
96
|
+
state.value = { status: 'none' }
|
|
97
|
+
resolvedFor.value = null
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
state.value = { status: 'checking' }
|
|
101
|
+
let last: RefState = { status: 'rejected', reason: 'document_ref_unrecognized' }
|
|
102
|
+
for (const source of sources.value) {
|
|
103
|
+
try {
|
|
104
|
+
const ref: ResolvedDocumentRef = await documents.resolveRef(source, text)
|
|
105
|
+
claimant.value = source
|
|
106
|
+
state.value = { status: 'ok', ref }
|
|
107
|
+
resolvedFor.value = text
|
|
108
|
+
return
|
|
109
|
+
} catch (e) {
|
|
110
|
+
last = classifyRefFailure(e)
|
|
111
|
+
// An outage leaves the paste UNJUDGED rather than refused, and asking the next source
|
|
112
|
+
// would turn one source being down into a different source's verdict.
|
|
113
|
+
if (last.status === 'unchecked') break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
state.value = last
|
|
117
|
+
// A refusal and an outage are verdicts too: re-running on the same text would reach the same
|
|
118
|
+
// one, and an `unchecked` paste is stageable, so it must survive the blur Continue causes.
|
|
119
|
+
resolvedFor.value = text
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Hand the resolved reference to the add-task form as a staged attachment.
|
|
124
|
+
*
|
|
125
|
+
* `needsImport` is true because nothing has been fetched yet: the form's own pre-create resolve
|
|
126
|
+
* (`useContextLinking().resolvePending`) performs the import, which is where an unreachable page
|
|
127
|
+
* becomes a correction the author can still make. Importing here as well would spend the fetch
|
|
128
|
+
* twice and move the failure back before the form exists.
|
|
129
|
+
*/
|
|
130
|
+
function stage() {
|
|
131
|
+
const frame = ui.startFromDesign
|
|
132
|
+
const resolved = row.value
|
|
133
|
+
const source = target.value
|
|
134
|
+
if (!frame || !resolved || !source) return
|
|
135
|
+
const descriptor = documents.descriptorFor(source)
|
|
136
|
+
ui.closeStartFromDesign()
|
|
137
|
+
ui.openAddTask(frame.frameId, {
|
|
138
|
+
context: [
|
|
139
|
+
{
|
|
140
|
+
kind: 'document',
|
|
141
|
+
source,
|
|
142
|
+
externalId: resolved.externalId,
|
|
143
|
+
title: resolved.label,
|
|
144
|
+
subtitle: descriptor?.label,
|
|
145
|
+
icon: descriptor?.icon,
|
|
146
|
+
needsImport: true,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<template>
|
|
154
|
+
<UModal v-model:open="open" :title="t('documents.startFromDesign.title')">
|
|
155
|
+
<template #body>
|
|
156
|
+
<div class="space-y-4">
|
|
157
|
+
<p class="text-sm text-slate-400">{{ t('documents.startFromDesign.intro') }}</p>
|
|
158
|
+
|
|
159
|
+
<!-- No connected design source: the flow cannot run, and saying which step is missing
|
|
160
|
+
beats an input that refuses every paste. The connect route is withheld from a member
|
|
161
|
+
for the same reason the picker's add tier is: connecting stores a credential. -->
|
|
162
|
+
<p v-if="sources.length === 0" class="text-sm text-amber-300">
|
|
163
|
+
{{ t('documents.startFromDesign.noSource') }}
|
|
164
|
+
</p>
|
|
165
|
+
|
|
166
|
+
<template v-else>
|
|
167
|
+
<UFormField :label="t('documents.startFromDesign.linkLabel')">
|
|
168
|
+
<UInput
|
|
169
|
+
v-model="pasted"
|
|
170
|
+
:placeholder="t('documents.startFromDesign.linkPlaceholder')"
|
|
171
|
+
class="w-full"
|
|
172
|
+
data-testid="start-from-design-link"
|
|
173
|
+
@blur="resolve"
|
|
174
|
+
@keyup.enter="resolve"
|
|
175
|
+
/>
|
|
176
|
+
</UFormField>
|
|
177
|
+
|
|
178
|
+
<div
|
|
179
|
+
v-if="state.status === 'checking'"
|
|
180
|
+
class="flex items-center gap-2 text-sm text-slate-400"
|
|
181
|
+
>
|
|
182
|
+
<UIcon name="i-lucide-loader" class="h-4 w-4 animate-spin" />
|
|
183
|
+
{{ t('documents.startFromDesign.checking') }}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div
|
|
187
|
+
v-else-if="row"
|
|
188
|
+
class="space-y-1 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
189
|
+
data-testid="start-from-design-resolved"
|
|
190
|
+
>
|
|
191
|
+
<div class="flex items-center gap-2 text-sm text-white">
|
|
192
|
+
<UIcon name="i-lucide-frame" class="h-4 w-4 text-indigo-400" />
|
|
193
|
+
<span class="truncate">{{ row.label }}</span>
|
|
194
|
+
</div>
|
|
195
|
+
<p v-if="row.trimmed" class="text-[11px] text-slate-400">
|
|
196
|
+
{{ t('documents.startFromDesign.trimmed') }}
|
|
197
|
+
</p>
|
|
198
|
+
<!-- Its own line, in amber: a trim resolves the same page, a drop widens ONE frame to
|
|
199
|
+
the whole design file, and the second is what a designer needs to see. -->
|
|
200
|
+
<p v-if="row.droppedScope" class="text-[11px] text-amber-300">
|
|
201
|
+
{{ t('documents.startFromDesign.widened', { scope: row.droppedScope }) }}
|
|
202
|
+
</p>
|
|
203
|
+
<p v-if="row.unchecked && target" class="text-[11px] text-slate-400">
|
|
204
|
+
{{ t('documents.startFromDesign.unchecked') }}
|
|
205
|
+
</p>
|
|
206
|
+
<p v-else-if="row.unchecked" class="text-[11px] text-amber-300">
|
|
207
|
+
{{ t('documents.startFromDesign.uncheckedAmbiguous') }}
|
|
208
|
+
</p>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<p
|
|
212
|
+
v-else-if="state.status === 'rejected'"
|
|
213
|
+
class="text-sm text-amber-300"
|
|
214
|
+
data-testid="start-from-design-rejected"
|
|
215
|
+
>
|
|
216
|
+
{{ t('documents.startFromDesign.rejected') }}
|
|
217
|
+
</p>
|
|
218
|
+
</template>
|
|
219
|
+
|
|
220
|
+
<div class="flex justify-end gap-2 pt-1">
|
|
221
|
+
<UButton color="neutral" variant="ghost" @click="ui.closeStartFromDesign()">
|
|
222
|
+
{{ t('common.cancel') }}
|
|
223
|
+
</UButton>
|
|
224
|
+
<UButton
|
|
225
|
+
color="primary"
|
|
226
|
+
icon="i-lucide-arrow-right"
|
|
227
|
+
:disabled="!row || !target"
|
|
228
|
+
data-testid="start-from-design-continue"
|
|
229
|
+
@click="stage"
|
|
230
|
+
>
|
|
231
|
+
{{ t('documents.startFromDesign.continue') }}
|
|
232
|
+
</UButton>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</template>
|
|
236
|
+
</UModal>
|
|
237
|
+
</template>
|
|
@@ -39,9 +39,14 @@ watch(
|
|
|
39
39
|
|
|
40
40
|
const slack = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
|
|
41
41
|
const linear = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
|
|
42
|
+
// The deployment's registered Figma app, which is what turns "Connect with Figma" on for every
|
|
43
|
+
// board in the account. Without it the Figma document source still connects, by personal access
|
|
44
|
+
// token — which is the step this exists to spare a designer.
|
|
45
|
+
const figma = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
|
|
42
46
|
const web = reactive({ braveApiKey: '', searxngUrl: '', searxngApiKey: '' })
|
|
43
47
|
const savingSlack = ref(false)
|
|
44
48
|
const savingLinear = ref(false)
|
|
49
|
+
const savingFigma = ref(false)
|
|
45
50
|
const savingWeb = ref(false)
|
|
46
51
|
|
|
47
52
|
const summary = computed(() => store.view?.summary ?? null)
|
|
@@ -289,6 +294,62 @@ async function clearLinear() {
|
|
|
289
294
|
}
|
|
290
295
|
}
|
|
291
296
|
|
|
297
|
+
async function saveFigma() {
|
|
298
|
+
if (!figma.clientId.trim() || !figma.clientSecret.trim() || !figma.redirectUrl.trim()) {
|
|
299
|
+
toast.add({ title: t('layout.accountDeployment.figma.validation'), color: 'error' })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
savingFigma.value = true
|
|
303
|
+
try {
|
|
304
|
+
await store.save(props.accountId, {
|
|
305
|
+
secrets: {
|
|
306
|
+
figmaOAuth: {
|
|
307
|
+
clientId: figma.clientId.trim(),
|
|
308
|
+
clientSecret: figma.clientSecret.trim(),
|
|
309
|
+
redirectUrl: figma.redirectUrl.trim(),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
figma.clientId = ''
|
|
314
|
+
figma.clientSecret = ''
|
|
315
|
+
figma.redirectUrl = ''
|
|
316
|
+
toast.add({
|
|
317
|
+
title: t('layout.accountDeployment.figma.saved'),
|
|
318
|
+
icon: 'i-lucide-check',
|
|
319
|
+
color: 'success',
|
|
320
|
+
})
|
|
321
|
+
} catch (e) {
|
|
322
|
+
toast.add({
|
|
323
|
+
title: t('layout.accountDeployment.figma.saveFailed'),
|
|
324
|
+
description: e instanceof Error ? e.message : String(e),
|
|
325
|
+
color: 'error',
|
|
326
|
+
})
|
|
327
|
+
} finally {
|
|
328
|
+
savingFigma.value = false
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function clearFigma() {
|
|
333
|
+
if (!(await confirmAction('clear', 'Figma'))) return
|
|
334
|
+
savingFigma.value = true
|
|
335
|
+
try {
|
|
336
|
+
await store.save(props.accountId, { secrets: { figmaOAuth: null } })
|
|
337
|
+
toast.add({
|
|
338
|
+
title: t('layout.accountDeployment.figma.cleared'),
|
|
339
|
+
icon: 'i-lucide-check',
|
|
340
|
+
color: 'success',
|
|
341
|
+
})
|
|
342
|
+
} catch (e) {
|
|
343
|
+
toast.add({
|
|
344
|
+
title: t('layout.accountDeployment.figma.clearFailed'),
|
|
345
|
+
description: e instanceof Error ? e.message : String(e),
|
|
346
|
+
color: 'error',
|
|
347
|
+
})
|
|
348
|
+
} finally {
|
|
349
|
+
savingFigma.value = false
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
292
353
|
async function saveWeb() {
|
|
293
354
|
const brave = web.braveApiKey.trim()
|
|
294
355
|
const searxng = web.searxngUrl.trim()
|
|
@@ -479,6 +540,67 @@ async function clearWeb() {
|
|
|
479
540
|
</div>
|
|
480
541
|
</section>
|
|
481
542
|
|
|
543
|
+
<!-- Figma app OAuth (the document source's designer-doable connect) -->
|
|
544
|
+
<section class="space-y-2 border-t border-slate-800 pt-6">
|
|
545
|
+
<div class="flex items-center gap-2">
|
|
546
|
+
<h4 class="text-sm font-semibold text-slate-200">
|
|
547
|
+
{{ t('layout.accountDeployment.figma.title') }}
|
|
548
|
+
</h4>
|
|
549
|
+
<UBadge
|
|
550
|
+
:color="summary?.figmaOAuthConfigured ? 'success' : 'neutral'"
|
|
551
|
+
variant="subtle"
|
|
552
|
+
size="xs"
|
|
553
|
+
>
|
|
554
|
+
{{
|
|
555
|
+
summary?.figmaOAuthConfigured
|
|
556
|
+
? t('layout.accountDeployment.configured')
|
|
557
|
+
: t('layout.accountDeployment.notSet')
|
|
558
|
+
}}
|
|
559
|
+
</UBadge>
|
|
560
|
+
</div>
|
|
561
|
+
<p class="text-[11px] text-slate-400">
|
|
562
|
+
{{ t('layout.accountDeployment.figma.description') }}
|
|
563
|
+
</p>
|
|
564
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
|
565
|
+
<UInput
|
|
566
|
+
v-model="figma.clientId"
|
|
567
|
+
:placeholder="t('layout.accountDeployment.figma.clientId')"
|
|
568
|
+
size="sm"
|
|
569
|
+
/>
|
|
570
|
+
<SecretInput
|
|
571
|
+
v-model="figma.clientSecret"
|
|
572
|
+
:placeholder="t('layout.accountDeployment.figma.clientSecret')"
|
|
573
|
+
size="sm"
|
|
574
|
+
/>
|
|
575
|
+
<UInput
|
|
576
|
+
v-model="figma.redirectUrl"
|
|
577
|
+
:placeholder="t('layout.accountDeployment.figma.redirectUrl')"
|
|
578
|
+
size="sm"
|
|
579
|
+
/>
|
|
580
|
+
</div>
|
|
581
|
+
<div class="flex gap-2">
|
|
582
|
+
<UButton
|
|
583
|
+
color="primary"
|
|
584
|
+
size="xs"
|
|
585
|
+
icon="i-lucide-save"
|
|
586
|
+
:loading="savingFigma"
|
|
587
|
+
@click="saveFigma"
|
|
588
|
+
>
|
|
589
|
+
{{ t('common.save') }}
|
|
590
|
+
</UButton>
|
|
591
|
+
<UButton
|
|
592
|
+
v-if="summary?.figmaOAuthConfigured"
|
|
593
|
+
color="neutral"
|
|
594
|
+
variant="ghost"
|
|
595
|
+
size="xs"
|
|
596
|
+
:loading="savingFigma"
|
|
597
|
+
@click="clearFigma"
|
|
598
|
+
>
|
|
599
|
+
{{ t('layout.accountDeployment.clear') }}
|
|
600
|
+
</UButton>
|
|
601
|
+
</div>
|
|
602
|
+
</section>
|
|
603
|
+
|
|
482
604
|
<!-- Web search keys -->
|
|
483
605
|
<section class="space-y-2 border-t border-slate-800 pt-6">
|
|
484
606
|
<div class="flex items-center gap-2">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
connectDocumentSourceContract,
|
|
3
3
|
disconnectDocumentSourceContract,
|
|
4
|
+
documentSourceOAuthUrlContract,
|
|
4
5
|
importDocumentContract,
|
|
5
6
|
linkDocumentContract,
|
|
6
7
|
linkDocumentForKindContract,
|
|
@@ -41,6 +42,14 @@ export function documentsApi({ send, ws }: ApiContext) {
|
|
|
41
42
|
body: { credentials },
|
|
42
43
|
}),
|
|
43
44
|
|
|
45
|
+
// The vendor authorization URL for a source's OAuth connect. Admin-tier: what it hands back
|
|
46
|
+
// is the first half of a credential write, completed by the public callback.
|
|
47
|
+
documentSourceOAuthUrl: (workspaceId: string, source: DocumentSourceKind) =>
|
|
48
|
+
send(documentSourceOAuthUrlContract, {
|
|
49
|
+
pathPrefix: ws(workspaceId),
|
|
50
|
+
pathParams: { source },
|
|
51
|
+
}),
|
|
52
|
+
|
|
44
53
|
disconnectDocumentSource: (workspaceId: string, source: DocumentSourceKind) =>
|
|
45
54
|
send(disconnectDocumentSourceContract, {
|
|
46
55
|
pathPrefix: ws(workspaceId),
|
|
@@ -77,12 +86,14 @@ export function documentsApi({ send, ws }: ApiContext) {
|
|
|
77
86
|
body: { query },
|
|
78
87
|
}),
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
// `frameId` makes the plan TARGET-AWARE: modules and tasks for a service that already exists
|
|
90
|
+
// rather than an architecture. The same frame is sent to the spawn, so the preview and the
|
|
91
|
+
// write agree about the target.
|
|
92
|
+
planDocument: (
|
|
93
|
+
workspaceId: string,
|
|
94
|
+
source: DocumentSourceKind,
|
|
95
|
+
body: { externalId: string; frameId?: string },
|
|
96
|
+
) => send(planDocumentContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
|
|
86
97
|
|
|
87
98
|
spawnDocument: (
|
|
88
99
|
workspaceId: string,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { computed, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
import { isDesignSource } from '@cat-factory/contracts'
|
|
3
|
+
import type {
|
|
4
|
+
DocumentConnection,
|
|
5
|
+
DocumentSourceDescriptor,
|
|
6
|
+
DocumentSourceKind,
|
|
7
|
+
} from '~/types/domain'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How this workspace CONNECTS to a document source, and what it may connect with.
|
|
11
|
+
*
|
|
12
|
+
* Split out of `stores/documents.ts` when the OAuth half landed, because "which credential can
|
|
13
|
+
* this board offer for this source" turned into a question with several parts: a source declares
|
|
14
|
+
* an OAuth half in code, the deployment may or may not have registered an app for it, and the
|
|
15
|
+
* typed-credential form is the fallback either way. The store's other concerns (the imported
|
|
16
|
+
* documents, their freshness, the doc-kind role links) never read any of it.
|
|
17
|
+
*
|
|
18
|
+
* It takes bound accessors rather than the store, so it stays testable on its own and cannot
|
|
19
|
+
* reach for state it has no business in.
|
|
20
|
+
*/
|
|
21
|
+
export interface DocumentSourceConnectDeps {
|
|
22
|
+
workspaceId: () => string
|
|
23
|
+
/**
|
|
24
|
+
* The sources this DEPLOYMENT can OAuth, as the backend reports them beside the descriptors.
|
|
25
|
+
*
|
|
26
|
+
* Owned by the caller (the probe writes it) rather than fetched here, because it arrives on the
|
|
27
|
+
* same response as the descriptors and a second read would be a second round trip that could
|
|
28
|
+
* disagree with the first.
|
|
29
|
+
*/
|
|
30
|
+
oauthSources: Ref<DocumentSourceKind[]>
|
|
31
|
+
/** The connected subset of the descriptors, in registry order. */
|
|
32
|
+
connectedSources: ComputedRef<DocumentSourceDescriptor[]>
|
|
33
|
+
/** Fold a new/updated connection into the caller's list. */
|
|
34
|
+
onConnected: (connection: DocumentConnection) => void
|
|
35
|
+
/** Drop a source's connection from the caller's list. */
|
|
36
|
+
onDisconnected: (source: DocumentSourceKind) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useDocumentSourceConnect(deps: DocumentSourceConnectDeps) {
|
|
40
|
+
const api = useApi()
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether this deployment can run the OAuth connect for a source RIGHT NOW.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately not `descriptor.oauth !== undefined`: that says the source supports the flow,
|
|
46
|
+
* which is true of Figma on every deployment, including the ones that have registered no app.
|
|
47
|
+
* A button rendered off the descriptor alone could only 503.
|
|
48
|
+
*/
|
|
49
|
+
function canConnectWithOAuth(source: DocumentSourceKind): boolean {
|
|
50
|
+
return deps.oauthSources.value.includes(source)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every CONNECTED design source, in the order the backend registered them.
|
|
55
|
+
*
|
|
56
|
+
* `isDesignSource` comes from contracts rather than a local list, for the reason the backend
|
|
57
|
+
* reads it there: whether a source describes a design is a fact both sides have to agree about,
|
|
58
|
+
* and a second copy here would drift the moment a source is added.
|
|
59
|
+
*/
|
|
60
|
+
const connectedDesignSources = computed(() =>
|
|
61
|
+
deps.connectedSources.value.map((s) => s.source).filter(isDesignSource),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
/** Connect the workspace to a source with its credential bag. */
|
|
65
|
+
async function connect(source: DocumentSourceKind, credentials: Record<string, string>) {
|
|
66
|
+
deps.onConnected(await api.connectDocumentSource(deps.workspaceId(), source, credentials))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Disconnect the workspace from a source. */
|
|
70
|
+
async function disconnect(source: DocumentSourceKind) {
|
|
71
|
+
await api.disconnectDocumentSource(deps.workspaceId(), source)
|
|
72
|
+
deps.onDisconnected(source)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Send the browser to a source's vendor consent screen.
|
|
77
|
+
*
|
|
78
|
+
* A full navigation rather than a popup: the vendor lands back on the app's own OAuth callback,
|
|
79
|
+
* which stores the grant and redirects here, so the returning page re-probes and sees the
|
|
80
|
+
* connection. A popup would leave the opener holding stale state with nothing to tell it.
|
|
81
|
+
*/
|
|
82
|
+
async function beginOAuthConnect(source: DocumentSourceKind) {
|
|
83
|
+
const { url } = await api.documentSourceOAuthUrl(deps.workspaceId(), source)
|
|
84
|
+
window.location.assign(url)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { canConnectWithOAuth, connectedDesignSources, connect, disconnect, beginOAuthConnect }
|
|
88
|
+
}
|
|
@@ -21,6 +21,7 @@ const NO_GATES: NavGates = {
|
|
|
21
21
|
canManageSettings: false,
|
|
22
22
|
githubAvailable: false,
|
|
23
23
|
libraryAvailable: false,
|
|
24
|
+
designSourceConnected: false,
|
|
24
25
|
infrastructureAvailable: false,
|
|
25
26
|
accountsEnabled: false,
|
|
26
27
|
isAccountAdmin: false,
|
|
@@ -42,6 +43,7 @@ const ALL_GATES: NavGates = {
|
|
|
42
43
|
canManageSettings: true,
|
|
43
44
|
githubAvailable: true,
|
|
44
45
|
libraryAvailable: true,
|
|
46
|
+
designSourceConnected: true,
|
|
45
47
|
infrastructureAvailable: true,
|
|
46
48
|
accountsEnabled: true,
|
|
47
49
|
isAccountAdmin: true,
|
|
@@ -89,6 +89,16 @@ export interface NavGates {
|
|
|
89
89
|
githubAvailable: boolean
|
|
90
90
|
/** The prompt-fragment library integration is enabled. */
|
|
91
91
|
libraryAvailable: boolean
|
|
92
|
+
/**
|
|
93
|
+
* The board has a CONNECTED design source (Figma, Zeplin).
|
|
94
|
+
*
|
|
95
|
+
* Availability, not permission, like `githubAvailable`: the start-from-design affordance and
|
|
96
|
+
* the tour that walks it both point at a frame-header button that only exists once a design
|
|
97
|
+
* source is connected, so a board without one is offered a walkthrough that hunts for a
|
|
98
|
+
* control nobody can see. Connecting is `integrations.manage`, but STARTING from a design is
|
|
99
|
+
* member-tier, so the gate is the connection rather than the permission to make one.
|
|
100
|
+
*/
|
|
101
|
+
designSourceConnected: boolean
|
|
92
102
|
/** An execution/test-env backend is reported (runner pool / environment / local). */
|
|
93
103
|
infrastructureAvailable: boolean
|
|
94
104
|
/** Accounts (auth) are enabled on the deployment. */
|
package/app/modular/nav-gates.ts
CHANGED
|
@@ -22,6 +22,7 @@ export function createNavGates(): NavGates {
|
|
|
22
22
|
const access = useWorkspaceAccess()
|
|
23
23
|
const github = useGitHubStore()
|
|
24
24
|
const library = useFragmentLibraryStore()
|
|
25
|
+
const documents = useDocumentsStore()
|
|
25
26
|
const accounts = useAccountsStore()
|
|
26
27
|
const auth = useAuthStore()
|
|
27
28
|
const providerConnections = useProviderConnectionsStore()
|
|
@@ -90,6 +91,9 @@ export function createNavGates(): NavGates {
|
|
|
90
91
|
get libraryAvailable() {
|
|
91
92
|
return library.available === true
|
|
92
93
|
},
|
|
94
|
+
get designSourceConnected() {
|
|
95
|
+
return documents.connectedDesignSources.length > 0
|
|
96
|
+
},
|
|
93
97
|
get infrastructureAvailable() {
|
|
94
98
|
// `integrations.manage` is required to provision/manage infrastructure, so
|
|
95
99
|
// gate the whole section on it too (a member/viewer would only 403 inside).
|
|
@@ -19,6 +19,7 @@ const ALL_GATES: NavGates = {
|
|
|
19
19
|
canManageSettings: true,
|
|
20
20
|
githubAvailable: true,
|
|
21
21
|
libraryAvailable: true,
|
|
22
|
+
designSourceConnected: true,
|
|
22
23
|
infrastructureAvailable: true,
|
|
23
24
|
accountsEnabled: true,
|
|
24
25
|
isAccountAdmin: true,
|
|
@@ -392,6 +393,10 @@ describe('tour availability across the catalog', () => {
|
|
|
392
393
|
// this is the tour the contextual offer raises when one does.
|
|
393
394
|
'diagnose-failure',
|
|
394
395
|
'review-merge',
|
|
396
|
+
// The same loop as a DESIGNER enters it. Offered rather than catalogue-only because on a
|
|
397
|
+
// board with a design source connected it IS the everyday loop, and its own requirement
|
|
398
|
+
// keeps it off every board without one.
|
|
399
|
+
'start-from-design',
|
|
395
400
|
]
|
|
396
401
|
const CATALOGUE_ONLY = [
|
|
397
402
|
'wire-models',
|