@sanity/language-filter 1.0.0-firstpass-v3-impl.1 → 1.0.0-v3-studio.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/language-filter",
3
- "version": "1.0.0-firstpass-v3-impl.1",
3
+ "version": "1.0.0-v3-studio.1",
4
4
  "description": "A Sanity plugin that supports filtering localized fields by language",
5
5
  "author": "Sanity.io <hello@sanity.io>",
6
6
  "license": "MIT",
@@ -28,7 +28,8 @@
28
28
  "watch": "parcel watch",
29
29
  "test": "jest",
30
30
  "link-watch": "plugin-kit link-watch",
31
- "prepublishOnly": "npm run build"
31
+ "prepublishOnly": "npm run build",
32
+ "prepare": "husky install"
32
33
  },
33
34
  "repository": {
34
35
  "type": "git",
@@ -40,14 +41,14 @@
40
41
  "dependencies": {
41
42
  "@sanity/icons": "^1.3.4",
42
43
  "@sanity/incompatible-plugin": "^1.0.0",
43
- "@sanity/ui": "^0.37.20",
44
+ "@sanity/ui": "^0.38.0",
44
45
  "@sanity/util": "3.0.0-dev-preview.15"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@babel/preset-env": "^7.18.10",
48
49
  "@babel/preset-react": "^7.18.6",
49
- "@commitlint/cli": "^17.0.3",
50
- "@commitlint/config-conventional": "^17.0.3",
50
+ "@commitlint/cli": "^17.1.2",
51
+ "@commitlint/config-conventional": "^17.1.0",
51
52
  "@parcel/packager-ts": "^2.7.0",
52
53
  "@parcel/transformer-typescript-types": "^2.7.0",
53
54
  "@sanity/plugin-kit": "^1.0.1",
@@ -55,11 +56,11 @@
55
56
  "@types/jest": "^28.1.8",
56
57
  "@typescript-eslint/eslint-plugin": "^5.35.1",
57
58
  "@typescript-eslint/parser": "^5.35.1",
58
- "eslint": "^8.22.0",
59
+ "eslint": "8.22.0",
59
60
  "eslint-config-prettier": "^8.5.0",
60
61
  "eslint-config-sanity": "^6.0.0",
61
62
  "eslint-plugin-prettier": "^4.2.1",
62
- "eslint-plugin-react": "^7.31.0",
63
+ "eslint-plugin-react": "^7.31.1",
63
64
  "eslint-plugin-react-hooks": "^4.6.0",
64
65
  "husky": "^8.0.1",
65
66
  "jest": "^28.1.3",
@@ -70,7 +71,7 @@
70
71
  "rimraf": "^3.0.2",
71
72
  "sanity": "3.0.0-dev-preview.15",
72
73
  "ts-jest": "^28.0.8",
73
- "typescript": "^4.7.4"
74
+ "typescript": "4.7.4"
74
75
  },
75
76
  "peerDependencies": {
76
77
  "react": "^17.0.0 || ^18.0.0",
@@ -0,0 +1,30 @@
1
+ import React, {createContext, PropsWithChildren, useContext, useMemo} from 'react'
2
+ import {LanguageFilterConfig} from './types'
3
+
4
+ export interface LanguageFilterContextValue {
5
+ // eslint-disable-next-line react/require-default-props
6
+ options: LanguageFilterConfig
7
+ // eslint-disable-next-line react/require-default-props
8
+ enabled: boolean
9
+ }
10
+
11
+ const LanguageFilterContext = createContext<LanguageFilterContextValue | undefined>(undefined)
12
+
13
+ export function LanguageFilterProvider({
14
+ options,
15
+ enabled,
16
+ children,
17
+ }: PropsWithChildren<
18
+ Omit<LanguageFilterContextValue, 'selectedLanguageIds' | 'setSelectedLanguageIds'>
19
+ >) {
20
+ const value = useMemo(() => ({options, enabled}), [options, enabled])
21
+ return <LanguageFilterContext.Provider value={value}>{children}</LanguageFilterContext.Provider>
22
+ }
23
+
24
+ export function useLanguageFilterContext() {
25
+ const value = useContext(LanguageFilterContext)
26
+ if (!value) {
27
+ throw new Error('LanguageFilterContext is missing')
28
+ }
29
+ return value
30
+ }
@@ -1,4 +1,3 @@
1
- import {TranslateIcon} from '@sanity/icons'
2
1
  import {Box, Button, Card, Checkbox, Flex, Popover, Stack, Text, useClickOutside} from '@sanity/ui'
3
2
  import React, {FormEvent, useCallback, useState} from 'react'
4
3
  import {usePaneLanguages} from './usePaneLanguages'
@@ -6,19 +5,23 @@ import {LanguageFilterConfig} from './types'
6
5
 
7
6
  export interface LanguageFilterMenuButtonProps {
8
7
  options: LanguageFilterConfig
8
+ onSelectedIdsChange: (ids: string[]) => void
9
9
  }
10
10
 
11
11
  export function LanguageFilterMenuButton(props: LanguageFilterMenuButtonProps) {
12
- const {options} = props
12
+ const {options, onSelectedIdsChange} = props
13
+
13
14
  const defaultLanguages = options.supportedLanguages.filter((l) =>
14
15
  options.defaultLanguages?.includes(l.id)
15
16
  )
17
+
16
18
  const languageOptions = options.supportedLanguages.filter(
17
19
  (l) => !options.defaultLanguages?.includes(l.id)
18
20
  )
19
21
  const [open, setOpen] = useState(false)
20
- const {visibleLanguages, allSelected, selectAll, selectNone, toggleLanguage} = usePaneLanguages({
22
+ const {activeLanguages, allSelected, selectAll, selectNone, toggleLanguage} = usePaneLanguages({
21
23
  options,
24
+ onSelectedIdsChange,
22
25
  })
23
26
  const [button, setButton] = useState<HTMLElement | null>(null)
24
27
  const [popover, setPopover] = useState<HTMLElement | null>(null)
@@ -45,11 +48,13 @@ export function LanguageFilterMenuButton(props: LanguageFilterMenuButtonProps) {
45
48
  const content = (
46
49
  <Box overflow="auto" padding={1}>
47
50
  {defaultLanguages.length > 0 && (
48
- <Card radius={2} tone="primary">
51
+ <Card radius={2}>
49
52
  <Stack padding={2} space={3}>
50
- <Text size={1} weight="semibold">
51
- Default language{defaultLanguages.length > 1 && <>s</>}
52
- </Text>
53
+ <Box paddingBottom={2}>
54
+ <Text size={1} weight="semibold">
55
+ Default language{defaultLanguages.length > 1 && <>s</>}
56
+ </Text>
57
+ </Box>
53
58
 
54
59
  {defaultLanguages.map((l) => (
55
60
  <Text key={l.id}>{l.title}</Text>
@@ -59,7 +64,7 @@ export function LanguageFilterMenuButton(props: LanguageFilterMenuButtonProps) {
59
64
  )}
60
65
 
61
66
  <Stack marginTop={3} padding={2} space={2}>
62
- <Box paddingBottom={1}>
67
+ <Box paddingBottom={2}>
63
68
  <Text size={1} weight="semibold">
64
69
  Show translations
65
70
  </Text>
@@ -81,7 +86,7 @@ export function LanguageFilterMenuButton(props: LanguageFilterMenuButtonProps) {
81
86
  id={lang.id}
82
87
  key={lang.id}
83
88
  onToggle={toggleLanguage}
84
- selected={visibleLanguages.includes(lang.id)}
89
+ selected={activeLanguages.includes(lang.id)}
85
90
  title={lang.title}
86
91
  />
87
92
  ))}
@@ -89,11 +94,25 @@ export function LanguageFilterMenuButton(props: LanguageFilterMenuButtonProps) {
89
94
  </Box>
90
95
  )
91
96
 
97
+ const langCount = options.supportedLanguages.length
92
98
  return (
93
- <Popover constrainSize content={content} open={open} portal ref={setPopover}>
99
+ <Popover content={content} open={open} portal ref={setPopover}>
94
100
  <Button
95
- icon={TranslateIcon}
96
- text={`Filter languages (${visibleLanguages.length}/${options.supportedLanguages.length})`}
101
+ text={
102
+ <Flex gap={1}>
103
+ <Box>Filter languages:</Box>
104
+ <Flex gap={1} justify="space-around">
105
+ <Flex
106
+ style={{width: `${Math.floor(Math.log10(langCount) + 1)}ch`}}
107
+ justify="flex-end"
108
+ >
109
+ {activeLanguages.length}
110
+ </Flex>
111
+ <Box>/</Box>
112
+ <Box>{langCount}</Box>
113
+ </Flex>
114
+ </Flex>
115
+ }
97
116
  mode="bleed"
98
117
  onClick={handleClick}
99
118
  ref={setButton}
@@ -1,33 +1,72 @@
1
- import React, {useMemo} from 'react'
2
- import {ObjectInputProps, ObjectMember} from 'sanity'
3
- import {ObjectInput} from 'sanity/form'
4
- import {usePaneLanguages} from './usePaneLanguages'
1
+ import React, {useEffect, useMemo} from 'react'
2
+ import {ObjectInputProps, ObjectMember, RenderInputCallback} from 'sanity'
5
3
  import {LanguageFilterConfig} from './types'
6
4
  import {defaultFilterField} from './filterField'
5
+ import {useLanguageFilterContext} from './LanguageFilterContext'
6
+ import {useSelectedLanguageIds} from './useSelectedLanguageIds'
7
7
 
8
- export type LanguageFilterObjectInputProps = {options: LanguageFilterConfig} & ObjectInputProps
8
+ export type LanguageFilterObjectInputProps = {
9
+ options: LanguageFilterConfig
10
+ next: RenderInputCallback
11
+ /**
12
+ * We need a way to communicate state changes between the pane menu and input components.
13
+ * LanguageFilter button lives outside the input-render tree, so Context is out.
14
+ * This is a workaround for that.
15
+ */
16
+ subscribeSelectedIds: (callback: (ids: string[]) => void) => () => void
17
+ } & ObjectInputProps
9
18
 
10
- export function LanguageFilterObjectInput(props: LanguageFilterObjectInputProps) {
11
- const {members: membersProp, level, options, path, schemaType, ...restProps} = props
12
- const {visibleLanguages} = usePaneLanguages({options})
19
+ export function LanguageFilterObjectInput(
20
+ props: ObjectInputProps & {
21
+ next: RenderInputCallback
22
+ subscribeSelectedIds: (callback: (ids: string[]) => void) => () => void
23
+ }
24
+ ) {
25
+ const {options, enabled} = useLanguageFilterContext()
26
+ const {next, subscribeSelectedIds, ...restProps} = props
27
+ if (!enabled || !options) {
28
+ return <>{next(restProps)}</>
29
+ }
30
+ return (
31
+ <FilteredObjectInput
32
+ {...restProps}
33
+ next={next}
34
+ options={options}
35
+ subscribeSelectedIds={subscribeSelectedIds}
36
+ />
37
+ )
38
+ }
39
+
40
+ function FilteredObjectInput(props: LanguageFilterObjectInputProps) {
41
+ const {
42
+ members: membersProp,
43
+ options,
44
+ schemaType,
45
+ next,
46
+ subscribeSelectedIds,
47
+ ...restProps
48
+ } = props
49
+ const [selectedIds, setSelectedIds] = useSelectedLanguageIds(options)
50
+
51
+ useEffect(() => {
52
+ const unsubscribe = subscribeSelectedIds(setSelectedIds)
53
+ return () => unsubscribe()
54
+ }, [subscribeSelectedIds, setSelectedIds])
55
+
56
+ const activeLanguages = useMemo(
57
+ () => [...(options.defaultLanguages ?? []), ...selectedIds],
58
+ [options.defaultLanguages, selectedIds]
59
+ )
13
60
 
14
61
  const filterField = options.filterField ?? defaultFilterField
15
62
 
16
63
  const members: ObjectMember[] = useMemo(() => {
17
64
  return membersProp.filter(
18
65
  (member) =>
19
- (member.kind === 'field' && filterField(schemaType, member, visibleLanguages)) ||
20
- (member.kind === 'fieldSet' && filterField(schemaType, member.fieldSet, visibleLanguages))
66
+ (member.kind === 'field' && filterField(schemaType, member, activeLanguages)) ||
67
+ (member.kind === 'fieldSet' && filterField(schemaType, member.fieldSet, activeLanguages))
21
68
  )
22
- }, [schemaType, membersProp, filterField, visibleLanguages])
69
+ }, [schemaType, membersProp, filterField, activeLanguages])
23
70
 
24
- return (
25
- <ObjectInput
26
- {...restProps}
27
- level={level}
28
- members={members}
29
- path={path}
30
- schemaType={schemaType}
31
- />
32
- )
71
+ return <>{next({...restProps, members, schemaType})}</>
33
72
  }
@@ -0,0 +1,32 @@
1
+ export type LanguageSubscription = (ids: string[]) => void
2
+ export type Unsubscribe = () => void
3
+ export type LanguageSubscribe = (subscription: LanguageSubscription) => Unsubscribe
4
+
5
+ export interface SelectedLanguageIdsBus {
6
+ onSelectedIdsChange: (ids: string[]) => void
7
+ subscribeSelectedIds: LanguageSubscribe
8
+ }
9
+
10
+ /**
11
+ * We need a way to communicate state changes between the pane menu and input components.
12
+ * LanguageFilter button lives outside the input-render tree, so Context is out.
13
+ * This is a workaround for that.
14
+ */
15
+ export function createSelectedLanguageIdsBus(): SelectedLanguageIdsBus {
16
+ const subs: LanguageSubscription[] = []
17
+
18
+ const onSelectedIdsChange = (ids: string[]) => {
19
+ subs.forEach((s) => s(ids))
20
+ }
21
+ const subscribeSelectedIds = (subscription: LanguageSubscription) => {
22
+ subs.push(subscription)
23
+ return () => {
24
+ subs.splice(subs.indexOf(subscription), 1)
25
+ }
26
+ }
27
+
28
+ return {
29
+ onSelectedIdsChange,
30
+ subscribeSelectedIds,
31
+ }
32
+ }
package/src/plugin.tsx CHANGED
@@ -4,6 +4,8 @@ import {LanguageFilterObjectInput} from './LanguageFilterObjectInput'
4
4
  import {LanguageFilterMenuButton} from './LanguageFilterMenuButton'
5
5
  import {LanguageFilterConfig} from './types'
6
6
  import {isLanguageFilterEnabled} from './filterField'
7
+ import {LanguageFilterProvider} from './LanguageFilterContext'
8
+ import {createSelectedLanguageIdsBus} from './languageSubscription'
7
9
 
8
10
  /**
9
11
  * ## Usage in sanity.config.ts (or .js)
@@ -39,8 +41,10 @@ import {isLanguageFilterEnabled} from './filterField'
39
41
  * ```
40
42
  */
41
43
  export const languageFilter = createPlugin<LanguageFilterConfig>((options) => {
44
+ const {onSelectedIdsChange, subscribeSelectedIds} = createSelectedLanguageIdsBus()
45
+
42
46
  const RenderLanguageFilter: _DocumentLanguageFilterComponent = () => {
43
- return <LanguageFilterMenuButton options={options} />
47
+ return <LanguageFilterMenuButton options={options} onSelectedIdsChange={onSelectedIdsChange} />
44
48
  }
45
49
 
46
50
  return {
@@ -57,10 +61,25 @@ export const languageFilter = createPlugin<LanguageFilterConfig>((options) => {
57
61
  form: {
58
62
  renderInput(props, next) {
59
63
  const enabled = isLanguageFilterEnabled(props.schemaType, options)
60
- if (enabled && props.schemaType.jsonType === 'object') {
61
- return <LanguageFilterObjectInput {...(props as ObjectInputProps)} options={options} />
64
+ // will only be considered enabled for document, so this is only done once
65
+ if (enabled) {
66
+ return (
67
+ <LanguageFilterProvider enabled={enabled} options={options}>
68
+ {next(props)}
69
+ </LanguageFilterProvider>
70
+ )
71
+ }
72
+ if (props.schemaType.jsonType === 'object') {
73
+ return (
74
+ <LanguageFilterObjectInput
75
+ {...(props as ObjectInputProps)}
76
+ next={next}
77
+ subscribeSelectedIds={subscribeSelectedIds}
78
+ />
79
+ )
62
80
  }
63
- return next(props)
81
+
82
+ return undefined
64
83
  },
65
84
  },
66
85
  }
@@ -1,49 +1,56 @@
1
1
  import {useCallback, useMemo} from 'react'
2
- import {usePaneRouter} from 'sanity/desk'
3
2
  import {LanguageFilterConfig} from './types'
3
+ import {
4
+ getSelectableLanguages,
5
+ persistLanguageIds,
6
+ useSelectedLanguageIds,
7
+ } from './useSelectedLanguageIds'
4
8
 
5
- // NOTE: use `+` instead of `,` since the desk tool URL format already uses comma
6
- const LANG_ID_SEPARATOR = '+'
9
+ export interface UsePaneLanguagesParams {
10
+ options: LanguageFilterConfig
11
+ /**
12
+ * We need a way to communicate state changes between the pane menu and input components.
13
+ * LanguageFilter button lives outside the input-render tree, so Context is out.
14
+ * This is a workaround for that.
15
+ */
16
+ onSelectedIdsChange: (ids: string[]) => void
17
+ }
7
18
 
8
- export function usePaneLanguages(props: {options: LanguageFilterConfig}): {
9
- visibleLanguages: string[]
19
+ export function usePaneLanguages(props: UsePaneLanguagesParams): {
20
+ activeLanguages: string[]
10
21
  allSelected: boolean
11
22
  selectAll: () => void
12
23
  selectNone: () => void
13
24
  toggleLanguage: (languageId: string) => void
14
25
  } {
15
- const {options} = props
16
- const {params, setParams} = usePaneRouter()
17
- const {supportedLanguages, defaultLanguages} = options
18
-
19
- const selectableLanguages = useMemo(
20
- () => supportedLanguages.filter((lang) => !defaultLanguages?.includes(lang.id)),
21
- [supportedLanguages, defaultLanguages]
22
- )
26
+ const {options, onSelectedIdsChange} = props
27
+ const {defaultLanguages} = options
23
28
 
24
- const selectedLanguages = useMemo(() => {
25
- if (params?.langs === '$none') {
26
- return []
27
- }
29
+ const [selectedIds, setSelectedIds] = useSelectedLanguageIds(options)
28
30
 
29
- if (params?.langs === '$all') {
30
- return selectableLanguages.map((lang) => lang.id)
31
- }
31
+ const selectableLanguages = useMemo(() => getSelectableLanguages(options), [options])
32
32
 
33
- return params?.langs?.split(LANG_ID_SEPARATOR) || selectableLanguages.map((lang) => lang.id)
34
- }, [params, selectableLanguages])
33
+ const updateSelectedIds = useCallback(
34
+ (ids: string[]) => {
35
+ setSelectedIds(ids)
36
+ persistLanguageIds(ids)
37
+ onSelectedIdsChange(ids)
38
+ },
39
+ [onSelectedIdsChange, setSelectedIds]
40
+ )
35
41
 
36
- const selectAll = useCallback(() => {
37
- setParams({...params, langs: '$all'})
38
- }, [params, setParams])
42
+ const selectAll = useCallback(
43
+ () => updateSelectedIds(selectableLanguages.map((l) => l.id)),
44
+ [updateSelectedIds, selectableLanguages]
45
+ )
39
46
 
40
47
  const selectNone = useCallback(() => {
41
- setParams({...params, langs: '$none'})
42
- }, [params, setParams])
48
+ updateSelectedIds([])
49
+ }, [updateSelectedIds])
43
50
 
44
51
  const toggleLanguage = useCallback(
45
52
  (languageId: string) => {
46
- let lang = selectedLanguages
53
+ let lang = selectedIds
47
54
 
48
55
  if (lang.includes(languageId)) {
49
56
  lang = lang.filter((l) => l !== languageId)
@@ -51,29 +58,19 @@ export function usePaneLanguages(props: {options: LanguageFilterConfig}): {
51
58
  lang = [...lang, languageId]
52
59
  }
53
60
 
54
- if (lang.length === 0) {
55
- setParams({...params, langs: '$none'}) // none
56
- return
57
- }
58
-
59
- if (lang.length === selectableLanguages.length) {
60
- setParams({...params, langs: '$all'})
61
- return
62
- }
63
-
64
- setParams({...params, langs: lang.join(LANG_ID_SEPARATOR)})
61
+ updateSelectedIds(lang)
65
62
  },
66
- [params, selectableLanguages, selectedLanguages, setParams]
63
+ [updateSelectedIds, selectedIds]
67
64
  )
68
65
 
69
- const visibleLanguages = useMemo(
70
- () => [...(defaultLanguages ?? []), ...selectedLanguages],
71
- [defaultLanguages, selectedLanguages]
66
+ const activeLanguages = useMemo(
67
+ () => [...(defaultLanguages ?? []), ...selectedIds],
68
+ [defaultLanguages, selectedIds]
72
69
  )
73
70
 
74
71
  return {
75
- visibleLanguages,
76
- allSelected: selectedLanguages.length === selectableLanguages.length,
72
+ activeLanguages,
73
+ allSelected: selectedIds.length === selectableLanguages.length,
77
74
  selectAll,
78
75
  selectNone,
79
76
  toggleLanguage,
@@ -0,0 +1,40 @@
1
+ import {Language, LanguageFilterConfig} from './types'
2
+ import {useState} from 'react'
3
+ const storageKey = '@sanity/plugin/language-filter/selected-languages'
4
+
5
+ export function getPersistedLanguageIds(options: LanguageFilterConfig): string[] {
6
+ const selectableLangs = getSelectableLanguages(options).map((l) => l.id)
7
+
8
+ let selected: string[] = selectableLangs
9
+ try {
10
+ const persistedValue = window.localStorage.getItem(storageKey)
11
+ if (persistedValue) {
12
+ selected = JSON.parse(persistedValue)
13
+ }
14
+ } catch (err) {} // eslint-disable-line no-empty
15
+
16
+ // constrain persisted/selected languages to the ones currently supported
17
+ selected = intersection(selected, selectableLangs)
18
+ return selected
19
+ }
20
+
21
+ export function persistLanguageIds(languageIds: string[]): void {
22
+ window.localStorage.setItem(storageKey, JSON.stringify(languageIds))
23
+ }
24
+
25
+ function intersection(array1: string[], array2: string[]) {
26
+ return array1.filter((value) => array2.includes(value))
27
+ }
28
+
29
+ export function getSelectableLanguages({
30
+ supportedLanguages,
31
+ defaultLanguages,
32
+ }: LanguageFilterConfig): Language[] {
33
+ return supportedLanguages.filter((lang) => !defaultLanguages?.includes(lang.id))
34
+ }
35
+
36
+ export function useSelectedLanguageIds(
37
+ options: LanguageFilterConfig
38
+ ): [string[], (ids: string[]) => void] {
39
+ return useState(() => getPersistedLanguageIds(options))
40
+ }