@cat-factory/app 0.236.1 → 0.237.1
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/README.md +18 -3
- package/app/components/board/nodes/BlockNode.vue +33 -152
- package/app/components/common/DescriptorFields.vue +102 -75
- package/app/components/context/ContextAttachmentFields.vue +9 -7
- package/app/components/documents/ContextDocumentPicker.vue +130 -12
- package/app/components/documents/TaskContextDocs.vue +18 -16
- package/app/components/tasks/BugHuntModal.vue +70 -61
- package/app/components/tasks/ContextIssuePicker.vue +52 -28
- package/app/components/tasks/TaskImportModal.vue +55 -39
- package/app/composables/useContainerTargets.spec.ts +112 -0
- package/app/composables/useContainerTargets.ts +62 -0
- package/app/docs/consumer-extensions.md +9 -5
- package/app/stores/ui/navigation.ts +8 -31
- package/app/utils/containerTargets.ts +71 -0
- package/app/utils/descriptorFields.spec.ts +68 -0
- package/app/utils/descriptorFields.ts +40 -1
- package/app/utils/sourcePicker.spec.ts +258 -0
- package/app/utils/sourcePicker.ts +251 -0
- package/i18n/locales/de.json +7 -6
- package/i18n/locales/en.json +7 -6
- package/i18n/locales/es.json +7 -6
- package/i18n/locales/fr.json +7 -6
- package/i18n/locales/he.json +7 -6
- package/i18n/locales/it.json +7 -6
- package/i18n/locales/ja.json +7 -6
- package/i18n/locales/pl.json +7 -6
- package/i18n/locales/tr.json +7 -6
- package/i18n/locales/uk.json +7 -6
- package/package.json +2 -2
- package/app/utils/taskSources.spec.ts +0 -100
- package/app/utils/taskSources.ts +0 -76
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildConnectionSourceChoices,
|
|
4
|
+
buildSourceChoices,
|
|
5
|
+
connectableSources,
|
|
6
|
+
menuIsPickable,
|
|
7
|
+
reconcileSource,
|
|
8
|
+
sourceMenuItems,
|
|
9
|
+
} from './sourcePicker'
|
|
10
|
+
import type { TaskSourceState } from '~/types/domain'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The pure source-selection behind `<ContextIssuePicker>`, `<BugHuntModal>` and
|
|
14
|
+
* `<ContextDocumentPicker>`. Pins what the always-visible selector promises: the source in use is
|
|
15
|
+
* named even when it is the only one, a source the workspace hasn't got yet is offered as something
|
|
16
|
+
* to ADD (worded for its actual state), a menu with nothing to decide is not dressed as a control,
|
|
17
|
+
* and the selection stays valid as the offered set changes underneath it.
|
|
18
|
+
*/
|
|
19
|
+
const state = (source: string, { available = true, enabled = true } = {}): TaskSourceState =>
|
|
20
|
+
({
|
|
21
|
+
source,
|
|
22
|
+
label: source.toUpperCase(),
|
|
23
|
+
icon: `i-lucide-${source}`,
|
|
24
|
+
available,
|
|
25
|
+
enabled,
|
|
26
|
+
credentialFields: [],
|
|
27
|
+
refLabel: '',
|
|
28
|
+
refPlaceholder: '',
|
|
29
|
+
}) as unknown as TaskSourceState
|
|
30
|
+
|
|
31
|
+
describe('buildSourceChoices', () => {
|
|
32
|
+
it('lists a single offered tracker, marked active (the selector is never hidden)', () => {
|
|
33
|
+
const groups = buildSourceChoices([state('github')], 'github')
|
|
34
|
+
expect(groups).toEqual([
|
|
35
|
+
[
|
|
36
|
+
{
|
|
37
|
+
action: 'select',
|
|
38
|
+
source: 'github',
|
|
39
|
+
label: 'GITHUB',
|
|
40
|
+
icon: 'i-lucide-github',
|
|
41
|
+
active: true,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('marks only the selected tracker active', () => {
|
|
48
|
+
const [offered] = buildSourceChoices([state('github'), state('jira')], 'jira')
|
|
49
|
+
expect(offered!.map((c) => [c.source, 'active' in c && c.active])).toEqual([
|
|
50
|
+
['github', false],
|
|
51
|
+
['jira', true],
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('offers an unavailable tracker as `connect` and an available-but-off one as `enable`', () => {
|
|
56
|
+
const [, addable] = buildSourceChoices(
|
|
57
|
+
[state('github'), state('jira', { available: false }), state('linear', { enabled: false })],
|
|
58
|
+
'github',
|
|
59
|
+
)
|
|
60
|
+
expect(addable).toEqual([
|
|
61
|
+
{ action: 'connect', source: 'jira', label: 'JIRA', icon: 'i-lucide-jira' },
|
|
62
|
+
{ action: 'enable', source: 'linear', label: 'LINEAR', icon: 'i-lucide-linear' },
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// What a surface's "nothing connected yet" state renders its add buttons from: it flattens
|
|
67
|
+
// the groups, so every choice there has to be addable. A `select` leaking through would
|
|
68
|
+
// offer to connect a tracker the workspace already has.
|
|
69
|
+
it('yields only addable choices when the workspace offers no tracker', () => {
|
|
70
|
+
const choices = buildSourceChoices(
|
|
71
|
+
[state('jira', { available: false }), state('linear', { enabled: false })],
|
|
72
|
+
undefined,
|
|
73
|
+
).flat()
|
|
74
|
+
expect(choices.map((c) => c.action)).toEqual(['connect', 'enable'])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('drops empty groups so the menu renders no stray separator', () => {
|
|
78
|
+
expect(buildSourceChoices([state('github')], 'github')).toHaveLength(1)
|
|
79
|
+
expect(buildSourceChoices([state('jira', { available: false })], undefined)).toHaveLength(1)
|
|
80
|
+
expect(buildSourceChoices([], undefined)).toEqual([])
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('connectableSources', () => {
|
|
85
|
+
const sources = [
|
|
86
|
+
{ source: 'github', label: 'GitHub', icon: 'i-lucide-github' },
|
|
87
|
+
{ source: 'confluence', label: 'Confluence', icon: 'i-lucide-file-text' },
|
|
88
|
+
]
|
|
89
|
+
const isConnected = (source: string) => source === 'github'
|
|
90
|
+
|
|
91
|
+
it('offers only the sources the workspace has not connected', () => {
|
|
92
|
+
const rows = connectableSources(sources, { isConnected, canConnect: true, available: true })
|
|
93
|
+
expect(rows.map((s) => s.source)).toEqual(['confluence'])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Connecting stores a workspace credential and stays admin-tier while attaching moved to the
|
|
97
|
+
// member tier, so a member's add tier is withheld rather than rendered into a 403.
|
|
98
|
+
it('withholds every source from a user who may not connect one', () => {
|
|
99
|
+
expect(
|
|
100
|
+
connectableSources(sources, { isConnected, canConnect: false, available: true }),
|
|
101
|
+
).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// The term the three former copies of this rule disagreed about. An integration the deployment
|
|
105
|
+
// has not configured has nothing to connect TO, so it is not the same fact as "already connected"
|
|
106
|
+
// and must not rest on the store happening to clear its source list.
|
|
107
|
+
it('withholds every source when the integration is unavailable to the deployment', () => {
|
|
108
|
+
expect(
|
|
109
|
+
connectableSources(sources, { isConnected: () => false, canConnect: true, available: false }),
|
|
110
|
+
).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('buildConnectionSourceChoices', () => {
|
|
115
|
+
const sources = [
|
|
116
|
+
{ source: 'github', label: 'GitHub', icon: 'i-lucide-github' },
|
|
117
|
+
{ source: 'confluence', label: 'Confluence', icon: 'i-lucide-file-text' },
|
|
118
|
+
]
|
|
119
|
+
const isConnected = (source: string) => source === 'github'
|
|
120
|
+
const opts = { isConnected, canConnect: true, available: true, selected: 'github' }
|
|
121
|
+
|
|
122
|
+
it('offers the connected source and the rest as something to add', () => {
|
|
123
|
+
expect(buildConnectionSourceChoices(sources, opts)).toEqual([
|
|
124
|
+
[
|
|
125
|
+
{
|
|
126
|
+
action: 'select',
|
|
127
|
+
source: 'github',
|
|
128
|
+
label: 'GitHub',
|
|
129
|
+
icon: 'i-lucide-github',
|
|
130
|
+
active: true,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
{
|
|
135
|
+
action: 'connect',
|
|
136
|
+
source: 'confluence',
|
|
137
|
+
label: 'Confluence',
|
|
138
|
+
icon: 'i-lucide-file-text',
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// No per-workspace toggle exists for these sources, so `enable` ("connected but switched off
|
|
145
|
+
// here") is a state they cannot be in. It is unrepresentable in the return TYPE, which is what
|
|
146
|
+
// stops a document surface wording an add entry as "Connect X" for a source already connected;
|
|
147
|
+
// this asserts the runtime half of that.
|
|
148
|
+
it('never words a document source as `enable`', () => {
|
|
149
|
+
const choices = buildConnectionSourceChoices(sources, {
|
|
150
|
+
...opts,
|
|
151
|
+
isConnected: () => false,
|
|
152
|
+
selected: undefined,
|
|
153
|
+
})
|
|
154
|
+
expect(
|
|
155
|
+
choices
|
|
156
|
+
.flat()
|
|
157
|
+
.map((c) => c.action)
|
|
158
|
+
.every((action) => action === 'connect'),
|
|
159
|
+
).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('yields the connected tier alone for a member, with no add group', () => {
|
|
163
|
+
const choices = buildConnectionSourceChoices(sources, { ...opts, canConnect: false })
|
|
164
|
+
expect(choices).toHaveLength(1)
|
|
165
|
+
expect(choices[0]!.map((c) => c.source)).toEqual(['github'])
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('menuIsPickable', () => {
|
|
170
|
+
// The papercut this exists for, and the one a member hits: a chevron opening a menu whose single
|
|
171
|
+
// entry re-selects what is already selected promises a choice that isn't there.
|
|
172
|
+
it('reports nothing to decide for a lone entry', () => {
|
|
173
|
+
expect(menuIsPickable([[{}]])).toBe(false)
|
|
174
|
+
expect(menuIsPickable([])).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('counts across groups, so one source plus one to add IS a choice', () => {
|
|
178
|
+
expect(menuIsPickable([[{}], [{}]])).toBe(true)
|
|
179
|
+
expect(menuIsPickable([[{}, {}]])).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('sourceMenuItems', () => {
|
|
184
|
+
const onSelect = vi.fn()
|
|
185
|
+
const onAdd = vi.fn()
|
|
186
|
+
|
|
187
|
+
it('marks the selected source as CHECKED, not merely glyphed', () => {
|
|
188
|
+
const [offered] = sourceMenuItems(
|
|
189
|
+
buildSourceChoices([state('github'), state('jira')], 'jira'),
|
|
190
|
+
{
|
|
191
|
+
onSelect,
|
|
192
|
+
onAdd,
|
|
193
|
+
addLabel: { connect: (l) => `connect ${l}`, enable: (l) => `enable ${l}` },
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
expect(offered!.map((i) => [i.label, i.type, i.checked])).toEqual([
|
|
197
|
+
['GITHUB', 'checkbox', false],
|
|
198
|
+
['JIRA', 'checkbox', true],
|
|
199
|
+
])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('words each add entry for its own action and marks it with the plug icon', () => {
|
|
203
|
+
const [, addable] = sourceMenuItems(
|
|
204
|
+
buildSourceChoices(
|
|
205
|
+
[state('github'), state('jira', { available: false }), state('linear', { enabled: false })],
|
|
206
|
+
'github',
|
|
207
|
+
),
|
|
208
|
+
{
|
|
209
|
+
onSelect,
|
|
210
|
+
onAdd,
|
|
211
|
+
addLabel: { connect: (l) => `connect ${l}`, enable: (l) => `enable ${l}` },
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
expect(addable!.map((i) => [i.label, i.icon])).toEqual([
|
|
215
|
+
['connect JIRA', 'i-lucide-plug'],
|
|
216
|
+
['enable LINEAR', 'i-lucide-plug'],
|
|
217
|
+
])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// A connection-only menu owes ONE wording, and the type says so: passing `enable` here would not
|
|
221
|
+
// compile. That is the guard, and this pins the behaviour it guards.
|
|
222
|
+
it('takes only the `connect` wording for a connection-only menu', () => {
|
|
223
|
+
const items = sourceMenuItems(
|
|
224
|
+
buildConnectionSourceChoices(
|
|
225
|
+
[{ source: 'confluence', label: 'Confluence', icon: 'i-lucide-file-text' }],
|
|
226
|
+
{ isConnected: () => false, canConnect: true, available: true, selected: undefined },
|
|
227
|
+
),
|
|
228
|
+
{ onSelect, onAdd, addLabel: { connect: (l) => `Connect ${l}` } },
|
|
229
|
+
)
|
|
230
|
+
expect(items.flat().map((i) => i.label)).toEqual(['Connect Confluence'])
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('reconcileSource', () => {
|
|
235
|
+
it('selects the tracker the user just went off to connect, once it is offered', () => {
|
|
236
|
+
expect(reconcileSource(['github', 'jira'], 'github', 'jira')).toBe('jira')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('keeps the current selection while the connect is still pending', () => {
|
|
240
|
+
expect(reconcileSource(['github'], 'github', 'jira')).toBe('github')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('keeps a still-offered selection', () => {
|
|
244
|
+
expect(reconcileSource(['github', 'jira'], 'jira', null)).toBe('jira')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('falls back to the first offered tracker when the selection stopped being offered', () => {
|
|
248
|
+
expect(reconcileSource(['github'], 'jira', null)).toBe('github')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('selects the first offered tracker when nothing is selected yet', () => {
|
|
252
|
+
expect(reconcileSource(['github'], undefined, null)).toBe('github')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('resolves to nothing when the workspace offers no tracker at all', () => {
|
|
256
|
+
expect(reconcileSource([], 'jira', 'jira')).toBeUndefined()
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure source-selection logic shared by every surface that picks an integration source: which
|
|
3
|
+
* one is selected, what its menu offers, and how that menu renders.
|
|
4
|
+
*
|
|
5
|
+
* The menu is deliberately two-tier (pick a source the workspace already has, or go and add one)
|
|
6
|
+
* because a source-picking surface is exactly where a user discovers the source they want is
|
|
7
|
+
* missing, and sending them off to the Integrations hub loses whatever they had in progress.
|
|
8
|
+
* Today `<ContextIssuePicker>` (attach a context issue) and `<BugHuntModal>` (scan a board for
|
|
9
|
+
* bugs) render it over TRACKERS, and `<ContextDocumentPicker>` (attach a context document) over
|
|
10
|
+
* DOCUMENT sources.
|
|
11
|
+
*
|
|
12
|
+
* The two integrations describe availability differently, so each gets its own CHOICE builder and
|
|
13
|
+
* they share one RENDERER. A tracker carries `available` plus a per-workspace `enabled` toggle, so
|
|
14
|
+
* it can be connected-but-off and its add entry has to say "enable" rather than "connect"; a
|
|
15
|
+
* document source is either connected or not, so `buildConnectionSourceChoices` cannot produce an
|
|
16
|
+
* `enable` choice at all. That is a fact about its RETURN TYPE rather than a convention, which is
|
|
17
|
+
* why these are two builders instead of one taking a flag: `sourceMenuItems` derives its wording
|
|
18
|
+
* map from what the choices can actually carry, so a document surface is not asked for wording it
|
|
19
|
+
* can never use, and a document source that one day GAINS a toggle fails the typecheck at every
|
|
20
|
+
* surface that renders it.
|
|
21
|
+
*/
|
|
22
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
23
|
+
|
|
24
|
+
/** What a menu needs to know about one configured source, whatever integration it belongs to. */
|
|
25
|
+
export interface SourceAvailability<S extends string> {
|
|
26
|
+
source: S
|
|
27
|
+
label: string
|
|
28
|
+
icon: string
|
|
29
|
+
/** A credential / installation is in place, so the source can be read right now. */
|
|
30
|
+
available: boolean
|
|
31
|
+
/**
|
|
32
|
+
* The workspace offers it. A tracker carries a per-workspace toggle that can be off while its
|
|
33
|
+
* credential is in place; an integration with no such toggle passes `true`.
|
|
34
|
+
*/
|
|
35
|
+
enabled: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A source whose only state is "connected or not", with no per-workspace toggle behind it. */
|
|
39
|
+
export interface ConnectableSource<S extends string> {
|
|
40
|
+
source: S
|
|
41
|
+
label: string
|
|
42
|
+
icon: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The two ways a source that is not on offer yet can be added from a menu. */
|
|
46
|
+
export type AddAction = 'connect' | 'enable'
|
|
47
|
+
|
|
48
|
+
/** A source the surface can use right now. */
|
|
49
|
+
interface SelectChoice<S extends string> {
|
|
50
|
+
action: 'select'
|
|
51
|
+
source: S
|
|
52
|
+
label: string
|
|
53
|
+
icon: string
|
|
54
|
+
active: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A configured source that is not offered yet, so it can be added from here. `connect` has no
|
|
59
|
+
* credential/App behind it; `enable` is connected but toggled off for the workspace. Both open the
|
|
60
|
+
* same connect modal, which serves either case: only the wording differs, so the user isn't told
|
|
61
|
+
* to "connect" something already connected.
|
|
62
|
+
*/
|
|
63
|
+
interface AddChoice<S extends string, A extends AddAction> {
|
|
64
|
+
action: A
|
|
65
|
+
source: S
|
|
66
|
+
label: string
|
|
67
|
+
icon: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One row of a source menu over an integration that has a per-workspace enable toggle. */
|
|
71
|
+
export type SourceChoice<S extends string> = SelectChoice<S> | AddChoice<S, AddAction>
|
|
72
|
+
|
|
73
|
+
/** One row of a source menu over an integration that is simply connected or not. */
|
|
74
|
+
export type ConnectionSourceChoice<S extends string> = SelectChoice<S> | AddChoice<S, 'connect'>
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A source menu, as non-empty groups (the sources on offer, then the ones the user could add).
|
|
78
|
+
* Empty groups are dropped so the menu never renders a stray separator.
|
|
79
|
+
*/
|
|
80
|
+
export function buildSourceChoices<S extends string>(
|
|
81
|
+
sources: readonly SourceAvailability<S>[],
|
|
82
|
+
selected: S | undefined,
|
|
83
|
+
): SourceChoice<S>[][] {
|
|
84
|
+
const offered: SourceChoice<S>[] = []
|
|
85
|
+
const addable: SourceChoice<S>[] = []
|
|
86
|
+
for (const s of sources) {
|
|
87
|
+
if (s.available && s.enabled) {
|
|
88
|
+
offered.push({
|
|
89
|
+
action: 'select',
|
|
90
|
+
source: s.source,
|
|
91
|
+
label: s.label,
|
|
92
|
+
icon: s.icon,
|
|
93
|
+
active: s.source === selected,
|
|
94
|
+
})
|
|
95
|
+
} else {
|
|
96
|
+
addable.push({
|
|
97
|
+
action: s.available ? 'enable' : 'connect',
|
|
98
|
+
source: s.source,
|
|
99
|
+
label: s.label,
|
|
100
|
+
icon: s.icon,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [offered, addable].filter((group) => group.length > 0)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The sources the acting user could add right now: those the workspace has not connected, and
|
|
109
|
+
* NONE when the integration is unavailable to this deployment or the user may not connect one.
|
|
110
|
+
*
|
|
111
|
+
* The single answer to "which document sources could I connect", so the picker's add tier and the
|
|
112
|
+
* hosts' connect shortcuts cannot drift. Both terms matter and neither implies the other: an
|
|
113
|
+
* unavailable integration has nothing to connect TO, and connecting stores a workspace credential,
|
|
114
|
+
* which is admin-tier while ATTACHING what it holds is member-tier. Offering a member an add entry
|
|
115
|
+
* would open a connect modal, take a token and 403, so what they see is what they can use.
|
|
116
|
+
*
|
|
117
|
+
* `available` is the store's probe result, so `null` (not probed yet) offers nothing: we do not know
|
|
118
|
+
* that there is anything to connect to, and a menu entry is a claim that there is.
|
|
119
|
+
*/
|
|
120
|
+
export function connectableSources<S extends string, T extends { source: S }>(
|
|
121
|
+
sources: readonly T[],
|
|
122
|
+
opts: { isConnected: (source: S) => boolean; canConnect: boolean; available: boolean | null },
|
|
123
|
+
): T[] {
|
|
124
|
+
if (opts.available !== true || !opts.canConnect) return []
|
|
125
|
+
return sources.filter((s) => !opts.isConnected(s.source))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A source menu for an integration that is either connected or not (document sources). Same two
|
|
130
|
+
* groups as {@link buildSourceChoices}, but the add tier can only ever be `connect`.
|
|
131
|
+
*/
|
|
132
|
+
export function buildConnectionSourceChoices<S extends string>(
|
|
133
|
+
sources: readonly ConnectableSource<S>[],
|
|
134
|
+
opts: {
|
|
135
|
+
isConnected: (source: S) => boolean
|
|
136
|
+
canConnect: boolean
|
|
137
|
+
available: boolean | null
|
|
138
|
+
selected: S | undefined
|
|
139
|
+
},
|
|
140
|
+
): ConnectionSourceChoice<S>[][] {
|
|
141
|
+
const connected: ConnectionSourceChoice<S>[] = sources
|
|
142
|
+
.filter((s) => opts.isConnected(s.source))
|
|
143
|
+
.map((s) => ({
|
|
144
|
+
action: 'select',
|
|
145
|
+
source: s.source,
|
|
146
|
+
label: s.label,
|
|
147
|
+
icon: s.icon,
|
|
148
|
+
active: s.source === opts.selected,
|
|
149
|
+
}))
|
|
150
|
+
const addable: ConnectionSourceChoice<S>[] = connectableSources(sources, opts).map((s) => ({
|
|
151
|
+
action: 'connect',
|
|
152
|
+
source: s.source,
|
|
153
|
+
label: s.label,
|
|
154
|
+
icon: s.icon,
|
|
155
|
+
}))
|
|
156
|
+
return [connected, addable].filter((group) => group.length > 0)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Wording for each way a source can be ADDED, as an exhaustive `Record` over exactly the add
|
|
161
|
+
* actions the menu's own choices can carry. A tracker surface owes both spellings; a document
|
|
162
|
+
* surface owes only `connect`, and gains a typecheck failure rather than the wrong wording if that
|
|
163
|
+
* ever stops being true.
|
|
164
|
+
*/
|
|
165
|
+
export type AddSourceLabels<A extends AddAction> = Record<A, (label: string) => string>
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render source choices as dropdown items. The ONE place the two-tier menu's presentation lives:
|
|
169
|
+
* the selected source is a CHECKED item rather than one carrying a decorative glyph, so a screen
|
|
170
|
+
* reader announces which source is in use instead of just naming it, and every add entry carries
|
|
171
|
+
* the plug icon that distinguishes "go and set this up" from "use this".
|
|
172
|
+
*/
|
|
173
|
+
export function sourceMenuItems<S extends string, A extends AddAction>(
|
|
174
|
+
groups: readonly (readonly (SelectChoice<S> | AddChoice<S, A>)[])[],
|
|
175
|
+
opts: {
|
|
176
|
+
onSelect: (source: S) => void
|
|
177
|
+
onAdd: (source: S) => void
|
|
178
|
+
addLabel: AddSourceLabels<A>
|
|
179
|
+
},
|
|
180
|
+
): DropdownMenuItem[][] {
|
|
181
|
+
return groups.map((group) =>
|
|
182
|
+
group.map((choice) =>
|
|
183
|
+
isAddChoice(choice)
|
|
184
|
+
? {
|
|
185
|
+
label: opts.addLabel[choice.action](choice.label),
|
|
186
|
+
icon: 'i-lucide-plug',
|
|
187
|
+
onSelect: () => opts.onAdd(choice.source),
|
|
188
|
+
}
|
|
189
|
+
: {
|
|
190
|
+
label: choice.label,
|
|
191
|
+
icon: choice.icon,
|
|
192
|
+
type: 'checkbox' as const,
|
|
193
|
+
checked: choice.active,
|
|
194
|
+
onSelect: () => opts.onSelect(choice.source),
|
|
195
|
+
},
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether a choice is an ADD entry. A hand-written predicate because the add member's `action` is
|
|
202
|
+
* the type parameter `A` rather than a literal, and TypeScript will not narrow a generic discriminant
|
|
203
|
+
* on its own: without this, reading `.active` off the select half fails to compile.
|
|
204
|
+
*/
|
|
205
|
+
function isAddChoice<S extends string, A extends AddAction>(
|
|
206
|
+
choice: SelectChoice<S> | AddChoice<S, A>,
|
|
207
|
+
): choice is AddChoice<S, A> {
|
|
208
|
+
return choice.action !== 'select'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The ADD half of a menu's choices, flattened: what a surface renders as buttons when nothing is
|
|
213
|
+
* offered yet and there is no selection to make. Narrowed here rather than at each call site so the
|
|
214
|
+
* wording map stays exhaustive over what actually arrives, and so a `select` choice cannot leak into
|
|
215
|
+
* a row that offers to connect a source the workspace already has.
|
|
216
|
+
*/
|
|
217
|
+
export function addChoicesOf<S extends string, A extends AddAction>(
|
|
218
|
+
groups: readonly (readonly (SelectChoice<S> | AddChoice<S, A>)[])[],
|
|
219
|
+
): AddChoice<S, A>[] {
|
|
220
|
+
return groups.flat().filter((choice) => isAddChoice(choice))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether a source menu has anything to decide. With a single entry the trigger can only re-pick
|
|
225
|
+
* what is already selected, so a surface names the source as a LABEL instead: a chevron opening a
|
|
226
|
+
* one-item menu promises a choice that isn't there. The state is reached most often by a member,
|
|
227
|
+
* whose add tier is withheld (see {@link connectableSources}), which is exactly the reader least
|
|
228
|
+
* able to tell a dead control from a broken one.
|
|
229
|
+
*/
|
|
230
|
+
export function menuIsPickable(groups: readonly (readonly unknown[])[]): boolean {
|
|
231
|
+
return groups.reduce((total, group) => total + group.length, 0) > 1
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The source a surface should hold once the offered set changes: after a connect, a disconnect, or
|
|
236
|
+
* the per-workspace toggle flipping elsewhere.
|
|
237
|
+
*
|
|
238
|
+
* `awaiting` is the source the user just left to connect: the moment it becomes offered it wins, so
|
|
239
|
+
* they land back on the source they went to add rather than on whatever was selected before.
|
|
240
|
+
* Otherwise a still-offered selection is kept, and a selection that stopped being offered falls
|
|
241
|
+
* back to the first one (reading a source the workspace no longer offers only yields errors).
|
|
242
|
+
*/
|
|
243
|
+
export function reconcileSource<S extends string>(
|
|
244
|
+
offered: readonly S[],
|
|
245
|
+
selected: S | undefined,
|
|
246
|
+
awaiting: S | null,
|
|
247
|
+
): S | undefined {
|
|
248
|
+
if (awaiting && offered.includes(awaiting)) return awaiting
|
|
249
|
+
if (selected && offered.includes(selected)) return selected
|
|
250
|
+
return offered[0]
|
|
251
|
+
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -2911,20 +2911,14 @@
|
|
|
2911
2911
|
},
|
|
2912
2912
|
"shared": "Geteilt",
|
|
2913
2913
|
"sharedTitle": "Über Workspaces in dieser Organisation hinweg geteilt",
|
|
2914
|
-
"bootstrapping": "Wird gebootstrappt…",
|
|
2915
2914
|
"bootstrappingRepository": "Repository wird gebootstrappt…",
|
|
2916
2915
|
"bootstrapStepsCount": "{completed}/{total} Schritte",
|
|
2917
|
-
"runFailed": "Lauf fehlgeschlagen",
|
|
2918
|
-
"mergedOfTotal": "{merged}/{total} gemergt",
|
|
2919
|
-
"noTasksYet": "Noch keine Aufgaben",
|
|
2920
|
-
"prCount": "{count} PR",
|
|
2921
2916
|
"prReadyCount": "{count} PR bereit",
|
|
2922
2917
|
"taskCount": "{count} Aufgabe | {count} Aufgaben",
|
|
2923
2918
|
"moduleCount": "{count} Modul | {count} Module",
|
|
2924
2919
|
"addTaskTitle": "Aufgabe hinzufügen",
|
|
2925
2920
|
"createTaskFromIssueTitle": "Aufgabe aus Issue erstellen",
|
|
2926
2921
|
"addRecurringTitle": "Wiederkehrende Pipeline hinzufügen",
|
|
2927
|
-
"collapseTitle": "Einklappen",
|
|
2928
2922
|
"dragService": "Service ziehen",
|
|
2929
2923
|
"dragTask": "Aufgabe ziehen",
|
|
2930
2924
|
"dragToResize": "Zum Ändern der Größe ziehen",
|
|
@@ -3786,6 +3780,11 @@
|
|
|
3786
3780
|
"noMatches": "Keine passenden Seiten.",
|
|
3787
3781
|
"emptySearchable": "Nach Titel suchen oder ein importiertes Dokument auswählen.",
|
|
3788
3782
|
"emptyRefOnly": "Fügen Sie eine Seiten-URL oder -ID ein, um sie anzuhängen.",
|
|
3783
|
+
"sourceLabel": "Quelle",
|
|
3784
|
+
"noSource": "Quelle wählen",
|
|
3785
|
+
"connectSource": "{label} verbinden",
|
|
3786
|
+
"needsSource": "Verbinden Sie eine Dokumentquelle, um eine Seite anzuhängen.",
|
|
3787
|
+
"needsSourceAdmin": "Es ist keine Dokumentquelle verbunden. Bitte einen Workspace-Admin, eine zu verbinden.",
|
|
3789
3788
|
"refChecking": "Referenz wird geprüft…",
|
|
3790
3789
|
"refUnrecognized": "Keine {source}-Referenz. Erwartet: {expected}",
|
|
3791
3790
|
"refOtherSource": "Das ist ein {claimed}-Link, kein {source}-Link.",
|
|
@@ -3922,6 +3921,7 @@
|
|
|
3922
3921
|
"searchIssues": "Issues durchsuchen",
|
|
3923
3922
|
"searchPlaceholder": "Nach Titel suchen, eine Issue-URL einfügen oder eine Issue-Nummer eingeben…",
|
|
3924
3923
|
"createTasksIn": "Aufgaben erstellen in",
|
|
3924
|
+
"creatingIn": "Neue Aufgaben landen in {container}",
|
|
3925
3925
|
"needFrameFirst": "Fügen Sie dem Board zuerst einen Service-Frame hinzu, um Aufgaben aus Issues zu erstellen.",
|
|
3926
3926
|
"searchFailed": "Suche fehlgeschlagen: {error}",
|
|
3927
3927
|
"searchResults": "Suchergebnisse",
|
|
@@ -3974,6 +3974,7 @@
|
|
|
3974
3974
|
"labels": "Labels",
|
|
3975
3975
|
"labelsHelp": "Durch Komma getrennt. Alle müssen vorhanden sein.",
|
|
3976
3976
|
"adoptInto": "Ausgewählten Fehler hinzufügen zu",
|
|
3977
|
+
"adoptingInto": "Der ausgewählte Fehler landet in {container}",
|
|
3977
3978
|
"run": "Jagen",
|
|
3978
3979
|
"running": "Board wird gelesen und die Funde werden bewertet…",
|
|
3979
3980
|
"huntFailed": "Die Jagd ist fehlgeschlagen",
|
package/i18n/locales/en.json
CHANGED
|
@@ -449,13 +449,8 @@
|
|
|
449
449
|
},
|
|
450
450
|
"shared": "Shared",
|
|
451
451
|
"sharedTitle": "Shared across workspaces in this org",
|
|
452
|
-
"bootstrapping": "Bootstrapping…",
|
|
453
452
|
"bootstrappingRepository": "Bootstrapping repository…",
|
|
454
453
|
"bootstrapStepsCount": "{completed}/{total} steps",
|
|
455
|
-
"runFailed": "Run failed",
|
|
456
|
-
"mergedOfTotal": "{merged}/{total} merged",
|
|
457
|
-
"noTasksYet": "No tasks yet",
|
|
458
|
-
"prCount": "{count} PR",
|
|
459
454
|
"prReadyCount": "{count} PR ready",
|
|
460
455
|
"taskCount": "{count} task | {count} tasks",
|
|
461
456
|
"@taskCount": {
|
|
@@ -468,7 +463,6 @@
|
|
|
468
463
|
"addTaskTitle": "Add task",
|
|
469
464
|
"createTaskFromIssueTitle": "Create task from issue",
|
|
470
465
|
"addRecurringTitle": "Add recurring pipeline",
|
|
471
|
-
"collapseTitle": "Collapse",
|
|
472
466
|
"dragService": "Drag service",
|
|
473
467
|
"dragTask": "Drag task",
|
|
474
468
|
"dragToResize": "Drag to resize",
|
|
@@ -4306,6 +4300,11 @@
|
|
|
4306
4300
|
"noMatches": "No matching pages.",
|
|
4307
4301
|
"emptySearchable": "Search by title, or pick an imported document.",
|
|
4308
4302
|
"emptyRefOnly": "Paste a page URL or ID to attach it.",
|
|
4303
|
+
"sourceLabel": "Source",
|
|
4304
|
+
"noSource": "Choose a source",
|
|
4305
|
+
"connectSource": "Connect {label}",
|
|
4306
|
+
"needsSource": "Connect a document source to attach a page.",
|
|
4307
|
+
"needsSourceAdmin": "No document source is connected. Ask a workspace admin to connect one.",
|
|
4309
4308
|
"refChecking": "Checking the reference…",
|
|
4310
4309
|
"refUnrecognized": "Not a {source} reference. Expected {expected}",
|
|
4311
4310
|
"refOtherSource": "That is a {claimed} link, not a {source} one.",
|
|
@@ -4442,6 +4441,7 @@
|
|
|
4442
4441
|
"searchIssues": "Search issues",
|
|
4443
4442
|
"searchPlaceholder": "Search by title, paste an issue URL, or type an issue number…",
|
|
4444
4443
|
"createTasksIn": "Create tasks in",
|
|
4444
|
+
"creatingIn": "New tasks land in {container}",
|
|
4445
4445
|
"needFrameFirst": "Add a service frame to the board first to create tasks from issues.",
|
|
4446
4446
|
"searchFailed": "Search failed: {error}",
|
|
4447
4447
|
"searchResults": "Search results",
|
|
@@ -4497,6 +4497,7 @@
|
|
|
4497
4497
|
"labels": "Labels",
|
|
4498
4498
|
"labelsHelp": "Comma separated. All of them must be present.",
|
|
4499
4499
|
"adoptInto": "Add the picked bug to",
|
|
4500
|
+
"adoptingInto": "The picked bug lands in {container}",
|
|
4500
4501
|
"run": "Hunt",
|
|
4501
4502
|
"running": "Reading the board and rating what it finds…",
|
|
4502
4503
|
"huntFailed": "The hunt failed",
|
package/i18n/locales/es.json
CHANGED
|
@@ -404,20 +404,14 @@
|
|
|
404
404
|
},
|
|
405
405
|
"shared": "Compartido",
|
|
406
406
|
"sharedTitle": "Compartido entre espacios de trabajo de esta organización",
|
|
407
|
-
"bootstrapping": "Arrancando…",
|
|
408
407
|
"bootstrappingRepository": "Arrancando repositorio…",
|
|
409
408
|
"bootstrapStepsCount": "{completed}/{total} pasos",
|
|
410
|
-
"runFailed": "La ejecución falló",
|
|
411
|
-
"mergedOfTotal": "{merged}/{total} fusionadas",
|
|
412
|
-
"noTasksYet": "Aún no hay tareas",
|
|
413
|
-
"prCount": "{count} PR",
|
|
414
409
|
"prReadyCount": "{count} PR listas",
|
|
415
410
|
"taskCount": "{count} tarea | {count} tareas",
|
|
416
411
|
"moduleCount": "{count} módulo | {count} módulos",
|
|
417
412
|
"addTaskTitle": "Añadir tarea",
|
|
418
413
|
"createTaskFromIssueTitle": "Crear tarea desde incidencia",
|
|
419
414
|
"addRecurringTitle": "Añadir pipeline recurrente",
|
|
420
|
-
"collapseTitle": "Contraer",
|
|
421
415
|
"dragService": "Arrastrar servicio",
|
|
422
416
|
"dragTask": "Arrastrar tarea",
|
|
423
417
|
"dragToResize": "Arrastra para cambiar el tamaño",
|
|
@@ -4169,6 +4163,11 @@
|
|
|
4169
4163
|
"noMatches": "No hay páginas que coincidan.",
|
|
4170
4164
|
"emptySearchable": "Busca por título o elige un documento importado.",
|
|
4171
4165
|
"emptyRefOnly": "Pega la URL o el ID de una página para adjuntarla.",
|
|
4166
|
+
"sourceLabel": "Fuente",
|
|
4167
|
+
"noSource": "Elige una fuente",
|
|
4168
|
+
"connectSource": "Conectar {label}",
|
|
4169
|
+
"needsSource": "Conecta una fuente de documentos para adjuntar una página.",
|
|
4170
|
+
"needsSourceAdmin": "No hay ninguna fuente de documentos conectada. Pide a un administrador del espacio de trabajo que conecte una.",
|
|
4172
4171
|
"refChecking": "Comprobando la referencia…",
|
|
4173
4172
|
"refUnrecognized": "No es una referencia de {source}. Se esperaba {expected}",
|
|
4174
4173
|
"refOtherSource": "Es un enlace de {claimed}, no de {source}.",
|
|
@@ -4305,6 +4304,7 @@
|
|
|
4305
4304
|
"searchIssues": "Buscar incidencias",
|
|
4306
4305
|
"searchPlaceholder": "Busca por título, pega la URL de una incidencia o escribe un número de incidencia…",
|
|
4307
4306
|
"createTasksIn": "Crear tareas en",
|
|
4307
|
+
"creatingIn": "Las tareas nuevas se crean en {container}",
|
|
4308
4308
|
"needFrameFirst": "Añade primero un marco de servicio al tablero para crear tareas desde incidencias.",
|
|
4309
4309
|
"searchFailed": "La búsqueda falló: {error}",
|
|
4310
4310
|
"searchResults": "Resultados de la búsqueda",
|
|
@@ -4357,6 +4357,7 @@
|
|
|
4357
4357
|
"labels": "Etiquetas",
|
|
4358
4358
|
"labelsHelp": "Separadas por comas. Todas deben estar presentes.",
|
|
4359
4359
|
"adoptInto": "Añadir el error elegido a",
|
|
4360
|
+
"adoptingInto": "El error elegido se añade a {container}",
|
|
4360
4361
|
"run": "Cazar",
|
|
4361
4362
|
"running": "Leyendo el tablero y valorando lo que encuentra…",
|
|
4362
4363
|
"huntFailed": "La caza ha fallado",
|