Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(manifest): add icons, tools & projectId #7980

Open
wants to merge 6 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@sanity/vision/src/visionTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const visionTool = definePlugin<VisionToolConfig | void>((options) => {
{
name: name || 'vision',
title: title || 'Vision',
type: 'sanity/vision',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(se later comments/questions about type)

icon: icon || EyeOpenIcon,
component: lazy(() => import('./SanityVision')),
options: config,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {getTimer} from '../../util/timing'

const MANIFEST_FILENAME = 'create-manifest.json'
const SCHEMA_FILENAME_SUFFIX = '.create-schema.json'
const TOOLS_FILENAME_SUFFIX = '.create-tools.json'

/** Escape-hatch env flags to change action behavior */
const FEATURE_ENABLED_ENV_NAME = 'SANITY_CLI_EXTRACT_MANIFEST_ENABLED'
Expand Down Expand Up @@ -90,7 +91,12 @@ async function extractManifest(
const workspaceFiles = await writeWorkspaceFiles(workspaceManifests, staticPath)

const manifest: CreateManifest = {
version: 1,
/**
* Version history:
* 1: Initial release.
* 2: Added tools file.
*/
version: 2,
joshuaellis marked this conversation as resolved.
Show resolved Hide resolved
createdAt: new Date().toISOString(),
workspaces: workspaceFiles,
}
Expand Down Expand Up @@ -157,26 +163,36 @@ function writeWorkspaceFiles(
): Promise<ManifestWorkspaceFile[]> {
const output = manifestWorkspaces.reduce<Promise<ManifestWorkspaceFile>[]>(
(workspaces, workspace) => {
return [...workspaces, writeWorkspaceSchemaFile(workspace, staticPath)]
return [...workspaces, writeWorkspaceFile(workspace, staticPath)]
},
[],
)
return Promise.all(output)
}

async function writeWorkspaceSchemaFile(
async function writeWorkspaceFile(
workspace: CreateWorkspaceManifest,
staticPath: string,
): Promise<ManifestWorkspaceFile> {
const schemaString = JSON.stringify(workspace.schema, null, 2)
const hash = createHash('sha1').update(schemaString).digest('hex')
const filename = `${hash.slice(0, 8)}${SCHEMA_FILENAME_SUFFIX}`

// workspaces with identical schemas will overwrite each others schema file. This is ok, since they are identical and can be shared
await writeFile(join(staticPath, filename), schemaString)
const [schemaFilename, toolsFilename] = await Promise.all([
createFile(staticPath, workspace.schema, SCHEMA_FILENAME_SUFFIX),
createFile(staticPath, workspace.tools, TOOLS_FILENAME_SUFFIX),
])

return {
...workspace,
schema: filename,
schema: schemaFilename,
tools: toolsFilename,
}
}

const createFile = async (path: string, content: any, filenameSuffix: string) => {
const stringifiedContent = JSON.stringify(content, null, 2)
const hash = createHash('sha1').update(stringifiedContent).digest('hex')
const filename = `${hash.slice(0, 8)}${filenameSuffix}`

// workspaces with identical data will overwrite each others file. This is ok, since they are identical and can be shared
await writeFile(join(path, filename), stringifiedContent)

return filename
}
37 changes: 37 additions & 0 deletions packages/sanity/src/_internal/manifest/Icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {ThemeProvider} from '@sanity/ui'
import {buildTheme} from '@sanity/ui/theme'
import {type ComponentType, createElement, isValidElement, type ReactNode} from 'react'
import {isValidElementType} from 'react-is'
import {createDefaultIcon} from 'sanity'
import {ServerStyleSheet, StyleSheetManager} from 'styled-components'

const theme = buildTheme()

interface SchemaIconProps {
icon?: ComponentType | ReactNode
title: string
subtitle?: string
}

const SchemaIcon = ({icon, title, subtitle}: SchemaIconProps): JSX.Element => {
const sheet = new ServerStyleSheet()

return (
<StyleSheetManager sheet={sheet.instance}>
<ThemeProvider theme={theme}>{normalizeIcon(icon, title, subtitle)}</ThemeProvider>
</StyleSheetManager>
)
}

function normalizeIcon(
icon: ComponentType | ReactNode | undefined,
title: string,
subtitle = '',
): JSX.Element {
if (isValidElementType(icon)) return createElement(icon)
if (isValidElement(icon)) return icon
return createDefaultIcon(title, subtitle)
}

export {SchemaIcon}
export type {SchemaIconProps}
50 changes: 50 additions & 0 deletions packages/sanity/src/_internal/manifest/__tests__/Icon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {studioTheme, ThemeProvider} from '@sanity/ui'
import {render as renderRTL, screen} from '@testing-library/react'
import {describe, expect, it} from 'vitest'

import {SchemaIcon, type SchemaIconProps} from '../Icon'

const render = (props?: Partial<SchemaIconProps>) =>
renderRTL(<SchemaIcon title="Studio" {...props} />, {
wrapper: ({children}) => <ThemeProvider theme={studioTheme}>{children}</ThemeProvider>,
})

describe('SchemaIcon', () => {
it("should render the title's first letter as uppercase when there is no icon present & the title is a single word", () => {
render()

expect(screen.getByText('S')).toBeInTheDocument()
})

it('should render the first two letters of a multi-word title as uppercase when there is no icon present', () => {
render({title: 'My Studio'})

expect(screen.getByText('MS')).toBeInTheDocument()
})

it('should render the icon when present as a ComponentType', () => {
render({
icon: () => (
<svg data-testid="icon">
<rect fill="#ff0000" height="32" width="32" />
</svg>
),
})

expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.queryByText('S')).not.toBeInTheDocument()
})

it('should render the icon when present as a ReactNode', () => {
render({
icon: (
<svg data-testid="icon">
<rect fill="#ff0000" height="32" width="32" />
</svg>
),
})

expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.queryByText('S')).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import startCase from 'lodash/startCase'
import {createElement} from 'react'
import {renderToString} from 'react-dom/server'
import {
type ArraySchemaType,
type BlockDefinition,
Expand All @@ -22,6 +24,7 @@ import {
type Workspace,
} from 'sanity'

import {SchemaIcon, type SchemaIconProps} from './Icon'
import {
getCustomFields,
isCrossDatasetReference,
Expand All @@ -40,6 +43,7 @@ import {
type ManifestSchemaType,
type ManifestSerializable,
type ManifestTitledValue,
type ManifestTool,
type ManifestValidationGroup,
type ManifestValidationRule,
} from './manifestTypes'
Expand Down Expand Up @@ -70,14 +74,22 @@ const INLINE_TYPES = ['document', 'object', 'image', 'file']

export function extractCreateWorkspaceManifest(workspace: Workspace): CreateWorkspaceManifest {
const serializedSchema = extractManifestSchemaTypes(workspace.schema)
const serializedTools = extractManifestTools(workspace.tools)

return {
name: workspace.name,
title: workspace.title,
subtitle: workspace.subtitle,
basePath: workspace.basePath,
projectId: workspace.projectId,
dataset: workspace.dataset,
icon: resolveIcon({
icon: workspace.icon,
title: workspace.title,
subtitle: workspace.subtitle,
}),
schema: serializedSchema,
tools: serializedTools,
}
}

Expand Down Expand Up @@ -500,3 +512,25 @@ function resolveTitleValueArray(possibleArray: unknown): ManifestTitledValue[] |

return titledValues
}

const extractManifestTools = (tools: Workspace['tools']): ManifestTool[] =>
tools.map(
({title, name, icon, type}) =>
({
title,
name,
type: type || null,
icon: resolveIcon({
icon,
title,
}),
}) satisfies ManifestTool,
)

const resolveIcon = (props: SchemaIconProps): string | null => {
try {
return renderToString(createElement(SchemaIcon, props))
} catch (error) {
return null
}
}
23 changes: 19 additions & 4 deletions packages/sanity/src/_internal/manifest/manifestTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@ export type ManifestSerializable =
| ManifestSerializable[]

export interface CreateManifest {
version: 1
version: number
createdAt: string
workspaces: ManifestWorkspaceFile[]
}

export interface ManifestWorkspaceFile {
name: string
dataset: string
export interface ManifestWorkspaceFile extends Omit<CreateWorkspaceManifest, 'schema' | 'tools'> {
schema: string // filename
tools: string // filename
bjoerge marked this conversation as resolved.
Show resolved Hide resolved
}

export interface CreateWorkspaceManifest {
Expand All @@ -23,7 +22,13 @@ export interface CreateWorkspaceManifest {
subtitle?: string
basePath: string
dataset: string
projectId: string
schema: ManifestSchemaType[]
tools: ManifestTool[]
/**
* returns null in the case of the icon not being able to be stringified
*/
icon: string | null
}

export interface ManifestSchemaType {
Expand Down Expand Up @@ -83,3 +88,13 @@ export type ManifestValidationRule = {
constraint?: ManifestSerializable
[index: string]: ManifestSerializable | undefined
}

export interface ManifestTool {
name: string
title: string
/**
* returns null in the case of the icon not being able to be stringified
*/
icon: string | null
type: string | null
}
1 change: 1 addition & 0 deletions packages/sanity/src/core/config/createDefaultIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const SvgText = styled.text`

/**
* Creates an icon element based on the input title
* @internal
*/
export function createDefaultIcon(title: string, subtitle: string) {
const rng1 = pseudoRandomNumber(`${title} ${subtitle}`)
Expand Down
1 change: 1 addition & 0 deletions packages/sanity/src/core/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './components'
export * from './ConfigPropertyError'
export * from './ConfigResolutionError'
export * from './createDefaultIcon'
export * from './defineConfig'
export * from './definePlugin'
export * from './document'
Expand Down
7 changes: 5 additions & 2 deletions packages/sanity/src/core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,6 @@ export interface Tool<Options = any> {

/**
* React component for the icon representing the tool.
*
* @deprecated Tool icons are no longer displayed.
*/
icon?: ComponentType

Expand Down Expand Up @@ -175,6 +173,11 @@ export interface Tool<Options = any> {
params: Record<string, unknown>,
payload: unknown,
) => boolean | {[key: string]: boolean}

/**
* The type of tool – used to group tools across multiple workspaces and have a constant identifier.
*/
type?: string
Copy link
Contributor

@snorrees snorrees Dec 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a foreseen issue adding type to tools?

It says "used to group tools", but the Studio doesnt do that. It is not clear to me how we want to design types/apis for studio types that are not used by studio but only by <redacted>.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type concept is a bit fuzzy to me as well. Probably missing some context. If it's for grouping, could it be a way for the tool to declare what group it belongs to, e.g. by group: 'edit'? If it's for identifying it, should the property be named "id" instead? Feels like it should have been the "name" property.

Is the idea that the consuming end doesn't need to know what types of tools exists; i.e. it just groups the ones with similar types? Or could the consuming aware of what types exists and will do the grouping based on that?

  • How do we guide plugin authors to chose type/group/identifier?
  • Are the of which tools are appearing in the other end ever going to be important?
  • How do we avoid name clashes/unintentionally grouping ?

}

/** @public */
Expand Down
2 changes: 2 additions & 0 deletions packages/sanity/src/core/scheduledPublishing/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,5 @@ export const DEFAULT_SCHEDULED_PUBLISH_PLUGIN_OPTIONS: Required<ScheduledPublish
export const TOOL_NAME = 'schedules'

export const TOOL_TITLE = 'Schedules'

export const TOOL_TYPE = 'sanity/scheduled-publishing'
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {CalendarIcon} from '@sanity/icons'
import {route} from 'sanity/router'

import {definePlugin} from '../../config'
import {TOOL_NAME, TOOL_TITLE} from '../constants'
import {TOOL_NAME, TOOL_TITLE, TOOL_TYPE} from '../constants'
import Tool from '../tool/Tool'
import resolveDocumentActions from './documentActions/schedule'
import resolveDocumentBadges from './documentBadges/scheduled'
Expand Down Expand Up @@ -47,6 +47,7 @@ export const scheduledPublishing = definePlugin({
{
name: TOOL_NAME,
title: TOOL_TITLE,
type: TOOL_TYPE,
icon: CalendarIcon,
component: Tool,
router: route.create('/', [route.create('/state/:state'), route.create('/date/:date')]),
Expand Down
1 change: 1 addition & 0 deletions packages/sanity/src/structure/structureTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const structureTool = definePlugin<StructureToolOptions | void>((options)
name: options?.name || 'structure',
title: options?.title || 'Structure',
icon,
type: 'sanity/structure',
component: lazy(() => import('./components/structureTool')),
canHandleIntent: (intent, params) => {
if (intent === 'create') return canHandleCreateIntent(params)
Expand Down
Loading