@sanity/sdk-react 3.1.0 → 3.2.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/_exports/dashboard-internal.d.ts +2 -0
- package/dist/_exports/dashboard-internal.js +2 -0
- package/dist/_exports/dashboard.d.ts +457 -7
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +623 -2
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +90 -87
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-DxUlukmF.js → useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js} +53 -56
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +1 -0
- package/package.json +34 -32
- package/src/_exports/dashboard-internal.ts +1 -0
- package/src/_exports/dashboard.test-d.ts +64 -0
- package/src/_exports/dashboard.ts +70 -0
- package/src/_exports/index.ts +1 -1
- package/src/components/auth/AuthBoundary.test.tsx +34 -0
- package/src/components/auth/AuthBoundary.tsx +1 -2
- package/src/context/DashboardTokenRefresh.test.tsx +124 -22
- package/src/context/DashboardTokenRefresh.tsx +31 -15
- package/src/dashboard/createRemoteInstance.test.ts +168 -0
- package/src/dashboard/createRemoteInstance.ts +123 -0
- package/src/dashboard/module.ts +39 -0
- package/src/dashboard/remoteClientState.ts +27 -0
- package/src/dashboard/urlFor.test.ts +246 -0
- package/src/dashboard/urlFor.ts +462 -0
- package/src/hooks/applications/useApplication.test-d.ts +1 -1
- package/src/hooks/dashboard/useApplication.test.tsx +59 -0
- package/src/hooks/dashboard/useApplication.ts +22 -0
- package/src/hooks/dashboard/useApplicationConfig.test.tsx +106 -0
- package/src/hooks/dashboard/useApplicationConfig.ts +45 -0
- package/src/hooks/dashboard/useApplicationConfigs.test.tsx +89 -0
- package/src/hooks/dashboard/useApplicationConfigs.ts +26 -0
- package/src/hooks/dashboard/useApplicationForegroundId.test.tsx +54 -0
- package/src/hooks/dashboard/useApplicationForegroundId.ts +22 -0
- package/src/hooks/dashboard/useApplications.test.tsx +249 -0
- package/src/hooks/dashboard/useApplications.ts +128 -0
- package/src/hooks/dashboard/useEmit.test.tsx +124 -0
- package/src/hooks/dashboard/useEmit.ts +78 -0
- package/src/hooks/dashboard/useNavigate.ts +1 -3
- package/src/hooks/dashboard/useRemoteClient.test.tsx +88 -0
- package/src/hooks/dashboard/useRemoteClient.ts +10 -0
- package/src/hooks/dashboard/useTopic.test.tsx +200 -0
- package/src/hooks/dashboard/useTopic.ts +29 -0
- package/src/hooks/dashboard/useUpdateFavorite.test.tsx +8 -1
- package/src/hooks/document/useApplyDocumentActions.ts +3 -4
- package/src/hooks/document/useCreateDocument.ts +2 -3
- package/src/hooks/document/useDocument.ts +11 -6
- package/src/hooks/document/useEditDocument.ts +5 -5
- package/src/hooks/projection/useDocumentProjection.ts +6 -7
- package/src/hooks/query/useQuery.ts +3 -4
- package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js.map +0 -1
- package/src/context/dashboardToken.ts +0 -63
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {describe, expect, expectTypeOf, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CanvasUrl,
|
|
5
|
+
type CoreApplicationUrl,
|
|
6
|
+
type DashboardUrl,
|
|
7
|
+
type MediaLibraryUrl,
|
|
8
|
+
type StudioIntentUrl,
|
|
9
|
+
type StudioUrl,
|
|
10
|
+
type StudioWorkspaceUrl,
|
|
11
|
+
UrlBuilder,
|
|
12
|
+
urlFor,
|
|
13
|
+
} from './urlFor'
|
|
14
|
+
|
|
15
|
+
class AcmeDocumentUrlBuilder extends UrlBuilder {
|
|
16
|
+
perspective(perspective: string): this {
|
|
17
|
+
return this.edit((url) => url.searchParams.set('perspective', perspective))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
panel(panelId: string): this {
|
|
21
|
+
return this.edit((url) => {
|
|
22
|
+
url.hash = `panel/${panelId}`
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class AcmeUrlBuilder extends UrlBuilder {
|
|
28
|
+
static readonly namespace = 'acme'
|
|
29
|
+
|
|
30
|
+
context(contextId: string): this {
|
|
31
|
+
return this.append('contexts', contextId)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
document(documentId: string): AcmeDocumentUrlBuilder {
|
|
35
|
+
return this.transitionTo(AcmeDocumentUrlBuilder, 'documents', documentId)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class PersonaUrlBuilder extends UrlBuilder {
|
|
40
|
+
static readonly namespace = 'persona'
|
|
41
|
+
|
|
42
|
+
person(personId: string): this {
|
|
43
|
+
return this.append('people', personId)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class CanvasAliasUrlBuilder extends UrlBuilder {
|
|
48
|
+
static readonly namespace = 'canvas'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('studio', () => {
|
|
52
|
+
it('builds studio URLs', () => {
|
|
53
|
+
expect(urlFor.studios().url()).toBe('/studios/')
|
|
54
|
+
expect(urlFor.studios('studio-1').url()).toBe('/studios/studio-1')
|
|
55
|
+
expect(urlFor.studios('studio-1').workspace('default').url()).toBe('/studios/studio-1/default')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('builds edit intent URLs', () => {
|
|
59
|
+
expect(
|
|
60
|
+
urlFor
|
|
61
|
+
.studios('studio-1')
|
|
62
|
+
.workspace('default')
|
|
63
|
+
.intent('edit', {id: 'drafts.document-1', type: 'article'})
|
|
64
|
+
.url(),
|
|
65
|
+
).toBe('/studios/studio-1/default/intent/edit/id=drafts.document-1;type=article/')
|
|
66
|
+
|
|
67
|
+
expect(urlFor.studios('studio-1').intent('edit', {id: 'document-1', mode: 'focus'}).url()).toBe(
|
|
68
|
+
'/studios/studio-1/intent/edit/id=document-1;mode=focus/',
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('builds create and release intent URLs', () => {
|
|
73
|
+
expect(
|
|
74
|
+
urlFor.studios('studio-1').intent('create', {template: 'article', type: 'article'}).url(),
|
|
75
|
+
).toBe('/studios/studio-1/intent/create/template=article;type=article/')
|
|
76
|
+
expect(urlFor.studios('studio-1').intent('release', {id: 'release-1'}).url()).toBe(
|
|
77
|
+
'/studios/studio-1/intent/release/id=release-1/',
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('adds perspectives and comments to intent URLs', () => {
|
|
82
|
+
const intent = urlFor
|
|
83
|
+
.studios('studio-1')
|
|
84
|
+
.intent('edit', {id: 'document/1', type: 'press release'})
|
|
85
|
+
|
|
86
|
+
expect(intent.perspective('release/1').url()).toBe(
|
|
87
|
+
'/studios/studio-1/intent/edit/id=document%2F1;type=press%20release/?perspective=release%2F1',
|
|
88
|
+
)
|
|
89
|
+
expect(intent.comment('comment/1').url()).toBe(
|
|
90
|
+
'/studios/studio-1/intent/edit/id=document%2F1;type=press%20release;inspect=sanity%2Fcomments;comment=comment%2F1/',
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('replaces an earlier comment instead of stacking one', () => {
|
|
95
|
+
expect(
|
|
96
|
+
urlFor
|
|
97
|
+
.studios('studio-1')
|
|
98
|
+
.intent('edit', {id: 'document-1'})
|
|
99
|
+
.comment('comment-1')
|
|
100
|
+
.comment('comment-2')
|
|
101
|
+
.url(),
|
|
102
|
+
).toBe(
|
|
103
|
+
'/studios/studio-1/intent/edit/id=document-1;inspect=sanity%2Fcomments;comment=comment-2/',
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('builds studio task URLs', () => {
|
|
108
|
+
expect(urlFor.studios('studio-1').workspace('default').task('task/1').url()).toBe(
|
|
109
|
+
'/studios/studio-1/default?selectedTask=task%2F1',
|
|
110
|
+
)
|
|
111
|
+
expect(urlFor.studios('studio-1').intent('edit', {id: 'document-1'}).task('task-1').url()).toBe(
|
|
112
|
+
'/studios/studio-1/intent/edit/id=document-1/?selectedTask=task-1',
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('builds arbitrary studio paths', () => {
|
|
117
|
+
expect(urlFor.studios('studio-1').path('custom', 'document/1').url()).toBe(
|
|
118
|
+
'/studios/studio-1/custom/document/1',
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('encodes studio and workspace identifiers', () => {
|
|
123
|
+
expect(urlFor.studios('studio/1').workspace('main space').url()).toBe(
|
|
124
|
+
'/studios/studio%2F1/main%20space',
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('exposes studio-specific builder interfaces', () => {
|
|
129
|
+
expectTypeOf(urlFor.studios()).toEqualTypeOf<DashboardUrl>()
|
|
130
|
+
expectTypeOf(urlFor.studios('studio-1')).toEqualTypeOf<StudioUrl>()
|
|
131
|
+
expectTypeOf(
|
|
132
|
+
urlFor.studios('studio-1').workspace('default'),
|
|
133
|
+
).toEqualTypeOf<StudioWorkspaceUrl>()
|
|
134
|
+
expectTypeOf(
|
|
135
|
+
urlFor.studios('studio-1').intent('edit', {id: 'document-1'}),
|
|
136
|
+
).toEqualTypeOf<StudioIntentUrl>()
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('coreApp', () => {
|
|
141
|
+
it('builds collection and application URLs', () => {
|
|
142
|
+
expect(urlFor.applications().url()).toBe('/applications/')
|
|
143
|
+
expect(urlFor.applications('app-1').url()).toBe('/applications/app-1')
|
|
144
|
+
expect(urlFor.applications('app-1').path('documents', 'document/1').url()).toBe(
|
|
145
|
+
'/applications/app-1/documents/document/1',
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('exposes application-specific builder interfaces', () => {
|
|
150
|
+
expectTypeOf(urlFor.applications()).toEqualTypeOf<DashboardUrl>()
|
|
151
|
+
expectTypeOf(urlFor.applications('app-1')).toEqualTypeOf<CoreApplicationUrl>()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('Media Library', () => {
|
|
156
|
+
it('builds media library URLs', () => {
|
|
157
|
+
expect(urlFor.mediaLibrary().url()).toBe('/media')
|
|
158
|
+
expect(urlFor.mediaLibrary().asset('asset/1').url()).toBe('/media/assets/asset%2F1')
|
|
159
|
+
expect(urlFor.mediaLibrary().collection('collection/1').url()).toBe(
|
|
160
|
+
'/media/collections/collection%2F1',
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('exposes the Media Library builder interface', () => {
|
|
165
|
+
expectTypeOf(urlFor.mediaLibrary()).toEqualTypeOf<MediaLibraryUrl>()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('Canvas', () => {
|
|
170
|
+
it('builds Canvas URLs', () => {
|
|
171
|
+
expect(urlFor.canvas().document('document/1').url()).toBe('/canvas/doc/document%2F1')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('exposes the Canvas builder interface', () => {
|
|
175
|
+
expectTypeOf(urlFor.canvas()).toEqualTypeOf<CanvasUrl>()
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('UrlBuilder', () => {
|
|
180
|
+
it('returns relative and absolute URL forms', () => {
|
|
181
|
+
const origin = 'https://dashboard.sanity.io'
|
|
182
|
+
const builder = new UrlBuilder(new URL('/applications/app-1', origin))
|
|
183
|
+
const absolute = new URL('/applications/app-1', origin)
|
|
184
|
+
|
|
185
|
+
expect(builder.toString()).toBe('/applications/app-1')
|
|
186
|
+
expect(builder.url()).toBe('/applications/app-1')
|
|
187
|
+
expect(builder.toURL({origin})).toEqual(absolute)
|
|
188
|
+
expect(builder.url({origin})).toBe(absolute.href)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('keeps builders immutable', () => {
|
|
192
|
+
const builder = new AcmeUrlBuilder(new URL('https://dashboard.sanity.io/acme'))
|
|
193
|
+
|
|
194
|
+
builder.context('context-1')
|
|
195
|
+
|
|
196
|
+
expect(builder.url()).toBe('/acme')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('extends the URL builder with custom builder classes', () => {
|
|
200
|
+
const extendedUrlFor = urlFor.extend({acme: AcmeUrlBuilder})
|
|
201
|
+
const reextendedUrlFor = extendedUrlFor.extend({persona: PersonaUrlBuilder})
|
|
202
|
+
|
|
203
|
+
expect(
|
|
204
|
+
extendedUrlFor
|
|
205
|
+
.acme()
|
|
206
|
+
.context('context/1')
|
|
207
|
+
.document('document/1')
|
|
208
|
+
.perspective('published')
|
|
209
|
+
.panel('review 1')
|
|
210
|
+
.url(),
|
|
211
|
+
).toBe(
|
|
212
|
+
'/acme/contexts/context%2F1/documents/document%2F1?perspective=published#panel/review%201',
|
|
213
|
+
)
|
|
214
|
+
expect(reextendedUrlFor.persona().person('person/1').url()).toBe('/persona/people/person%2F1')
|
|
215
|
+
expect(reextendedUrlFor.acme().url()).toBe('/acme')
|
|
216
|
+
expect(urlFor).not.toHaveProperty('acme')
|
|
217
|
+
|
|
218
|
+
expectTypeOf(extendedUrlFor.acme()).toEqualTypeOf<AcmeUrlBuilder>()
|
|
219
|
+
expectTypeOf(
|
|
220
|
+
extendedUrlFor.acme().document('document-1'),
|
|
221
|
+
).toEqualTypeOf<AcmeDocumentUrlBuilder>()
|
|
222
|
+
expectTypeOf(reextendedUrlFor.persona()).toEqualTypeOf<PersonaUrlBuilder>()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('rejects duplicate URL namespaces', () => {
|
|
226
|
+
const extendedUrlFor = urlFor.extend({acme: AcmeUrlBuilder})
|
|
227
|
+
|
|
228
|
+
expect(() => urlFor.extend({canvas: AcmeUrlBuilder} as never)).toThrow(
|
|
229
|
+
'URL builder "canvas" already exists',
|
|
230
|
+
)
|
|
231
|
+
expect(() => urlFor.extend({canvasAlias: CanvasAliasUrlBuilder})).toThrow(
|
|
232
|
+
'URL namespace "canvas" already exists',
|
|
233
|
+
)
|
|
234
|
+
expect(() => extendedUrlFor.extend({acmeAlias: AcmeUrlBuilder})).toThrow(
|
|
235
|
+
'URL namespace "acme" already exists',
|
|
236
|
+
)
|
|
237
|
+
expect(() => urlFor.extend({first: AcmeUrlBuilder, second: AcmeUrlBuilder})).toThrow(
|
|
238
|
+
'URL namespace "acme" already exists',
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('builds the home URL', () => {
|
|
243
|
+
expect(urlFor.home().url()).toBe('/')
|
|
244
|
+
expectTypeOf(urlFor.home()).toEqualTypeOf<DashboardUrl>()
|
|
245
|
+
})
|
|
246
|
+
})
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/** @public */
|
|
2
|
+
export interface DashboardUrl {
|
|
3
|
+
url(options?: {origin?: string}): string
|
|
4
|
+
toURL(options: {origin: string}): URL
|
|
5
|
+
toString(): string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// URL requires an origin; public output always strips this parsing base.
|
|
9
|
+
const relativeUrlBase = 'https://dashboard.invalid'
|
|
10
|
+
|
|
11
|
+
/** @public */
|
|
12
|
+
export interface EditIntentParameters {
|
|
13
|
+
id: string
|
|
14
|
+
/** The document schema type, for example `post`. */
|
|
15
|
+
type?: string
|
|
16
|
+
/** Requests a Studio edit mode, such as `structure` or `presentation`. */
|
|
17
|
+
mode?: 'structure' | 'presentation' | (string & Record<never, never>)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @public */
|
|
21
|
+
export interface CreateIntentParameters {
|
|
22
|
+
template: string
|
|
23
|
+
type: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @public */
|
|
27
|
+
export interface ReleaseIntentParameters {
|
|
28
|
+
id: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @public */
|
|
32
|
+
export interface StudioWorkspaceUrl extends DashboardUrl {
|
|
33
|
+
intent(intent: 'edit', parameters: EditIntentParameters): StudioIntentUrl
|
|
34
|
+
intent(intent: 'create', parameters: CreateIntentParameters): StudioIntentUrl
|
|
35
|
+
intent(intent: 'release', parameters: ReleaseIntentParameters): StudioIntentUrl
|
|
36
|
+
path(...path: string[]): DashboardUrl
|
|
37
|
+
task(taskId: string): DashboardUrl
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @public */
|
|
41
|
+
export interface StudioUrl extends StudioWorkspaceUrl {
|
|
42
|
+
workspace(workspace: string): StudioWorkspaceUrl
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @public */
|
|
46
|
+
export interface StudioIntentUrl extends DashboardUrl {
|
|
47
|
+
perspective(perspective: string): StudioIntentUrl
|
|
48
|
+
comment(commentId: string): StudioIntentUrl
|
|
49
|
+
task(taskId: string): DashboardUrl
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @public */
|
|
53
|
+
export interface CoreApplicationUrl extends DashboardUrl {
|
|
54
|
+
path(...path: string[]): DashboardUrl
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @public */
|
|
58
|
+
export interface MediaLibraryUrl extends DashboardUrl {
|
|
59
|
+
asset(assetId: string): DashboardUrl
|
|
60
|
+
collection(collectionId: string): DashboardUrl
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @public */
|
|
64
|
+
export interface CanvasUrl extends DashboardUrl {
|
|
65
|
+
document(documentId: string): DashboardUrl
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type IntentArguments =
|
|
69
|
+
| [intent: 'edit', parameters: EditIntentParameters]
|
|
70
|
+
| [intent: 'create', parameters: CreateIntentParameters]
|
|
71
|
+
| [intent: 'release', parameters: ReleaseIntentParameters]
|
|
72
|
+
|
|
73
|
+
type BuilderRegistry = Readonly<
|
|
74
|
+
Record<
|
|
75
|
+
string,
|
|
76
|
+
{
|
|
77
|
+
readonly namespace: string
|
|
78
|
+
new (url: URL): UrlBuilder
|
|
79
|
+
}
|
|
80
|
+
>
|
|
81
|
+
>
|
|
82
|
+
|
|
83
|
+
type BuilderMethods<Builders extends BuilderRegistry> = {
|
|
84
|
+
readonly [Name in keyof Builders]: () => InstanceType<Builders[Name]>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const splitPath = (path: readonly string[]) =>
|
|
88
|
+
path.flatMap((part) => part.split('/')).filter(Boolean)
|
|
89
|
+
|
|
90
|
+
const intentParametersOf = (...[intent, parameters]: IntentArguments) => {
|
|
91
|
+
const searchParameters = new URLSearchParams()
|
|
92
|
+
|
|
93
|
+
switch (intent) {
|
|
94
|
+
case 'edit': {
|
|
95
|
+
const {id, type, mode} = parameters
|
|
96
|
+
searchParameters.set('id', id)
|
|
97
|
+
if (type !== undefined) searchParameters.set('type', type)
|
|
98
|
+
if (mode !== undefined) searchParameters.set('mode', mode)
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
case 'create': {
|
|
102
|
+
searchParameters.set('template', parameters.template)
|
|
103
|
+
searchParameters.set('type', parameters.type)
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
case 'release': {
|
|
107
|
+
searchParameters.set('id', parameters.id)
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return searchParameters
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const parseIntentParameters = (parameters: string) =>
|
|
116
|
+
new URLSearchParams(parameters.replaceAll(';', '&'))
|
|
117
|
+
|
|
118
|
+
const serializeIntentParameters = (parameters: URLSearchParams) =>
|
|
119
|
+
Array.from(
|
|
120
|
+
parameters,
|
|
121
|
+
([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
|
122
|
+
).join(';')
|
|
123
|
+
|
|
124
|
+
const appendSegments = (url: URL, segments: readonly string[]): URL => {
|
|
125
|
+
const nextUrl = new URL(url)
|
|
126
|
+
if (segments.length === 0) return nextUrl
|
|
127
|
+
|
|
128
|
+
nextUrl.pathname = `${nextUrl.pathname.replace(/\/$/, '')}/${segments
|
|
129
|
+
.map(encodeURIComponent)
|
|
130
|
+
.join('/')}`
|
|
131
|
+
return nextUrl
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Base class for immutable URL grammars.
|
|
136
|
+
*
|
|
137
|
+
* @public
|
|
138
|
+
*/
|
|
139
|
+
export class UrlBuilder implements DashboardUrl {
|
|
140
|
+
readonly #url: URL
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Creates an immutable URL builder at the supplied URL.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* const builder = new UrlBuilder(
|
|
148
|
+
* new URL('/applications/my-app', 'https://dashboard.sanity.io'),
|
|
149
|
+
* )
|
|
150
|
+
*
|
|
151
|
+
* builder.url() // '/applications/my-app'
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
constructor(url: URL) {
|
|
155
|
+
this.#url = new URL(url)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Returns a builder with each value appended as one encoded path segment.
|
|
160
|
+
*
|
|
161
|
+
* Route literals and identifiers can be passed together. A slash inside a value stays within
|
|
162
|
+
* that segment instead of creating another route level.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```ts
|
|
166
|
+
* class DocumentUrlBuilder extends UrlBuilder {
|
|
167
|
+
* document(documentId: string) {
|
|
168
|
+
* return this.append('documents', documentId)
|
|
169
|
+
* }
|
|
170
|
+
* }
|
|
171
|
+
*
|
|
172
|
+
* const builder = new DocumentUrlBuilder(new URL('https://dashboard.sanity.io'))
|
|
173
|
+
* builder.document('drafts/document-1').url()
|
|
174
|
+
* // '/documents/drafts%2Fdocument-1'
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
protected append(...segments: string[]): this {
|
|
178
|
+
return this.#create(appendSegments(this.#url, segments))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Returns a builder with changes made through the platform `URL` API.
|
|
183
|
+
*
|
|
184
|
+
* This supports URL details outside the path grammar, including search parameters and hashes.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* ```ts
|
|
188
|
+
* class DocumentUrlBuilder extends UrlBuilder {
|
|
189
|
+
* perspective(name: string) {
|
|
190
|
+
* return this.edit((url) => url.searchParams.set('perspective', name))
|
|
191
|
+
* }
|
|
192
|
+
*
|
|
193
|
+
* panel(panelId: string) {
|
|
194
|
+
* return this.edit((url) => {
|
|
195
|
+
* url.hash = `panel/${panelId}`
|
|
196
|
+
* })
|
|
197
|
+
* }
|
|
198
|
+
* }
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
protected edit(update: (url: URL) => void): this {
|
|
202
|
+
const url = new URL(this.#url)
|
|
203
|
+
update(url)
|
|
204
|
+
return this.#create(url)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Returns another builder type rooted at the appended path segments.
|
|
209
|
+
*
|
|
210
|
+
* Use this inside a custom builder method only when its route enters a child grammar with
|
|
211
|
+
* different methods; use `append` when chaining stays on the current grammar.
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```ts
|
|
215
|
+
* class DocumentUrlBuilder extends UrlBuilder {
|
|
216
|
+
* perspective(name: string) {
|
|
217
|
+
* return this.edit((url) => url.searchParams.set('perspective', name))
|
|
218
|
+
* }
|
|
219
|
+
* }
|
|
220
|
+
*
|
|
221
|
+
* class ApplicationUrlBuilder extends UrlBuilder {
|
|
222
|
+
* document(documentId: string) {
|
|
223
|
+
* return this.transitionTo(DocumentUrlBuilder, 'documents', documentId)
|
|
224
|
+
* }
|
|
225
|
+
* }
|
|
226
|
+
*
|
|
227
|
+
* const builder = new ApplicationUrlBuilder(new URL('https://dashboard.sanity.io'))
|
|
228
|
+
* builder.document('document-1').perspective('published').url()
|
|
229
|
+
* // '/documents/document-1?perspective=published'
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
protected transitionTo<Builder extends UrlBuilder>(
|
|
233
|
+
Builder: new (url: URL) => Builder,
|
|
234
|
+
...segments: string[]
|
|
235
|
+
): Builder {
|
|
236
|
+
return new Builder(appendSegments(this.#url, segments))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Returns the URL as a relative string by default.
|
|
241
|
+
*
|
|
242
|
+
* Supplying an origin returns an absolute URL string for the same dashboard route.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```ts
|
|
246
|
+
* const builder = new UrlBuilder(
|
|
247
|
+
* new URL('/applications/my-app', 'https://dashboard.sanity.io'),
|
|
248
|
+
* )
|
|
249
|
+
*
|
|
250
|
+
* builder.url() // '/applications/my-app'
|
|
251
|
+
* builder.url({origin: 'https://dashboard.sanity.io'})
|
|
252
|
+
* // 'https://dashboard.sanity.io/applications/my-app'
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
url(options?: {origin?: string}): string {
|
|
256
|
+
const relativeUrl = this.#relativeUrl()
|
|
257
|
+
return options?.origin === undefined ? relativeUrl : new URL(relativeUrl, options.origin).href
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Returns the URL as a platform `URL` object using the supplied origin.
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* ```ts
|
|
265
|
+
* const builder = new UrlBuilder(
|
|
266
|
+
* new URL('/applications/my-app', 'https://dashboard.sanity.io'),
|
|
267
|
+
* )
|
|
268
|
+
*
|
|
269
|
+
* builder.toURL({origin: 'https://dashboard.sanity.io'}).pathname
|
|
270
|
+
* // '/applications/my-app'
|
|
271
|
+
* ```
|
|
272
|
+
*/
|
|
273
|
+
toURL({origin}: {origin: string}): URL {
|
|
274
|
+
return new URL(this.#relativeUrl(), origin)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Returns the same relative URL as {@link UrlBuilder.url}.
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```ts
|
|
282
|
+
* const builder = new UrlBuilder(
|
|
283
|
+
* new URL('/applications/my-app', 'https://dashboard.sanity.io'),
|
|
284
|
+
* )
|
|
285
|
+
*
|
|
286
|
+
* `${builder}` // '/applications/my-app'
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
toString(): string {
|
|
290
|
+
return this.url()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#relativeUrl(): string {
|
|
294
|
+
return `${this.#url.pathname}${this.#url.search}${this.#url.hash}`
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#create(url: URL): this {
|
|
298
|
+
const Builder = this.constructor as new (url: URL) => this
|
|
299
|
+
return new Builder(url)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
class CoreApplicationUrlBuilder extends UrlBuilder implements CoreApplicationUrl {
|
|
304
|
+
static readonly namespace = 'applications'
|
|
305
|
+
|
|
306
|
+
path(...path: string[]): this {
|
|
307
|
+
return this.append(...splitPath(path))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
class MediaLibraryUrlBuilder extends UrlBuilder implements MediaLibraryUrl {
|
|
312
|
+
static readonly namespace = 'media'
|
|
313
|
+
|
|
314
|
+
asset(assetId: string): DashboardUrl {
|
|
315
|
+
return this.append('assets', assetId)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
collection(collectionId: string): DashboardUrl {
|
|
319
|
+
return this.append('collections', collectionId)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
class CanvasUrlBuilder extends UrlBuilder implements CanvasUrl {
|
|
324
|
+
static readonly namespace = 'canvas'
|
|
325
|
+
|
|
326
|
+
document(documentId: string): DashboardUrl {
|
|
327
|
+
return this.append('doc', documentId)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
class StudioUrlBuilder
|
|
332
|
+
extends UrlBuilder
|
|
333
|
+
implements StudioUrl, StudioWorkspaceUrl, StudioIntentUrl
|
|
334
|
+
{
|
|
335
|
+
static readonly namespace = 'studios'
|
|
336
|
+
|
|
337
|
+
workspace(workspace: string): StudioWorkspaceUrl {
|
|
338
|
+
return this.append(workspace)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
intent(intent: 'edit', parameters: EditIntentParameters): StudioIntentUrl
|
|
342
|
+
intent(intent: 'create', parameters: CreateIntentParameters): StudioIntentUrl
|
|
343
|
+
intent(intent: 'release', parameters: ReleaseIntentParameters): StudioIntentUrl
|
|
344
|
+
intent(...args: IntentArguments): StudioIntentUrl {
|
|
345
|
+
return this.append('intent', args[0]).edit((url) => {
|
|
346
|
+
url.pathname = `${url.pathname}/${serializeIntentParameters(intentParametersOf(...args))}/`
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
perspective(perspective: string): StudioIntentUrl {
|
|
351
|
+
return this.edit((url) => url.searchParams.set('perspective', perspective))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
comment(commentId: string): StudioIntentUrl {
|
|
355
|
+
return this.edit((url) => {
|
|
356
|
+
const segments = url.pathname.split('/')
|
|
357
|
+
const parametersIndex = segments.length - 2
|
|
358
|
+
const parameters = parseIntentParameters(segments[parametersIndex]!)
|
|
359
|
+
parameters.set('inspect', 'sanity/comments')
|
|
360
|
+
parameters.set('comment', commentId)
|
|
361
|
+
segments[parametersIndex] = serializeIntentParameters(parameters)
|
|
362
|
+
url.pathname = segments.join('/')
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
task(taskId: string): DashboardUrl {
|
|
367
|
+
return this.edit((url) => url.searchParams.set('selectedTask', taskId))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
path(...path: string[]): DashboardUrl {
|
|
371
|
+
return this.append(...splitPath(path))
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** @public */
|
|
376
|
+
export interface Urls {
|
|
377
|
+
studios(): DashboardUrl
|
|
378
|
+
studios(appId: string): StudioUrl
|
|
379
|
+
applications(): DashboardUrl
|
|
380
|
+
applications(appId: string): CoreApplicationUrl
|
|
381
|
+
mediaLibrary(): MediaLibraryUrl
|
|
382
|
+
canvas(): CanvasUrl
|
|
383
|
+
home(): DashboardUrl
|
|
384
|
+
extend<
|
|
385
|
+
const Builders extends Readonly<
|
|
386
|
+
Record<
|
|
387
|
+
string,
|
|
388
|
+
{
|
|
389
|
+
readonly namespace: string
|
|
390
|
+
new (url: URL): UrlBuilder
|
|
391
|
+
}
|
|
392
|
+
>
|
|
393
|
+
>,
|
|
394
|
+
>(
|
|
395
|
+
builders: Builders & Partial<Record<keyof this, never>>,
|
|
396
|
+
): this & {readonly [Name in keyof Builders]: () => InstanceType<Builders[Name]>}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const createRootBuilder = <Builder extends UrlBuilder>(
|
|
400
|
+
Builder: (new (url: URL) => Builder) & {readonly namespace: string},
|
|
401
|
+
...segments: string[]
|
|
402
|
+
): Builder =>
|
|
403
|
+
new Builder(appendSegments(new URL(relativeUrlBase), [Builder.namespace, ...segments]))
|
|
404
|
+
|
|
405
|
+
const createUrls = <const Builders extends BuilderRegistry>(
|
|
406
|
+
builders: Builders,
|
|
407
|
+
): Urls & BuilderMethods<Builders> => {
|
|
408
|
+
function studios(): DashboardUrl
|
|
409
|
+
function studios(appId: string): StudioUrl
|
|
410
|
+
function studios(appId?: string): DashboardUrl | StudioUrl {
|
|
411
|
+
return appId === undefined
|
|
412
|
+
? new UrlBuilder(new URL('/studios/', relativeUrlBase))
|
|
413
|
+
: createRootBuilder(StudioUrlBuilder, appId)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function applications(): DashboardUrl
|
|
417
|
+
function applications(appId: string): CoreApplicationUrl
|
|
418
|
+
function applications(appId?: string): DashboardUrl | CoreApplicationUrl {
|
|
419
|
+
return appId === undefined
|
|
420
|
+
? new UrlBuilder(new URL('/applications/', relativeUrlBase))
|
|
421
|
+
: createRootBuilder(CoreApplicationUrlBuilder, appId)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const methods = Object.fromEntries(
|
|
425
|
+
Object.entries(builders).map(([name, Builder]) => [name, () => createRootBuilder(Builder)]),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
const urls = {
|
|
429
|
+
studios,
|
|
430
|
+
applications,
|
|
431
|
+
mediaLibrary: () => createRootBuilder(MediaLibraryUrlBuilder),
|
|
432
|
+
canvas: () => createRootBuilder(CanvasUrlBuilder),
|
|
433
|
+
home: () => new UrlBuilder(new URL(relativeUrlBase)),
|
|
434
|
+
extend<const AddedBuilders extends BuilderRegistry>(addedBuilders: AddedBuilders) {
|
|
435
|
+
const namespaces = new Set([
|
|
436
|
+
'',
|
|
437
|
+
StudioUrlBuilder.namespace,
|
|
438
|
+
CoreApplicationUrlBuilder.namespace,
|
|
439
|
+
MediaLibraryUrlBuilder.namespace,
|
|
440
|
+
CanvasUrlBuilder.namespace,
|
|
441
|
+
...Object.values(builders).map((Builder) => Builder.namespace),
|
|
442
|
+
])
|
|
443
|
+
|
|
444
|
+
for (const [name, Builder] of Object.entries(addedBuilders)) {
|
|
445
|
+
if (name in urls || name in methods) {
|
|
446
|
+
throw new Error(`URL builder "${name}" already exists`)
|
|
447
|
+
}
|
|
448
|
+
if (namespaces.has(Builder.namespace)) {
|
|
449
|
+
throw new Error(`URL namespace "${Builder.namespace}" already exists`)
|
|
450
|
+
}
|
|
451
|
+
namespaces.add(Builder.namespace)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return createUrls({...builders, ...addedBuilders})
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return Object.assign(urls, methods) as Urls & BuilderMethods<Builders>
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** @public */
|
|
462
|
+
export const urlFor: Urls = createUrls({})
|