@smartcat/sanity-plugin 1.3.0 → 1.4.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/dist/index.cjs +368 -132
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +371 -135
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/tool/AddItemsSearch.tsx +462 -0
- package/src/tool/DocTitle.tsx +23 -0
- package/src/tool/ProjectView.tsx +15 -237
- package/src/tool/itemActions.tsx +216 -0
- package/src/tool/processing.test.ts +11 -1
- package/src/tool/processing.ts +9 -3
package/package.json
CHANGED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import {Fragment, useCallback, useEffect, useState, type MouseEvent} from 'react'
|
|
2
|
+
import {useClient, useSchema} from 'sanity'
|
|
3
|
+
import {useRouter} from 'sanity/router'
|
|
4
|
+
import {Badge, Box, Button, Card, Checkbox, Flex, Menu, MenuButton, MenuItem, Spinner, Stack, Text, TextInput, useToast} from '@sanity/ui'
|
|
5
|
+
import {AddIcon, CheckmarkIcon, ChevronDownIcon, SearchIcon} from '@sanity/icons'
|
|
6
|
+
import {uuid} from '@sanity/uuid'
|
|
7
|
+
import {API_VERSION, TRANSLATION_PROJECT_TYPE} from '../lib/constants'
|
|
8
|
+
import {ITEM_ID} from '../lib/projectItems'
|
|
9
|
+
import {DocTitle} from './DocTitle'
|
|
10
|
+
import {OpenInStudioButton, ViewLocjsonButton} from './itemActions'
|
|
11
|
+
import type {SmartcatLanguage} from '../types'
|
|
12
|
+
|
|
13
|
+
/** A raw matched row — a draft or published document variant. */
|
|
14
|
+
interface SearchRow {
|
|
15
|
+
_id: string
|
|
16
|
+
_type: string
|
|
17
|
+
/** True when this row is a draft, or a published doc that has a draft. */
|
|
18
|
+
hasDraft: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A deduped search hit — one logical document, addable to the project. */
|
|
22
|
+
interface SearchHit {
|
|
23
|
+
/** Published (bare) id, the value stored as an item's `docId`. */
|
|
24
|
+
docId: string
|
|
25
|
+
_type: string
|
|
26
|
+
/** Whether a draft exists — drives the source badge and the added item's `sourceIsPublished`. */
|
|
27
|
+
hasDraft: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Documents fetched per search before de-duplication. Higher than what we show,
|
|
32
|
+
* since a document can appear twice (draft + published). Inlined into the query
|
|
33
|
+
* (GROQ slices must be constant).
|
|
34
|
+
*/
|
|
35
|
+
const QUERY_LIMIT = 50
|
|
36
|
+
|
|
37
|
+
/** Distinct documents shown after de-duplication. */
|
|
38
|
+
const DISPLAY_LIMIT = 25
|
|
39
|
+
|
|
40
|
+
/** Max query tokens honored, to keep the generated query bounded. */
|
|
41
|
+
const MAX_TOKENS = 6
|
|
42
|
+
|
|
43
|
+
/** Don't search until the box has some substance, or single letters flood results. */
|
|
44
|
+
const MIN_QUERY_LENGTH = 2
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fields a term matches against in the default ("title") mode, plus `_type`/`_id`
|
|
48
|
+
* so a term can match the schema type name or id. Kept in one place: this is the
|
|
49
|
+
* curated notion of "a document's title" across arbitrary schema types.
|
|
50
|
+
*/
|
|
51
|
+
const TITLE_FIELDS = '[title, name, heading, label, question, slug.current, _type, _id]'
|
|
52
|
+
|
|
53
|
+
/** Title-ish fields used for scoring, so title matches rank above `_type`/content hits. */
|
|
54
|
+
const SCORE_FIELDS = '[title, name, heading, label, question, slug.current]'
|
|
55
|
+
|
|
56
|
+
/** Types the plugin never treats as translatable, excluded when no allowlist is configured. */
|
|
57
|
+
const EXCLUDED_TYPES = ['smartcat.translationProject', 'smartcat.settings', 'translation.metadata']
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Turn the raw search box text into a GROQ query + params, or `null` when there's
|
|
61
|
+
* nothing worth searching for yet.
|
|
62
|
+
*
|
|
63
|
+
* Each whitespace-separated term must match (AND across terms); within a term the
|
|
64
|
+
* match spans the title fields plus `_type`/`_id`. So `"faq cancel"` finds an FAQ
|
|
65
|
+
* titled "Can I cancel my subscription" (`faq`→`_type`, `cancel`→title) but not a
|
|
66
|
+
* `page` whose body mentions "cancel" — unless "Also search in content" is on,
|
|
67
|
+
* which widens each term to the whole document (`@`).
|
|
68
|
+
*/
|
|
69
|
+
function buildSearch(
|
|
70
|
+
rawQuery: string,
|
|
71
|
+
searchContent: boolean,
|
|
72
|
+
translatableTypes: string[] | undefined,
|
|
73
|
+
): {query: string; params: Record<string, unknown>} | null {
|
|
74
|
+
if (rawQuery.trim().length < MIN_QUERY_LENGTH) return null
|
|
75
|
+
const tokens = rawQuery
|
|
76
|
+
.trim()
|
|
77
|
+
.split(/\s+/)
|
|
78
|
+
// Neutralize text::query operators so plain text can't be read as phrase
|
|
79
|
+
// quoting (`"`), exclusion (leading `-`), or an explicit wildcard (`*`).
|
|
80
|
+
.map((t) => t.replace(/["*]/g, '').replace(/^-+/, '').trim())
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.slice(0, MAX_TOKENS)
|
|
83
|
+
if (tokens.length === 0) return null
|
|
84
|
+
|
|
85
|
+
// Whole-document search when the editor opts in; otherwise the curated fields.
|
|
86
|
+
const target = searchContent ? '@' : TITLE_FIELDS
|
|
87
|
+
const params: Record<string, unknown> = {}
|
|
88
|
+
const tokenClauses: string[] = []
|
|
89
|
+
const scoreClauses: string[] = []
|
|
90
|
+
tokens.forEach((tok, i) => {
|
|
91
|
+
// Trailing wildcard → prefix matching, so results narrow as the editor types.
|
|
92
|
+
params[`t${i}`] = `${tok}*`
|
|
93
|
+
tokenClauses.push(`${target} match text::query($t${i})`)
|
|
94
|
+
scoreClauses.push(`boost(${SCORE_FIELDS} match text::query($t${i}), 2)`)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Mirror resolveConfig: an explicit allowlist is a positive filter; otherwise
|
|
98
|
+
// exclude the plugin's own + document-i18n metadata + system types — never
|
|
99
|
+
// enumerating "all types", which could be thousands.
|
|
100
|
+
let typeFilter: string
|
|
101
|
+
if (translatableTypes) {
|
|
102
|
+
params.types = translatableTypes
|
|
103
|
+
typeFilter = '_type in $types'
|
|
104
|
+
} else {
|
|
105
|
+
params.excluded = EXCLUDED_TYPES
|
|
106
|
+
typeFilter = '!(_type in $excluded) && !(_type match "sanity.*")'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Drafts are included (the plugin can translate the draft version). Both a
|
|
110
|
+
// draft and its published counterpart may match; de-duplication happens after
|
|
111
|
+
// the fetch. `hasDraft` lets a published-only match still detect its draft, so
|
|
112
|
+
// a document whose draft didn't match the term is still flagged correctly.
|
|
113
|
+
const filter = [typeFilter, ...tokenClauses].join(' && ')
|
|
114
|
+
const projection = '{_id, _type, "hasDraft": _id in path("drafts.**") || count(*[_id == "drafts." + ^._id]) > 0}'
|
|
115
|
+
const query = `*[${filter}] | score(${scoreClauses.join(', ')}) | order(_score desc)[0...${QUERY_LIMIT}]${projection}`
|
|
116
|
+
return {query, params}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Collapse draft/published variants into one hit per logical document, keyed by
|
|
121
|
+
* the bare (published) id. Input is ordered by relevance; the Map preserves that
|
|
122
|
+
* order (first occurrence wins the position), and `hasDraft` is OR-ed across
|
|
123
|
+
* variants so a draft is detected whichever variant matched.
|
|
124
|
+
*/
|
|
125
|
+
function dedupeHits(rows: SearchRow[]): SearchHit[] {
|
|
126
|
+
const byDoc = new Map<string, SearchHit>()
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
const docId = row._id.replace(/^drafts\./, '')
|
|
129
|
+
const existing = byDoc.get(docId)
|
|
130
|
+
if (existing) existing.hasDraft = existing.hasDraft || row.hasDraft
|
|
131
|
+
else byDoc.set(docId, {docId, _type: row._type, hasDraft: row.hasDraft})
|
|
132
|
+
}
|
|
133
|
+
return [...byDoc.values()].slice(0, DISPLAY_LIMIT)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A translation project a searched document is an item of. */
|
|
137
|
+
interface ProjectRef {
|
|
138
|
+
_id: string
|
|
139
|
+
name?: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* For the given document ids, the projects that include each as a (source) item.
|
|
144
|
+
* One batched query builds a `docId → projects` map; `references()`/sibling
|
|
145
|
+
* membership is deliberately out of scope — what matters here is whether adding
|
|
146
|
+
* the document to a project would be redundant.
|
|
147
|
+
*/
|
|
148
|
+
const MEMBERSHIP_QUERY = `*[_type == "${TRANSLATION_PROJECT_TYPE}" && count(items[${ITEM_ID} in $ids]) > 0] | order(name asc){
|
|
149
|
+
_id,
|
|
150
|
+
name,
|
|
151
|
+
"docIds": items[${ITEM_ID} in $ids]{"id": ${ITEM_ID}}
|
|
152
|
+
}`
|
|
153
|
+
|
|
154
|
+
/** Shape of one row from {@link MEMBERSHIP_QUERY}. */
|
|
155
|
+
interface MembershipRow {
|
|
156
|
+
_id: string
|
|
157
|
+
name?: string
|
|
158
|
+
docIds: {id: string}[]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A project name that navigates to its page in the Translations tool (SPA nav). */
|
|
162
|
+
function ProjectChip({project, current}: {project: ProjectRef; current: boolean}) {
|
|
163
|
+
const router = useRouter()
|
|
164
|
+
const onClick = useCallback(
|
|
165
|
+
(e: MouseEvent) => {
|
|
166
|
+
e.preventDefault()
|
|
167
|
+
router.navigate({projectId: project._id})
|
|
168
|
+
},
|
|
169
|
+
[router, project._id],
|
|
170
|
+
)
|
|
171
|
+
return (
|
|
172
|
+
<a href="#" onClick={onClick}>
|
|
173
|
+
{project.name || 'Untitled project'}
|
|
174
|
+
{current ? ' (this project)' : ''}
|
|
175
|
+
</a>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface AddItemsSearchProps {
|
|
180
|
+
projectId: string
|
|
181
|
+
/** Configured translatable types (undefined = every non-plugin type). */
|
|
182
|
+
translatableTypes?: string[]
|
|
183
|
+
/** Published ids already in the project, so their results show as added, not addable. */
|
|
184
|
+
existingDocIds: Set<string>
|
|
185
|
+
/** Source language (Sanity locale id) — for the LocJSON preview. */
|
|
186
|
+
sourceLanguage: string
|
|
187
|
+
/** Configured languages (titles + Smartcat codes) — for the LocJSON preview. */
|
|
188
|
+
languages: SmartcatLanguage[]
|
|
189
|
+
/** Project target languages — the per-language bilingual LocJSON variants to offer. */
|
|
190
|
+
targetLanguages: string[]
|
|
191
|
+
/** document-internationalization locale field name. */
|
|
192
|
+
documentI18nLanguageField: string
|
|
193
|
+
/** internationalized-array locale key. */
|
|
194
|
+
fieldI18nLanguageField: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Search box + results, shown under a project's item list, for finding documents
|
|
199
|
+
* anywhere in the dataset and adding them to the project without opening each one.
|
|
200
|
+
* Searches drafts and published documents; a document with a draft is added
|
|
201
|
+
* draft-as-source (the plugin translates the draft), matching a fresh "Add to
|
|
202
|
+
* translation project", with existing-translations upload left off. Linked-content
|
|
203
|
+
* gathering is not applied here — each result adds just that one document.
|
|
204
|
+
*/
|
|
205
|
+
export function AddItemsSearch({
|
|
206
|
+
projectId,
|
|
207
|
+
translatableTypes,
|
|
208
|
+
existingDocIds,
|
|
209
|
+
sourceLanguage,
|
|
210
|
+
languages,
|
|
211
|
+
targetLanguages,
|
|
212
|
+
documentI18nLanguageField,
|
|
213
|
+
fieldI18nLanguageField,
|
|
214
|
+
}: AddItemsSearchProps) {
|
|
215
|
+
const client = useClient({apiVersion: API_VERSION})
|
|
216
|
+
const schema = useSchema()
|
|
217
|
+
const toast = useToast()
|
|
218
|
+
const [query, setQuery] = useState('')
|
|
219
|
+
const [searchContent, setSearchContent] = useState(false)
|
|
220
|
+
const [results, setResults] = useState<SearchHit[] | null>(null)
|
|
221
|
+
const [loading, setLoading] = useState(false)
|
|
222
|
+
const [addingId, setAddingId] = useState<string | null>(null)
|
|
223
|
+
// Optimistic: added rows show as "Added" immediately, before the project's
|
|
224
|
+
// live subscription refreshes `existingDocIds`.
|
|
225
|
+
const [addedIds, setAddedIds] = useState<Set<string>>(new Set())
|
|
226
|
+
// docId → the projects that already include it (live), so each result lists
|
|
227
|
+
// its memberships the way the document editor's status banner does.
|
|
228
|
+
const [projectsByDoc, setProjectsByDoc] = useState<Map<string, ProjectRef[]>>(new Map())
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const built = buildSearch(query, searchContent, translatableTypes)
|
|
232
|
+
if (!built) {
|
|
233
|
+
setResults(null)
|
|
234
|
+
setLoading(false)
|
|
235
|
+
return undefined
|
|
236
|
+
}
|
|
237
|
+
setLoading(true)
|
|
238
|
+
let cancelled = false
|
|
239
|
+
const handle = setTimeout(() => {
|
|
240
|
+
client
|
|
241
|
+
.fetch<SearchRow[]>(built.query, built.params)
|
|
242
|
+
.then((res) => {
|
|
243
|
+
if (!cancelled) setResults(dedupeHits(res))
|
|
244
|
+
})
|
|
245
|
+
.catch((err) => {
|
|
246
|
+
if (cancelled) return
|
|
247
|
+
setResults([])
|
|
248
|
+
toast.push({status: 'error', title: 'Search failed', description: String(err)})
|
|
249
|
+
})
|
|
250
|
+
.finally(() => {
|
|
251
|
+
if (!cancelled) setLoading(false)
|
|
252
|
+
})
|
|
253
|
+
}, 250)
|
|
254
|
+
return () => {
|
|
255
|
+
cancelled = true
|
|
256
|
+
clearTimeout(handle)
|
|
257
|
+
}
|
|
258
|
+
}, [query, searchContent, translatableTypes, client, toast])
|
|
259
|
+
|
|
260
|
+
// Resolve (and keep live) the projects each visible result belongs to. Keyed
|
|
261
|
+
// on the result ids so it re-runs when the result set changes; the listener
|
|
262
|
+
// then reflects adds/removes anywhere without a manual refetch.
|
|
263
|
+
const docIdsKey = (results ?? []).map((r) => r.docId).join(',')
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
const ids = docIdsKey ? docIdsKey.split(',') : []
|
|
266
|
+
if (ids.length === 0) {
|
|
267
|
+
setProjectsByDoc(new Map())
|
|
268
|
+
return undefined
|
|
269
|
+
}
|
|
270
|
+
let cancelled = false
|
|
271
|
+
const load = () =>
|
|
272
|
+
client
|
|
273
|
+
.fetch<MembershipRow[]>(MEMBERSHIP_QUERY, {ids})
|
|
274
|
+
.then((rows) => {
|
|
275
|
+
if (cancelled) return
|
|
276
|
+
const map = new Map<string, ProjectRef[]>()
|
|
277
|
+
for (const row of rows) {
|
|
278
|
+
for (const {id} of row.docIds ?? []) {
|
|
279
|
+
const list = map.get(id) ?? []
|
|
280
|
+
list.push({_id: row._id, name: row.name})
|
|
281
|
+
map.set(id, list)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
setProjectsByDoc(map)
|
|
285
|
+
})
|
|
286
|
+
.catch(() => !cancelled && setProjectsByDoc(new Map()))
|
|
287
|
+
load()
|
|
288
|
+
const sub = client.listen(MEMBERSHIP_QUERY, {ids}, {visibility: 'query'}).subscribe({next: load, error: () => {}})
|
|
289
|
+
return () => {
|
|
290
|
+
cancelled = true
|
|
291
|
+
sub.unsubscribe()
|
|
292
|
+
}
|
|
293
|
+
}, [docIdsKey, client])
|
|
294
|
+
|
|
295
|
+
// Once the project's live items reflect an optimistic add, drop it from the
|
|
296
|
+
// local set. Otherwise `addedIds` (which only ever grows) would keep a row
|
|
297
|
+
// marked "Added" even after it's removed from the project — `existingDocIds`
|
|
298
|
+
// then becomes the single source of truth, so removal flips it back to addable.
|
|
299
|
+
const existingKey = [...existingDocIds].sort().join(',')
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
setAddedIds((prev) => {
|
|
302
|
+
if (prev.size === 0) return prev
|
|
303
|
+
const next = new Set([...prev].filter((id) => !existingDocIds.has(id)))
|
|
304
|
+
return next.size === prev.size ? prev : next
|
|
305
|
+
})
|
|
306
|
+
// existingDocIds is a fresh Set each render; `existingKey` tracks its contents.
|
|
307
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
308
|
+
}, [existingKey])
|
|
309
|
+
|
|
310
|
+
const addItem = async (hit: SearchHit, sendExistingTranslations: boolean) => {
|
|
311
|
+
setAddingId(hit.docId)
|
|
312
|
+
try {
|
|
313
|
+
const item = {
|
|
314
|
+
_type: 'smartcat.projectItem',
|
|
315
|
+
_key: uuid(),
|
|
316
|
+
docId: hit.docId,
|
|
317
|
+
docType: hit._type,
|
|
318
|
+
// Prefer the draft as source when one exists (the plugin translates the
|
|
319
|
+
// draft version); the caller chose whether to also upload existing
|
|
320
|
+
// translations — matching a fresh "Add to translation project".
|
|
321
|
+
sourceIsPublished: !hit.hasDraft,
|
|
322
|
+
sendExistingTranslations,
|
|
323
|
+
}
|
|
324
|
+
await client.patch(projectId).setIfMissing({items: []}).insert('after', 'items[-1]', [item]).commit()
|
|
325
|
+
setAddedIds((prev) => new Set(prev).add(hit.docId))
|
|
326
|
+
toast.push({status: 'success', title: 'Added to project'})
|
|
327
|
+
} catch (err) {
|
|
328
|
+
toast.push({status: 'error', title: 'Failed to add item', description: String(err)})
|
|
329
|
+
} finally {
|
|
330
|
+
setAddingId(null)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<Stack space={3}>
|
|
336
|
+
<Card padding={4} radius={2} border>
|
|
337
|
+
<Stack space={4}>
|
|
338
|
+
<Stack space={3}>
|
|
339
|
+
<TextInput
|
|
340
|
+
icon={SearchIcon}
|
|
341
|
+
placeholder="Search documents to add — by title, or schema type…"
|
|
342
|
+
value={query}
|
|
343
|
+
onChange={(e) => setQuery(e.currentTarget.value)}
|
|
344
|
+
clearButton={query.length > 0}
|
|
345
|
+
onClear={() => setQuery('')}
|
|
346
|
+
/>
|
|
347
|
+
<Flex as="label" align="center" gap={2} style={{cursor: 'pointer'}}>
|
|
348
|
+
<Checkbox checked={searchContent} onChange={(e) => setSearchContent(e.currentTarget.checked)} />
|
|
349
|
+
<Text size={1} muted>
|
|
350
|
+
Also search in content (slower; matches body text, not just titles)
|
|
351
|
+
</Text>
|
|
352
|
+
</Flex>
|
|
353
|
+
</Stack>
|
|
354
|
+
|
|
355
|
+
{loading ? (
|
|
356
|
+
<Flex align="center" justify="center" padding={4}>
|
|
357
|
+
<Spinner muted />
|
|
358
|
+
</Flex>
|
|
359
|
+
) : results === null ? null : results.length === 0 ? (
|
|
360
|
+
<Text muted align="center" size={1}>
|
|
361
|
+
No documents match “{query.trim()}”.
|
|
362
|
+
</Text>
|
|
363
|
+
) : (
|
|
364
|
+
<Stack space={2}>
|
|
365
|
+
{results.map((hit) => {
|
|
366
|
+
// "Added" means added to *this* project — the only case where
|
|
367
|
+
// adding again is redundant. Membership in other projects is fine
|
|
368
|
+
// and just listed below.
|
|
369
|
+
const inThisProject = addedIds.has(hit.docId) || existingDocIds.has(hit.docId)
|
|
370
|
+
const memberships = projectsByDoc.get(hit.docId) ?? []
|
|
371
|
+
// Preview (and translate) the draft when there is one; else the published doc.
|
|
372
|
+
const previewId = hit.hasDraft ? `drafts.${hit.docId}` : hit.docId
|
|
373
|
+
return (
|
|
374
|
+
<Card key={hit.docId} padding={2} radius={2} shadow={1}>
|
|
375
|
+
<Flex align="center" gap={3}>
|
|
376
|
+
<Box flex={1}>
|
|
377
|
+
<Stack space={2}>
|
|
378
|
+
<Flex align="center" gap={2}>
|
|
379
|
+
<Badge
|
|
380
|
+
tone={hit.hasDraft ? 'caution' : 'positive'}
|
|
381
|
+
paddingX={2}
|
|
382
|
+
title={
|
|
383
|
+
hit.hasDraft
|
|
384
|
+
? 'The draft version will be the translation source'
|
|
385
|
+
: 'The published version will be the translation source'
|
|
386
|
+
}
|
|
387
|
+
>
|
|
388
|
+
{hit.hasDraft ? 'Draft' : 'Published'}
|
|
389
|
+
</Badge>
|
|
390
|
+
<Text weight="medium" textOverflow="ellipsis">
|
|
391
|
+
<DocTitle doc={{_id: previewId, _type: hit._type}} />
|
|
392
|
+
</Text>
|
|
393
|
+
<Badge tone="primary" paddingX={2}>
|
|
394
|
+
{hit._type}
|
|
395
|
+
</Badge>
|
|
396
|
+
</Flex>
|
|
397
|
+
{memberships.length > 0 && (
|
|
398
|
+
<Text size={0} muted>
|
|
399
|
+
In:{' '}
|
|
400
|
+
{memberships.map((p, i) => (
|
|
401
|
+
<Fragment key={p._id}>
|
|
402
|
+
{i > 0 && ', '}
|
|
403
|
+
<ProjectChip project={p} current={p._id === projectId} />
|
|
404
|
+
</Fragment>
|
|
405
|
+
))}
|
|
406
|
+
</Text>
|
|
407
|
+
)}
|
|
408
|
+
</Stack>
|
|
409
|
+
</Box>
|
|
410
|
+
<OpenInStudioButton id={hit.docId} type={hit._type} />
|
|
411
|
+
<ViewLocjsonButton
|
|
412
|
+
docId={hit.docId}
|
|
413
|
+
type={hit._type}
|
|
414
|
+
sourceIsPublished={!hit.hasDraft}
|
|
415
|
+
client={client}
|
|
416
|
+
schema={schema}
|
|
417
|
+
sourceLanguage={sourceLanguage}
|
|
418
|
+
documentI18nLanguageField={documentI18nLanguageField}
|
|
419
|
+
fieldI18nLanguageField={fieldI18nLanguageField}
|
|
420
|
+
targetLanguages={targetLanguages}
|
|
421
|
+
languages={languages}
|
|
422
|
+
/>
|
|
423
|
+
{inThisProject ? (
|
|
424
|
+
<Button mode="bleed" icon={CheckmarkIcon} text="Added" fontSize={1} padding={2} disabled />
|
|
425
|
+
) : (
|
|
426
|
+
<MenuButton
|
|
427
|
+
id={`add-${hit.docId}`}
|
|
428
|
+
button={
|
|
429
|
+
<Button
|
|
430
|
+
mode="ghost"
|
|
431
|
+
icon={AddIcon}
|
|
432
|
+
iconRight={ChevronDownIcon}
|
|
433
|
+
text="Add"
|
|
434
|
+
fontSize={1}
|
|
435
|
+
padding={2}
|
|
436
|
+
loading={addingId === hit.docId}
|
|
437
|
+
disabled={addingId !== null}
|
|
438
|
+
/>
|
|
439
|
+
}
|
|
440
|
+
menu={
|
|
441
|
+
<Menu>
|
|
442
|
+
<MenuItem text="Add source" onClick={() => addItem(hit, false)} />
|
|
443
|
+
<MenuItem text="Add source + translations" onClick={() => addItem(hit, true)} />
|
|
444
|
+
</Menu>
|
|
445
|
+
}
|
|
446
|
+
popover={{portal: true}}
|
|
447
|
+
/>
|
|
448
|
+
)}
|
|
449
|
+
</Flex>
|
|
450
|
+
</Card>
|
|
451
|
+
)
|
|
452
|
+
})}
|
|
453
|
+
</Stack>
|
|
454
|
+
)}
|
|
455
|
+
</Stack>
|
|
456
|
+
</Card>
|
|
457
|
+
<Text size={1} muted>
|
|
458
|
+
You can also add content from a document’s “⋯ → Add to translation project” menu.
|
|
459
|
+
</Text>
|
|
460
|
+
</Stack>
|
|
461
|
+
)
|
|
462
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {useSchema, useValuePreview} from 'sanity'
|
|
2
|
+
import {UNTITLED} from '../lib/documentTitle'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a document's title the way the Studio does — by running the schema
|
|
6
|
+
* type's `preview.prepare` — so types without a literal `title` field (e.g.
|
|
7
|
+
* field-level localized docs) show the same label as in the structure tree,
|
|
8
|
+
* not 'Untitled'.
|
|
9
|
+
*/
|
|
10
|
+
export function DocTitle({doc}: {doc: {_id: string; _type: string} | null}) {
|
|
11
|
+
const schema = useSchema()
|
|
12
|
+
const schemaType = doc ? schema.get(doc._type) : undefined
|
|
13
|
+
const preview = useValuePreview({
|
|
14
|
+
enabled: Boolean(doc && schemaType),
|
|
15
|
+
schemaType,
|
|
16
|
+
value: doc ? {_id: doc._id, _type: doc._type} : undefined,
|
|
17
|
+
})
|
|
18
|
+
// String-only: never fall back to the raw projected value, which is an
|
|
19
|
+
// internationalized-array object for field-level i18n types (renders as React #31).
|
|
20
|
+
const title = typeof preview.value?.title === 'string' ? preview.value.title : undefined
|
|
21
|
+
// Placeholder while the async preview resolves, so a real title isn't briefly mislabeled.
|
|
22
|
+
return <>{title ?? (preview.isLoading ? '…' : UNTITLED)}</>
|
|
23
|
+
}
|