sanity-plugin-internationalized-array 0.0.5 → 1.0.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.
Files changed (38) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +19 -15
  3. package/lib/cjs/index.js +468 -0
  4. package/lib/cjs/index.js.map +1 -0
  5. package/lib/esm/index.js +449 -0
  6. package/lib/esm/index.js.map +1 -0
  7. package/lib/types/index.d.ts +19 -0
  8. package/lib/types/index.d.ts.map +1 -0
  9. package/package.json +47 -34
  10. package/sanity.json +7 -6
  11. package/src/components/Feedback.tsx +27 -0
  12. package/src/components/InternationalizedArrayInput.tsx +220 -0
  13. package/src/components/Table.tsx +88 -0
  14. package/src/components/getToneFromValidation.ts +18 -0
  15. package/src/index.tsx +51 -0
  16. package/src/internationalizedArray.ts +83 -51
  17. package/src/types.ts +19 -8
  18. package/v2-incompatible.js +11 -0
  19. package/lib/LanguageArray/Table.js +0 -72
  20. package/lib/LanguageArray/Table.js.map +0 -1
  21. package/lib/LanguageArray/ValueInput.js +0 -17
  22. package/lib/LanguageArray/ValueInput.js.map +0 -1
  23. package/lib/LanguageArray/index.js +0 -247
  24. package/lib/LanguageArray/index.js.map +0 -1
  25. package/lib/LanguageArray/useUnsetInputComponent.js +0 -32
  26. package/lib/LanguageArray/useUnsetInputComponent.js.map +0 -1
  27. package/lib/index.js +0 -13
  28. package/lib/index.js.map +0 -1
  29. package/lib/internationalizedArray.js +0 -104
  30. package/lib/internationalizedArray.js.map +0 -1
  31. package/lib/types.js +0 -2
  32. package/lib/types.js.map +0 -1
  33. package/migrations/transformObjectToArray.js +0 -94
  34. package/src/LanguageArray/Table.tsx +0 -73
  35. package/src/LanguageArray/ValueInput.tsx +0 -6
  36. package/src/LanguageArray/index.tsx +0 -311
  37. package/src/LanguageArray/useUnsetInputComponent.ts +0 -17
  38. package/src/index.ts +0 -3
