@cat-factory/app 0.235.0 → 0.236.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 +24 -0
- package/app/components/auth/LoginScreen.vue +15 -3
- package/app/components/auth/UserMenu.vue +1 -0
- package/app/components/board/AddTaskModal.vue +31 -12
- package/app/components/board/CreateInitiativeModal.vue +33 -6
- package/app/components/context/ContextAttachmentFields.vue +63 -42
- package/app/components/documents/ContextDocumentPicker.logic.spec.ts +186 -0
- package/app/components/documents/ContextDocumentPicker.logic.ts +178 -0
- package/app/components/documents/ContextDocumentPicker.vue +269 -34
- package/app/components/layout/WorkspaceMembersSettings.vue +1 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +1 -0
- package/app/components/riskPolicy/RiskPolicyPicker.vue +3 -0
- package/app/components/settings/RiskPolicyPanel.vue +22 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +7 -0
- package/app/composables/api/documents.ts +10 -0
- package/app/composables/useAiReadiness.ts +4 -3
- package/app/composables/useContextLinking.spec.ts +95 -0
- package/app/composables/useContextLinking.ts +96 -15
- package/app/pages/index.vue +7 -1
- package/app/stores/documents.ts +12 -0
- package/app/stores/models.spec.ts +64 -0
- package/app/stores/models.ts +21 -0
- package/app/types/documents.ts +2 -0
- package/i18n/locales/de.json +15 -4
- package/i18n/locales/en.json +19 -2
- package/i18n/locales/es.json +15 -4
- package/i18n/locales/fr.json +15 -4
- package/i18n/locales/he.json +15 -4
- package/i18n/locales/it.json +15 -4
- package/i18n/locales/ja.json +15 -4
- package/i18n/locales/pl.json +15 -4
- package/i18n/locales/tr.json +15 -4
- package/i18n/locales/uk.json +15 -4
- package/package.json +2 -2
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
documentRefReasonSchema,
|
|
3
|
+
type DocumentRefReason,
|
|
4
|
+
type DocumentSourceKind,
|
|
5
|
+
type ResolvedDocumentRef,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
7
|
+
import { apiErrorEnvelope, apiErrorReason } from '~/composables/api/errors'
|
|
8
|
+
|
|
9
|
+
// The pure half of ContextDocumentPicker: deciding what counts as a pasted REFERENCE rather than a
|
|
10
|
+
// search phrase, reading the backend's verdict on one, and describing the row it becomes.
|
|
11
|
+
// Extracted for the reason every `*.logic.ts` here is (a decision worth a test should not need a
|
|
12
|
+
// mounted component to reach), and these three carry the whole "don't silently accept a bad link"
|
|
13
|
+
// rule the picker exists to enforce.
|
|
14
|
+
|
|
15
|
+
/** What the picker knows about the reference currently in its input. */
|
|
16
|
+
export type RefState =
|
|
17
|
+
/** Nothing pasted, or the text reads as a search phrase. */
|
|
18
|
+
| { status: 'none' }
|
|
19
|
+
| { status: 'checking' }
|
|
20
|
+
| { status: 'ok'; ref: ResolvedDocumentRef }
|
|
21
|
+
/** The SOURCE refused it. `reason` decides which correction the user is offered. */
|
|
22
|
+
| { status: 'rejected'; reason: DocumentRefReason; claimedBy?: string; expected?: string }
|
|
23
|
+
/**
|
|
24
|
+
* The resolve call itself failed (offline, 5xx). The reference is UNJUDGED, not refused, and
|
|
25
|
+
* saying so matters: a pre-flight outage rendered as "your link is wrong" sends the user off to
|
|
26
|
+
* fix a link that was fine.
|
|
27
|
+
*/
|
|
28
|
+
| { status: 'unchecked'; message: string }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The text to resolve as a reference, or null when there is nothing to resolve.
|
|
32
|
+
*
|
|
33
|
+
* A source with no catalogue search takes any non-empty text (pasting is the only way to attach a
|
|
34
|
+
* page there, mirroring the import modal's single input). A searchable one only treats text that
|
|
35
|
+
* READS as a reference that way, so typing a title does not fire a resolve on every keystroke and
|
|
36
|
+
* does not render a refusal at someone who is simply searching.
|
|
37
|
+
*/
|
|
38
|
+
export function refCandidateOf(query: string, searchable: boolean): string | null {
|
|
39
|
+
const trimmed = query.trim()
|
|
40
|
+
if (!trimmed) return null
|
|
41
|
+
if (!searchable) return trimmed
|
|
42
|
+
return looksLikeDocumentRef(trimmed) ? trimmed : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The bare-id forms a document source's ref grammar accepts UNAMBIGUOUSLY, so no phrase matches. */
|
|
46
|
+
const BARE_ID_SHAPES = [
|
|
47
|
+
/** A Notion page id, dashless. */
|
|
48
|
+
/^[0-9a-f]{32}$/i,
|
|
49
|
+
/** A dashed UUID (Notion, Linear). */
|
|
50
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
51
|
+
/** A Confluence page id. */
|
|
52
|
+
/^\d{4,}$/,
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether text typed into a SEARCHABLE source's box is a reference rather than a search phrase.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately narrow, because the consequence of a false positive changed. It used to cost an
|
|
59
|
+
* extra row the user could ignore; now the resolve verdict is RENDERED, so a phrase mistaken for a
|
|
60
|
+
* reference produces "Not a Notion reference" underneath the search box, in amber, above the
|
|
61
|
+
* results for that same phrase. `auth/login flow` and `sprint #4 plan` are searches, so the first
|
|
62
|
+
* rule is that anything containing whitespace is one, whatever punctuation it carries: the old
|
|
63
|
+
* "contains `/` or `#`" test read both of those as malformed links.
|
|
64
|
+
*
|
|
65
|
+
* What remains is a URL (with or without its scheme, which people routinely paste off), or one of
|
|
66
|
+
* the `BARE_ID_SHAPES` no title could be confused with. A bare id in an unrecognised shape simply
|
|
67
|
+
* is not offered here, which is the pre-existing behaviour: the backend stays the judge of every
|
|
68
|
+
* candidate this admits, and this only decides which ones are worth ASKING about.
|
|
69
|
+
*/
|
|
70
|
+
function looksLikeDocumentRef(text: string): boolean {
|
|
71
|
+
if (/\s/.test(text)) return false
|
|
72
|
+
if (/^https?:\/\//i.test(text)) return true
|
|
73
|
+
if (/^[\w-]+(\.[\w-]+)+\/\S/.test(text)) return true
|
|
74
|
+
return BARE_ID_SHAPES.some((shape) => shape.test(text))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Classify a failed resolve.
|
|
79
|
+
*
|
|
80
|
+
* A refusal is only a refusal when the backend NAMED a reason we know: the reason vocabulary is
|
|
81
|
+
* the closed contract picklist, so an unrecognised value (an older/newer backend, a proxy's own
|
|
82
|
+
* error page, a network fault) lands as `unchecked`. Guessing "rejected" from the mere presence of
|
|
83
|
+
* an error is the misattribution this whole surface is meant to avoid: it would tell the user
|
|
84
|
+
* their link is malformed on the strength of a 502.
|
|
85
|
+
*
|
|
86
|
+
* The reason is read through `apiErrorReason`, the one helper that normalises this contract (which
|
|
87
|
+
* client threw, a non-object `details`, a non-string `reason`); only the two extra details each
|
|
88
|
+
* correction needs are picked off the envelope here.
|
|
89
|
+
*/
|
|
90
|
+
export function classifyRefFailure(error: unknown): RefState {
|
|
91
|
+
const reason = apiErrorReason(error)
|
|
92
|
+
if (!isRefReason(reason)) {
|
|
93
|
+
return { status: 'unchecked', message: error instanceof Error ? error.message : String(error) }
|
|
94
|
+
}
|
|
95
|
+
const details = (apiErrorEnvelope(error)?.details ?? {}) as Record<string, unknown>
|
|
96
|
+
return {
|
|
97
|
+
status: 'rejected',
|
|
98
|
+
reason,
|
|
99
|
+
...(typeof details.claimedBy === 'string' ? { claimedBy: details.claimedBy } : {}),
|
|
100
|
+
...(typeof details.expected === 'string' ? { expected: details.expected } : {}),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Narrow an unknown `details.reason` to the contract's own picklist, never a bare cast. */
|
|
105
|
+
function isRefReason(value: unknown): value is DocumentRefReason {
|
|
106
|
+
return (
|
|
107
|
+
typeof value === 'string' &&
|
|
108
|
+
(documentRefReasonSchema.options as readonly string[]).includes(value)
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** How a reference is presented as an attachable row. */
|
|
113
|
+
export interface RefRow {
|
|
114
|
+
/** The reference to stage: the resolved canonical id, or the pasted text when unjudged. */
|
|
115
|
+
externalId: string
|
|
116
|
+
/** The source that claimed it; null when the pre-flight never got a verdict. */
|
|
117
|
+
source: DocumentSourceKind | null
|
|
118
|
+
/** The canonical link, when the source can rebuild one from the id alone. */
|
|
119
|
+
canonicalUrl: string | null
|
|
120
|
+
/** What the row reads as: the imported page's title when we hold it, else the canonical form. */
|
|
121
|
+
label: string
|
|
122
|
+
/** The paste carried NOISE the canonical form drops, so the trim is worth stating explicitly. */
|
|
123
|
+
trimmed: boolean
|
|
124
|
+
/**
|
|
125
|
+
* The frame/screen the paste named that this reference does NOT cover (see the contract's
|
|
126
|
+
* `droppedScope`). Kept apart from {@link trimmed} because they are opposite facts wearing the
|
|
127
|
+
* same clothes: a trim resolves the same page, a drop WIDENS one frame to a whole design file.
|
|
128
|
+
*/
|
|
129
|
+
droppedScope: string | null
|
|
130
|
+
/** The pre-flight could not reach a verdict. Stageable, with the import as the backstop. */
|
|
131
|
+
unchecked: boolean
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Describe the row a reference becomes, or null when there is nothing to offer.
|
|
136
|
+
*
|
|
137
|
+
* The label is the CANONICAL form, never the pasted text: showing what was typed would hide the
|
|
138
|
+
* one thing worth confirming, which is that a share link's title segment and `?p=`/`&t=` tracking
|
|
139
|
+
* params are gone and the frame the URL named survived the trim. `trimmed` is what lets the row
|
|
140
|
+
* say so instead of leaving the change to be noticed, and `droppedScope` is what keeps a widened
|
|
141
|
+
* reference from hiding behind that same note.
|
|
142
|
+
*
|
|
143
|
+
* An UNCHECKED reference still yields a row, carrying the pasted text. "The source refused this"
|
|
144
|
+
* and "we could not ask" are different facts and only the first is a reason to refuse a paste: a
|
|
145
|
+
* transient 502 or an offline moment must not make attaching a perfectly good link impossible,
|
|
146
|
+
* which is what suppressing the row does. The import remains the backstop it always was, so the
|
|
147
|
+
* worst case is the pre-PR behaviour rather than a dead end.
|
|
148
|
+
*/
|
|
149
|
+
export function refRowFor(state: RefState, pasted: string, importedTitle?: string): RefRow | null {
|
|
150
|
+
if (state.status === 'ok') {
|
|
151
|
+
const canonical = state.ref.canonicalUrl ?? state.ref.externalId
|
|
152
|
+
return {
|
|
153
|
+
externalId: state.ref.externalId,
|
|
154
|
+
source: state.ref.source,
|
|
155
|
+
canonicalUrl: state.ref.canonicalUrl,
|
|
156
|
+
label: importedTitle ?? canonical,
|
|
157
|
+
trimmed: canonical !== pasted.trim(),
|
|
158
|
+
droppedScope: state.ref.droppedScope,
|
|
159
|
+
unchecked: false,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (state.status === 'unchecked') {
|
|
163
|
+
const text = pasted.trim()
|
|
164
|
+
if (!text) return null
|
|
165
|
+
return {
|
|
166
|
+
externalId: text,
|
|
167
|
+
// Unknown: only the resolve answers which source claims a paste, and guessing it here is
|
|
168
|
+
// what would stage a Figma link against the Notion picker's key space.
|
|
169
|
+
source: null,
|
|
170
|
+
canonicalUrl: null,
|
|
171
|
+
label: text,
|
|
172
|
+
trimmed: false,
|
|
173
|
+
droppedScope: null,
|
|
174
|
+
unchecked: true,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
@@ -8,7 +8,31 @@
|
|
|
8
8
|
// collects PendingContext items and links them once the block exists (see
|
|
9
9
|
// useContextLinking). A search hit / pasted ref carries `needsImport: true` so
|
|
10
10
|
// it's fetched + persisted before linking. Mirrors ContextIssuePicker.
|
|
11
|
-
|
|
11
|
+
//
|
|
12
|
+
// A PASTED REF IS RESOLVED BEFORE IT CAN BE STAGED, and that is the point of the ref
|
|
13
|
+
// half of this picker. It used to stage whatever text was in the box, so a share link
|
|
14
|
+
// carrying a title segment and `?p=`/`&t=` tracking params (what Figma's own Copy link
|
|
15
|
+
// button produces) was accepted verbatim, and a link the source could not read at all
|
|
16
|
+
// was accepted just as readily. The verdict arrived as a failed import AFTER the task
|
|
17
|
+
// had been created. The resolve call is the source's own `parseRef`, so what is shown
|
|
18
|
+
// here is what the import will do: the canonical trimmed link when it parses, and one
|
|
19
|
+
// of two named corrections when it does not (see `REF_REJECTIONS`).
|
|
20
|
+
//
|
|
21
|
+
// Two distinctions the row has to keep, because collapsing either one recreates the bug in
|
|
22
|
+
// quieter form. A reference the source could parse only by DROPPING the frame the link named
|
|
23
|
+
// carries that fact separately from the trim (`droppedScope`): both change what was pasted,
|
|
24
|
+
// but one resolves the same page and the other attaches the whole design file. And a resolve
|
|
25
|
+
// call that could not be MADE leaves the reference unjudged rather than refused, still
|
|
26
|
+
// stageable with the import as the backstop, because only the source's own refusal is evidence
|
|
27
|
+
// against a link. An outage that made attaching impossible would be a worse failure than the
|
|
28
|
+
// one the pre-flight fixes.
|
|
29
|
+
import type { DocumentRefReason, DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
|
|
30
|
+
import {
|
|
31
|
+
classifyRefFailure,
|
|
32
|
+
refCandidateOf,
|
|
33
|
+
refRowFor,
|
|
34
|
+
type RefState,
|
|
35
|
+
} from '~/components/documents/ContextDocumentPicker.logic'
|
|
12
36
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
13
37
|
import RepoContextDocPicker from '~/components/documents/RepoContextDocPicker.vue'
|
|
14
38
|
|
|
@@ -46,6 +70,45 @@ const results = ref<DocumentSearchResult[]>([])
|
|
|
46
70
|
const searching = ref(false)
|
|
47
71
|
const searchError = ref<string | null>(null)
|
|
48
72
|
|
|
73
|
+
// What the ref half of the picker knows about the current query. A refusal carries the backend's
|
|
74
|
+
// machine-readable REASON rather than its prose, because the two reasons ask for different
|
|
75
|
+
// corrections and only one of them is fixable from here (see `REF_REJECTIONS`).
|
|
76
|
+
const refState = ref<RefState>({ status: 'none' })
|
|
77
|
+
// Monotonic token so a slow resolve for an earlier query cannot land on a later one: the
|
|
78
|
+
// input is resolved on every keystroke, so out-of-order responses are the normal case.
|
|
79
|
+
let refSeq = 0
|
|
80
|
+
|
|
81
|
+
/** A source's display name, for a kind the backend named in a refusal (never a raw slug). */
|
|
82
|
+
function sourceLabel(kind: string): string {
|
|
83
|
+
return documents.sources.find((s) => s.source === kind)?.label ?? kind
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Translated copy per refusal reason, an exhaustive `Record` over the closed union so a reason
|
|
88
|
+
* added on the backend fails the typecheck here rather than reaching the user as English server
|
|
89
|
+
* prose. Each message names the correction that reason actually calls for.
|
|
90
|
+
*/
|
|
91
|
+
const REF_REJECTIONS: Record<
|
|
92
|
+
DocumentRefReason,
|
|
93
|
+
(input: { claimedBy?: string; expected?: string }) => string
|
|
94
|
+
> = {
|
|
95
|
+
document_ref_unrecognized: ({ expected }) =>
|
|
96
|
+
t('documents.picker.refUnrecognized', {
|
|
97
|
+
source: sourceLabel(source.value ?? ''),
|
|
98
|
+
expected: expected ?? descriptor.value?.refPlaceholder ?? '',
|
|
99
|
+
}),
|
|
100
|
+
document_ref_claimed_by_other_source: ({ claimedBy }) =>
|
|
101
|
+
t('documents.picker.refOtherSource', {
|
|
102
|
+
source: sourceLabel(source.value ?? ''),
|
|
103
|
+
claimed: sourceLabel(claimedBy ?? ''),
|
|
104
|
+
}),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The text to resolve as a reference, or null when there is nothing to resolve. */
|
|
108
|
+
const refCandidate = computed(() =>
|
|
109
|
+
source.value ? refCandidateOf(query.value, searchable.value) : null,
|
|
110
|
+
)
|
|
111
|
+
|
|
49
112
|
// Debounced search: free text hits the source; a query that's clearly a URL/ID
|
|
50
113
|
// is left to the explicit "by reference" row below (search won't surface it).
|
|
51
114
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -53,9 +116,14 @@ watch([query, source], () => {
|
|
|
53
116
|
if (timer) clearTimeout(timer)
|
|
54
117
|
results.value = []
|
|
55
118
|
searchError.value = null
|
|
119
|
+
refState.value = { status: 'none' }
|
|
120
|
+
refSeq++
|
|
56
121
|
const q = query.value.trim()
|
|
57
|
-
if (!q
|
|
58
|
-
timer = setTimeout(
|
|
122
|
+
if (!q) return
|
|
123
|
+
timer = setTimeout(() => {
|
|
124
|
+
if (searchable.value) void runSearch()
|
|
125
|
+
if (refCandidate.value) void resolveRef(refCandidate.value)
|
|
126
|
+
}, 300)
|
|
59
127
|
})
|
|
60
128
|
|
|
61
129
|
async function runSearch() {
|
|
@@ -73,6 +141,26 @@ async function runSearch() {
|
|
|
73
141
|
}
|
|
74
142
|
}
|
|
75
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Ask the backend what this text resolves to for the selected source. A 422 is the source's
|
|
146
|
+
* own refusal and lands as `rejected` with its reason; anything else (offline, 5xx) leaves
|
|
147
|
+
* the ref UNCHECKED rather than refused, so an outage in the pre-flight never reads to the
|
|
148
|
+
* user as "your link is wrong".
|
|
149
|
+
*/
|
|
150
|
+
async function resolveRef(candidate: string) {
|
|
151
|
+
const src = source.value
|
|
152
|
+
if (!src) return
|
|
153
|
+
const seq = ++refSeq
|
|
154
|
+
refState.value = { status: 'checking' }
|
|
155
|
+
try {
|
|
156
|
+
const ref = await documents.resolveRef(src, candidate)
|
|
157
|
+
if (seq === refSeq) refState.value = { status: 'ok', ref }
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if (seq !== refSeq) return
|
|
160
|
+
refState.value = classifyRefFailure(e)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
76
164
|
const icon = computed(() => descriptor.value?.icon ?? 'i-lucide-file-text')
|
|
77
165
|
|
|
78
166
|
function keyFor(externalId: string): string {
|
|
@@ -103,30 +191,96 @@ const searchRows = computed(() => {
|
|
|
103
191
|
.filter((r) => !chosen.value.has(keyFor(r.externalId)))
|
|
104
192
|
})
|
|
105
193
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
194
|
+
/** The already-imported document a resolved ref points at, when the workspace holds it. */
|
|
195
|
+
const refImported = computed(() => {
|
|
196
|
+
const state = refState.value
|
|
197
|
+
if (state.status !== 'ok') return undefined
|
|
198
|
+
return documents.documents.find(
|
|
199
|
+
(d) => d.source === state.ref.source && d.externalId === state.ref.externalId,
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
/** Every id another row in this dropdown already offers, so one page is never listed twice. */
|
|
204
|
+
const offeredIds = computed(
|
|
205
|
+
() =>
|
|
206
|
+
new Set([
|
|
207
|
+
...importedRows.value.map((d) => d.externalId),
|
|
208
|
+
...searchRows.value.map((r) => r.externalId),
|
|
209
|
+
]),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The attachable row for the reference in the box: the CANONICAL form the source settled on, never
|
|
214
|
+
* the text that was typed (see `refRowFor`, which also carries the unjudged case).
|
|
215
|
+
*
|
|
216
|
+
* Suppressed in two cases, each with its own reason. Already STAGED, keyed on the resolved external
|
|
217
|
+
* id so pasting the share link and then the bare id cannot stage it twice (the `refAlreadyAttached`
|
|
218
|
+
* line says so, since a row that silently vanishes reads as a picker that lost the paste). Already
|
|
219
|
+
* OFFERED by an imported/search row above, which is the same page reachable by one click: the
|
|
220
|
+
* dedupe is against what is VISIBLE rather than against the whole imported list, because a URL
|
|
221
|
+
* query matches no title, so testing the full list would suppress the only row on offer.
|
|
222
|
+
*/
|
|
109
223
|
const refRow = computed(() => {
|
|
110
|
-
const
|
|
111
|
-
if (!
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
224
|
+
const pasted = refCandidate.value
|
|
225
|
+
if (!pasted) return null
|
|
226
|
+
const row = refRowFor(refState.value, pasted, refImported.value?.title)
|
|
227
|
+
if (!row) return null
|
|
228
|
+
if (chosen.value.has(keyFor(row.externalId)) || offeredIds.value.has(row.externalId)) return null
|
|
229
|
+
return { ...row, imported: !!refImported.value }
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
/** A resolved reference the caller has already staged: suppressed as a row, stated as a line. */
|
|
233
|
+
const refAlreadyAttached = computed(() => {
|
|
234
|
+
const state = refState.value
|
|
235
|
+
return state.status === 'ok' && chosen.value.has(keyFor(state.ref.externalId))
|
|
121
236
|
})
|
|
122
237
|
|
|
238
|
+
/** The refusal to render under the input, in the reader's language, or null. */
|
|
239
|
+
const refRejection = computed(() => {
|
|
240
|
+
const state = refState.value
|
|
241
|
+
if (state.status !== 'rejected') return null
|
|
242
|
+
return REF_REJECTIONS[state.reason]({
|
|
243
|
+
...(state.claimedBy ? { claimedBy: state.claimedBy } : {}),
|
|
244
|
+
...(state.expected ? { expected: state.expected } : {}),
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The source named by a `claimed_by_other_source` refusal, when it is one this workspace has
|
|
250
|
+
* connected. Absent when it is not: offering to switch to a source the picker cannot select
|
|
251
|
+
* would be a dead end, and the refusal copy alone already names what the link is.
|
|
252
|
+
*/
|
|
253
|
+
const refSwitchTarget = computed(() => {
|
|
254
|
+
const state = refState.value
|
|
255
|
+
if (state.status !== 'rejected' || !state.claimedBy) return undefined
|
|
256
|
+
return documents.connectedSources.find((s) => s.source === state.claimedBy)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
function switchToClaimingSource() {
|
|
260
|
+
const target = refSwitchTarget.value
|
|
261
|
+
if (target) source.value = target.source
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Whether a line about the pasted reference is being rendered under the input. It is what keeps the
|
|
266
|
+
* dropdown TOTAL: every state now shows a row, a line, or the empty state. Keying the empty state
|
|
267
|
+
* on `status === 'none'` alone left a hole (a resolved reference the caller had already staged
|
|
268
|
+
* suppressed the row AND the empty state) that rendered as a blank panel explaining nothing.
|
|
269
|
+
*/
|
|
270
|
+
const refNotice = computed(
|
|
271
|
+
() =>
|
|
272
|
+
refState.value.status !== 'none' &&
|
|
273
|
+
(refState.value.status !== 'ok' || refAlreadyAttached.value),
|
|
274
|
+
)
|
|
275
|
+
|
|
123
276
|
const empty = computed(
|
|
124
277
|
() =>
|
|
125
278
|
!searching.value &&
|
|
126
279
|
!searchError.value &&
|
|
127
280
|
importedRows.value.length === 0 &&
|
|
128
281
|
searchRows.value.length === 0 &&
|
|
129
|
-
refRow.value === null
|
|
282
|
+
refRow.value === null &&
|
|
283
|
+
!refNotice.value,
|
|
130
284
|
)
|
|
131
285
|
|
|
132
286
|
function pickImported(externalId: string, title: string, excerpt: string) {
|
|
@@ -154,16 +308,26 @@ function pickSearch(r: DocumentSearchResult) {
|
|
|
154
308
|
})
|
|
155
309
|
}
|
|
156
310
|
|
|
157
|
-
|
|
158
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Stage a reference. The canonical external id is staged, never the pasted text, so the item the
|
|
313
|
+
* host commits is the one the pre-flight judged. A reference the workspace has already imported
|
|
314
|
+
* skips the re-fetch (`needsImport: false`) and carries its real title.
|
|
315
|
+
*
|
|
316
|
+
* An UNJUDGED reference (the resolve call itself failed) carries the pasted text against the
|
|
317
|
+
* SELECTED source, which is what the picker did before any pre-flight existed: the import will
|
|
318
|
+
* judge it, and a source outage is not a reason to make attaching impossible.
|
|
319
|
+
*/
|
|
320
|
+
function pickRef(row: NonNullable<typeof refRow.value>) {
|
|
321
|
+
const src = row.source ?? source.value
|
|
322
|
+
if (!src) return
|
|
159
323
|
emit('pick', {
|
|
160
324
|
kind: 'document',
|
|
161
|
-
source:
|
|
162
|
-
externalId:
|
|
163
|
-
title:
|
|
164
|
-
subtitle: descriptor.value?.label,
|
|
325
|
+
source: src,
|
|
326
|
+
externalId: row.externalId,
|
|
327
|
+
title: row.label,
|
|
328
|
+
subtitle: row.canonicalUrl ?? descriptor.value?.label,
|
|
165
329
|
icon: icon.value,
|
|
166
|
-
needsImport:
|
|
330
|
+
needsImport: !row.imported,
|
|
167
331
|
})
|
|
168
332
|
query.value = ''
|
|
169
333
|
}
|
|
@@ -212,6 +376,50 @@ onMounted(() => {
|
|
|
212
376
|
{{ t('documents.picker.searchFailed', { error: searchError }) }}
|
|
213
377
|
</p>
|
|
214
378
|
|
|
379
|
+
<!-- The pre-flight's verdict on a pasted ref. A refusal is stated HERE, under the input
|
|
380
|
+
the user can still edit, rather than as a toast after the task is created. -->
|
|
381
|
+
<p
|
|
382
|
+
v-if="refState.status === 'checking'"
|
|
383
|
+
class="px-1 text-[11px] text-slate-500"
|
|
384
|
+
data-testid="doc-ref-checking"
|
|
385
|
+
>
|
|
386
|
+
{{ t('documents.picker.refChecking') }}
|
|
387
|
+
</p>
|
|
388
|
+
<div
|
|
389
|
+
v-else-if="refRejection"
|
|
390
|
+
class="flex flex-wrap items-center gap-x-2 gap-y-1 px-1 text-[11px] text-amber-400"
|
|
391
|
+
data-testid="doc-ref-rejected"
|
|
392
|
+
>
|
|
393
|
+
<span>{{ refRejection }}</span>
|
|
394
|
+
<UButton
|
|
395
|
+
v-if="refSwitchTarget"
|
|
396
|
+
color="neutral"
|
|
397
|
+
variant="link"
|
|
398
|
+
size="xs"
|
|
399
|
+
class="p-0"
|
|
400
|
+
data-testid="doc-ref-switch-source"
|
|
401
|
+
@click="switchToClaimingSource"
|
|
402
|
+
>
|
|
403
|
+
{{ t('documents.picker.refSwitchSource', { source: refSwitchTarget.label }) }}
|
|
404
|
+
</UButton>
|
|
405
|
+
</div>
|
|
406
|
+
<p
|
|
407
|
+
v-else-if="refState.status === 'unchecked'"
|
|
408
|
+
class="px-1 text-[11px] text-amber-400"
|
|
409
|
+
data-testid="doc-ref-unchecked"
|
|
410
|
+
>
|
|
411
|
+
{{ t('documents.picker.refCheckFailed', { error: refState.message }) }}
|
|
412
|
+
</p>
|
|
413
|
+
<!-- Already staged, so the row is suppressed. Stated, because a paste that produces nothing
|
|
414
|
+
at all reads as a picker that dropped it. -->
|
|
415
|
+
<p
|
|
416
|
+
v-else-if="refAlreadyAttached"
|
|
417
|
+
class="px-1 text-[11px] text-slate-500"
|
|
418
|
+
data-testid="doc-ref-already-attached"
|
|
419
|
+
>
|
|
420
|
+
{{ t('documents.picker.refAlreadyAttached') }}
|
|
421
|
+
</p>
|
|
422
|
+
|
|
215
423
|
<div class="max-h-56 space-y-0.5 overflow-y-auto">
|
|
216
424
|
<!-- Already-imported documents (linked directly, no re-fetch). -->
|
|
217
425
|
<button
|
|
@@ -240,21 +448,48 @@ onMounted(() => {
|
|
|
240
448
|
<span class="truncate">{{ r.title }}</span>
|
|
241
449
|
</button>
|
|
242
450
|
|
|
243
|
-
<!-- Explicit URL/ID reference
|
|
451
|
+
<!-- Explicit URL/ID reference, RESOLVED: the row shows the canonical form the source
|
|
452
|
+
settled on, so a share link's title segment and tracking params are visibly gone
|
|
453
|
+
before the attachment is staged. -->
|
|
244
454
|
<button
|
|
245
455
|
v-if="refRow"
|
|
246
456
|
type="button"
|
|
247
|
-
class="flex w-full items-
|
|
457
|
+
class="flex w-full items-start gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
|
|
458
|
+
data-testid="doc-ref-attach"
|
|
248
459
|
@click="pickRef(refRow)"
|
|
249
460
|
>
|
|
250
|
-
<UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
251
|
-
<span class="
|
|
252
|
-
<
|
|
253
|
-
<
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
461
|
+
<UIcon name="i-lucide-link" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
462
|
+
<span class="min-w-0">
|
|
463
|
+
<span class="block truncate">
|
|
464
|
+
<i18n-t keypath="documents.picker.attachByReference" scope="global">
|
|
465
|
+
<template #ref>
|
|
466
|
+
<span class="text-slate-200">{{ refRow.label }}</span>
|
|
467
|
+
</template>
|
|
468
|
+
</i18n-t>
|
|
469
|
+
</span>
|
|
470
|
+
<span v-if="refRow.trimmed" class="block truncate text-[11px] text-slate-500">
|
|
471
|
+
{{ t('documents.picker.refTrimmed') }}
|
|
472
|
+
</span>
|
|
473
|
+
<!-- A WIDENED reference, which the trim note above must never be left to imply: the
|
|
474
|
+
frame this link named could not be read, so what gets attached is everything
|
|
475
|
+
around it. Amber and separate, because it is a loss rather than tidying. -->
|
|
476
|
+
<span
|
|
477
|
+
v-if="refRow.droppedScope"
|
|
478
|
+
class="block text-[11px] text-amber-400"
|
|
479
|
+
data-testid="doc-ref-widened"
|
|
480
|
+
>
|
|
481
|
+
{{ t('documents.picker.refWidened', { scope: refRow.droppedScope }) }}
|
|
482
|
+
</span>
|
|
257
483
|
</span>
|
|
484
|
+
<UBadge
|
|
485
|
+
v-if="refRow.imported"
|
|
486
|
+
color="neutral"
|
|
487
|
+
variant="soft"
|
|
488
|
+
size="xs"
|
|
489
|
+
class="ms-auto shrink-0"
|
|
490
|
+
>
|
|
491
|
+
{{ t('documents.picker.importedBadge') }}
|
|
492
|
+
</UBadge>
|
|
258
493
|
</button>
|
|
259
494
|
|
|
260
495
|
<EmptyState
|
|
@@ -187,6 +187,7 @@ function memberLabel(userId: string, name?: string | null, email?: string | null
|
|
|
187
187
|
:key="m.userId"
|
|
188
188
|
class="flex items-center justify-between gap-2 rounded-md bg-slate-800/40 px-2 py-1"
|
|
189
189
|
data-testid="workspace-member-row"
|
|
190
|
+
:data-user-id="m.userId"
|
|
190
191
|
>
|
|
191
192
|
<span class="truncate">{{ memberLabel(m.userId, m.name, m.email) }}</span>
|
|
192
193
|
<span class="flex shrink-0 items-center gap-2">
|
|
@@ -113,6 +113,9 @@ function choose(id: string) {
|
|
|
113
113
|
|
|
114
114
|
<template>
|
|
115
115
|
<UPopover v-model:open="open" :content="{ align: 'start' }">
|
|
116
|
+
<!-- A consumer that supplies its own `#trigger` must carry `risk-policy-picker-trigger` on it:
|
|
117
|
+
the popover trigger is `as-child`, so the slotted element REPLACES the default button below
|
|
118
|
+
and takes its test hook with it (the inspector's icon button is the one such consumer). -->
|
|
116
119
|
<slot name="trigger" :label="triggerLabel">
|
|
117
120
|
<UButton
|
|
118
121
|
color="neutral"
|
|
@@ -282,7 +282,7 @@ async function create() {
|
|
|
282
282
|
</script>
|
|
283
283
|
|
|
284
284
|
<template>
|
|
285
|
-
<div class="space-y-4">
|
|
285
|
+
<div class="space-y-4" data-testid="risk-policy-panel">
|
|
286
286
|
<i18n-t
|
|
287
287
|
keypath="settings.riskPolicy.intro"
|
|
288
288
|
tag="p"
|
|
@@ -298,6 +298,8 @@ async function create() {
|
|
|
298
298
|
v-for="p in store.presets"
|
|
299
299
|
:key="p.id"
|
|
300
300
|
class="rounded-lg border border-slate-700 bg-slate-800/40 p-3"
|
|
301
|
+
data-testid="risk-policy-row"
|
|
302
|
+
:data-policy-id="p.id"
|
|
301
303
|
>
|
|
302
304
|
<div class="mb-3 flex items-center gap-2">
|
|
303
305
|
<UInput
|
|
@@ -478,6 +480,7 @@ async function create() {
|
|
|
478
480
|
v-model="draft.name"
|
|
479
481
|
size="sm"
|
|
480
482
|
:placeholder="t('settings.riskPolicy.create.namePlaceholder')"
|
|
483
|
+
data-testid="risk-policy-create-name"
|
|
481
484
|
/>
|
|
482
485
|
</label>
|
|
483
486
|
<label class="block w-20">
|
|
@@ -490,19 +493,34 @@ async function create() {
|
|
|
490
493
|
:min="0"
|
|
491
494
|
:max="100"
|
|
492
495
|
size="sm"
|
|
496
|
+
data-testid="risk-policy-create-complexity"
|
|
493
497
|
/>
|
|
494
498
|
</label>
|
|
495
499
|
<label class="block w-20">
|
|
496
500
|
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
497
501
|
{{ t('settings.riskPolicy.create.risk') }}
|
|
498
502
|
</span>
|
|
499
|
-
<UInput
|
|
503
|
+
<UInput
|
|
504
|
+
v-model.number="draft.maxRisk"
|
|
505
|
+
type="number"
|
|
506
|
+
:min="0"
|
|
507
|
+
:max="100"
|
|
508
|
+
size="sm"
|
|
509
|
+
data-testid="risk-policy-create-risk"
|
|
510
|
+
/>
|
|
500
511
|
</label>
|
|
501
512
|
<label class="block w-20">
|
|
502
513
|
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
503
514
|
{{ t('settings.riskPolicy.create.impact') }}
|
|
504
515
|
</span>
|
|
505
|
-
<UInput
|
|
516
|
+
<UInput
|
|
517
|
+
v-model.number="draft.maxImpact"
|
|
518
|
+
type="number"
|
|
519
|
+
:min="0"
|
|
520
|
+
:max="100"
|
|
521
|
+
size="sm"
|
|
522
|
+
data-testid="risk-policy-create-impact"
|
|
523
|
+
/>
|
|
506
524
|
</label>
|
|
507
525
|
<label class="block w-20">
|
|
508
526
|
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
@@ -549,6 +567,7 @@ async function create() {
|
|
|
549
567
|
icon="i-lucide-plus"
|
|
550
568
|
:loading="creating"
|
|
551
569
|
:disabled="!draft.name.trim()"
|
|
570
|
+
data-testid="risk-policy-create-submit"
|
|
552
571
|
@click="create"
|
|
553
572
|
>
|
|
554
573
|
{{ t('settings.riskPolicy.add') }}
|
|
@@ -312,6 +312,13 @@ async function save() {
|
|
|
312
312
|
</template>
|
|
313
313
|
<template #body>
|
|
314
314
|
<UTabs v-model="activeTab" :items="tabs" variant="link" :ui="tabsUi">
|
|
315
|
+
<!-- The tab LABEL, overridden only to carry a per-tab test hook: `UTabs` renders its own
|
|
316
|
+
triggers and forwards nothing from an item, so this slot is the one place a stable
|
|
317
|
+
selector can name which tab a click means (the labels themselves are translated). -->
|
|
318
|
+
<template #default="{ item }">
|
|
319
|
+
<span :data-testid="`workspace-settings-tab-${item.value}`">{{ item.label }}</span>
|
|
320
|
+
</template>
|
|
321
|
+
|
|
315
322
|
<!-- Workspace -->
|
|
316
323
|
<template #workspace>
|
|
317
324
|
<div class="space-y-6">
|