@cat-factory/app 0.248.0 → 0.250.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/auth/LoginScreen.vue +8 -2
- package/app/components/board/AddTaskModal.vue +1 -0
- package/app/components/board/nodes/BlockNode.vue +24 -0
- package/app/components/board/nodes/TaskCard.vue +1 -1
- package/app/components/bootstrap/BootstrapModal.vue +5 -3
- 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/github/GitHubPanel.vue +49 -10
- package/app/components/layout/AccountDeploymentSettings.vue +122 -0
- package/app/components/panels/InspectorPanel.vue +14 -9
- package/app/components/vcs/GitLabConnect.vue +8 -2
- package/app/composables/api/documents.ts +17 -6
- package/app/composables/useDocumentSourceConnect.ts +88 -0
- package/app/composables/usePipelineErrorToast.ts +2 -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/github/vcsConnect.ts +19 -0
- package/app/stores/github.spec.ts +116 -6
- package/app/stores/github.ts +23 -6
- package/app/stores/ui/modals.ts +15 -0
- package/app/utils/vcs.spec.ts +105 -14
- package/app/utils/vcs.ts +113 -29
- package/i18n/locales/de.json +118 -25
- package/i18n/locales/en.json +118 -25
- package/i18n/locales/es.json +118 -25
- package/i18n/locales/fr.json +118 -25
- package/i18n/locales/he.json +118 -25
- package/i18n/locales/it.json +118 -25
- package/i18n/locales/ja.json +118 -25
- package/i18n/locales/pl.json +118 -25
- package/i18n/locales/tr.json +118 -25
- package/i18n/locales/uk.json +118 -25
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -333,6 +333,8 @@ const UNAVAILABLE_DESCRIPTION_KEYS: Record<UnavailableReason, string> = {
|
|
|
333
333
|
binary_generators_unreachable: 'errors.unavailable.description.binary_generators_unreachable',
|
|
334
334
|
foundational_builtins_unreachable:
|
|
335
335
|
'errors.unavailable.description.foundational_builtins_unreachable',
|
|
336
|
+
connection_credentials_unreadable:
|
|
337
|
+
'errors.unavailable.description.connection_credentials_unreadable',
|
|
336
338
|
}
|
|
337
339
|
|
|
338
340
|
/**
|
|
@@ -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',
|
|
@@ -152,6 +152,15 @@ export const TUTORIAL_REQUIREMENTS = {
|
|
|
152
152
|
labelKey: 'tutorial.requirements.library',
|
|
153
153
|
met: (gates) => gates.libraryAvailable,
|
|
154
154
|
},
|
|
155
|
+
// The CONNECTION rather than the permission to make one: starting from a design is
|
|
156
|
+
// member-tier, and the frame-header button the tour clicks exists only once some admin has
|
|
157
|
+
// connected a design source. A tour gated on `integrations.manage` would be withheld from
|
|
158
|
+
// exactly the persona it is written for.
|
|
159
|
+
designSource: {
|
|
160
|
+
id: 'design-source',
|
|
161
|
+
labelKey: 'tutorial.requirements.designSource',
|
|
162
|
+
met: (gates) => gates.designSourceConnected,
|
|
163
|
+
},
|
|
155
164
|
} as const satisfies Record<string, TutorialRequirement>
|
|
156
165
|
|
|
157
166
|
export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
@@ -575,6 +584,74 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
575
584
|
},
|
|
576
585
|
],
|
|
577
586
|
},
|
|
587
|
+
{
|
|
588
|
+
id: 'start-from-design',
|
|
589
|
+
order: 55,
|
|
590
|
+
icon: 'i-lucide-frame',
|
|
591
|
+
titleKey: 'tutorial.tours.startFromDesign.title',
|
|
592
|
+
descriptionKey: 'tutorial.tours.startFromDesign.description',
|
|
593
|
+
// The delivery loop as a DESIGNER enters it, which is a different first step from
|
|
594
|
+
// `first-task`: the work starts from a frame in Figma, not from a title someone types. It
|
|
595
|
+
// rides the loop's order (after the run tours, before the platform half) rather than
|
|
596
|
+
// replacing `first-task`, because it ends in the same add-task form and everything after
|
|
597
|
+
// that point is the arc those tours already cover.
|
|
598
|
+
//
|
|
599
|
+
// Offered at launch, unlike the platform tours, and gated on the design source being
|
|
600
|
+
// CONNECTED: on a board with one, this is the everyday loop rather than reference material.
|
|
601
|
+
requires: [
|
|
602
|
+
TUTORIAL_REQUIREMENTS.boardWrite,
|
|
603
|
+
TUTORIAL_REQUIREMENTS.service,
|
|
604
|
+
TUTORIAL_REQUIREMENTS.designSource,
|
|
605
|
+
],
|
|
606
|
+
steps: [
|
|
607
|
+
{
|
|
608
|
+
id: 'intro',
|
|
609
|
+
titleKey: 'tutorial.tours.startFromDesign.steps.intro.title',
|
|
610
|
+
bodyKey: 'tutorial.tours.startFromDesign.steps.intro.body',
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
id: 'open',
|
|
614
|
+
target: 'frame-start-from-design',
|
|
615
|
+
advanceOn: 'target-click',
|
|
616
|
+
placement: 'bottom',
|
|
617
|
+
titleKey: 'tutorial.tours.startFromDesign.steps.open.title',
|
|
618
|
+
bodyKey: 'tutorial.tours.startFromDesign.steps.open.body',
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
id: 'paste',
|
|
622
|
+
target: 'start-from-design-link',
|
|
623
|
+
// Inside the modal the previous click opens.
|
|
624
|
+
waitForTargetMs: 8000,
|
|
625
|
+
placement: 'bottom',
|
|
626
|
+
titleKey: 'tutorial.tours.startFromDesign.steps.paste.title',
|
|
627
|
+
bodyKey: 'tutorial.tours.startFromDesign.steps.paste.body',
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
// Left to the anchor-skip rather than given a `when`: the resolved card appears only
|
|
631
|
+
// once a link has actually been pasted, and someone walking the tour without one in
|
|
632
|
+
// hand should reach the finish card rather than stall on an input they cannot fill.
|
|
633
|
+
id: 'resolved',
|
|
634
|
+
target: 'start-from-design-resolved',
|
|
635
|
+
waitForTargetMs: 8000,
|
|
636
|
+
placement: 'bottom',
|
|
637
|
+
titleKey: 'tutorial.tours.startFromDesign.steps.resolved.title',
|
|
638
|
+
bodyKey: 'tutorial.tours.startFromDesign.steps.resolved.body',
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
id: 'continue',
|
|
642
|
+
target: 'start-from-design-continue',
|
|
643
|
+
advanceOn: 'target-click',
|
|
644
|
+
placement: 'top',
|
|
645
|
+
titleKey: 'tutorial.tours.startFromDesign.steps.continue.title',
|
|
646
|
+
bodyKey: 'tutorial.tours.startFromDesign.steps.continue.body',
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
id: 'finish',
|
|
650
|
+
titleKey: 'tutorial.tours.startFromDesign.steps.finish.title',
|
|
651
|
+
bodyKey: 'tutorial.tours.startFromDesign.steps.finish.body',
|
|
652
|
+
},
|
|
653
|
+
],
|
|
654
|
+
},
|
|
578
655
|
// ---------------------------------------------------------------------------------------
|
|
579
656
|
// The platform half. Ordered after the whole delivery loop so the catalogue reads in the
|
|
580
657
|
// order someone meets these things: learn the loop, then the machinery under it.
|
package/app/pages/index.vue
CHANGED
|
@@ -55,6 +55,9 @@ const DocumentTemplatesModal = defineAsyncComponent(
|
|
|
55
55
|
const SpawnPreviewModal = defineAsyncComponent(
|
|
56
56
|
() => import('~/components/documents/SpawnPreviewModal.vue'),
|
|
57
57
|
)
|
|
58
|
+
const StartFromDesignModal = defineAsyncComponent(
|
|
59
|
+
() => import('~/components/documents/StartFromDesignModal.vue'),
|
|
60
|
+
)
|
|
58
61
|
const BootstrapModal = defineAsyncComponent(
|
|
59
62
|
() => import('~/components/bootstrap/BootstrapModal.vue'),
|
|
60
63
|
)
|
|
@@ -472,6 +475,7 @@ watch(
|
|
|
472
475
|
<DocumentImportModal v-if="ui.documentImport" />
|
|
473
476
|
<DocumentTemplatesModal v-if="ui.documentTemplates" />
|
|
474
477
|
<SpawnPreviewModal v-if="ui.spawnPreview" />
|
|
478
|
+
<StartFromDesignModal v-if="ui.startFromDesign" />
|
|
475
479
|
<BootstrapModal v-if="ui.bootstrapOpen" />
|
|
476
480
|
<AddServiceFromRepoModal v-if="ui.addServiceOpen" />
|
|
477
481
|
<GitHubPanel v-if="ui.githubOpen" />
|
|
@@ -154,3 +154,63 @@ describe('documents store: manual refresh', () => {
|
|
|
154
154
|
expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
|
|
155
155
|
})
|
|
156
156
|
})
|
|
157
|
+
|
|
158
|
+
describe('documents store: the OAuth and design halves of the source list', () => {
|
|
159
|
+
/** The source listing the probe reads: descriptors PLUS what this deployment can OAuth. */
|
|
160
|
+
function stubSources(oauthSources: string[]) {
|
|
161
|
+
stubApi({
|
|
162
|
+
listDocumentSources: () =>
|
|
163
|
+
Promise.resolve({
|
|
164
|
+
sources: [
|
|
165
|
+
{
|
|
166
|
+
source: 'figma',
|
|
167
|
+
label: 'Figma',
|
|
168
|
+
icon: 'i',
|
|
169
|
+
credentialFields: [],
|
|
170
|
+
refLabel: '',
|
|
171
|
+
refPlaceholder: '',
|
|
172
|
+
oauth: { scopes: ['file_content:read'] },
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
source: 'notion',
|
|
176
|
+
label: 'Notion',
|
|
177
|
+
icon: 'i',
|
|
178
|
+
credentialFields: [],
|
|
179
|
+
refLabel: '',
|
|
180
|
+
refPlaceholder: '',
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
oauthSources,
|
|
184
|
+
}),
|
|
185
|
+
listDocumentConnections: () =>
|
|
186
|
+
Promise.resolve({ connections: [{ source: 'figma', label: 'Figma', connectedAt: 1 }] }),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it('offers OAuth where the deployment has a registered app', async () => {
|
|
191
|
+
stubSources(['figma'])
|
|
192
|
+
const store = useDocumentsStore()
|
|
193
|
+
await store.probe()
|
|
194
|
+
expect(store.canConnectWithOAuth('figma')).toBe(true)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('withholds it where the SOURCE declares an OAuth half but the deployment registered nothing', async () => {
|
|
198
|
+
// Figma declares the half in both cases; only the registered app decides whether the button
|
|
199
|
+
// is offered. Folded onto the descriptor, this case would render a "Connect with Figma" that
|
|
200
|
+
// can only 503.
|
|
201
|
+
stubSources([])
|
|
202
|
+
const store = useDocumentsStore()
|
|
203
|
+
await store.probe()
|
|
204
|
+
expect(store.descriptorFor('figma')?.oauth).toBeDefined()
|
|
205
|
+
expect(store.canConnectWithOAuth('figma')).toBe(false)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('lists only CONNECTED design sources, so no affordance opens onto a dead end', async () => {
|
|
209
|
+
stubSources(['figma'])
|
|
210
|
+
const store = useDocumentsStore()
|
|
211
|
+
await store.probe()
|
|
212
|
+
// Notion is connectable and not a design source; Figma is both connected and a design one.
|
|
213
|
+
// The classification comes from contracts, so adding a source cannot leave it unclassified.
|
|
214
|
+
expect(store.connectedDesignSources).toEqual(['figma'])
|
|
215
|
+
})
|
|
216
|
+
})
|
package/app/stores/documents.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
} from '~/types/domain'
|
|
15
15
|
import { isConnectableSource } from '@cat-factory/contracts'
|
|
16
16
|
import { useDocumentFreshness } from '~/composables/useDocumentFreshness'
|
|
17
|
+
import { useDocumentSourceConnect } from '~/composables/useDocumentSourceConnect'
|
|
17
18
|
import { useSourceIntegration } from '~/composables/useSourceIntegration'
|
|
18
19
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
19
20
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
@@ -32,6 +33,10 @@ export const useDocumentsStore = defineStore('documents', () => {
|
|
|
32
33
|
const api = useApi()
|
|
33
34
|
const workspace = useWorkspaceStore()
|
|
34
35
|
|
|
36
|
+
// What this DEPLOYMENT can OAuth, reported beside the descriptors and read through
|
|
37
|
+
// `useDocumentSourceConnect`, which owns why that is a separate fact from the descriptor's own.
|
|
38
|
+
const oauthSources = ref<DocumentSourceKind[]>([])
|
|
39
|
+
|
|
35
40
|
// Shared opt-in / probe / connections lifecycle (see `useSourceIntegration`).
|
|
36
41
|
const integration = useSourceIntegration<
|
|
37
42
|
DocumentSourceKind,
|
|
@@ -41,16 +46,32 @@ export const useDocumentsStore = defineStore('documents', () => {
|
|
|
41
46
|
enabled: () => !!workspace.workspaceId,
|
|
42
47
|
workspaceId: () => workspace.workspaceId,
|
|
43
48
|
fetch: async () => {
|
|
44
|
-
const [{ sources }, { connections }] = await Promise.all([
|
|
49
|
+
const [{ sources, oauthSources: oauth }, { connections }] = await Promise.all([
|
|
45
50
|
api.listDocumentSources(workspace.requireId()),
|
|
46
51
|
api.listDocumentConnections(workspace.requireId()),
|
|
47
52
|
])
|
|
53
|
+
oauthSources.value = oauth
|
|
48
54
|
return { sources, connections }
|
|
49
55
|
},
|
|
50
56
|
})
|
|
51
57
|
const { available, sources, connections, connectedSources, anyConnected } = integration
|
|
52
58
|
const { descriptorFor, connectionFor, isConnected, probe, ensureProbed } = integration
|
|
53
59
|
|
|
60
|
+
// Connecting a source, and what this board may connect it WITH, in its own collaborator: the
|
|
61
|
+
// question grew several parts when OAuth landed, and none of the store's other concerns (the
|
|
62
|
+
// imported documents, their freshness, the doc-kind role links) read any of them.
|
|
63
|
+
const { canConnectWithOAuth, connectedDesignSources, connect, disconnect, beginOAuthConnect } =
|
|
64
|
+
useDocumentSourceConnect({
|
|
65
|
+
workspaceId: () => workspace.requireId(),
|
|
66
|
+
oauthSources,
|
|
67
|
+
connectedSources,
|
|
68
|
+
onConnected: (conn) => {
|
|
69
|
+
integration.upsertConnection(conn)
|
|
70
|
+
available.value = true
|
|
71
|
+
},
|
|
72
|
+
onDisconnected: (source) => integration.removeConnection(source),
|
|
73
|
+
})
|
|
74
|
+
|
|
54
75
|
const { items: documents, upsert: upsertDoc } = useUpsertList<SourceDocument>({
|
|
55
76
|
key: (d) => `${d.source}:${d.externalId}`,
|
|
56
77
|
prepend: true,
|
|
@@ -76,19 +97,6 @@ export const useDocumentsStore = defineStore('documents', () => {
|
|
|
76
97
|
return documents.value.filter((d) => d.linkedBlockId === blockId)
|
|
77
98
|
}
|
|
78
99
|
|
|
79
|
-
/** Connect the workspace to a source with its credential bag. */
|
|
80
|
-
async function connect(source: DocumentSourceKind, credentials: Record<string, string>) {
|
|
81
|
-
const conn = await api.connectDocumentSource(workspace.requireId(), source, credentials)
|
|
82
|
-
integration.upsertConnection(conn)
|
|
83
|
-
available.value = true
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Disconnect the workspace from a source. */
|
|
87
|
-
async function disconnect(source: DocumentSourceKind) {
|
|
88
|
-
await api.disconnectDocumentSource(workspace.requireId(), source)
|
|
89
|
-
integration.removeConnection(source)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
100
|
/** Load the imported documents for the workspace (across sources). */
|
|
93
101
|
async function loadDocuments() {
|
|
94
102
|
documents.value = await api.listDocuments(workspace.requireId())
|
|
@@ -125,20 +133,36 @@ export const useDocumentsStore = defineStore('documents', () => {
|
|
|
125
133
|
return results
|
|
126
134
|
}
|
|
127
135
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Preview the board structure a page would expand into (no writes).
|
|
138
|
+
*
|
|
139
|
+
* With `frameId` the preview is TARGET-AWARE: the planner is told which service the work goes
|
|
140
|
+
* inside and proposes its modules and tasks instead of an architecture.
|
|
141
|
+
*/
|
|
142
|
+
function plan(
|
|
143
|
+
source: DocumentSourceKind,
|
|
144
|
+
externalId: string,
|
|
145
|
+
frameId?: string,
|
|
146
|
+
): Promise<DocumentBoardPlan> {
|
|
147
|
+
return api.planDocument(workspace.requireId(), source, {
|
|
148
|
+
externalId,
|
|
149
|
+
...(frameId ? { frameId } : {}),
|
|
150
|
+
})
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
/**
|
|
134
|
-
* Apply a page's structure to the board
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
154
|
+
* Apply a page's structure to the board, then refresh the board snapshot.
|
|
155
|
+
*
|
|
156
|
+
* `frameId` must be the SAME frame the preview was planned for. The endpoint re-plans against
|
|
157
|
+
* it, so a targeted preview and its write agree; sending a frame the preview did not use would
|
|
158
|
+
* flatten a board-wide plan into it and discard the frame titles and types the user approved,
|
|
159
|
+
* which is why this was board-level only until target-aware planning existed.
|
|
139
160
|
*/
|
|
140
|
-
async function spawn(source: DocumentSourceKind, externalId: string) {
|
|
141
|
-
const { result } = await api.spawnDocument(workspace.requireId(), source, {
|
|
161
|
+
async function spawn(source: DocumentSourceKind, externalId: string, frameId?: string) {
|
|
162
|
+
const { result } = await api.spawnDocument(workspace.requireId(), source, {
|
|
163
|
+
externalId,
|
|
164
|
+
...(frameId ? { frameId } : {}),
|
|
165
|
+
})
|
|
142
166
|
await workspace.refresh()
|
|
143
167
|
return result
|
|
144
168
|
}
|
|
@@ -218,6 +242,10 @@ export const useDocumentsStore = defineStore('documents', () => {
|
|
|
218
242
|
return {
|
|
219
243
|
available,
|
|
220
244
|
sources,
|
|
245
|
+
oauthSources,
|
|
246
|
+
canConnectWithOAuth,
|
|
247
|
+
connectedDesignSources,
|
|
248
|
+
beginOAuthConnect,
|
|
221
249
|
connections,
|
|
222
250
|
documents,
|
|
223
251
|
loading,
|
|
@@ -15,6 +15,7 @@ export interface VcsProviderViews {
|
|
|
15
15
|
canConnectGitLabPat: ComputedRef<boolean>
|
|
16
16
|
soleConnectProvider: ComputedRef<VcsProvider | null>
|
|
17
17
|
surfaceProvider: ComputedRef<VcsProvider | null>
|
|
18
|
+
surfaceWebUrl: ComputedRef<string | null>
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/** The derived provider questions above, off the probed connection + capability state. */
|
|
@@ -53,12 +54,30 @@ export function createVcsProviderViews(ctx: GitHubStoreContext): VcsProviderView
|
|
|
53
54
|
connection.value !== null ? provider.value : soleConnectProvider.value,
|
|
54
55
|
)
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The browser-facing host of the instance {@link surfaceProvider} names: the connected one's,
|
|
59
|
+
* or (with nothing bound) the host the deployment advertises for the provider it could
|
|
60
|
+
* connect. Null when neither is known, which is what a link builder needs to WITHHOLD rather
|
|
61
|
+
* than fall back to the provider's public instance.
|
|
62
|
+
*
|
|
63
|
+
* It has to come off the connect OPTION before a connection exists, because the two surfaces
|
|
64
|
+
* that need a host that early (the PAT box's token link, bootstrap's create-repository button)
|
|
65
|
+
* render while the connect box is still on screen.
|
|
66
|
+
*/
|
|
67
|
+
const surfaceWebUrl = computed<string | null>(() => {
|
|
68
|
+
if (connection.value !== null) return connection.value.webUrl
|
|
69
|
+
const sole = soleConnectProvider.value
|
|
70
|
+
if (!sole) return null
|
|
71
|
+
return connectOptions.value.find((o) => o.provider === sole)?.webUrl ?? null
|
|
72
|
+
})
|
|
73
|
+
|
|
56
74
|
return {
|
|
57
75
|
provider,
|
|
58
76
|
canConnectGitHubApp,
|
|
59
77
|
canConnectGitLabPat,
|
|
60
78
|
soleConnectProvider,
|
|
61
79
|
surfaceProvider,
|
|
80
|
+
surfaceWebUrl,
|
|
62
81
|
}
|
|
63
82
|
}
|
|
64
83
|
|