@@ -0,0 +1,27 @@
1
+ import {Text, Card, Stack, Code} from '@sanity/ui'
2
+ import React from 'react'
3
+
4
+ const schemaExample = {
5
+ languages: [
6
+ {id: 'en', title: 'English'},
7
+ {id: 'no', title: 'Norsk'},
8
+ ],
9
+ }
10
+
11
+ export default function Feedback() {
12
+ return (
13
+ <Card tone="caution" border radius={2} padding={3}>
14
+ <Stack space={4}>
15
+ <Text>
16
+ An array of language objects must be passed into the <code>internationalizedArray</code>{' '}
17
+ helper function, each with an <code>id</code> and <code>title</code> field. Example:
18
+ </Text>
19
+ <Card padding={2} border radius={2}>
20
+ <Code size={1} language="javascript">
21
+ {JSON.stringify(schemaExample, null, 2)}
22
+ </Code>
23
+ </Card>
24
+ </Stack>
25
+ </Card>
26
+ )
27
+ }
@@ -0,0 +1,220 @@
1
+ import React, {useCallback, useMemo} from 'react'
2
+ import {
3
+ PatchEvent,
4
+ ArrayOfObjectsInputProps,
5
+ MemberItem,
6
+ unset,
7
+ insert,
8
+ set,
9
+ setIfMissing,
10
+ FormFieldValidationStatus,
11
+ } from 'sanity/form'
12
+ import {Box, Button, Flex, Grid, Label, Stack} from '@sanity/ui'
13
+
14
+ import {Language, Value, ArraySchemaWithLanguageOptions} from '../types'
15
+ import {Table, TableCell, TableRow} from './Table'
16
+ import {AddIcon, RemoveIcon, RestoreIcon} from '@sanity/icons'
17
+ import Feedback from './Feedback'
18
+ import {getToneFromValidation} from './getToneFromValidation'
19
+
20
+ export type InternationalizedArrayInputProps = ArrayOfObjectsInputProps<
21
+ Value,
22
+ ArraySchemaWithLanguageOptions
23
+ >
24
+
25
+ export default function InternationalizedArrayInput(props: InternationalizedArrayInputProps) {
26
+ const {members, value, schemaType, onChange} = props
27
+ const readOnly = typeof schemaType.readOnly === 'boolean' ? schemaType.readOnly : false
28
+ const {options} = schemaType
29
+
30
+ const languages: Language[] = useMemo(() => options?.languages ?? [], [options])
31
+
32
+ const handleAddLanguage = useCallback(
33
+ (languageId?: string) => {
34
+ // Create new items
35
+ const newItems = languageId
36
+ ? // Just one for this language
37
+ [{_key: languageId}]
38
+ : // Or one for every missing language
39
+ languages
40
+ .filter((language) =>
41
+ value?.length ? !value.find((v) => v._key === language.id) : true
42
+ )
43
+ .map((language) => ({_key: language.id}))
44
+
45
+ // Insert new items in the correct order
46
+ const languagesInUse = value?.length ? value.map((v) => v) : []
47
+
48
+ const insertions = newItems.map((item) => {
49
+ // What's the original index of this language?
50
+ const languageIndex = languages.findIndex((l) => item._key === l.id)
51
+
52
+ // What languages are there beyond that index?
53
+ const remainingLanguages = languages.slice(languageIndex + 1)
54
+
55
+ // So what is the index in the current value array of the next language in the language array?
56
+ const nextLanguageIndex = languagesInUse.findIndex((l) =>
57
+ remainingLanguages.find((r) => r.id === l._key)
58
+ )
59
+
60
+ // Keep local state up to date incase multiple insertions are being made
61
+ if (nextLanguageIndex < 0) {
62
+ languagesInUse.push(item)
63
+ } else {
64
+ languagesInUse.splice(nextLanguageIndex, 0, item)
65
+ }
66
+
67
+ return nextLanguageIndex < 0
68
+ ? // No next language (-1), add to end of array
69
+ insert([item], 'after', [nextLanguageIndex])
70
+ : // Next language found, insert before that
71
+ insert([item], 'before', [nextLanguageIndex])
72
+ })
73
+
74
+ onChange([setIfMissing([]), ...insertions])
75
+ },
76
+ [languages, onChange, value]
77
+ )
78
+
79
+ const handleUnsetByKey = useCallback(
80
+ (_key) => {
81
+ onChange(unset([{_key}]))
82
+ },
83
+ [onChange]
84
+ )
85
+
86
+ // TODO: This is lazy, reordering and re-setting the whole array – it could be surgical
87
+ const handleRestoreOrder = useCallback(() => {
88
+ if (!value?.length) {
89
+ return
90
+ }
91
+
92
+ // Create a new value array in the correct order
93
+ // This would also strip out values that don't have a language as the key
94
+ const updatedValue = value
95
+ .reduce((acc, v) => {
96
+ const newIndex = languages.findIndex((l) => l.id === v?._key)
97
+
98
+ if (newIndex) {
99
+ acc[newIndex] = v
100
+ }
101
+
102
+ return acc
103
+ }, [] as Value[])
104
+ .filter(Boolean)
105
+
106
+ onChange(set(updatedValue))
107
+ }, [languages, onChange, value])
108
+
109
+ const allKeysAreLanguages = useMemo(() => {
110
+ return value?.every((v) => languages.find((l) => l?.id === v?._key))
111
+ }, [value, languages])
112
+
113
+ // Check languages are in the correct order
114
+ const languagesOutOfOrder = useMemo(() => {
115
+ if (!value?.length) {
116
+ return []
117
+ }
118
+
119
+ const languagesInUse = languages.filter((l) => value.find((v) => v._key === l.id))
120
+
121
+ return value
122
+ .map((v, vIndex) => (vIndex === languagesInUse.findIndex((l) => l.id === v._key) ? null : v))
123
+ .filter(Boolean)
124
+ }, [value, languages])
125
+
126
+ const languagesAreValid = useMemo(
127
+ () => languages?.length && languages.every((item) => item.id && item.title),
128
+ [languages]
129
+ )
130
+
131
+ if (!languagesAreValid) {
132
+ return <Feedback />
133
+ }
134
+
135
+ return (
136
+ <Stack space={2}>
137
+ {members?.length > 0 ? (
138
+ <Table>
139
+ <tbody>
140
+ {members.map((member) => (
141
+ <TableRow
142
+ key={member.key}
143
+ tone={
144
+ member?.item?.validation?.length > 0
145
+ ? getToneFromValidation(member.item.validation)
146
+ : undefined
147
+ }
148
+ >
149
+ <TableCell style={{verticalAlign: 'bottom'}}>
150
+ <Box paddingY={3} paddingRight={2}>
151
+ <Label muted size={1}>
152
+ {member.key}
153
+ </Label>
154
+ </Box>
155
+ </TableCell>
156
+ <TableCell paddingRight={2} style={{width: `100%`}}>
157
+ <MemberItem {...props} member={member} />
158
+ </TableCell>
159
+ <TableCell style={{verticalAlign: 'bottom'}}>
160
+ <Flex align="center" justify="flex-end" gap={3}>
161
+ {/* Possibly unncessary, validation shows up in <MemberItem /> */}
162
+ {member.item.validation.length > 0 ? (
163
+ <Box paddingLeft={2}>
164
+ <FormFieldValidationStatus validation={member.item.validation} />
165
+ </Box>
166
+ ) : null}
167
+ <Button
168
+ mode="ghost"
169
+ icon={RemoveIcon}
170
+ tone="critical"
171
+ disabled={typeof readOnly === 'boolean' ? readOnly : false}
172
+ onClick={() => handleUnsetByKey(member.key)}
173
+ />
174
+ </Flex>
175
+ </TableCell>
176
+ </TableRow>
177
+ ))}
178
+ </tbody>
179
+ </Table>
180
+ ) : null}
181
+
182
+ {languagesOutOfOrder.length > 0 && allKeysAreLanguages ? (
183
+ <Button
184
+ tone="caution"
185
+ icon={RestoreIcon}
186
+ onClick={() => handleRestoreOrder()}
187
+ text="Restore order of languages"
188
+ />
189
+ ) : null}
190
+
191
+ {value && value.length < languages.length ? (
192
+ <Stack space={2}>
193
+ {/* No more than 5 columns */}
194
+ <Grid columns={Math.min(languages.length, 5)} gap={2}>
195
+ {languages.map((language) => (
196
+ <Button
197
+ key={language.id}
198
+ tone="primary"
199
+ mode="ghost"
200
+ fontSize={1}
201
+ disabled={readOnly || Boolean(value?.find((item) => item._key === language.id))}
202
+ text={language.id.toUpperCase()}
203
+ icon={AddIcon}
204
+ onClick={() => handleAddLanguage(language.id)}
205
+ />
206
+ ))}
207
+ </Grid>
208
+ <Button
209
+ tone="primary"
210
+ mode="ghost"
211
+ disabled={readOnly || (value && value?.length >= languages?.length)}
212
+ icon={AddIcon}
213
+ text={value?.length ? `Add missing languages` : `Add all languages`}
214
+ onClick={() => handleAddLanguage()}
215
+ />
216
+ </Stack>
217
+ ) : null}
218
+ </Stack>
219
+ )
220
+ }
@@ -0,0 +1,88 @@
1
+ import React from 'react'
2
+ import styled, {css} from 'styled-components'
3
+ import {Box, BoxProps, Card, CardProps} from '@sanity/ui'
4
+
5
+ // Wrappers required because of bug with passing down "as" prop
6
+ // https://github.com/styled-components/styled-components/issues/2449
7
+
8
+ // Table
9
+ const TableWrapper = (props = {}) => {
10
+ return <Box as="table" {...props} />
11
+ }
12
+
13
+ const StyledTable = styled(TableWrapper)(
14
+ () =>
15
+ css`
16
+ display: table;
17
+ width: 100%;
18
+
19
+ &:not([hidden]) {
20
+ display: table;
21
+ }
22
+ `
23
+ )
24
+
25
+ type TableProps = BoxProps & {
26
+ children: React.ReactNode
27
+ style?: React.CSSProperties
28
+ }
29
+
30
+ export function Table(props: TableProps) {
31
+ const {children, ...rest} = props
32
+
33
+ return <StyledTable {...rest}>{children}</StyledTable>
34
+ }
35
+
36
+ // Row
37
+ const RowWrapper = (props = {}) => {
38
+ return <Card as="tr" {...props} />
39
+ }
40
+
41
+ const StyledRow = styled(RowWrapper)(
42
+ () =>
43
+ css`
44
+ display: table-row;
45
+
46
+ &:not([hidden]) {
47
+ display: table-row;
48
+ }
49
+ `
50
+ )
51
+
52
+ type TableRowProps = CardProps & {
53
+ children: React.ReactNode
54
+ style?: React.CSSProperties
55
+ }
56
+
57
+ export function TableRow(props: TableRowProps) {
58
+ const {children, ...rest} = props
59
+
60
+ return <StyledRow {...rest}>{children}</StyledRow>
61
+ }
62
+
63
+ // Cell
64
+ const CellWrapper = (props = {}) => {
65
+ return <Box as="td" {...props} />
66
+ }
67
+
68
+ const StyledCell = styled(CellWrapper)(
69
+ () =>
70
+ css`
71
+ display: table-cell;
72
+
73
+ &:not([hidden]) {
74
+ display: table-cell;
75
+ }
76
+ `
77
+ )
78
+
79
+ type TableCellProps = BoxProps & {
80
+ children: React.ReactNode
81
+ style?: React.CSSProperties
82
+ }
83
+
84
+ export function TableCell(props: TableCellProps) {
85
+ const {children, ...rest} = props
86
+
87
+ return <StyledCell {...rest}>{children}</StyledCell>
88
+ }
@@ -0,0 +1,18 @@
1
+ import {NodeValidation} from 'sanity/form'
2
+ import {CardTone} from '@sanity/ui'
3
+
4
+ export function getToneFromValidation(validations: NodeValidation[]): CardTone {
5
+ if (!validations.length) {
6
+ return `default`
7
+ }
8
+
9
+ const validationLevels = validations.map((v) => v.level)
10
+
11
+ if (validationLevels.includes('error')) {
12
+ return `critical`
13
+ } else if (validationLevels.includes('warning')) {
14
+ return `caution`
15
+ }
16
+
17
+ return `default`
18
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,51 @@
1
+ export * from './internationalizedArray'
2
+
3
+ /**
4
+ * Because of the complexity of the field, there's no utility currently to using the plugin framework
5
+ * We need to register:
6
+ * - an array field which
7
+ * - only has a single object field with
8
+ * - a single inner field and
9
+ * - an array of languages
10
+ * ...this is easier with a helper function
11
+ */
12
+
13
+ // import React from 'react'
14
+ // import {createPlugin} from 'sanity'
15
+
16
+ // import InternationalizedArrayInput from './components/InternationalizedArrayInput'
17
+ // import {PluginConfig} from './types'
18
+
19
+ // const CONFIG_DEFAULT = {languages: []}
20
+
21
+ // export const internationalizedArray = createPlugin<PluginConfig>((config = CONFIG_DEFAULT) => {
22
+ // return {
23
+ // name: 'sanity-plugin-internationalized-array',
24
+ // form: {
25
+ // renderInput: (inputProps: unknown, next: unknown) => {
26
+ // if (
27
+ // config.languages.length &&
28
+ // inputProps?.schemaType?.jsonType === 'array' &&
29
+ // inputProps?.schemaType?.options.i18n === true
30
+ // ) {
31
+ // if (inputProps.schemaType.of.length > 1) {
32
+ // return <div>Cannot have more than one field type in the array</div>
33
+ // }
34
+
35
+ // if (inputProps.schemaType.of[0].jsonType !== 'object') {
36
+ // return <div>Single Field in the Array must be an object</div>
37
+ // }
38
+
39
+ // if (inputProps.schemaType.of[0].fields[0].name !== 'value') {
40
+ // return <div>Single Field in the Object must be named `value`</div>
41
+ // }
42
+
43
+ // console.log({inputProps})
44
+ // return <InternationalizedArrayInput inputProps={inputProps} {...config} />
45
+ // }
46
+
47
+ // return null
48
+ // },
49
+ // },
50
+ // }
51
+ // })
@@ -1,28 +1,47 @@
1
- import {ArrayConfig, Value} from './types'
2
- import LanguageArray from './LanguageArray'
1
+ import {
2
+ CustomValidatorResult,
3
+ defineField,
4
+ FieldDefinition,
5
+ Rule,
6
+ SchemaType,
7
+ ValidationError,
8
+ } from 'sanity'
3
9
 
4
- export function internationalizedArray(config: ArrayConfig) {
5
- const {name = `title`, type = `string`, languages = [], showNativeInput = false} = config
10
+ import InternationalizedArrayInput from './components/InternationalizedArrayInput'
11
+ import {AllowedType, ArrayConfig, Language, Value} from './types'
6
12
 
7
- return {
13
+ const CONFIG_DEFAULT = {name: `title`, type: `string` as AllowedType, languages: []}
14
+
15
+ export function internationalizedArray(config: ArrayConfig = CONFIG_DEFAULT): FieldDefinition {
16
+ const {name, type, languages} = config
17
+
18
+ const configValidation = Array.isArray(config?.validation)
19
+ ? config.validation
20
+ : [config?.validation]
21
+
22
+ return defineField({
8
23
  name,
9
24
  title: config?.title ?? undefined,
10
25
  group: config?.group ?? undefined,
11
26
  hidden: config?.hidden ?? undefined,
12
27
  readOnly: config?.readOnly ?? undefined,
13
28
  type: 'array',
14
- inputComponent: LanguageArray,
15
- options: {
16
- languages,
17
- showNativeInput,
18
- },
29
+ components: {input: InternationalizedArrayInput},
30
+ options: {languages},
19
31
  of: [
20
32
  {
21
33
  type: 'object',
22
- fields: [{name: 'value', type}],
34
+ fields: [
35
+ {
36
+ name: 'value',
37
+ type,
38
+ },
39
+ ],
23
40
  preview: {
24
41
  select: {title: 'value', key: '_key'},
25
- prepare({title, key}) {
42
+ prepare(select) {
43
+ const {title, key} = select as Record<string, string>
44
+
26
45
  return {
27
46
  title,
28
47
  subtitle: key.toUpperCase(),
@@ -31,49 +50,62 @@ export function internationalizedArray(config: ArrayConfig) {
31
50
  },
32
51
  },
33
52
  ],
34
- validation: (Rule) =>
35
- Rule.max(languages.length).custom((value: Value[], context) => {
36
- const {languages} = context.type.options
53
+ // @ts-ignore
54
+ validation: (rule: Rule) => {
55
+ const rules = [] as Rule[]
37
56
 
38
- const nonLanguageKeys = value?.length
39
- ? value.filter((item) => !languages.find((language) => item._key === language.id))
40
- : []
41
-
42
- if (nonLanguageKeys.length) {
43
- return {
44
- message: `Array item keys must be valid languages registered to the field type`,
45
- paths: nonLanguageKeys.map((item) => ({_key: item._key})),
57
+ rules.push(
58
+ rule.custom<Value[]>((value, context) => {
59
+ const {languages: contextLanguages}: {languages: Language[]} =
60
+ context?.type?.options ?? {}
61
+ const nonLanguageKeys = value?.length
62
+ ? value.filter(
63
+ (item) => !contextLanguages.find((language) => item._key === language.id)
64
+ )
65
+ : []
66
+ if (nonLanguageKeys.length) {
67
+ return {
68
+ message: `Array item keys must be valid languages registered to the field type`,
69
+ paths: nonLanguageKeys.map((item) => [{_key: item._key}]),
70
+ }
46
71
  }
47
- }
48
-
49
- // Ensure there's no duplicate `language` fields
50
- const valuesByLanguage = value?.length
51
- ? value
52
- .filter((item) => Boolean(item?._key))
53
- .reduce((acc, cur) => {
54
- if (acc[cur._key]) {
55
- return {...acc, [cur._key]: [...acc[cur._key], cur]}
56
- }
57
72
 
58
- return {
59
- ...acc,
60
- [cur._key]: [cur],
61
- }
62
- }, {})
63
- : {}
64
-
65
- const duplicateValues = Object.values(valuesByLanguage)
66
- .filter((item) => item?.length > 1)
67
- .flat()
73
+ // Ensure there's no duplicate `language` fields
74
+ type KeyedValues = {
75
+ [key: string]: Value[]
76
+ }
68
77
 
69
- if (duplicateValues.length) {
70
- return {
71
- message: 'There can only be one field per language',
72
- paths: duplicateValues.map((item) => ({_key: item._key})),
78
+ const valuesByLanguage = value?.length
79
+ ? value
80
+ .filter((item) => Boolean(item?._key))
81
+ .reduce((acc, cur) => {
82
+ if (acc[cur._key]) {
83
+ return {...acc, [cur._key]: [...acc[cur._key], cur]}
84
+ }
85
+ return {
86
+ ...acc,
87
+ [cur._key]: [cur],
88
+ }
89
+ }, {} as KeyedValues)
90
+ : {}
91
+ const duplicateValues = Object.values(valuesByLanguage)
92
+ .filter((item) => item?.length > 1)
93
+ .flat()
94
+ if (duplicateValues.length) {
95
+ return {
96
+ message: 'There can only be one field per language',
97
+ paths: duplicateValues.map((item) => [{_key: item._key}]),
98
+ }
73
99
  }
74
- }
100
+ return true
101
+ })
102
+ )
75
103
 
76
- return true
77
- }),
78
- }
104
+ if (languages?.length) {
105
+ rules.push(rule.max(languages.length))
106
+ }
107
+
108
+ return [...rules, ...configValidation].filter(Boolean)
109
+ },
110
+ })
79
111
  }
package/src/types.ts CHANGED
@@ -1,10 +1,21 @@
1
- export type ArrayConfig = Options & {
1
+ import {Rule, ArraySchemaType} from 'sanity'
2
+
3
+ export type Language = {
4
+ id: string
5
+ title: string
6
+ }
7
+
8
+ export type AllowedType = 'string' | 'number' | 'boolean' | 'text'
9
+
10
+ export type ArrayConfig = {
2
11
  name: string
3
- type: 'string' | 'number' | 'boolean' | 'text'
12
+ type: AllowedType
13
+ languages: Language[]
4
14
  title?: string
5
15
  group?: string
6
16
  hidden?: boolean | (() => boolean)
7
17
  readOnly?: boolean | (() => boolean)
18
+ validation?: Rule | Rule[]
8
19
  }
9
20
 
10
21
  export type Value = {
@@ -12,12 +23,12 @@ export type Value = {
12
23
  value?: string
13
24
  }
14
25
 
15
- export type Language = {
16
- id: string
17
- title: string
26
+ export type PluginConfig = {
27
+ languages: Language[]
18
28
  }
19
29
 
20
- export type Options = {
21
- languages: Language[]
22
- showNativeInput: boolean
30
+ export type ArraySchemaWithLanguageOptions = ArraySchemaType & {
31
+ options: {
32
+ languages: Language[]
33
+ }
23
34
  }
@@ -0,0 +1,11 @@
1
+ const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin')
2
+ const {name, version, sanityExchangeUrl} = require('./package.json')
3
+
4
+ export default showIncompatiblePluginDialog({
5
+ name: name,
6
+ versions: {
7
+ v3: version,
8
+ v2: undefined,
9
+ },
10
+ sanityExchangeUrl,
11
+ })