Feature/typescript conversion (#44)
This commit is contained in:
parent
7fe23baf0b
commit
e60e1fa755
19
.eslintrc.js
19
.eslintrc.js
@ -1,5 +1,3 @@
|
||||
const fs = require('fs');
|
||||
|
||||
module.exports = {
|
||||
parser: 'babel-eslint',
|
||||
extends: [
|
||||
@ -21,9 +19,13 @@ module.exports = {
|
||||
CMS_ENV: false,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks
|
||||
'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies
|
||||
'no-console': [0],
|
||||
'react/prop-types': [0],
|
||||
'react/require-default-props': 0,
|
||||
'import/no-named-as-default': 0,
|
||||
"react/react-in-jsx-scope": "off",
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
@ -45,8 +47,16 @@ module.exports = {
|
||||
],
|
||||
'unicorn/prefer-string-slice': 'error',
|
||||
'react/no-unknown-property': ['error', { ignore: ['css'] }],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: ['babel', '@emotion', 'cypress', 'unicorn'],
|
||||
plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
@ -79,6 +89,9 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
'react/prop-types': [0],
|
||||
'react/require-default-props': 0,
|
||||
'no-duplicate-imports': [0], // handled by @typescript-eslint
|
||||
'@typescript-eslint/ban-types': [0], // TODO enable in future
|
||||
'@typescript-eslint/no-non-null-assertion': [0],
|
||||
|
@ -84,7 +84,7 @@ function plugins() {
|
||||
}
|
||||
|
||||
if (!isProduction) {
|
||||
return [...defaultPlugins, 'react-hot-loader/babel'];
|
||||
return [...defaultPlugins];
|
||||
}
|
||||
|
||||
return defaultPlugins;
|
||||
|
471
dev-test/backends/azure/config.yml
Normal file
471
dev-test/backends/azure/config.yml
Normal file
@ -0,0 +1,471 @@
|
||||
backend:
|
||||
name: azure
|
||||
branch: master
|
||||
repo: organization/project/repo # replace with actual path
|
||||
tenant_id: tenantId # replace with your tenantId
|
||||
app_id: appId # replace with your appId
|
||||
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
collections:
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
description: >
|
||||
The description is a great place for tone setting, high level information,
|
||||
and editing guidelines that are specific to a collection.
|
||||
folder: _posts
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
||||
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
|
||||
sortable_fields:
|
||||
fields:
|
||||
- title
|
||||
- date
|
||||
default:
|
||||
field: title
|
||||
create: true
|
||||
view_filters:
|
||||
- label: Posts With Index
|
||||
field: title
|
||||
pattern: 'This is post #'
|
||||
- label: Posts Without Index
|
||||
field: title
|
||||
pattern: front matter post
|
||||
- label: Drafts
|
||||
field: draft
|
||||
pattern: true
|
||||
view_groups:
|
||||
- label: Year
|
||||
field: date
|
||||
pattern: '\d{4}'
|
||||
- label: Drafts
|
||||
field: draft
|
||||
fields:
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Draft
|
||||
name: draft
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Publish Date
|
||||
name: date
|
||||
widget: datetime
|
||||
date_format: yyyy-MM-dd
|
||||
time_format: 'HH:mm'
|
||||
format: 'yyyy-MM-dd HH:mm'
|
||||
- label: Cover Image
|
||||
name: image
|
||||
widget: image
|
||||
required: false
|
||||
- label: Body
|
||||
name: body
|
||||
widget: markdown
|
||||
hint: Main content goes here.
|
||||
- name: faq
|
||||
label: FAQ
|
||||
folder: _faqs
|
||||
create: true
|
||||
fields:
|
||||
- label: Question
|
||||
name: title
|
||||
widget: string
|
||||
- label: Answer
|
||||
name: body
|
||||
widget: markdown
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
widget: list
|
||||
summary: '{{fields.post | split(''|'', ''$1'')}}'
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: '{{title}}|{{date}}'
|
||||
- name: settings
|
||||
label: Settings
|
||||
delete: false
|
||||
editor:
|
||||
preview: false
|
||||
files:
|
||||
- name: general
|
||||
label: Site Settings
|
||||
file: _data/settings.json
|
||||
description: General Site Settings
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Global title
|
||||
name: site_title
|
||||
widget: string
|
||||
- label: Post Settings
|
||||
name: posts
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Default Author
|
||||
name: author
|
||||
widget: string
|
||||
- label: Default Thumbnail
|
||||
name: thumb
|
||||
widget: image
|
||||
required: false
|
||||
- name: authors
|
||||
label: Authors
|
||||
file: _data/authors.yml
|
||||
description: Author descriptions
|
||||
fields:
|
||||
- name: authors
|
||||
label: Authors
|
||||
label_singular: Author
|
||||
widget: list
|
||||
fields:
|
||||
- label: Name
|
||||
name: name
|
||||
widget: string
|
||||
hint: First and Last
|
||||
- label: Description
|
||||
name: description
|
||||
widget: text
|
||||
- name: kitchenSink
|
||||
label: Kitchen Sink
|
||||
folder: _sink
|
||||
create: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: true
|
||||
- label: Map
|
||||
name: map
|
||||
widget: map
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
hint: 'Plain text, not markdown'
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
hint: To infinity and beyond!
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Color
|
||||
name: color
|
||||
widget: color
|
||||
- label: Color string editable and alpha enabled
|
||||
name: colorEditable
|
||||
widget: color
|
||||
enable_alpha: true
|
||||
allow_input: true
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Select multiple
|
||||
name: select_multiple
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
multiple: true
|
||||
- label: Select numeric
|
||||
name: select_numeric
|
||||
widget: select
|
||||
options:
|
||||
- label: One
|
||||
value: 1
|
||||
- label: Two
|
||||
value: 2
|
||||
- label: Three
|
||||
value: 3
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
collapsed: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Typed List
|
||||
name: typed_list
|
||||
widget: list
|
||||
types:
|
||||
- label: Type 1 Object
|
||||
name: type_1_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Type 2 Object
|
||||
name: type_2_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Type 3 Object
|
||||
name: type_3_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
12
dev-test/backends/azure/index.html
Normal file
12
dev-test/backends/azure/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<title>Static CMS - Azure Development Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/static-cms-core.js"></script>
|
||||
<script type="module" src="/index.js"></script>
|
||||
</body>
|
||||
</html>
|
469
dev-test/backends/bitbucket/config.yml
Normal file
469
dev-test/backends/bitbucket/config.yml
Normal file
@ -0,0 +1,469 @@
|
||||
backend:
|
||||
name: bitbucket
|
||||
branch: master
|
||||
repo: owner/repo
|
||||
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
collections:
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
description: >
|
||||
The description is a great place for tone setting, high level information,
|
||||
and editing guidelines that are specific to a collection.
|
||||
folder: _posts
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
||||
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
|
||||
sortable_fields:
|
||||
fields:
|
||||
- title
|
||||
- date
|
||||
default:
|
||||
field: title
|
||||
create: true
|
||||
view_filters:
|
||||
- label: Posts With Index
|
||||
field: title
|
||||
pattern: 'This is post #'
|
||||
- label: Posts Without Index
|
||||
field: title
|
||||
pattern: front matter post
|
||||
- label: Drafts
|
||||
field: draft
|
||||
pattern: true
|
||||
view_groups:
|
||||
- label: Year
|
||||
field: date
|
||||
pattern: '\d{4}'
|
||||
- label: Drafts
|
||||
field: draft
|
||||
fields:
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Draft
|
||||
name: draft
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Publish Date
|
||||
name: date
|
||||
widget: datetime
|
||||
date_format: yyyy-MM-dd
|
||||
time_format: 'HH:mm'
|
||||
format: 'yyyy-MM-dd HH:mm'
|
||||
- label: Cover Image
|
||||
name: image
|
||||
widget: image
|
||||
required: false
|
||||
- label: Body
|
||||
name: body
|
||||
widget: markdown
|
||||
hint: Main content goes here.
|
||||
- name: faq
|
||||
label: FAQ
|
||||
folder: _faqs
|
||||
create: true
|
||||
fields:
|
||||
- label: Question
|
||||
name: title
|
||||
widget: string
|
||||
- label: Answer
|
||||
name: body
|
||||
widget: markdown
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
widget: list
|
||||
summary: '{{fields.post | split(''|'', ''$1'')}}'
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: '{{title}}|{{date}}'
|
||||
- name: settings
|
||||
label: Settings
|
||||
delete: false
|
||||
editor:
|
||||
preview: false
|
||||
files:
|
||||
- name: general
|
||||
label: Site Settings
|
||||
file: _data/settings.json
|
||||
description: General Site Settings
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Global title
|
||||
name: site_title
|
||||
widget: string
|
||||
- label: Post Settings
|
||||
name: posts
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Default Author
|
||||
name: author
|
||||
widget: string
|
||||
- label: Default Thumbnail
|
||||
name: thumb
|
||||
widget: image
|
||||
required: false
|
||||
- name: authors
|
||||
label: Authors
|
||||
file: _data/authors.yml
|
||||
description: Author descriptions
|
||||
fields:
|
||||
- name: authors
|
||||
label: Authors
|
||||
label_singular: Author
|
||||
widget: list
|
||||
fields:
|
||||
- label: Name
|
||||
name: name
|
||||
widget: string
|
||||
hint: First and Last
|
||||
- label: Description
|
||||
name: description
|
||||
widget: text
|
||||
- name: kitchenSink
|
||||
label: Kitchen Sink
|
||||
folder: _sink
|
||||
create: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: true
|
||||
- label: Map
|
||||
name: map
|
||||
widget: map
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
hint: 'Plain text, not markdown'
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
hint: To infinity and beyond!
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Color
|
||||
name: color
|
||||
widget: color
|
||||
- label: Color string editable and alpha enabled
|
||||
name: colorEditable
|
||||
widget: color
|
||||
enable_alpha: true
|
||||
allow_input: true
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Select multiple
|
||||
name: select_multiple
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
multiple: true
|
||||
- label: Select numeric
|
||||
name: select_numeric
|
||||
widget: select
|
||||
options:
|
||||
- label: One
|
||||
value: 1
|
||||
- label: Two
|
||||
value: 2
|
||||
- label: Three
|
||||
value: 3
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
collapsed: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Typed List
|
||||
name: typed_list
|
||||
widget: list
|
||||
types:
|
||||
- label: Type 1 Object
|
||||
name: type_1_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Type 2 Object
|
||||
name: type_2_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Type 3 Object
|
||||
name: type_3_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
12
dev-test/backends/bitbucket/index.html
Normal file
12
dev-test/backends/bitbucket/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<title>Static CMS - Bitbucket Development Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/static-cms-core.js"></script>
|
||||
<script type="module" src="/index.js"></script>
|
||||
</body>
|
||||
</html>
|
468
dev-test/backends/git-gateway/config.yml
Normal file
468
dev-test/backends/git-gateway/config.yml
Normal file
@ -0,0 +1,468 @@
|
||||
backend:
|
||||
name: git-gateway
|
||||
branch: master
|
||||
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
collections:
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
description: >
|
||||
The description is a great place for tone setting, high level information,
|
||||
and editing guidelines that are specific to a collection.
|
||||
folder: _posts
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
||||
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
|
||||
sortable_fields:
|
||||
fields:
|
||||
- title
|
||||
- date
|
||||
default:
|
||||
field: title
|
||||
create: true
|
||||
view_filters:
|
||||
- label: Posts With Index
|
||||
field: title
|
||||
pattern: 'This is post #'
|
||||
- label: Posts Without Index
|
||||
field: title
|
||||
pattern: front matter post
|
||||
- label: Drafts
|
||||
field: draft
|
||||
pattern: true
|
||||
view_groups:
|
||||
- label: Year
|
||||
field: date
|
||||
pattern: '\d{4}'
|
||||
- label: Drafts
|
||||
field: draft
|
||||
fields:
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Draft
|
||||
name: draft
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Publish Date
|
||||
name: date
|
||||
widget: datetime
|
||||
date_format: yyyy-MM-dd
|
||||
time_format: 'HH:mm'
|
||||
format: 'yyyy-MM-dd HH:mm'
|
||||
- label: Cover Image
|
||||
name: image
|
||||
widget: image
|
||||
required: false
|
||||
- label: Body
|
||||
name: body
|
||||
widget: markdown
|
||||
hint: Main content goes here.
|
||||
- name: faq
|
||||
label: FAQ
|
||||
folder: _faqs
|
||||
create: true
|
||||
fields:
|
||||
- label: Question
|
||||
name: title
|
||||
widget: string
|
||||
- label: Answer
|
||||
name: body
|
||||
widget: markdown
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
widget: list
|
||||
summary: '{{fields.post | split(''|'', ''$1'')}}'
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: '{{title}}|{{date}}'
|
||||
- name: settings
|
||||
label: Settings
|
||||
delete: false
|
||||
editor:
|
||||
preview: false
|
||||
files:
|
||||
- name: general
|
||||
label: Site Settings
|
||||
file: _data/settings.json
|
||||
description: General Site Settings
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Global title
|
||||
name: site_title
|
||||
widget: string
|
||||
- label: Post Settings
|
||||
name: posts
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Default Author
|
||||
name: author
|
||||
widget: string
|
||||
- label: Default Thumbnail
|
||||
name: thumb
|
||||
widget: image
|
||||
required: false
|
||||
- name: authors
|
||||
label: Authors
|
||||
file: _data/authors.yml
|
||||
description: Author descriptions
|
||||
fields:
|
||||
- name: authors
|
||||
label: Authors
|
||||
label_singular: Author
|
||||
widget: list
|
||||
fields:
|
||||
- label: Name
|
||||
name: name
|
||||
widget: string
|
||||
hint: First and Last
|
||||
- label: Description
|
||||
name: description
|
||||
widget: text
|
||||
- name: kitchenSink
|
||||
label: Kitchen Sink
|
||||
folder: _sink
|
||||
create: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: true
|
||||
- label: Map
|
||||
name: map
|
||||
widget: map
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
hint: 'Plain text, not markdown'
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
hint: To infinity and beyond!
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Color
|
||||
name: color
|
||||
widget: color
|
||||
- label: Color string editable and alpha enabled
|
||||
name: colorEditable
|
||||
widget: color
|
||||
enable_alpha: true
|
||||
allow_input: true
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Select multiple
|
||||
name: select_multiple
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
multiple: true
|
||||
- label: Select numeric
|
||||
name: select_numeric
|
||||
widget: select
|
||||
options:
|
||||
- label: One
|
||||
value: 1
|
||||
- label: Two
|
||||
value: 2
|
||||
- label: Three
|
||||
value: 3
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
collapsed: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Typed List
|
||||
name: typed_list
|
||||
widget: list
|
||||
types:
|
||||
- label: Type 1 Object
|
||||
name: type_1_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Type 2 Object
|
||||
name: type_2_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Type 3 Object
|
||||
name: type_3_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
12
dev-test/backends/git-gateway/index.html
Normal file
12
dev-test/backends/git-gateway/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<title>Static CMS - Git Gateway Development Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/static-cms-core.js"></script>
|
||||
<script type="module" src="/index.js"></script>
|
||||
</body>
|
||||
</html>
|
469
dev-test/backends/github/config.yml
Normal file
469
dev-test/backends/github/config.yml
Normal file
@ -0,0 +1,469 @@
|
||||
backend:
|
||||
name: github
|
||||
branch: master
|
||||
repo: owner/repo
|
||||
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
collections:
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
description: >
|
||||
The description is a great place for tone setting, high level information,
|
||||
and editing guidelines that are specific to a collection.
|
||||
folder: _posts
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
||||
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
|
||||
sortable_fields:
|
||||
fields:
|
||||
- title
|
||||
- date
|
||||
default:
|
||||
field: title
|
||||
create: true
|
||||
view_filters:
|
||||
- label: Posts With Index
|
||||
field: title
|
||||
pattern: 'This is post #'
|
||||
- label: Posts Without Index
|
||||
field: title
|
||||
pattern: front matter post
|
||||
- label: Drafts
|
||||
field: draft
|
||||
pattern: true
|
||||
view_groups:
|
||||
- label: Year
|
||||
field: date
|
||||
pattern: '\d{4}'
|
||||
- label: Drafts
|
||||
field: draft
|
||||
fields:
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Draft
|
||||
name: draft
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Publish Date
|
||||
name: date
|
||||
widget: datetime
|
||||
date_format: yyyy-MM-dd
|
||||
time_format: 'HH:mm'
|
||||
format: 'yyyy-MM-dd HH:mm'
|
||||
- label: Cover Image
|
||||
name: image
|
||||
widget: image
|
||||
required: false
|
||||
- label: Body
|
||||
name: body
|
||||
widget: markdown
|
||||
hint: Main content goes here.
|
||||
- name: faq
|
||||
label: FAQ
|
||||
folder: _faqs
|
||||
create: true
|
||||
fields:
|
||||
- label: Question
|
||||
name: title
|
||||
widget: string
|
||||
- label: Answer
|
||||
name: body
|
||||
widget: markdown
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
widget: list
|
||||
summary: '{{fields.post | split(''|'', ''$1'')}}'
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: '{{title}}|{{date}}'
|
||||
- name: settings
|
||||
label: Settings
|
||||
delete: false
|
||||
editor:
|
||||
preview: false
|
||||
files:
|
||||
- name: general
|
||||
label: Site Settings
|
||||
file: _data/settings.json
|
||||
description: General Site Settings
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Global title
|
||||
name: site_title
|
||||
widget: string
|
||||
- label: Post Settings
|
||||
name: posts
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Default Author
|
||||
name: author
|
||||
widget: string
|
||||
- label: Default Thumbnail
|
||||
name: thumb
|
||||
widget: image
|
||||
required: false
|
||||
- name: authors
|
||||
label: Authors
|
||||
file: _data/authors.yml
|
||||
description: Author descriptions
|
||||
fields:
|
||||
- name: authors
|
||||
label: Authors
|
||||
label_singular: Author
|
||||
widget: list
|
||||
fields:
|
||||
- label: Name
|
||||
name: name
|
||||
widget: string
|
||||
hint: First and Last
|
||||
- label: Description
|
||||
name: description
|
||||
widget: text
|
||||
- name: kitchenSink
|
||||
label: Kitchen Sink
|
||||
folder: _sink
|
||||
create: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: true
|
||||
- label: Map
|
||||
name: map
|
||||
widget: map
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
hint: 'Plain text, not markdown'
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
hint: To infinity and beyond!
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Color
|
||||
name: color
|
||||
widget: color
|
||||
- label: Color string editable and alpha enabled
|
||||
name: colorEditable
|
||||
widget: color
|
||||
enable_alpha: true
|
||||
allow_input: true
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Select multiple
|
||||
name: select_multiple
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
multiple: true
|
||||
- label: Select numeric
|
||||
name: select_numeric
|
||||
widget: select
|
||||
options:
|
||||
- label: One
|
||||
value: 1
|
||||
- label: Two
|
||||
value: 2
|
||||
- label: Three
|
||||
value: 3
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
collapsed: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Typed List
|
||||
name: typed_list
|
||||
widget: list
|
||||
types:
|
||||
- label: Type 1 Object
|
||||
name: type_1_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Type 2 Object
|
||||
name: type_2_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Type 3 Object
|
||||
name: type_3_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
12
dev-test/backends/github/index.html
Normal file
12
dev-test/backends/github/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<title>Static CMS - GitHub Development Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/static-cms-core.js"></script>
|
||||
<script type="module" src="/index.js"></script>
|
||||
</body>
|
||||
</html>
|
469
dev-test/backends/gitlab/config.yml
Normal file
469
dev-test/backends/gitlab/config.yml
Normal file
@ -0,0 +1,469 @@
|
||||
backend:
|
||||
name: gitlab
|
||||
branch: master
|
||||
repo: owner/repo
|
||||
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
collections:
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
description: >
|
||||
The description is a great place for tone setting, high level information,
|
||||
and editing guidelines that are specific to a collection.
|
||||
folder: _posts
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
||||
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
|
||||
sortable_fields:
|
||||
fields:
|
||||
- title
|
||||
- date
|
||||
default:
|
||||
field: title
|
||||
create: true
|
||||
view_filters:
|
||||
- label: Posts With Index
|
||||
field: title
|
||||
pattern: 'This is post #'
|
||||
- label: Posts Without Index
|
||||
field: title
|
||||
pattern: front matter post
|
||||
- label: Drafts
|
||||
field: draft
|
||||
pattern: true
|
||||
view_groups:
|
||||
- label: Year
|
||||
field: date
|
||||
pattern: '\d{4}'
|
||||
- label: Drafts
|
||||
field: draft
|
||||
fields:
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Draft
|
||||
name: draft
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Publish Date
|
||||
name: date
|
||||
widget: datetime
|
||||
date_format: yyyy-MM-dd
|
||||
time_format: 'HH:mm'
|
||||
format: 'yyyy-MM-dd HH:mm'
|
||||
- label: Cover Image
|
||||
name: image
|
||||
widget: image
|
||||
required: false
|
||||
- label: Body
|
||||
name: body
|
||||
widget: markdown
|
||||
hint: Main content goes here.
|
||||
- name: faq
|
||||
label: FAQ
|
||||
folder: _faqs
|
||||
create: true
|
||||
fields:
|
||||
- label: Question
|
||||
name: title
|
||||
widget: string
|
||||
- label: Answer
|
||||
name: body
|
||||
widget: markdown
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
widget: list
|
||||
summary: '{{fields.post | split(''|'', ''$1'')}}'
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: '{{title}}|{{date}}'
|
||||
- name: settings
|
||||
label: Settings
|
||||
delete: false
|
||||
editor:
|
||||
preview: false
|
||||
files:
|
||||
- name: general
|
||||
label: Site Settings
|
||||
file: _data/settings.json
|
||||
description: General Site Settings
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Global title
|
||||
name: site_title
|
||||
widget: string
|
||||
- label: Post Settings
|
||||
name: posts
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Default Author
|
||||
name: author
|
||||
widget: string
|
||||
- label: Default Thumbnail
|
||||
name: thumb
|
||||
widget: image
|
||||
required: false
|
||||
- name: authors
|
||||
label: Authors
|
||||
file: _data/authors.yml
|
||||
description: Author descriptions
|
||||
fields:
|
||||
- name: authors
|
||||
label: Authors
|
||||
label_singular: Author
|
||||
widget: list
|
||||
fields:
|
||||
- label: Name
|
||||
name: name
|
||||
widget: string
|
||||
hint: First and Last
|
||||
- label: Description
|
||||
name: description
|
||||
widget: text
|
||||
- name: kitchenSink
|
||||
label: Kitchen Sink
|
||||
folder: _sink
|
||||
create: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: true
|
||||
- label: Map
|
||||
name: map
|
||||
widget: map
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
hint: 'Plain text, not markdown'
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
hint: To infinity and beyond!
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Color
|
||||
name: color
|
||||
widget: color
|
||||
- label: Color string editable and alpha enabled
|
||||
name: colorEditable
|
||||
widget: color
|
||||
enable_alpha: true
|
||||
allow_input: true
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Select multiple
|
||||
name: select_multiple
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
multiple: true
|
||||
- label: Select numeric
|
||||
name: select_numeric
|
||||
widget: select
|
||||
options:
|
||||
- label: One
|
||||
value: 1
|
||||
- label: Two
|
||||
value: 2
|
||||
- label: Three
|
||||
value: 3
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
collapsed: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Typed List
|
||||
name: typed_list
|
||||
widget: list
|
||||
types:
|
||||
- label: Type 1 Object
|
||||
name: type_1_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Type 2 Object
|
||||
name: type_2_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Type 3 Object
|
||||
name: type_3_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
12
dev-test/backends/gitlab/index.html
Normal file
12
dev-test/backends/gitlab/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<title>Static CMS - GitLab Development Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/static-cms-core.js"></script>
|
||||
<script type="module" src="/index.js"></script>
|
||||
</body>
|
||||
</html>
|
472
dev-test/backends/proxy/config.yml
Normal file
472
dev-test/backends/proxy/config.yml
Normal file
@ -0,0 +1,472 @@
|
||||
backend:
|
||||
name: github
|
||||
branch: main
|
||||
repo: owner/repo
|
||||
|
||||
media_folder: static/media
|
||||
public_folder: /media
|
||||
|
||||
local_backend: true
|
||||
|
||||
collections:
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
description: >
|
||||
The description is a great place for tone setting, high level information,
|
||||
and editing guidelines that are specific to a collection.
|
||||
folder: _posts
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
||||
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
|
||||
sortable_fields:
|
||||
fields:
|
||||
- title
|
||||
- date
|
||||
default:
|
||||
field: title
|
||||
create: true
|
||||
view_filters:
|
||||
- label: Posts With Index
|
||||
field: title
|
||||
pattern: 'This is post #'
|
||||
- label: Posts Without Index
|
||||
field: title
|
||||
pattern: front matter post
|
||||
- label: Drafts
|
||||
field: draft
|
||||
pattern: true
|
||||
view_groups:
|
||||
- label: Year
|
||||
field: date
|
||||
pattern: '\d{4}'
|
||||
- label: Drafts
|
||||
field: draft
|
||||
fields:
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Draft
|
||||
name: draft
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Publish Date
|
||||
name: date
|
||||
widget: datetime
|
||||
date_format: yyyy-MM-dd
|
||||
time_format: 'HH:mm'
|
||||
format: 'yyyy-MM-dd HH:mm'
|
||||
- label: Cover Image
|
||||
name: image
|
||||
widget: image
|
||||
required: false
|
||||
- label: Body
|
||||
name: body
|
||||
widget: markdown
|
||||
hint: Main content goes here.
|
||||
- name: faq
|
||||
label: FAQ
|
||||
folder: _faqs
|
||||
create: true
|
||||
fields:
|
||||
- label: Question
|
||||
name: title
|
||||
widget: string
|
||||
- label: Answer
|
||||
name: body
|
||||
widget: markdown
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
widget: list
|
||||
summary: '{{fields.post | split(''|'', ''$1'')}}'
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: '{{title}}|{{date}}'
|
||||
- name: settings
|
||||
label: Settings
|
||||
delete: false
|
||||
editor:
|
||||
preview: false
|
||||
files:
|
||||
- name: general
|
||||
label: Site Settings
|
||||
file: _data/settings.json
|
||||
description: General Site Settings
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Global title
|
||||
name: site_title
|
||||
widget: string
|
||||
- label: Post Settings
|
||||
name: posts
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
min: 1
|
||||
max: 10
|
||||
- label: Default Author
|
||||
name: author
|
||||
widget: string
|
||||
- label: Default Thumbnail
|
||||
name: thumb
|
||||
widget: image
|
||||
required: false
|
||||
- name: authors
|
||||
label: Authors
|
||||
file: _data/authors.yml
|
||||
description: Author descriptions
|
||||
fields:
|
||||
- name: authors
|
||||
label: Authors
|
||||
label_singular: Author
|
||||
widget: list
|
||||
fields:
|
||||
- label: Name
|
||||
name: name
|
||||
widget: string
|
||||
hint: First and Last
|
||||
- label: Description
|
||||
name: description
|
||||
widget: text
|
||||
- name: kitchenSink
|
||||
label: Kitchen Sink
|
||||
folder: _sink
|
||||
create: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: Title
|
||||
name: title
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: true
|
||||
- label: Map
|
||||
name: map
|
||||
widget: map
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
hint: 'Plain text, not markdown'
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
hint: To infinity and beyond!
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Color
|
||||
name: color
|
||||
widget: color
|
||||
- label: Color string editable and alpha enabled
|
||||
name: colorEditable
|
||||
widget: color
|
||||
enable_alpha: true
|
||||
allow_input: true
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Select multiple
|
||||
name: select_multiple
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
multiple: true
|
||||
- label: Select numeric
|
||||
name: select_numeric
|
||||
widget: select
|
||||
options:
|
||||
- label: One
|
||||
value: 1
|
||||
- label: Two
|
||||
value: 2
|
||||
- label: Three
|
||||
value: 3
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
collapsed: true
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
default: false
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: List
|
||||
name: list
|
||||
widget: list
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
widget: relationKitchenSinkPost
|
||||
collection: posts
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Hidden
|
||||
name: hidden
|
||||
widget: hidden
|
||||
default: hidden
|
||||
- label: Object
|
||||
name: object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Typed List
|
||||
name: typed_list
|
||||
widget: list
|
||||
types:
|
||||
- label: Type 1 Object
|
||||
name: type_1_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Type 2 Object
|
||||
name: type_2_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Type 3 Object
|
||||
name: type_3_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
12
dev-test/backends/proxy/index.html
Normal file
12
dev-test/backends/proxy/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<title>Static CMS - Proxy Development Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/static-cms-core.js"></script>
|
||||
<script type="module" src="/index.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -55,7 +55,7 @@ collections:
|
||||
required: false
|
||||
- label: Body
|
||||
name: body
|
||||
widget: text
|
||||
widget: markdown
|
||||
hint: Main content goes here.
|
||||
- name: faq
|
||||
label: FAQ
|
||||
@ -67,12 +67,12 @@ collections:
|
||||
widget: string
|
||||
- label: Answer
|
||||
name: body
|
||||
widget: text
|
||||
widget: markdown
|
||||
- name: posts
|
||||
label: Posts
|
||||
label_singular: Post
|
||||
widget: list
|
||||
summary: '{{fields.post | split(''|'', ''$1'')}}'
|
||||
summary: "{{fields.post | split('|', '$1')}}"
|
||||
fields:
|
||||
- label: Related Post
|
||||
name: post
|
||||
@ -85,6 +85,408 @@ collections:
|
||||
- title
|
||||
- body
|
||||
value_field: '{{title}}|{{date}}'
|
||||
- name: widgets
|
||||
label: Widgets
|
||||
delete: false
|
||||
editor:
|
||||
preview: false
|
||||
files:
|
||||
- name: boolean
|
||||
label: Boolean
|
||||
file: _widgets/boolean.json
|
||||
description: Boolean widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: boolean
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: boolean
|
||||
pattern: ['true', 'Must be true']
|
||||
required: false
|
||||
- name: code
|
||||
label: Code
|
||||
file: _widgets/code.json
|
||||
description: Code widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: code
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: code
|
||||
pattern: ['.{12,}', 'Must have at least 12 characters']
|
||||
allow_input: true
|
||||
required: false
|
||||
- name: language
|
||||
label: 'Language Selection'
|
||||
widget: code
|
||||
allow_language_selection: true
|
||||
required: false
|
||||
- name: color
|
||||
label: Color
|
||||
file: _widgets/color.json
|
||||
description: Color widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: color
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: color
|
||||
pattern: ['^#([0-9a-fA-F]{3})(?:[0-9a-fA-F]{3})?$', 'Must be a valid hex code']
|
||||
allow_input: true
|
||||
required: false
|
||||
- name: alpha
|
||||
label: Alpha
|
||||
widget: color
|
||||
enable_alpha: true
|
||||
required: false
|
||||
- name: datetime
|
||||
label: DateTime
|
||||
file: _widgets/datetime.json
|
||||
description: DateTime widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: datetime
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: datetime
|
||||
format: 'MMM d, yyyy h:mm aaa'
|
||||
date_format: 'MMM d, yyyy'
|
||||
time_format: 'h:mm aaa'
|
||||
pattern: ['pm', 'Must be in the afternoon']
|
||||
required: false
|
||||
- name: date_and_time
|
||||
label: Date and Time
|
||||
widget: datetime
|
||||
format: 'MMM d, yyyy h:mm aaa'
|
||||
date_format: 'MMM d, yyyy'
|
||||
time_format: 'h:mm aaa'
|
||||
required: false
|
||||
- name: date
|
||||
label: Date
|
||||
widget: datetime
|
||||
format: 'MMM d, yyyy'
|
||||
date_format: 'MMM d, yyyy'
|
||||
required: false
|
||||
- name: time
|
||||
label: Time
|
||||
widget: datetime
|
||||
format: 'h:mm aaa'
|
||||
time_format: 'h:mm aaa'
|
||||
required: false
|
||||
- name: file
|
||||
label: File
|
||||
file: _widgets/file.json
|
||||
description: File widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: file
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: file
|
||||
pattern: ['\.pdf', 'Must be a pdf']
|
||||
required: false
|
||||
- name: choose_url
|
||||
label: 'Choose URL'
|
||||
widget: file
|
||||
required: false
|
||||
media_library:
|
||||
choose_url: true
|
||||
- name: image
|
||||
label: Image
|
||||
file: _widgets/image.json
|
||||
description: Image widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: image
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: image
|
||||
pattern: ['\.png', 'Must be a png']
|
||||
required: false
|
||||
- name: choose_url
|
||||
label: 'Choose URL'
|
||||
widget: image
|
||||
required: false
|
||||
media_library:
|
||||
choose_url: true
|
||||
- name: list
|
||||
label: List
|
||||
file: _widgets/list.yml
|
||||
description: List widget
|
||||
fields:
|
||||
- name: list
|
||||
label: List
|
||||
widget: list
|
||||
fields:
|
||||
- label: Name
|
||||
name: name
|
||||
widget: string
|
||||
hint: First and Last
|
||||
- label: Description
|
||||
name: description
|
||||
widget: text
|
||||
- name: typed_list
|
||||
label: Typed List
|
||||
widget: list
|
||||
types:
|
||||
- label: Type 1 Object
|
||||
name: type_1_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: String
|
||||
name: string
|
||||
widget: string
|
||||
- label: Boolean
|
||||
name: boolean
|
||||
widget: boolean
|
||||
- label: Text
|
||||
name: text
|
||||
widget: text
|
||||
- label: Type 2 Object
|
||||
name: type_2_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number
|
||||
name: number
|
||||
widget: number
|
||||
- label: Select
|
||||
name: select
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
- label: Type 3 Object
|
||||
name: type_3_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
- label: File
|
||||
name: file
|
||||
widget: file
|
||||
- name: map
|
||||
label: Map
|
||||
file: _widgets/map.json
|
||||
description: Map widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: map
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: map
|
||||
pattern: ['\[-([7-9][0-9]|1[0-2][0-9])\.', 'Must be between latitude -70 and -129']
|
||||
required: false
|
||||
- name: markdown
|
||||
label: Markdown
|
||||
file: _widgets/markdown.json
|
||||
description: Markdown widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: markdown
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: markdown
|
||||
pattern: ['# [a-zA-Z0-9]+', 'Must have a header']
|
||||
required: false
|
||||
- name: number
|
||||
label: Number
|
||||
file: _widgets/number.json
|
||||
description: Number widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: number
|
||||
- name: min
|
||||
label: 'Min Validation'
|
||||
widget: number
|
||||
min: 5
|
||||
required: false
|
||||
- name: max
|
||||
label: 'Max Validation'
|
||||
widget: number
|
||||
max: 10
|
||||
required: false
|
||||
- name: min_and_max
|
||||
label: 'Min and Max Validation'
|
||||
widget: number
|
||||
min: 5
|
||||
max: 10
|
||||
required: false
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: number
|
||||
pattern: ['[0-9]{3,}', 'Must be at least 3 digits']
|
||||
required: false
|
||||
- name: object
|
||||
label: Object
|
||||
file: _widgets/object.json
|
||||
description: Object widget
|
||||
fields:
|
||||
- label: Required Validation
|
||||
name: required
|
||||
widget: object
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
- label: Default Author
|
||||
name: author
|
||||
widget: string
|
||||
- label: Default Thumbnail
|
||||
name: thumb
|
||||
widget: image
|
||||
- label: Optional Validation
|
||||
name: optional
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- label: Number of posts on frontpage
|
||||
name: front_limit
|
||||
widget: number
|
||||
required: false
|
||||
- label: Default Author
|
||||
name: author
|
||||
widget: string
|
||||
required: false
|
||||
- label: Default Thumbnail
|
||||
name: thumb
|
||||
widget: image
|
||||
required: false
|
||||
- name: relation
|
||||
label: Relation
|
||||
file: _widgets/relation.json
|
||||
description: Relation widget
|
||||
fields:
|
||||
- label: Required Validation
|
||||
name: required
|
||||
widget: relation
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: Optional Validation
|
||||
name: optional
|
||||
widget: relation
|
||||
required: false
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- label: Multiple
|
||||
name: multiple
|
||||
widget: relation
|
||||
multiple: true
|
||||
required: false
|
||||
collection: posts
|
||||
display_fields:
|
||||
- title
|
||||
- date
|
||||
search_fields:
|
||||
- title
|
||||
- body
|
||||
value_field: title
|
||||
- name: select
|
||||
label: Select
|
||||
file: _widgets/select.json
|
||||
description: Select widget
|
||||
fields:
|
||||
- label: Required Validation
|
||||
name: required
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- label: Pattern Validation
|
||||
name: pattern
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
pattern: ['[a-b]', 'Must be a or b']
|
||||
required: false
|
||||
- label: Value and Label
|
||||
name: value_and_label
|
||||
widget: select
|
||||
options:
|
||||
- value: a
|
||||
label: A fancy label
|
||||
- value: b
|
||||
label: Another fancy label
|
||||
- value: c
|
||||
label: And one more fancy label
|
||||
- label: Multiple
|
||||
name: multiple
|
||||
widget: select
|
||||
options:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
pattern: ['[a-b]', 'Must be a or b']
|
||||
multiple: true
|
||||
required: false
|
||||
- label: Value and Label Multiple
|
||||
name: value_and_label_multiple
|
||||
widget: select
|
||||
multiple: true
|
||||
options:
|
||||
- value: a
|
||||
label: A fancy label
|
||||
- value: b
|
||||
label: Another fancy label
|
||||
- value: c
|
||||
label: And one more fancy label
|
||||
- name: string
|
||||
label: String
|
||||
file: _widgets/string.json
|
||||
description: String widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: string
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: string
|
||||
pattern: ['.{12,}', 'Must have at least 12 characters']
|
||||
required: false
|
||||
- name: text
|
||||
label: Text
|
||||
file: _widgets/text.json
|
||||
description: Text widget
|
||||
fields:
|
||||
- name: required
|
||||
label: 'Required Validation'
|
||||
widget: text
|
||||
- name: pattern
|
||||
label: 'Pattern Validation'
|
||||
widget: text
|
||||
pattern: ['.{12,}', 'Must have at least 12 characters']
|
||||
required: false
|
||||
- name: settings
|
||||
label: Settings
|
||||
delete: false
|
||||
@ -173,21 +575,18 @@ collections:
|
||||
hint: To infinity and beyond!
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: string
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Date
|
||||
name: date
|
||||
widget: datetime
|
||||
- label: Color
|
||||
name: color
|
||||
widget: color
|
||||
- label: Color string editable and alpha enabled
|
||||
name: colorEditable
|
||||
widget: color
|
||||
enableAlpha: true
|
||||
allowInput: true
|
||||
enable_alpha: true
|
||||
allow_input: true
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
@ -251,13 +650,10 @@ collections:
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Date
|
||||
name: date
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
@ -289,13 +685,10 @@ collections:
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Date
|
||||
name: date
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
@ -327,13 +720,10 @@ collections:
|
||||
widget: number
|
||||
- label: Markdown
|
||||
name: markdown
|
||||
widget: text
|
||||
widget: markdown
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Date
|
||||
name: date
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
@ -377,9 +767,6 @@ collections:
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Date
|
||||
name: date
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
@ -419,9 +806,6 @@ collections:
|
||||
- label: Datetime
|
||||
name: datetime
|
||||
widget: datetime
|
||||
- label: Date
|
||||
name: date
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
@ -476,9 +860,6 @@ collections:
|
||||
name: type_3_object
|
||||
widget: object
|
||||
fields:
|
||||
- label: Date
|
||||
name: date
|
||||
widget: datetime
|
||||
- label: Image
|
||||
name: image
|
||||
widget: image
|
||||
|
File diff suppressed because one or more lines are too long
@ -1,35 +1,4 @@
|
||||
// Register all the things
|
||||
window.CMS.registerBackend('git-gateway', window.CMS.GitGatewayBackend);
|
||||
window.CMS.registerBackend('proxy', window.CMS.ProxyBackend);
|
||||
window.CMS.registerBackend('test-repo', window.CMS.TestBackend);
|
||||
window.CMS.registerWidget([
|
||||
window.CMS.StringWidget.Widget(),
|
||||
window.CMS.NumberWidget.Widget(),
|
||||
window.CMS.TextWidget.Widget(),
|
||||
window.CMS.ImageWidget.Widget(),
|
||||
window.CMS.FileWidget.Widget(),
|
||||
window.CMS.SelectWidget.Widget(),
|
||||
window.CMS.MarkdownWidget.Widget(),
|
||||
window.CMS.ListWidget.Widget(),
|
||||
window.CMS.ObjectWidget.Widget(),
|
||||
window.CMS.RelationWidget.Widget(),
|
||||
window.CMS.BooleanWidget.Widget(),
|
||||
window.CMS.DateTimeWidget.Widget(),
|
||||
window.CMS.ColorStringWidget.Widget(),
|
||||
]);
|
||||
window.CMS.registerEditorComponent(window.CMS.imageEditorComponent);
|
||||
window.CMS.registerEditorComponent({
|
||||
id: 'code-block',
|
||||
label: 'Code Block',
|
||||
widget: 'code',
|
||||
type: 'code-block',
|
||||
});
|
||||
window.CMS.registerLocale('en', window.CMS.locales.en);
|
||||
|
||||
Object.keys(window.CMS.images).forEach(iconName => {
|
||||
window.CMS.registerIcon(iconName, window.h(window.CMS.Icon, { type: iconName }));
|
||||
});
|
||||
|
||||
window.CMS.init();
|
||||
|
||||
const PostPreview = window.createClass({
|
||||
@ -41,10 +10,10 @@ const PostPreview = window.createClass({
|
||||
window.h(
|
||||
'div',
|
||||
{ className: 'cover' },
|
||||
window.h('h1', {}, entry.getIn(['data', 'title'])),
|
||||
window.h('h1', {}, entry.data.title),
|
||||
this.props.widgetFor('image'),
|
||||
),
|
||||
window.h('p', {}, window.h('small', {}, 'Written ' + entry.getIn(['data', 'date']))),
|
||||
window.h('p', {}, window.h('small', {}, 'Written ' + entry.data.date)),
|
||||
window.h('div', { className: 'text' }, this.props.widgetFor('body')),
|
||||
);
|
||||
},
|
||||
@ -53,9 +22,9 @@ const PostPreview = window.createClass({
|
||||
const GeneralPreview = window.createClass({
|
||||
render: function () {
|
||||
const entry = this.props.entry;
|
||||
const title = entry.getIn(['data', 'site_title']);
|
||||
const posts = entry.getIn(['data', 'posts']);
|
||||
const thumb = posts && posts.get('thumb');
|
||||
const title = entry.data.site_title;
|
||||
const posts = entry.data.posts;
|
||||
const thumb = posts && posts.thumb;
|
||||
|
||||
return window.h(
|
||||
'div',
|
||||
@ -65,10 +34,10 @@ const GeneralPreview = window.createClass({
|
||||
'dl',
|
||||
{},
|
||||
window.h('dt', {}, 'Posts on Frontpage'),
|
||||
window.h('dd', {}, this.props.widgetsFor('posts').getIn(['widgets', 'front_limit']) || 0),
|
||||
window.h('dd', {}, this.props.widgetsFor('posts').widgets.front_limit || 0),
|
||||
|
||||
window.h('dt', {}, 'Default Author'),
|
||||
window.h('dd', {}, this.props.widgetsFor('posts').getIn(['data', 'author']) || 'None'),
|
||||
window.h('dd', {}, this.props.widgetsFor('posts').data.author || 'None'),
|
||||
|
||||
window.h('dt', {}, 'Default Thumbnail'),
|
||||
window.h(
|
||||
@ -91,8 +60,8 @@ const AuthorsPreview = window.createClass({
|
||||
'div',
|
||||
{ key: index },
|
||||
window.h('hr', {}),
|
||||
window.h('strong', {}, author.getIn(['data', 'name'])),
|
||||
author.getIn(['widgets', 'description']),
|
||||
window.h('strong', {}, author.data.name),
|
||||
author.widgets.description,
|
||||
);
|
||||
}),
|
||||
);
|
||||
@ -108,49 +77,31 @@ const RelationKitchenSinkPostPreview = window.createClass({
|
||||
// the title of the selected post, since our `value_field` in the config
|
||||
// is "title".
|
||||
const { value, fieldsMetaData } = this.props;
|
||||
const post = fieldsMetaData && fieldsMetaData.getIn(['posts', value]);
|
||||
const post = fieldsMetaData && fieldsMetaData.posts.value;
|
||||
const style = { border: '2px solid #ccc', borderRadius: '8px', padding: '20px' };
|
||||
return post
|
||||
? window.h(
|
||||
'div',
|
||||
{ style: style },
|
||||
window.h('h2', {}, 'Related Post'),
|
||||
window.h('h3', {}, post.get('title')),
|
||||
window.h('img', { src: post.get('image') }),
|
||||
window.h('p', {}, post.get('body', '').slice(0, 100) + '...'),
|
||||
window.h('h3', {}, post.title),
|
||||
window.h('img', { src: post.image }),
|
||||
window.h('p', {}, (post.body ?? '').slice(0, 100) + '...'),
|
||||
)
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
const previewStyles = `
|
||||
html,
|
||||
body {
|
||||
color: #444;
|
||||
font-size: 14px;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
window.CMS.registerPreviewTemplate('posts', PostPreview);
|
||||
window.CMS.registerPreviewTemplate('general', GeneralPreview);
|
||||
window.CMS.registerPreviewTemplate('authors', AuthorsPreview);
|
||||
window.CMS.registerPreviewStyle(previewStyles, { raw: true });
|
||||
// Pass the name of a registered control to reuse with a new widget preview.
|
||||
window.CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview);
|
||||
window.CMS.registerAdditionalLink('example', 'Example.com', 'https://example.com', 'page');
|
||||
window.CMS.registerAdditionalLink({
|
||||
id: 'example',
|
||||
title: 'Example.com',
|
||||
data: 'https://example.com',
|
||||
options: {
|
||||
icon: 'page',
|
||||
},
|
||||
});
|
||||
|
924
index.d.ts
vendored
924
index.d.ts
vendored
@ -1,924 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare module '@staticcms/core' {
|
||||
import type { Iterable as ImmutableIterable, List, Map } from 'immutable';
|
||||
import type { ComponentType, FocusEventHandler, ReactNode } from 'react';
|
||||
import type { t } from 'react-polyglot';
|
||||
import type { Pluggable } from 'unified';
|
||||
|
||||
export type CmsBackendType =
|
||||
| 'azure'
|
||||
| 'git-gateway'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'bitbucket'
|
||||
| 'test-repo'
|
||||
| 'proxy';
|
||||
|
||||
export type CmsMapWidgetType = 'Point' | 'LineString' | 'Polygon';
|
||||
|
||||
export type CmsMarkdownWidgetButton =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'code'
|
||||
| 'link'
|
||||
| 'heading-one'
|
||||
| 'heading-two'
|
||||
| 'heading-three'
|
||||
| 'heading-four'
|
||||
| 'heading-five'
|
||||
| 'heading-six'
|
||||
| 'quote'
|
||||
| 'code-block'
|
||||
| 'bulleted-list'
|
||||
| 'numbered-list';
|
||||
|
||||
export interface CmsSelectWidgetOptionObject {
|
||||
label: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export type CmsCollectionFormatType =
|
||||
| 'yml'
|
||||
| 'yaml'
|
||||
| 'toml'
|
||||
| 'json'
|
||||
| 'frontmatter'
|
||||
| 'yaml-frontmatter'
|
||||
| 'toml-frontmatter'
|
||||
| 'json-frontmatter';
|
||||
|
||||
export type CmsAuthScope = 'repo' | 'public_repo';
|
||||
|
||||
export type CmsSlugEncoding = 'unicode' | 'ascii';
|
||||
|
||||
export interface CmsI18nConfig {
|
||||
structure: 'multiple_folders' | 'multiple_files' | 'single_file';
|
||||
locales: string[];
|
||||
default_locale?: string;
|
||||
}
|
||||
|
||||
export interface CmsFieldBase {
|
||||
name: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
pattern?: [string, string];
|
||||
i18n?: boolean | 'translate' | 'duplicate' | 'none';
|
||||
media_folder?: string;
|
||||
public_folder?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface CmsFieldBoolean {
|
||||
widget: 'boolean';
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export interface CmsFieldCode {
|
||||
widget: 'code';
|
||||
default?: any;
|
||||
|
||||
default_language?: string;
|
||||
allow_language_selection?: boolean;
|
||||
keys?: { code: string; lang: string };
|
||||
output_code_only?: boolean;
|
||||
}
|
||||
|
||||
export interface CmsFieldColor {
|
||||
widget: 'color';
|
||||
default?: string;
|
||||
|
||||
allowInput?: boolean;
|
||||
enableAlpha?: boolean;
|
||||
}
|
||||
|
||||
export interface CmsFieldDateTime {
|
||||
widget: 'datetime';
|
||||
default?: string;
|
||||
|
||||
format?: string;
|
||||
date_format?: boolean | string;
|
||||
time_format?: boolean | string;
|
||||
picker_utc?: boolean;
|
||||
|
||||
/**
|
||||
* @deprecated Use date_format instead
|
||||
*/
|
||||
dateFormat?: boolean | string;
|
||||
/**
|
||||
* @deprecated Use time_format instead
|
||||
*/
|
||||
timeFormat?: boolean | string;
|
||||
/**
|
||||
* @deprecated Use picker_utc instead
|
||||
*/
|
||||
pickerUtc?: boolean;
|
||||
}
|
||||
|
||||
export interface CmsFieldFileOrImage {
|
||||
widget: 'file' | 'image';
|
||||
default?: string;
|
||||
|
||||
media_library?: CmsMediaLibrary;
|
||||
allow_multiple?: boolean;
|
||||
config?: any;
|
||||
}
|
||||
|
||||
export interface CmsFieldObject {
|
||||
widget: 'object';
|
||||
default?: any;
|
||||
|
||||
collapsed?: boolean;
|
||||
summary?: string;
|
||||
fields: CmsField[];
|
||||
}
|
||||
|
||||
export interface CmsFieldList {
|
||||
widget: 'list';
|
||||
default?: any;
|
||||
|
||||
allow_add?: boolean;
|
||||
collapsed?: boolean;
|
||||
summary?: string;
|
||||
minimize_collapsed?: boolean;
|
||||
label_singular?: string;
|
||||
field?: CmsField;
|
||||
fields?: CmsField[];
|
||||
max?: number;
|
||||
min?: number;
|
||||
add_to_top?: boolean;
|
||||
types?: (CmsFieldBase & CmsFieldObject)[];
|
||||
}
|
||||
|
||||
export interface CmsFieldMap {
|
||||
widget: 'map';
|
||||
default?: string;
|
||||
|
||||
decimals?: number;
|
||||
type?: CmsMapWidgetType;
|
||||
}
|
||||
|
||||
export interface CmsFieldMarkdown {
|
||||
widget: 'markdown';
|
||||
default?: string;
|
||||
|
||||
minimal?: boolean;
|
||||
buttons?: CmsMarkdownWidgetButton[];
|
||||
editor_components?: string[];
|
||||
modes?: ('raw' | 'rich_text')[];
|
||||
|
||||
/**
|
||||
* @deprecated Use editor_components instead
|
||||
*/
|
||||
editorComponents?: string[];
|
||||
}
|
||||
|
||||
export interface CmsFieldNumber {
|
||||
widget: 'number';
|
||||
default?: string | number;
|
||||
|
||||
value_type?: 'int' | 'float' | string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
||||
step?: number;
|
||||
|
||||
/**
|
||||
* @deprecated Use valueType instead
|
||||
*/
|
||||
valueType?: 'int' | 'float' | string;
|
||||
}
|
||||
|
||||
export interface CmsFieldSelect {
|
||||
widget: 'select';
|
||||
default?: string | string[];
|
||||
|
||||
options: string[] | CmsSelectWidgetOptionObject[];
|
||||
multiple?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface CmsFieldRelation {
|
||||
widget: 'relation';
|
||||
default?: string | string[];
|
||||
|
||||
collection: string;
|
||||
value_field: string;
|
||||
search_fields: string[];
|
||||
file?: string;
|
||||
display_fields?: string[];
|
||||
multiple?: boolean;
|
||||
options_length?: number;
|
||||
|
||||
/**
|
||||
* @deprecated Use value_field instead
|
||||
*/
|
||||
valueField?: string;
|
||||
/**
|
||||
* @deprecated Use search_fields instead
|
||||
*/
|
||||
searchFields?: string[];
|
||||
/**
|
||||
* @deprecated Use display_fields instead
|
||||
*/
|
||||
displayFields?: string[];
|
||||
/**
|
||||
* @deprecated Use options_length instead
|
||||
*/
|
||||
optionsLength?: number;
|
||||
}
|
||||
|
||||
export interface CmsFieldHidden {
|
||||
widget: 'hidden';
|
||||
default?: any;
|
||||
}
|
||||
|
||||
export interface CmsFieldStringOrText {
|
||||
// This is the default widget, so declaring its type is optional.
|
||||
widget?: 'string' | 'text';
|
||||
default?: string;
|
||||
}
|
||||
|
||||
export interface CmsFieldMeta {
|
||||
name: string;
|
||||
label: string;
|
||||
widget: string;
|
||||
required: boolean;
|
||||
index_file: string;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
export type CmsField = CmsFieldBase &
|
||||
(
|
||||
| CmsFieldBoolean
|
||||
| CmsFieldCode
|
||||
| CmsFieldColor
|
||||
| CmsFieldDateTime
|
||||
| CmsFieldFileOrImage
|
||||
| CmsFieldList
|
||||
| CmsFieldMap
|
||||
| CmsFieldMarkdown
|
||||
| CmsFieldNumber
|
||||
| CmsFieldObject
|
||||
| CmsFieldRelation
|
||||
| CmsFieldSelect
|
||||
| CmsFieldHidden
|
||||
| CmsFieldStringOrText
|
||||
| CmsFieldMeta
|
||||
);
|
||||
|
||||
export interface CmsCollectionFile {
|
||||
name: string;
|
||||
label: string;
|
||||
file: string;
|
||||
fields: CmsField[];
|
||||
label_singular?: string;
|
||||
description?: string;
|
||||
i18n?: boolean | CmsI18nConfig;
|
||||
media_folder?: string;
|
||||
public_folder?: string;
|
||||
editor?: {
|
||||
preview?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ViewFilter {
|
||||
label: string;
|
||||
field: string;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export interface ViewGroup {
|
||||
label: string;
|
||||
field: string;
|
||||
pattern?: string;
|
||||
}
|
||||
|
||||
export type SortDirection = 'Ascending' | 'Descending' | 'None';
|
||||
|
||||
export interface CmsSortableFieldsDefault {
|
||||
field: string;
|
||||
direction?: SortDirection;
|
||||
}
|
||||
|
||||
export interface CmsSortableFields {
|
||||
default?: CmsSortableFieldsDefault;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface CmsCollection {
|
||||
name: string;
|
||||
icon?: string;
|
||||
label: string;
|
||||
label_singular?: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
files?: CmsCollectionFile[];
|
||||
identifier_field?: string;
|
||||
summary?: string;
|
||||
slug?: string;
|
||||
create?: boolean;
|
||||
delete?: boolean;
|
||||
hide?: boolean;
|
||||
editor?: {
|
||||
preview?: boolean;
|
||||
};
|
||||
publish?: boolean;
|
||||
nested?: {
|
||||
depth: number;
|
||||
};
|
||||
meta?: { path?: { label: string; widget: string; index_file: string } };
|
||||
|
||||
/**
|
||||
* It accepts the following values: yml, yaml, toml, json, md, markdown, html
|
||||
*
|
||||
* You may also specify a custom extension not included in the list above, by specifying the format value.
|
||||
*/
|
||||
extension?: string;
|
||||
format?: CmsCollectionFormatType;
|
||||
|
||||
frontmatter_delimiter?: string[] | string;
|
||||
fields?: CmsField[];
|
||||
filter?: { field: string; value: any };
|
||||
path?: string;
|
||||
media_folder?: string;
|
||||
public_folder?: string;
|
||||
sortable_fields?: CmsSortableFields;
|
||||
view_filters?: ViewFilter[];
|
||||
view_groups?: ViewGroup[];
|
||||
i18n?: boolean | CmsI18nConfig;
|
||||
}
|
||||
|
||||
export interface CmsBackend {
|
||||
name: CmsBackendType;
|
||||
auth_scope?: CmsAuthScope;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
api_root?: string;
|
||||
site_domain?: string;
|
||||
base_url?: string;
|
||||
auth_endpoint?: string;
|
||||
app_id?: string;
|
||||
auth_type?: 'implicit' | 'pkce';
|
||||
proxy_url?: string;
|
||||
commit_messages?: {
|
||||
create?: string;
|
||||
update?: string;
|
||||
delete?: string;
|
||||
uploadMedia?: string;
|
||||
deleteMedia?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CmsSlug {
|
||||
encoding?: CmsSlugEncoding;
|
||||
clean_accents?: boolean;
|
||||
sanitize_replacement?: string;
|
||||
}
|
||||
|
||||
export interface CmsLocalBackend {
|
||||
url?: string;
|
||||
allowed_hosts?: string[];
|
||||
}
|
||||
|
||||
export interface CmsConfig {
|
||||
backend: CmsBackend;
|
||||
collections: CmsCollection[];
|
||||
locale?: string;
|
||||
site_url?: string;
|
||||
display_url?: string;
|
||||
logo_url?: string;
|
||||
media_folder?: string;
|
||||
public_folder?: string;
|
||||
media_folder_relative?: boolean;
|
||||
media_library?: CmsMediaLibrary;
|
||||
load_config_file?: boolean;
|
||||
integrations?: {
|
||||
hooks: string[];
|
||||
provider: string;
|
||||
collections?: '*' | string[];
|
||||
applicationID?: string;
|
||||
apiKey?: string;
|
||||
getSignedFormURL?: string;
|
||||
}[];
|
||||
slug?: CmsSlug;
|
||||
i18n?: CmsI18nConfig;
|
||||
local_backend?: boolean | CmsLocalBackend;
|
||||
editor?: {
|
||||
preview?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InitOptions {
|
||||
config: CmsConfig;
|
||||
}
|
||||
|
||||
export interface EditorComponentField {
|
||||
name: string;
|
||||
label: string;
|
||||
widget: string;
|
||||
}
|
||||
|
||||
export interface EditorComponentWidgetOptions {
|
||||
id: string;
|
||||
label: string;
|
||||
widget: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface EditorComponentManualOptions {
|
||||
id: string;
|
||||
label: string;
|
||||
fields: EditorComponentField[];
|
||||
pattern: RegExp;
|
||||
allow_add?: boolean;
|
||||
fromBlock: (match: RegExpMatchArray) => any;
|
||||
toBlock: (data: any) => string;
|
||||
toPreview: (data: any) => string;
|
||||
}
|
||||
|
||||
export type EditorComponentOptions = EditorComponentManualOptions | EditorComponentWidgetOptions;
|
||||
|
||||
export interface PreviewStyleOptions {
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
export interface PreviewStyle extends PreviewStyleOptions {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type CmsBackendClass = Implementation;
|
||||
|
||||
export interface CmsRegistryBackend {
|
||||
init: (args: any) => CmsBackendClass;
|
||||
}
|
||||
|
||||
export interface CmsWidgetControlProps<T = any> {
|
||||
value: T;
|
||||
field: Map<string, any>;
|
||||
onChange: (value: T) => void;
|
||||
forID: string;
|
||||
classNameWrapper: string;
|
||||
setActiveStyle: FocusEventHandler;
|
||||
setInactiveStyle: FocusEventHandler;
|
||||
t: t;
|
||||
}
|
||||
|
||||
export interface CmsWidgetPreviewProps<T = any> {
|
||||
value: T;
|
||||
field: Map<string, any>;
|
||||
metadata: Map<string, any>;
|
||||
getAsset: GetAssetFunction;
|
||||
entry: Map<string, any>;
|
||||
fieldsMetaData: Map<string, any>;
|
||||
}
|
||||
|
||||
export interface CmsWidgetParam<T = any> {
|
||||
name: string;
|
||||
controlComponent: CmsWidgetControlProps<T>;
|
||||
previewComponent?: CmsWidgetPreviewProps<T>;
|
||||
validator?: (props: {
|
||||
field: Map<string, any>;
|
||||
value: T | undefined | null;
|
||||
t: t;
|
||||
}) => boolean | { error: any } | Promise<boolean | { error: any }>;
|
||||
globalStyles?: any;
|
||||
}
|
||||
|
||||
export interface CmsWidget<T = any> {
|
||||
control: ComponentType<CmsWidgetControlProps<T>>;
|
||||
preview?: ComponentType<CmsWidgetPreviewProps<T>>;
|
||||
globalStyles?: any;
|
||||
}
|
||||
|
||||
export type CmsWidgetValueSerializer = any; // TODO: type properly
|
||||
|
||||
export type CmsMediaLibraryOptions = any; // TODO: type properly
|
||||
|
||||
export interface CmsMediaLibrary {
|
||||
name: string;
|
||||
config?: CmsMediaLibraryOptions;
|
||||
}
|
||||
|
||||
export interface CmsEventListener {
|
||||
name: 'prePublish' | 'postPublish' | 'preSave' | 'postSave';
|
||||
handler: ({
|
||||
entry,
|
||||
author,
|
||||
}: {
|
||||
entry: Map<string, any>;
|
||||
author: { login: string; name: string };
|
||||
}) => any;
|
||||
}
|
||||
|
||||
export type CmsEventListenerOptions = any; // TODO: type properly
|
||||
|
||||
export type CmsLocalePhrases = any; // TODO: type properly
|
||||
|
||||
export interface CmsRegistry {
|
||||
backends: {
|
||||
[name: string]: CmsRegistryBackend;
|
||||
};
|
||||
templates: {
|
||||
[name: string]: ComponentType<any>;
|
||||
};
|
||||
previewStyles: PreviewStyle[];
|
||||
widgets: {
|
||||
[name: string]: CmsWidget;
|
||||
};
|
||||
editorComponents: Map<string, ComponentType<any>>;
|
||||
widgetValueSerializers: {
|
||||
[name: string]: CmsWidgetValueSerializer;
|
||||
};
|
||||
mediaLibraries: CmsMediaLibrary[];
|
||||
locales: {
|
||||
[name: string]: CmsLocalePhrases;
|
||||
};
|
||||
}
|
||||
|
||||
type GetAssetFunction = (asset: string) => {
|
||||
url: string;
|
||||
path: string;
|
||||
field?: any;
|
||||
fileObj: File;
|
||||
};
|
||||
|
||||
export type PreviewTemplateComponentProps = {
|
||||
entry: Map<string, any>;
|
||||
collection: Map<string, any>;
|
||||
widgetFor: (name: any, fields?: any, values?: any, fieldsMetaData?: any) => JSX.Element | null;
|
||||
widgetsFor: (name: any) => any;
|
||||
getAsset: GetAssetFunction;
|
||||
boundGetAsset: (collection: any, path: any) => GetAssetFunction;
|
||||
fieldsMetaData: Map<string, any>;
|
||||
config: Map<string, any>;
|
||||
fields: List<Map<string, any>>;
|
||||
isLoadingAsset: boolean;
|
||||
window: Window;
|
||||
document: Document;
|
||||
};
|
||||
|
||||
export interface CMSApi {
|
||||
getBackend: (name: string) => CmsRegistryBackend | undefined;
|
||||
getEditorComponents: () => Map<string, ComponentType<any>>;
|
||||
getRemarkPlugins: () => Array<Pluggable>;
|
||||
getLocale: (locale: string) => CmsLocalePhrases | undefined;
|
||||
getMediaLibrary: (name: string) => CmsMediaLibrary | undefined;
|
||||
resolveWidget: (name: string) => CmsWidget | undefined;
|
||||
getPreviewStyles: () => PreviewStyle[];
|
||||
getPreviewTemplate: (name: string) => ComponentType<PreviewTemplateComponentProps> | undefined;
|
||||
getWidget: (name: string) => CmsWidget | undefined;
|
||||
getWidgetValueSerializer: (widgetName: string) => CmsWidgetValueSerializer | undefined;
|
||||
init: (options?: InitOptions) => void;
|
||||
registerBackend: (name: string, backendClass: CmsBackendClass) => void;
|
||||
registerEditorComponent: (options: EditorComponentOptions) => void;
|
||||
registerRemarkPlugin: (plugin: Pluggable) => void;
|
||||
registerEventListener: (
|
||||
eventListener: CmsEventListener,
|
||||
options?: CmsEventListenerOptions,
|
||||
) => void;
|
||||
registerLocale: (locale: string, phrases: CmsLocalePhrases) => void;
|
||||
registerMediaLibrary: (mediaLibrary: CmsMediaLibrary, options?: CmsMediaLibraryOptions) => void;
|
||||
registerPreviewStyle: (filePath: string, options?: PreviewStyleOptions) => void;
|
||||
registerPreviewTemplate: (
|
||||
name: string,
|
||||
component: ComponentType<PreviewTemplateComponentProps>,
|
||||
) => void;
|
||||
registerWidget: (
|
||||
widget: string | CmsWidgetParam | CmsWidgetParam[],
|
||||
control?: ComponentType<CmsWidgetControlProps> | string,
|
||||
preview?: ComponentType<CmsWidgetPreviewProps>,
|
||||
) => void;
|
||||
registerWidgetValueSerializer: (
|
||||
widgetName: string,
|
||||
serializer: CmsWidgetValueSerializer,
|
||||
) => void;
|
||||
registerIcon: (iconName: string, icon: ReactNode) => void;
|
||||
getIcon: (iconName: string) => ReactNode;
|
||||
registerAdditionalLink: (
|
||||
id: string,
|
||||
title: string,
|
||||
data: string | ComponentType,
|
||||
iconName?: string,
|
||||
) => void;
|
||||
getAdditionalLinks: () => { title: string; data: string | ComponentType; iconName?: string }[];
|
||||
getAdditionalLink: (
|
||||
id: string,
|
||||
) => { title: string; data: string | ComponentType; iconName?: string } | undefined;
|
||||
}
|
||||
|
||||
export const CMS: CMSApi;
|
||||
|
||||
export default CMS;
|
||||
|
||||
// Backends
|
||||
export type DisplayURLObject = { id: string; path: string };
|
||||
|
||||
export type DisplayURL = DisplayURLObject | string;
|
||||
|
||||
export type DataFile = {
|
||||
path: string;
|
||||
slug: string;
|
||||
raw: string;
|
||||
newPath?: string;
|
||||
};
|
||||
|
||||
export type AssetProxy = {
|
||||
path: string;
|
||||
fileObj?: File;
|
||||
toBase64?: () => Promise<string>;
|
||||
};
|
||||
|
||||
export type Entry = {
|
||||
dataFiles: DataFile[];
|
||||
assets: AssetProxy[];
|
||||
};
|
||||
|
||||
export type PersistOptions = {
|
||||
newEntry?: boolean;
|
||||
commitMessage: string;
|
||||
collectionName?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type DeleteOptions = {};
|
||||
|
||||
export type Credentials = { token: string | {}; refresh_token?: string };
|
||||
|
||||
export type User = Credentials & {
|
||||
backendName?: string;
|
||||
login?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export interface ImplementationEntry {
|
||||
data: string;
|
||||
file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string };
|
||||
}
|
||||
|
||||
export type ImplementationFile = {
|
||||
id?: string | null | undefined;
|
||||
label?: string;
|
||||
path: string;
|
||||
};
|
||||
export interface ImplementationMediaFile {
|
||||
name: string;
|
||||
id: string;
|
||||
size?: number;
|
||||
displayURL?: DisplayURL;
|
||||
path: string;
|
||||
draft?: boolean;
|
||||
url?: string;
|
||||
file?: File;
|
||||
}
|
||||
|
||||
export type CursorStoreObject = {
|
||||
actions: Set<string>;
|
||||
data: Map<string, unknown>;
|
||||
meta: Map<string, unknown>;
|
||||
};
|
||||
|
||||
export type CursorStore = {
|
||||
get<K extends keyof CursorStoreObject>(
|
||||
key: K,
|
||||
defaultValue?: CursorStoreObject[K],
|
||||
): CursorStoreObject[K];
|
||||
getIn<V>(path: string[]): V;
|
||||
set<K extends keyof CursorStoreObject, V extends CursorStoreObject[K]>(
|
||||
key: K,
|
||||
value: V,
|
||||
): CursorStoreObject[K];
|
||||
setIn(path: string[], value: unknown): CursorStore;
|
||||
hasIn(path: string[]): boolean;
|
||||
mergeIn(path: string[], value: unknown): CursorStore;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
update: (...args: any[]) => CursorStore;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateIn: (...args: any[]) => CursorStore;
|
||||
};
|
||||
|
||||
export type ActionHandler = (action: string) => unknown;
|
||||
|
||||
export class Cursor {
|
||||
static create(...args: {}[]): Cursor;
|
||||
updateStore(...args: any[]): Cursor;
|
||||
updateInStore(...args: any[]): Cursor;
|
||||
hasAction(action: string): boolean;
|
||||
addAction(action: string): Cursor;
|
||||
removeAction(action: string): Cursor;
|
||||
setActions(actions: Iterable<string>): Cursor;
|
||||
mergeActions(actions: Set<string>): Cursor;
|
||||
getActionHandlers(handler: ActionHandler): ImmutableIterable<string, unknown>;
|
||||
setData(data: {}): Cursor;
|
||||
mergeData(data: {}): Cursor;
|
||||
wrapData(data: {}): Cursor;
|
||||
unwrapData(): [Map<string, unknown>, Cursor];
|
||||
clearData(): Cursor;
|
||||
setMeta(meta: {}): Cursor;
|
||||
mergeMeta(meta: {}): Cursor;
|
||||
}
|
||||
|
||||
class Implementation {
|
||||
authComponent: () => void;
|
||||
restoreUser: (user: User) => Promise<User>;
|
||||
|
||||
authenticate: (credentials: Credentials) => Promise<User>;
|
||||
logout: () => Promise<void> | void | null;
|
||||
getToken: () => Promise<string | null>;
|
||||
|
||||
getEntry: (path: string) => Promise<ImplementationEntry>;
|
||||
entriesByFolder: (
|
||||
folder: string,
|
||||
extension: string,
|
||||
depth: number,
|
||||
) => Promise<ImplementationEntry[]>;
|
||||
entriesByFiles: (files: ImplementationFile[]) => Promise<ImplementationEntry[]>;
|
||||
|
||||
getMediaDisplayURL?: (displayURL: DisplayURL) => Promise<string>;
|
||||
getMedia: (folder?: string) => Promise<ImplementationMediaFile[]>;
|
||||
getMediaFile: (path: string) => Promise<ImplementationMediaFile>;
|
||||
|
||||
persistEntry: (entry: Entry, opts: PersistOptions) => Promise<void>;
|
||||
persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise<ImplementationMediaFile>;
|
||||
deleteFiles: (paths: string[], commitMessage: string) => Promise<void>;
|
||||
|
||||
allEntriesByFolder?: (
|
||||
folder: string,
|
||||
extension: string,
|
||||
depth: number,
|
||||
) => Promise<ImplementationEntry[]>;
|
||||
traverseCursor?: (
|
||||
cursor: Cursor,
|
||||
action: string,
|
||||
) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>;
|
||||
|
||||
isGitBackend?: () => boolean;
|
||||
status: () => Promise<{
|
||||
auth: { status: boolean };
|
||||
api: { status: boolean; statusPage: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
export const AzureBackend: Implementation;
|
||||
export const BitbucketBackend: Implementation;
|
||||
export const GitGatewayBackend: Implementation;
|
||||
export const GitHubBackend: Implementation;
|
||||
export const GitLabBackend: Implementation;
|
||||
export const ProxyBackend: Implementation;
|
||||
export const TestBackend: Implementation;
|
||||
|
||||
// Widgets
|
||||
export const BooleanWidget: {
|
||||
Widget: () => CmsWidgetParam<boolean>;
|
||||
};
|
||||
export const CodeWidget: {
|
||||
Widget: () => CmsWidgetParam<any>;
|
||||
};
|
||||
export const ColorStringWidget: {
|
||||
Widget: () => CmsWidgetParam<string>;
|
||||
};
|
||||
export const DateTimeWidget: {
|
||||
Widget: () => CmsWidgetParam<Date | string>;
|
||||
};
|
||||
export const FileWidget: {
|
||||
Widget: () => CmsWidgetParam<string | string[] | List<string>>;
|
||||
};
|
||||
export const ImageWidget: {
|
||||
Widget: () => CmsWidgetParam<string | string[] | List<string>>;
|
||||
};
|
||||
export const ListWidget: {
|
||||
Widget: () => CmsWidgetParam<List<any>>;
|
||||
};
|
||||
export const MapWidget: {
|
||||
Widget: () => CmsWidgetParam<any>;
|
||||
};
|
||||
export const MarkdownWidget: {
|
||||
Widget: () => CmsWidgetParam<string>;
|
||||
};
|
||||
export const NumberWidget: {
|
||||
Widget: () => CmsWidgetParam<string | number>;
|
||||
};
|
||||
export const ObjectWidget: {
|
||||
Widget: () => CmsWidgetParam<Map<string, any> | Record<string, any>>;
|
||||
};
|
||||
export const RelationWidget: {
|
||||
Widget: () => CmsWidgetParam<any>;
|
||||
};
|
||||
export const SelectWidget: {
|
||||
Widget: () => CmsWidgetParam<string | string[]>;
|
||||
};
|
||||
export const StringWidget: {
|
||||
Widget: () => CmsWidgetParam<string>;
|
||||
};
|
||||
export const TextWidget: {
|
||||
Widget: () => CmsWidgetParam<string>;
|
||||
};
|
||||
|
||||
export const MediaLibraryCloudinary: {
|
||||
name: string;
|
||||
init: ({
|
||||
options,
|
||||
handleInsert,
|
||||
}?: {
|
||||
options?: Record<string, any> | undefined;
|
||||
handleInsert: any;
|
||||
}) => Promise<{
|
||||
show: ({
|
||||
config,
|
||||
allowMultiple,
|
||||
}?: {
|
||||
config?: Record<string, any> | undefined;
|
||||
allowMultiple: boolean;
|
||||
}) => any;
|
||||
hide: () => any;
|
||||
enableStandalone: () => boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const MediaLibraryUploadcare: {
|
||||
name: string;
|
||||
init: ({
|
||||
options,
|
||||
handleInsert,
|
||||
}?: {
|
||||
options?:
|
||||
| {
|
||||
config: Record<string, any>;
|
||||
settings: Record<string, any>;
|
||||
}
|
||||
| undefined;
|
||||
handleInsert: any;
|
||||
}) => Promise<{
|
||||
show: ({
|
||||
value,
|
||||
config,
|
||||
allowMultiple,
|
||||
imagesOnly,
|
||||
}?: {
|
||||
value: any;
|
||||
config?: Record<string, any> | undefined;
|
||||
allowMultiple: boolean;
|
||||
imagesOnly?: boolean | undefined;
|
||||
}) => any;
|
||||
enableStandalone: () => boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const imageEditorComponent: EditorComponentManualOptions;
|
||||
|
||||
export const locales: {
|
||||
cs: Record<string, any>;
|
||||
da: Record<string, any>;
|
||||
de: Record<string, any>;
|
||||
en: Record<string, any>;
|
||||
es: Record<string, any>;
|
||||
ca: Record<string, any>;
|
||||
fr: Record<string, any>;
|
||||
gr: Record<string, any>;
|
||||
hu: Record<string, any>;
|
||||
it: Record<string, any>;
|
||||
lt: Record<string, any>;
|
||||
ja: Record<string, any>;
|
||||
nl: Record<string, any>;
|
||||
nb_no: Record<string, any>;
|
||||
nn_no: Record<string, any>;
|
||||
pl: Record<string, any>;
|
||||
pt: Record<string, any>;
|
||||
ro: Record<string, any>;
|
||||
ru: Record<string, any>;
|
||||
sv: Record<string, any>;
|
||||
th: Record<string, any>;
|
||||
tr: Record<string, any>;
|
||||
uk: Record<string, any>;
|
||||
vi: Record<string, any>;
|
||||
zh_Hant: Record<string, any>;
|
||||
ko: Record<string, any>;
|
||||
hr: Record<string, any>;
|
||||
bg: Record<string, any>;
|
||||
zh_Hans: Record<string, any>;
|
||||
he: Record<string, any>;
|
||||
};
|
||||
|
||||
class NetlifyAuthenticator {
|
||||
constructor(config: Record<string, any>);
|
||||
|
||||
refresh: (args: {
|
||||
provider: string;
|
||||
refresh_token: string;
|
||||
}) => Promise<{ token: string; refresh_token: string }>;
|
||||
}
|
||||
export { NetlifyAuthenticator };
|
||||
|
||||
// Images
|
||||
export interface IconProps {
|
||||
type: string;
|
||||
direction?: 'right' | 'down' | 'left' | 'up';
|
||||
size?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Icon: React.ComponentType<IconProps>;
|
||||
|
||||
export const images: Record<string, ReactNode>;
|
||||
}
|
89
package.json
89
package.json
@ -13,18 +13,20 @@
|
||||
"scripts": {
|
||||
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore **/__tests__ --extensions \".js,.jsx,.ts,.tsx\"",
|
||||
"build:webpack": "webpack",
|
||||
"build": "cross-env NODE_ENV=production run-s build:esm build:webpack",
|
||||
"build:types": "tsc",
|
||||
"build": "cross-env NODE_ENV=production run-s build:esm build:webpack build:types",
|
||||
"clean": "rimraf dist dev-test/dist",
|
||||
"develop": "webpack serve --hot",
|
||||
"develop": "webpack serve",
|
||||
"format:prettier": "prettier \"{{src,scripts,website}/**/,}*.{js,jsx,ts,tsx,css}\"",
|
||||
"format": "run-s \"lint:js --fix --quiet\" \"format:prettier --write\"",
|
||||
"lint-quiet": "run-p -c --aggregate-output \"lint:* --quiet\"",
|
||||
"lint:css": "stylelint --ignore-path .gitignore \"{src/**/*.{css,js,jsx,ts,tsx},website/**/*.css}\"",
|
||||
"lint:format": "prettier \"{{src,scripts,website}/**/,}*.{js,jsx,ts,tsx,css}\" --list-different",
|
||||
"lint:js": "eslint --color --ignore-path .gitignore \"{{src,scripts,website}/**/,}*.{js,jsx,ts,tsx}\"",
|
||||
"lint:format": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --list-different",
|
||||
"lint:js": "eslint --color --ignore-path .gitignore \"src/**/*.{js,jsx,ts,tsx}\"",
|
||||
"lint": "run-p -c --aggregate-output \"lint:*\"",
|
||||
"prepublishOnly": "yarn build",
|
||||
"start": "run-s clean develop"
|
||||
"start": "run-s clean develop",
|
||||
"type-check": "tsc --watch"
|
||||
},
|
||||
"module": "dist/esm/index.js",
|
||||
"main": "dist/static-cms-core.js",
|
||||
@ -48,25 +50,27 @@
|
||||
"@emotion/css": "11.10.0",
|
||||
"@emotion/react": "11.10.4",
|
||||
"@emotion/styled": "11.10.4",
|
||||
"@hot-loader/react-dom": "17.0.2",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@mui/icons-material": "5.10.6",
|
||||
"@mui/material": "5.10.6",
|
||||
"@mui/x-date-pickers": "5.0.4",
|
||||
"@reduxjs/toolkit": "1.8.5",
|
||||
"ajv": "6.12.6",
|
||||
"ajv-errors": "1.0.1",
|
||||
"ajv-keywords": "3.5.2",
|
||||
"@toast-ui/react-editor": "3.2.2",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-errors": "3.0.0",
|
||||
"ajv-keywords": "5.1.0",
|
||||
"apollo-cache-inmemory": "1.6.6",
|
||||
"apollo-client": "2.6.10",
|
||||
"apollo-link-context": "1.0.20",
|
||||
"apollo-link-http": "1.5.17",
|
||||
"array-move": "4.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"buffer": "6.0.3",
|
||||
"clean-stack": "4.2.0",
|
||||
"codemirror": "5.65.9",
|
||||
"common-tags": "1.8.1",
|
||||
"copy-text-to-clipboard": "3.0.1",
|
||||
"create-react-class": "15.7.0",
|
||||
"date-fns": "2.29.3",
|
||||
"deepmerge": "4.2.2",
|
||||
"diacritics": "1.3.0",
|
||||
"dompurify": "2.4.0",
|
||||
@ -80,7 +84,6 @@
|
||||
"gray-matter": "4.0.3",
|
||||
"history": "4.10.1",
|
||||
"immer": "9.0.15",
|
||||
"immutable": "3.8.2",
|
||||
"ini": "2.0.0",
|
||||
"is-hotkey": "0.2.0",
|
||||
"js-base64": "3.7.2",
|
||||
@ -90,60 +93,44 @@
|
||||
"lodash": "4.17.21",
|
||||
"mdast-util-definitions": "1.2.5",
|
||||
"mdast-util-to-string": "1.1.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"minimatch": "3.0.4",
|
||||
"moment": "2.29.4",
|
||||
"node-polyglot": "2.4.2",
|
||||
"ol": "6.15.1",
|
||||
"path-browserify": "1.0.1",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react": "18.2.0",
|
||||
"react-aria-menubutton": "7.0.3",
|
||||
"react-codemirror2": "7.2.1",
|
||||
"react-color": "2.19.3",
|
||||
"react-datetime": "3.1.1",
|
||||
"react-dnd": "14.0.5",
|
||||
"react-dnd-html5-backend": "14.1.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dom": "18.2.0",
|
||||
"react-frame-component": "5.2.3",
|
||||
"react-hot-loader": "4.13.0",
|
||||
"react-immutable-proptypes": "2.2.0",
|
||||
"react-is": "18.2.0",
|
||||
"react-markdown": "6.0.3",
|
||||
"react-modal": "3.15.1",
|
||||
"react-polyglot": "0.7.2",
|
||||
"react-redux": "8.0.4",
|
||||
"react-router-dom": "5.3.3",
|
||||
"react-router-dom": "6.4.1",
|
||||
"react-scroll-sync": "0.9.0",
|
||||
"react-select": "4.3.1",
|
||||
"react-sortable-hoc": "2.0.0",
|
||||
"react-split-pane": "0.1.92",
|
||||
"react-textarea-autosize": "8.3.4",
|
||||
"react-toggled": "1.2.7",
|
||||
"react-topbar-progress-indicator": "4.1.1",
|
||||
"react-transition-group": "4.4.5",
|
||||
"react-virtualized-auto-sizer": "1.0.7",
|
||||
"react-waypoint": "10.3.0",
|
||||
"react-window": "1.8.7",
|
||||
"rehype-parse": "6.0.2",
|
||||
"rehype-remark": "8.1.1",
|
||||
"rehype-stringify": "7.0.0",
|
||||
"rehype-stringify": "9.0.3",
|
||||
"remark-gfm": "3.0.1",
|
||||
"remark-parse": "6.0.3",
|
||||
"remark-rehype": "4.0.1",
|
||||
"remark-stringify": "6.0.4",
|
||||
"remark-parse": "10.0.1",
|
||||
"remark-rehype": "10.1.0",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"semaphore": "1.1.0",
|
||||
"slate": "0.47.9",
|
||||
"slate-base64-serializer": "0.2.115",
|
||||
"slate-plain-serializer": "0.7.13",
|
||||
"slate-react": "0.22.10",
|
||||
"slate-soft-break": "0.9.0",
|
||||
"stream-browserify": "3.0.0",
|
||||
"tomlify-j0.4": "3.0.0",
|
||||
"ts-loader": "9.4.1",
|
||||
"unified": "7.1.0",
|
||||
"unist-builder": "1.0.4",
|
||||
"unist-util-visit-parents": "2.1.2",
|
||||
"unified": "10.1.2",
|
||||
"uploadcare-widget": "3.19.0",
|
||||
"uploadcare-widget-tab-effects": "1.5.0",
|
||||
"url": "0.11.0",
|
||||
@ -169,17 +156,29 @@
|
||||
"@babel/preset-typescript": "7.18.6",
|
||||
"@emotion/eslint-plugin": "11.10.0",
|
||||
"@octokit/rest": "16.43.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.8",
|
||||
"@stylelint/postcss-css-in-js": "0.37.3",
|
||||
"@types/codemirror": "5.60.5",
|
||||
"@types/common-tags": "1.8.0",
|
||||
"@types/create-react-class": "15.6.3",
|
||||
"@types/dompurify": "2.3.4",
|
||||
"@types/fs-extra": "9.0.13",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/is-hotkey": "0.1.7",
|
||||
"@types/jest": "29.1.2",
|
||||
"@types/js-base64": "3.3.1",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/jwt-decode": "2.2.1",
|
||||
"@types/lodash": "4.14.185",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/react": "17.0.50",
|
||||
"@types/react-dom": "17.0.17",
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/minimatch": "5.1.2",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"@types/react": "18.0.21",
|
||||
"@types/react-color": "3.0.6",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react-scroll-sync": "0.8.4",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.1",
|
||||
"@types/react-window": "1.8.5",
|
||||
"@types/url-join": "4.0.1",
|
||||
"@types/uuid": "3.4.10",
|
||||
"@typescript-eslint/eslint-plugin": "5.38.0",
|
||||
@ -207,11 +206,13 @@
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.31.8",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-unicorn": "41.0.1",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "10.1.0",
|
||||
"gitlab": "14.2.2",
|
||||
"http-server": "14.1.1",
|
||||
"jest": "29.1.2",
|
||||
"js-yaml": "4.1.0",
|
||||
"mockserver-client": "5.14.0",
|
||||
"mockserver-node": "5.14.0",
|
||||
@ -222,19 +223,19 @@
|
||||
"postcss": "8.4.16",
|
||||
"postcss-scss": "4.0.5",
|
||||
"prettier": "2.7.1",
|
||||
"process": "0.11.10",
|
||||
"react-refresh": "0.14.0",
|
||||
"react-svg-loader": "3.0.3",
|
||||
"rehype": "7.0.0",
|
||||
"rimraf": "3.0.2",
|
||||
"simple-git": "3.14.1",
|
||||
"slate-hyperscript": "0.13.9",
|
||||
"source-map-loader": "^4.0.0",
|
||||
"source-map-loader": "4.0.0",
|
||||
"style-loader": "3.3.1",
|
||||
"stylelint": "14.12.1",
|
||||
"stylelint-config-standard-scss": "3.0.0",
|
||||
"stylelint-config-styled-components": "0.1.1",
|
||||
"stylelint-processor-styled-components": "1.10.0",
|
||||
"to-string-loader": "1.2.0",
|
||||
"typescript": "3.9.10",
|
||||
"unist-util-visit": "1.4.1",
|
||||
"typescript": "4.8.4",
|
||||
"webpack": "5.74.0",
|
||||
"webpack-cli": "4.10.0",
|
||||
"webpack-dev-server": "4.11.1"
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { currentBackend } from '../backend';
|
||||
import { addSnackbar } from '../store/slices/snackbars';
|
||||
|
||||
import type { Credentials, User } from '../lib/util';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { State } from '../types/redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { Credentials, User } from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export const AUTH_REQUEST = 'AUTH_REQUEST';
|
||||
export const AUTH_SUCCESS = 'AUTH_SUCCESS';
|
||||
@ -47,9 +47,14 @@ export function logout() {
|
||||
|
||||
// Check if user data token is cached and is valid
|
||||
export function authenticateUser() {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
if (!state.config.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(state.config.config);
|
||||
|
||||
dispatch(authenticating());
|
||||
return Promise.resolve(backend.currentUser())
|
||||
.then(user => {
|
||||
@ -67,9 +72,13 @@ export function authenticateUser() {
|
||||
}
|
||||
|
||||
export function loginUser(credentials: Credentials) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
if (!state.config.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(state.config.config);
|
||||
|
||||
dispatch(authenticating());
|
||||
return backend
|
||||
@ -94,9 +103,13 @@ export function loginUser(credentials: Credentials) {
|
||||
}
|
||||
|
||||
export function logoutUser() {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
if (!state.config.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(state.config.config);
|
||||
Promise.resolve(backend.logout()).then(() => {
|
||||
dispatch(logout());
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { history } from '../routing/history';
|
||||
import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper';
|
||||
|
||||
export function searchCollections(query: string, collection: string) {
|
||||
export function searchCollections(query: string, collection?: string) {
|
||||
if (collection) {
|
||||
history.push(`/collections/${collection}/search/${query}`);
|
||||
} else {
|
||||
|
@ -1,52 +1,50 @@
|
||||
import yaml from 'yaml';
|
||||
import { fromJS } from 'immutable';
|
||||
import deepmerge from 'deepmerge';
|
||||
import { produce } from 'immer';
|
||||
import { trimStart, trim, isEmpty } from 'lodash';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { validateConfig } from '../constants/configSchema';
|
||||
import { selectDefaultSortableFields } from '../reducers/collections';
|
||||
import { getIntegrations, selectIntegration } from '../reducers/integrations';
|
||||
import { resolveBackend } from '../backend';
|
||||
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
|
||||
import { FILES, FOLDER } from '../constants/collectionTypes';
|
||||
import { validateConfig } from '../constants/configSchema';
|
||||
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
|
||||
import { selectDefaultSortableFields } from '../lib/util/collection.util';
|
||||
import { getIntegrations, selectIntegration } from '../reducers/integrations';
|
||||
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { State } from '../types/redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type {
|
||||
CmsConfig,
|
||||
CmsField,
|
||||
CmsFieldBase,
|
||||
CmsFieldObject,
|
||||
CmsFieldList,
|
||||
CmsI18nConfig,
|
||||
CmsLocalBackend,
|
||||
CmsCollection,
|
||||
Collection,
|
||||
Config,
|
||||
Field,
|
||||
BaseField,
|
||||
ListField,
|
||||
ObjectField,
|
||||
I18nInfo,
|
||||
LocalBackend,
|
||||
} from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
|
||||
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
|
||||
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
|
||||
|
||||
function isObjectField(field: CmsField): field is CmsFieldBase & CmsFieldObject {
|
||||
return 'fields' in (field as CmsFieldObject);
|
||||
function isObjectField(field: Field): field is BaseField & ObjectField {
|
||||
return 'fields' in (field as ObjectField);
|
||||
}
|
||||
|
||||
function isFieldList(field: CmsField): field is CmsFieldBase & CmsFieldList {
|
||||
return 'types' in (field as CmsFieldList) || 'field' in (field as CmsFieldList);
|
||||
function isFieldList(field: Field): field is BaseField & ListField {
|
||||
return 'types' in (field as ListField) || 'field' in (field as ListField);
|
||||
}
|
||||
|
||||
function traverseFieldsJS<Field extends CmsField>(
|
||||
fields: Field[],
|
||||
updater: <T extends CmsField>(field: T) => T,
|
||||
): Field[] {
|
||||
function traverseFieldsJS<F extends Field>(
|
||||
fields: F[],
|
||||
updater: <T extends Field>(field: T) => T,
|
||||
): F[] {
|
||||
return fields.map(field => {
|
||||
const newField = updater(field);
|
||||
if (isObjectField(newField)) {
|
||||
return { ...newField, fields: traverseFieldsJS(newField.fields, updater) };
|
||||
} else if (isFieldList(newField) && newField.field) {
|
||||
return { ...newField, field: traverseFieldsJS([newField.field], updater)[0] };
|
||||
} else if (isFieldList(newField) && newField.types) {
|
||||
return { ...newField, types: traverseFieldsJS(newField.types, updater) };
|
||||
}
|
||||
@ -68,7 +66,7 @@ function getConfigUrl() {
|
||||
return 'config.yml';
|
||||
}
|
||||
|
||||
function setDefaultPublicFolderForField<T extends CmsField>(field: T) {
|
||||
function setDefaultPublicFolderForField<T extends Field>(field: T) {
|
||||
if ('media_folder' in field && !('public_folder' in field)) {
|
||||
return { ...field, public_folder: field.media_folder };
|
||||
}
|
||||
@ -88,7 +86,7 @@ const WIDGET_KEY_MAP = {
|
||||
optionsLength: 'options_length',
|
||||
} as const;
|
||||
|
||||
function setSnakeCaseConfig<T extends CmsField>(field: T) {
|
||||
function setSnakeCaseConfig<T extends Field>(field: T) {
|
||||
const deprecatedKeys = Object.keys(WIDGET_KEY_MAP).filter(
|
||||
camel => camel in field,
|
||||
) as ReadonlyArray<keyof typeof WIDGET_KEY_MAP>;
|
||||
@ -104,7 +102,7 @@ function setSnakeCaseConfig<T extends CmsField>(field: T) {
|
||||
return Object.assign({}, field, ...snakeValues) as T;
|
||||
}
|
||||
|
||||
function setI18nField<T extends CmsField>(field: T) {
|
||||
function setI18nField<T extends Field>(field: T) {
|
||||
if (field[I18N] === true) {
|
||||
return { ...field, [I18N]: I18N_FIELD.TRANSLATE };
|
||||
} else if (field[I18N] === false || !field[I18N]) {
|
||||
@ -113,24 +111,21 @@ function setI18nField<T extends CmsField>(field: T) {
|
||||
return field;
|
||||
}
|
||||
|
||||
function getI18nDefaults(
|
||||
collectionOrFileI18n: boolean | CmsI18nConfig,
|
||||
defaultI18n: CmsI18nConfig,
|
||||
) {
|
||||
function getI18nDefaults(collectionOrFileI18n: boolean | I18nInfo, defaultI18n: I18nInfo) {
|
||||
if (typeof collectionOrFileI18n === 'boolean') {
|
||||
return defaultI18n;
|
||||
} else {
|
||||
const locales = collectionOrFileI18n.locales || defaultI18n.locales;
|
||||
const defaultLocale = collectionOrFileI18n.default_locale || locales[0];
|
||||
const mergedI18n: CmsI18nConfig = deepmerge(defaultI18n, collectionOrFileI18n);
|
||||
const defaultLocale = collectionOrFileI18n.defaultLocale || locales[0];
|
||||
const mergedI18n: I18nInfo = deepmerge(defaultI18n, collectionOrFileI18n);
|
||||
mergedI18n.locales = locales;
|
||||
mergedI18n.default_locale = defaultLocale;
|
||||
mergedI18n.defaultLocale = defaultLocale;
|
||||
throwOnMissingDefaultLocale(mergedI18n);
|
||||
return mergedI18n;
|
||||
}
|
||||
}
|
||||
|
||||
function setI18nDefaultsForFields(collectionOrFileFields: CmsField[], hasI18n: boolean) {
|
||||
function setI18nDefaultsForFields(collectionOrFileFields: Field[], hasI18n: boolean) {
|
||||
if (hasI18n) {
|
||||
return traverseFieldsJS(collectionOrFileFields, setI18nField);
|
||||
} else {
|
||||
@ -142,7 +137,7 @@ function setI18nDefaultsForFields(collectionOrFileFields: CmsField[], hasI18n: b
|
||||
}
|
||||
}
|
||||
|
||||
function throwOnInvalidFileCollectionStructure(i18n?: CmsI18nConfig) {
|
||||
function throwOnInvalidFileCollectionStructure(i18n?: I18nInfo) {
|
||||
if (i18n && i18n.structure !== I18N_STRUCTURE.SINGLE_FILE) {
|
||||
throw new Error(
|
||||
`i18n configuration for files collections is limited to ${I18N_STRUCTURE.SINGLE_FILE} structure`,
|
||||
@ -150,24 +145,23 @@ function throwOnInvalidFileCollectionStructure(i18n?: CmsI18nConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
function throwOnMissingDefaultLocale(i18n?: CmsI18nConfig) {
|
||||
if (i18n && i18n.default_locale && !i18n.locales.includes(i18n.default_locale)) {
|
||||
function throwOnMissingDefaultLocale(i18n?: I18nInfo) {
|
||||
if (i18n && i18n.defaultLocale && !i18n.locales.includes(i18n.defaultLocale)) {
|
||||
throw new Error(
|
||||
`i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${
|
||||
i18n.default_locale
|
||||
i18n.defaultLocale
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasIntegration(config: CmsConfig, collection: CmsCollection) {
|
||||
// TODO remove fromJS when Immutable is removed from the integrations state slice
|
||||
const integrations = getIntegrations(fromJS(config));
|
||||
function hasIntegration(config: Config, collection: Collection) {
|
||||
const integrations = getIntegrations(config);
|
||||
const integration = selectIntegration(integrations, collection.name, 'listEntries');
|
||||
return !!integration;
|
||||
}
|
||||
|
||||
export function normalizeConfig(config: CmsConfig) {
|
||||
export function normalizeConfig(config: Config) {
|
||||
const { collections = [] } = config;
|
||||
|
||||
const normalizedCollections = collections.map(collection => {
|
||||
@ -193,7 +187,7 @@ export function normalizeConfig(config: CmsConfig) {
|
||||
return { ...config, collections: normalizedCollections };
|
||||
}
|
||||
|
||||
export function applyDefaults(originalConfig: CmsConfig) {
|
||||
export function applyDefaults(originalConfig: Config) {
|
||||
return produce(originalConfig, config => {
|
||||
config.slug = config.slug || {};
|
||||
config.collections = config.collections || [];
|
||||
@ -225,7 +219,7 @@ export function applyDefaults(originalConfig: CmsConfig) {
|
||||
const i18n = config[I18N];
|
||||
|
||||
if (i18n) {
|
||||
i18n.default_locale = i18n.default_locale || i18n.locales[0];
|
||||
i18n.defaultLocale = i18n.defaultLocale || i18n.locales[0];
|
||||
}
|
||||
|
||||
throwOnMissingDefaultLocale(i18n);
|
||||
@ -233,10 +227,6 @@ export function applyDefaults(originalConfig: CmsConfig) {
|
||||
const backend = resolveBackend(config);
|
||||
|
||||
for (const collection of config.collections) {
|
||||
if (!('publish' in collection)) {
|
||||
collection.publish = true;
|
||||
}
|
||||
|
||||
let collectionI18n = collection[I18N];
|
||||
|
||||
if (i18n && collectionI18n) {
|
||||
@ -251,7 +241,7 @@ export function applyDefaults(originalConfig: CmsConfig) {
|
||||
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
|
||||
}
|
||||
|
||||
const { folder, files, view_filters, view_groups, meta } = collection;
|
||||
const { folder, files, view_filters, view_groups } = collection;
|
||||
|
||||
if (folder) {
|
||||
collection.type = FOLDER;
|
||||
@ -270,16 +260,6 @@ export function applyDefaults(originalConfig: CmsConfig) {
|
||||
}
|
||||
|
||||
collection.folder = trim(folder, '/');
|
||||
|
||||
if (meta && meta.path) {
|
||||
const metaField = {
|
||||
name: 'path',
|
||||
meta: true,
|
||||
required: true,
|
||||
...meta.path,
|
||||
};
|
||||
collection.fields = [metaField, ...(collection.fields || [])];
|
||||
}
|
||||
}
|
||||
|
||||
if (files) {
|
||||
@ -288,7 +268,6 @@ export function applyDefaults(originalConfig: CmsConfig) {
|
||||
throwOnInvalidFileCollectionStructure(collectionI18n);
|
||||
|
||||
delete collection.nested;
|
||||
delete collection.meta;
|
||||
|
||||
for (const file of files) {
|
||||
file.file = trimStart(file.file, '/');
|
||||
@ -322,8 +301,7 @@ export function applyDefaults(originalConfig: CmsConfig) {
|
||||
if (!collection.sortable_fields) {
|
||||
collection.sortable_fields = {
|
||||
fields: selectDefaultSortableFields(
|
||||
// TODO remove fromJS when Immutable is removed from the collections state slice
|
||||
fromJS(collection),
|
||||
collection,
|
||||
backend,
|
||||
hasIntegration(config, collection),
|
||||
),
|
||||
@ -358,35 +336,29 @@ export function parseConfig(data: string) {
|
||||
typeof window.CMS_ENV === 'string' &&
|
||||
config[window.CMS_ENV]
|
||||
) {
|
||||
const configKeys = Object.keys(config[window.CMS_ENV]) as ReadonlyArray<keyof CmsConfig>;
|
||||
const configKeys = Object.keys(config[window.CMS_ENV]) as ReadonlyArray<keyof Config>;
|
||||
for (const key of configKeys) {
|
||||
config[key] = config[window.CMS_ENV][key] as CmsConfig[keyof CmsConfig];
|
||||
config[key] = config[window.CMS_ENV][key] as Config[keyof Config];
|
||||
}
|
||||
}
|
||||
return config as Partial<CmsConfig>;
|
||||
return config as Config;
|
||||
}
|
||||
|
||||
async function getConfigYaml(file: string, hasManualConfig: boolean) {
|
||||
async function getConfigYaml(file: string): Promise<Config> {
|
||||
const response = await fetch(file, { credentials: 'same-origin' }).catch(error => error as Error);
|
||||
if (response instanceof Error || response.status !== 200) {
|
||||
if (hasManualConfig) {
|
||||
return {};
|
||||
}
|
||||
const message = response instanceof Error ? response.message : response.status;
|
||||
throw new Error(`Failed to load config.yml (${message})`);
|
||||
}
|
||||
const contentType = response.headers.get('Content-Type') || 'Not-Found';
|
||||
const contentType = response.headers.get('Content-Type') ?? 'Not-Found';
|
||||
const isYaml = contentType.indexOf('yaml') !== -1;
|
||||
if (!isYaml) {
|
||||
console.info(`Response for ${file} was not yaml. (Content-Type: ${contentType})`);
|
||||
if (hasManualConfig) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return parseConfig(await response.text());
|
||||
}
|
||||
|
||||
export function configLoaded(config: CmsConfig) {
|
||||
export function configLoaded(config: Config) {
|
||||
return {
|
||||
type: CONFIG_SUCCESS,
|
||||
payload: config,
|
||||
@ -407,7 +379,7 @@ export function configFailed(err: Error) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function detectProxyServer(localBackend?: boolean | CmsLocalBackend) {
|
||||
export async function detectProxyServer(localBackend?: boolean | LocalBackend) {
|
||||
const allowedHosts = [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
@ -448,14 +420,12 @@ export async function detectProxyServer(localBackend?: boolean | CmsLocalBackend
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLocalBackend(originalConfig: CmsConfig) {
|
||||
export async function handleLocalBackend(originalConfig: Config) {
|
||||
if (!originalConfig.local_backend) {
|
||||
return originalConfig;
|
||||
}
|
||||
|
||||
const {
|
||||
proxyUrl
|
||||
} = await detectProxyServer(originalConfig.local_backend);
|
||||
const { proxyUrl } = await detectProxyServer(originalConfig.local_backend);
|
||||
|
||||
if (!proxyUrl) {
|
||||
return originalConfig;
|
||||
@ -467,23 +437,16 @@ export async function handleLocalBackend(originalConfig: CmsConfig) {
|
||||
});
|
||||
}
|
||||
|
||||
export function loadConfig(manualConfig: Partial<CmsConfig> = {}, onLoad: () => unknown) {
|
||||
export function loadConfig(manualConfig: Config | undefined, onLoad: () => unknown) {
|
||||
if (window.CMS_CONFIG) {
|
||||
return configLoaded(window.CMS_CONFIG);
|
||||
}
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>) => {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>) => {
|
||||
dispatch(configLoading());
|
||||
|
||||
try {
|
||||
const configUrl = getConfigUrl();
|
||||
const hasManualConfig = !isEmpty(manualConfig);
|
||||
const configYaml =
|
||||
manualConfig.load_config_file === false
|
||||
? {}
|
||||
: await getConfigYaml(configUrl, hasManualConfig);
|
||||
|
||||
// Merge manual config into the config.yml one
|
||||
const mergedConfig = deepmerge(configYaml, manualConfig);
|
||||
const mergedConfig = manualConfig ? manualConfig : await getConfigYaml(configUrl);
|
||||
|
||||
validateConfig(mergedConfig);
|
||||
|
||||
@ -497,9 +460,12 @@ export function loadConfig(manualConfig: Partial<CmsConfig> = {}, onLoad: () =>
|
||||
if (typeof onLoad === 'function') {
|
||||
onLoad();
|
||||
}
|
||||
} catch (err: any) {
|
||||
dispatch(configFailed(err));
|
||||
throw err;
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
dispatch(configFailed(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,13 +1,14 @@
|
||||
import { isAbsolutePath } from '../lib/util';
|
||||
import { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import { selectMediaFilePath } from '../reducers/entries';
|
||||
import { selectMediaFilePath } from '../lib/util/media.util';
|
||||
import { selectMediaFileByPath } from '../reducers/mediaLibrary';
|
||||
import { getMediaFile, waitForMediaLibraryToLoad, getMediaDisplayURL } from './mediaLibrary';
|
||||
import { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary';
|
||||
|
||||
import type AssetProxy from '../valueObjects/AssetProxy';
|
||||
import type { Collection, State, EntryMap, EntryField } from '../types/redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { Field, Collection, Entry } from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
import type AssetProxy from '../valueObjects/AssetProxy';
|
||||
|
||||
export const ADD_ASSETS = 'ADD_ASSETS';
|
||||
export const ADD_ASSET = 'ADD_ASSET';
|
||||
@ -42,7 +43,7 @@ export function loadAssetFailure(path: string, error: Error) {
|
||||
}
|
||||
|
||||
export function loadAsset(resolvedPath: string) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
try {
|
||||
dispatch(loadAssetRequest(resolvedPath));
|
||||
// load asset url from backend
|
||||
@ -59,19 +60,15 @@ export function loadAsset(resolvedPath: string) {
|
||||
dispatch(addAsset(asset));
|
||||
}
|
||||
dispatch(loadAssetSuccess(resolvedPath));
|
||||
} catch (e: any) {
|
||||
dispatch(loadAssetFailure(resolvedPath, e));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
dispatch(loadAssetFailure(resolvedPath, error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface GetAssetArgs {
|
||||
collection: Collection;
|
||||
entry: EntryMap;
|
||||
path: string;
|
||||
field?: EntryField;
|
||||
}
|
||||
|
||||
const emptyAsset = createAssetProxy({
|
||||
path: 'empty.svg',
|
||||
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
|
||||
@ -79,25 +76,18 @@ const emptyAsset = createAssetProxy({
|
||||
}),
|
||||
});
|
||||
|
||||
export function boundGetAsset(
|
||||
dispatch: ThunkDispatch<State, {}, AnyAction>,
|
||||
collection: Collection,
|
||||
entry: EntryMap,
|
||||
) {
|
||||
function bound(path: string, field: EntryField) {
|
||||
const asset = dispatch(getAsset({ collection, entry, path, field }));
|
||||
return asset;
|
||||
}
|
||||
|
||||
return bound;
|
||||
}
|
||||
|
||||
export function getAsset({ collection, entry, path, field }: GetAssetArgs) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
if (!path) return emptyAsset;
|
||||
export function getAsset(collection: Collection, entry: Entry, path: string, field?: Field) {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
if (!path) {
|
||||
return emptyAsset;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, field);
|
||||
if (!state.config.config) {
|
||||
return emptyAsset;
|
||||
}
|
||||
|
||||
const resolvedPath = selectMediaFilePath(state.config.config, collection, entry, path, field);
|
||||
|
||||
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
|
||||
if (isLoading) {
|
||||
|
@ -1,33 +1,28 @@
|
||||
import { Map } from 'immutable';
|
||||
|
||||
import { currentBackend } from '../backend';
|
||||
import confirm from '../components/UI/Confirm';
|
||||
import { getIntegrationProvider } from '../integrations';
|
||||
import { getMediaIntegrationProvider } from '../integrations';
|
||||
import { sanitizeSlug } from '../lib/urlHelper';
|
||||
import { basename, getBlobSHA } from '../lib/util';
|
||||
import { selectIntegration } from '../reducers';
|
||||
import {
|
||||
selectEditingDraft,
|
||||
selectMediaFilePath,
|
||||
selectMediaFilePublicPath,
|
||||
} from '../reducers/entries';
|
||||
import { selectEditingDraft } from '../reducers/entries';
|
||||
import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary';
|
||||
import { addSnackbar } from '../store/slices/snackbars';
|
||||
import { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
|
||||
import { addAsset, removeAsset } from './media';
|
||||
import { waitUntilWithTimeout } from './waitUntil';
|
||||
import { selectMediaFilePath, selectMediaFilePublicPath } from '../lib/util/media.util';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { ImplementationMediaFile } from '../lib/util';
|
||||
import type {
|
||||
Field,
|
||||
DisplayURLState,
|
||||
EntryField,
|
||||
ImplementationMediaFile,
|
||||
MediaFile,
|
||||
MediaLibraryInstance,
|
||||
State,
|
||||
} from '../types/redux';
|
||||
} from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
import type AssetProxy from '../valueObjects/AssetProxy';
|
||||
|
||||
export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN';
|
||||
@ -60,21 +55,21 @@ export function createMediaLibrary(instance: MediaLibraryInstance) {
|
||||
}
|
||||
|
||||
export function clearMediaControl(id: string) {
|
||||
return (_dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
const mediaLibrary = state.mediaLibrary.externalLibrary;
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.onClearControl({ id });
|
||||
mediaLibrary.onClearControl?.({ id });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function removeMediaControl(id: string) {
|
||||
return (_dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
const mediaLibrary = state.mediaLibrary.externalLibrary;
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.onRemoveControl({ id });
|
||||
mediaLibrary.onRemoveControl?.({ id });
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -84,41 +79,46 @@ export function openMediaLibrary(
|
||||
controlID?: string;
|
||||
forImage?: boolean;
|
||||
privateUpload?: boolean;
|
||||
value?: string;
|
||||
value?: string | string[];
|
||||
allowMultiple?: boolean;
|
||||
config?: Map<string, unknown>;
|
||||
field?: EntryField;
|
||||
replaceIndex?: number;
|
||||
config?: Record<string, unknown>;
|
||||
field?: Field;
|
||||
} = {},
|
||||
) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
const mediaLibrary = state.mediaLibrary.externalLibrary;
|
||||
if (mediaLibrary) {
|
||||
const { controlID: id, value, config = Map(), allowMultiple, forImage } = payload;
|
||||
mediaLibrary.show({ id, value, config: config.toJS(), allowMultiple, imagesOnly: forImage });
|
||||
const { controlID: id, value, config = {}, allowMultiple, forImage } = payload;
|
||||
mediaLibrary.show({ id, value, config, allowMultiple, imagesOnly: forImage });
|
||||
}
|
||||
dispatch(mediaLibraryOpened(payload));
|
||||
};
|
||||
}
|
||||
|
||||
export function closeMediaLibrary() {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
const mediaLibrary = state.mediaLibrary.externalLibrary;
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.hide();
|
||||
mediaLibrary.hide?.();
|
||||
}
|
||||
dispatch(mediaLibraryClosed());
|
||||
};
|
||||
}
|
||||
|
||||
export function insertMedia(mediaPath: string | string[], field: EntryField | undefined) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
export function insertMedia(mediaPath: string | string[], field: Field | undefined) {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const config = state.config;
|
||||
const entry = state.entryDraft.get('entry');
|
||||
const collectionName = state.entryDraft.getIn(['entry', 'collection']);
|
||||
const collection = state.collections.get(collectionName);
|
||||
const config = state.config.config;
|
||||
const entry = state.entryDraft.entry;
|
||||
const collectionName = state.entryDraft.entry?.collection;
|
||||
if (!collectionName || !config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = state.collections[collectionName];
|
||||
if (Array.isArray(mediaPath)) {
|
||||
mediaPath = mediaPath.map(path =>
|
||||
selectMediaFilePublicPath(config, collection, path, entry, field),
|
||||
@ -137,13 +137,26 @@ export function removeInsertedMedia(controlID: string) {
|
||||
export function loadMedia(
|
||||
opts: { delay?: number; query?: string; page?: number; privateUpload?: boolean } = {},
|
||||
) {
|
||||
const { delay = 0, query = '', page = 1, privateUpload } = opts;
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const { delay = 0, query = '', page = 1, privateUpload = false } = opts;
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(config);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
if (integration) {
|
||||
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
|
||||
const provider = getMediaIntegrationProvider(
|
||||
state.integrations,
|
||||
backend.getToken,
|
||||
integration,
|
||||
);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
dispatch(mediaLoading(page));
|
||||
try {
|
||||
const files = await provider.retrieve(query, page, privateUpload);
|
||||
@ -213,12 +226,17 @@ function createMediaFileFromAsset({
|
||||
|
||||
export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
const { privateUpload, field } = opts;
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(config);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
const files: MediaFile[] = selectMediaFiles(state, field);
|
||||
const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.slug);
|
||||
const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug);
|
||||
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
|
||||
|
||||
const editingDraft = selectEditingDraft(state.entryDraft);
|
||||
@ -254,11 +272,15 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
let assetProxy: AssetProxy;
|
||||
if (integration) {
|
||||
try {
|
||||
const provider = getIntegrationProvider(
|
||||
const provider = getMediaIntegrationProvider(
|
||||
state.integrations,
|
||||
backend.getToken,
|
||||
integration,
|
||||
);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
const response = await provider.upload(file, privateUpload);
|
||||
assetProxy = createAssetProxy({
|
||||
url: response.asset.url,
|
||||
@ -273,9 +295,13 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
} else if (privateUpload) {
|
||||
throw new Error('The Private Upload option is only available for Asset Store Integration');
|
||||
} else {
|
||||
const entry = state.entryDraft.get('entry');
|
||||
const collection = state.collections.get(entry?.get('collection'));
|
||||
const path = selectMediaFilePath(state.config, collection, entry, fileName, field);
|
||||
const entry = state.entryDraft.entry;
|
||||
if (!entry?.collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = state.collections[entry?.collection];
|
||||
const path = selectMediaFilePath(config, collection, entry, fileName, field);
|
||||
assetProxy = createAssetProxy({
|
||||
file,
|
||||
path,
|
||||
@ -296,11 +322,11 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
id,
|
||||
file,
|
||||
assetProxy,
|
||||
draft: editingDraft,
|
||||
draft: Boolean(editingDraft),
|
||||
});
|
||||
return dispatch(addDraftEntryMediaFile(mediaFile));
|
||||
} else {
|
||||
mediaFile = await backend.persistMedia(state.config, assetProxy);
|
||||
mediaFile = await backend.persistMedia(config, assetProxy);
|
||||
}
|
||||
|
||||
return dispatch(mediaPersisted(mediaFile, { privateUpload }));
|
||||
@ -322,28 +348,45 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
|
||||
export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(config);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
if (integration) {
|
||||
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
|
||||
const provider = getMediaIntegrationProvider(
|
||||
state.integrations,
|
||||
backend.getToken,
|
||||
integration,
|
||||
);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
dispatch(mediaDeleting());
|
||||
|
||||
try {
|
||||
await provider.delete(file.id);
|
||||
return dispatch(mediaDeleted(file, { privateUpload }));
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToDeleteMedia',
|
||||
details: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (error instanceof Error) {
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToDeleteMedia',
|
||||
details: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return dispatch(mediaDeleteFailed({ privateUpload }));
|
||||
}
|
||||
}
|
||||
@ -358,56 +401,72 @@ export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
|
||||
dispatch(mediaDeleting());
|
||||
dispatch(removeAsset(file.path));
|
||||
|
||||
await backend.deleteMedia(state.config, file.path);
|
||||
await backend.deleteMedia(config, file.path);
|
||||
|
||||
dispatch(mediaDeleted(file));
|
||||
if (editingDraft) {
|
||||
dispatch(removeDraftEntryMediaFile({ id: file.id }));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToDeleteMedia',
|
||||
details: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (error instanceof Error) {
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToDeleteMedia',
|
||||
details: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return dispatch(mediaDeleteFailed());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMediaFile(state: State, path: string) {
|
||||
const backend = currentBackend(state.config);
|
||||
export async function getMediaFile(state: RootState, path: string) {
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return { url: '' };
|
||||
}
|
||||
|
||||
const backend = currentBackend(config);
|
||||
const { url } = await backend.getMediaFile(path);
|
||||
return { url };
|
||||
}
|
||||
|
||||
export function loadMediaDisplayURL(file: MediaFile) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const { displayURL, id } = file;
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, id);
|
||||
if (
|
||||
!id ||
|
||||
!displayURL ||
|
||||
displayURLState.get('url') ||
|
||||
displayURLState.get('isFetching') ||
|
||||
displayURLState.get('err')
|
||||
displayURLState.url ||
|
||||
displayURLState.isFetching ||
|
||||
displayURLState.err
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (typeof displayURL === 'string') {
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
dispatch(mediaDisplayURLSuccess(id, displayURL));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = currentBackend(state.config);
|
||||
const backend = currentBackend(config);
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
const newURL = await backend.getMediaDisplayURL(displayURL);
|
||||
if (newURL) {
|
||||
@ -415,9 +474,12 @@ export function loadMediaDisplayURL(file: MediaFile) {
|
||||
} else {
|
||||
throw new Error('No display URL was returned!');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
dispatch(mediaDisplayURLFailure(id, err));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
dispatch(mediaDisplayURLFailure(id, error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -426,11 +488,11 @@ function mediaLibraryOpened(payload: {
|
||||
controlID?: string;
|
||||
forImage?: boolean;
|
||||
privateUpload?: boolean;
|
||||
value?: string;
|
||||
value?: string | string[];
|
||||
replaceIndex?: number;
|
||||
allowMultiple?: boolean;
|
||||
config?: Map<string, unknown>;
|
||||
field?: EntryField;
|
||||
config?: Record<string, unknown>;
|
||||
field?: Field;
|
||||
}) {
|
||||
return { type: MEDIA_LIBRARY_OPEN, payload } as const;
|
||||
}
|
||||
@ -450,9 +512,9 @@ export function mediaLoading(page: number) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
interface MediaOptions {
|
||||
export interface MediaOptions {
|
||||
privateUpload?: boolean;
|
||||
field?: EntryField;
|
||||
field?: Field;
|
||||
page?: number;
|
||||
canPaginate?: boolean;
|
||||
dynamicSearch?: boolean;
|
||||
@ -524,10 +586,10 @@ export function mediaDisplayURLFailure(key: string, err: Error) {
|
||||
}
|
||||
|
||||
export async function waitForMediaLibraryToLoad(
|
||||
dispatch: ThunkDispatch<State, {}, AnyAction>,
|
||||
state: State,
|
||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||
state: RootState,
|
||||
) {
|
||||
if (state.mediaLibrary.get('isLoading') !== false && !state.mediaLibrary.get('externalLibrary')) {
|
||||
if (state.mediaLibrary.isLoading !== false && !state.mediaLibrary.externalLibrary) {
|
||||
await waitUntilWithTimeout(dispatch, resolve => ({
|
||||
predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS || type === MEDIA_LOAD_FAILURE,
|
||||
run: () => resolve(),
|
||||
@ -536,17 +598,17 @@ export async function waitForMediaLibraryToLoad(
|
||||
}
|
||||
|
||||
export async function getMediaDisplayURL(
|
||||
dispatch: ThunkDispatch<State, {}, AnyAction>,
|
||||
state: State,
|
||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||
state: RootState,
|
||||
file: MediaFile,
|
||||
) {
|
||||
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, file.id);
|
||||
|
||||
let url: string | null | undefined;
|
||||
if (displayURLState.get('url')) {
|
||||
if (displayURLState.url) {
|
||||
// url was already loaded
|
||||
url = displayURLState.get('url');
|
||||
} else if (displayURLState.get('err')) {
|
||||
url = displayURLState.url;
|
||||
} else if (displayURLState.err) {
|
||||
// url loading had an error
|
||||
url = null;
|
||||
} else {
|
||||
@ -558,7 +620,7 @@ export async function getMediaDisplayURL(
|
||||
run: (_dispatch, _getState, action) => resolve(action.payload.url),
|
||||
}));
|
||||
|
||||
if (!displayURLState.get('isFetching')) {
|
||||
if (!displayURLState.isFetching) {
|
||||
// load display url
|
||||
dispatch(loadMediaDisplayURL(file));
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { State } from '../types/redux';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
|
||||
|
||||
@ -21,8 +21,10 @@ export function loadScroll() {
|
||||
}
|
||||
|
||||
export function toggleScroll() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return async (dispatch: ThunkDispatch<State, undefined, AnyAction>, _getState: () => State) => {
|
||||
return async (
|
||||
dispatch: ThunkDispatch<RootState, undefined, AnyAction>,
|
||||
_getState: () => RootState,
|
||||
) => {
|
||||
return dispatch(togglingScroll());
|
||||
};
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
import { currentBackend } from '../backend';
|
||||
import { getIntegrationProvider } from '../integrations';
|
||||
import { getSearchIntegrationProvider } from '../integrations';
|
||||
import { selectIntegration } from '../reducers';
|
||||
|
||||
import type { State } from '../types/redux';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { EntryValue } from '../valueObjects/Entry';
|
||||
import type { Entry, SearchQueryResponse } from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
/*
|
||||
* Constant Declarations
|
||||
@ -33,7 +33,7 @@ export function searchingEntries(searchTerm: string, searchCollections: string[]
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function searchSuccess(entries: EntryValue[], page: number) {
|
||||
export function searchSuccess(entries: Entry[], page: number) {
|
||||
return {
|
||||
type: SEARCH_ENTRIES_SUCCESS,
|
||||
payload: {
|
||||
@ -59,17 +59,7 @@ export function querying(searchTerm: string) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
type SearchResponse = {
|
||||
entries: EntryValue[];
|
||||
pagination: number;
|
||||
};
|
||||
|
||||
type QueryResponse = {
|
||||
hits: EntryValue[];
|
||||
query: string;
|
||||
};
|
||||
|
||||
export function querySuccess(namespace: string, hits: EntryValue[]) {
|
||||
export function querySuccess(namespace: string, hits: Entry[]) {
|
||||
return {
|
||||
type: QUERY_SUCCESS,
|
||||
payload: {
|
||||
@ -100,11 +90,19 @@ export function clearSearch() {
|
||||
|
||||
// SearchEntries will search for complete entries in all collections.
|
||||
export function searchEntries(searchTerm: string, searchCollections: string[], page = 0) {
|
||||
return async (dispatch: ThunkDispatch<State, undefined, AnyAction>, getState: () => State) => {
|
||||
return async (
|
||||
dispatch: ThunkDispatch<RootState, undefined, AnyAction>,
|
||||
getState: () => RootState,
|
||||
) => {
|
||||
const state = getState();
|
||||
const { search } = state;
|
||||
const backend = currentBackend(state.config);
|
||||
const allCollections = searchCollections || state.collections.keySeq().toArray();
|
||||
const configState = state.config;
|
||||
if (!configState.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(configState.config);
|
||||
const allCollections = searchCollections || Object.keys(state.collections);
|
||||
const collections = allCollections.filter(collection =>
|
||||
selectIntegration(state, collection, 'search'),
|
||||
);
|
||||
@ -124,24 +122,30 @@ export function searchEntries(searchTerm: string, searchCollections: string[], p
|
||||
dispatch(searchingEntries(searchTerm, allCollections, page));
|
||||
|
||||
const searchPromise = integration
|
||||
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(
|
||||
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.search(
|
||||
collections,
|
||||
searchTerm,
|
||||
page,
|
||||
)
|
||||
: backend.search(
|
||||
state.collections
|
||||
.filter((_, key: string) => allCollections.indexOf(key) !== -1)
|
||||
.valueSeq()
|
||||
.toArray(),
|
||||
Object.entries(state.collections)
|
||||
.filter(([key, _value]) => allCollections.indexOf(key) !== -1)
|
||||
.map(([_key, value]) => value),
|
||||
searchTerm,
|
||||
);
|
||||
|
||||
try {
|
||||
const response: SearchResponse = await searchPromise;
|
||||
const response = await searchPromise;
|
||||
if (!response) {
|
||||
return dispatch(searchFailure(new Error(`No integration found for name "${integration}"`)));
|
||||
}
|
||||
|
||||
return dispatch(searchSuccess(response.entries, response.pagination));
|
||||
} catch (error: any) {
|
||||
return dispatch(searchFailure(error));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
return dispatch(searchFailure(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -156,29 +160,40 @@ export function query(
|
||||
file?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
dispatch(querying(searchTerm));
|
||||
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const configState = state.config;
|
||||
if (!configState.config) {
|
||||
return dispatch(queryFailure(new Error('Config not found')));
|
||||
}
|
||||
|
||||
const backend = currentBackend(configState.config);
|
||||
const integration = selectIntegration(state, collectionName, 'search');
|
||||
const collection = state.collections.find(
|
||||
collection => collection.get('name') === collectionName,
|
||||
const collection = Object.values(state.collections).find(
|
||||
collection => collection.name === collectionName,
|
||||
);
|
||||
if (!collection) {
|
||||
return dispatch(queryFailure(new Error('Collection not found')));
|
||||
}
|
||||
|
||||
const queryPromise = integration
|
||||
? getIntegrationProvider(state.integrations, backend.getToken, integration).searchBy(
|
||||
searchFields.map(f => `data.${f}`),
|
||||
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.searchBy(
|
||||
JSON.stringify(searchFields.map(f => `data.${f}`)),
|
||||
collectionName,
|
||||
searchTerm,
|
||||
)
|
||||
: backend.query(collection, searchFields, searchTerm, file, limit);
|
||||
|
||||
try {
|
||||
const response: QueryResponse = await queryPromise;
|
||||
const response: SearchQueryResponse = await queryPromise;
|
||||
return dispatch(querySuccess(namespace, response.hits));
|
||||
} catch (error: any) {
|
||||
return dispatch(queryFailure(error));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
return dispatch(queryFailure(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { addSnackbar, removeSnackbarById } from '../store/slices/snackbars';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { State } from '../types/redux';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export const STATUS_REQUEST = 'STATUS_REQUEST';
|
||||
export const STATUS_SUCCESS = 'STATUS_SUCCESS';
|
||||
@ -33,20 +33,21 @@ export function statusFailure(error: Error) {
|
||||
}
|
||||
|
||||
export function checkBackendStatus() {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
try {
|
||||
const state = getState();
|
||||
if (state.status.isFetching) {
|
||||
const config = state.config.config;
|
||||
if (state.status.isFetching || !config) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(statusRequest());
|
||||
const backend = currentBackend(state.config);
|
||||
const backend = currentBackend(config);
|
||||
const status = await backend.status();
|
||||
|
||||
const backendDownKey = 'ui.toast.onBackendDown';
|
||||
const previousBackendDownNotifs = state.snackbar.messages.filter(
|
||||
n => n.message?.key === backendDownKey,
|
||||
n => typeof n.message !== 'string' && n.message.key === backendDownKey,
|
||||
);
|
||||
|
||||
if (status.api.status === false) {
|
||||
@ -69,7 +70,9 @@ export function checkBackendStatus() {
|
||||
const authError = status.auth.status === false;
|
||||
if (authError) {
|
||||
const key = 'ui.toast.onLoggedOut';
|
||||
const existingNotification = state.snackbar.messages.find(n => n.message?.key === key);
|
||||
const existingNotification = state.snackbar.messages.find(
|
||||
n => typeof n.message !== 'string' && n.message.key === key,
|
||||
);
|
||||
if (!existingNotification) {
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
@ -81,8 +84,11 @@ export function checkBackendStatus() {
|
||||
}
|
||||
|
||||
dispatch(statusSuccess(status));
|
||||
} catch (error: any) {
|
||||
dispatch(statusFailure(error));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
dispatch(statusFailure(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { WAIT_UNTIL_ACTION } from '../store/middleware/waitUntilAction';
|
||||
|
||||
import type { WaitActionArgs } from '../store/middleware/waitUntilAction';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { State } from '../types/redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { RootState } from '../store';
|
||||
import type { WaitActionArgs } from '../store/middleware/waitUntilAction';
|
||||
|
||||
export function waitUntil({ predicate, run }: WaitActionArgs) {
|
||||
return {
|
||||
@ -14,17 +14,17 @@ export function waitUntil({ predicate, run }: WaitActionArgs) {
|
||||
}
|
||||
|
||||
export async function waitUntilWithTimeout<T>(
|
||||
dispatch: ThunkDispatch<State, {}, AnyAction>,
|
||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||
waitActionArgs: (resolve: (value?: T) => void) => WaitActionArgs,
|
||||
timeout = 30000,
|
||||
): Promise<T | null | undefined | void> {
|
||||
): Promise<T | null | undefined> {
|
||||
let waitDone = false;
|
||||
|
||||
const waitPromise = new Promise<T | undefined>(resolve => {
|
||||
dispatch(waitUntil(waitActionArgs(resolve)));
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<T | null | void>(resolve => {
|
||||
const timeoutPromise = new Promise<T | null>(resolve => {
|
||||
setTimeout(() => {
|
||||
if (waitDone) {
|
||||
resolve(null);
|
||||
|
403
src/backend.ts
403
src/backend.ts
@ -1,7 +1,9 @@
|
||||
import * as fuzzy from 'fuzzy';
|
||||
import { fromJS, List, Set } from 'immutable';
|
||||
import { attempt, flatten, get, isError, set, trim, uniq } from 'lodash';
|
||||
import { basename, dirname, extname, join } from 'path';
|
||||
import attempt from 'lodash/attempt';
|
||||
import flatten from 'lodash/flatten';
|
||||
import get from 'lodash/get';
|
||||
import isError from 'lodash/isError';
|
||||
import uniq from 'lodash/uniq';
|
||||
|
||||
import { FILES, FOLDER } from './constants/collectionTypes';
|
||||
import { resolveFormat } from './formats/formats';
|
||||
@ -14,7 +16,7 @@ import {
|
||||
getI18nFiles,
|
||||
getI18nFilesDepth,
|
||||
groupEntries,
|
||||
hasI18n
|
||||
hasI18n,
|
||||
} from './lib/i18n';
|
||||
import { getBackend, invokeEvent } from './lib/registry';
|
||||
import { sanitizeChar } from './lib/urlHelper';
|
||||
@ -24,9 +26,8 @@ import {
|
||||
Cursor,
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
getPathDepth,
|
||||
localForage
|
||||
localForage,
|
||||
} from './lib/util';
|
||||
import { stringTemplate } from './lib/widgets';
|
||||
import {
|
||||
selectAllowDeletion,
|
||||
selectAllowNewEntries,
|
||||
@ -35,42 +36,42 @@ import {
|
||||
selectFieldsComments,
|
||||
selectFileEntryLabel,
|
||||
selectFolderEntryExtension,
|
||||
selectHasMetaPath,
|
||||
selectInferedField,
|
||||
selectMediaFolders
|
||||
} from './reducers/collections';
|
||||
import { selectMediaFilePath } from './reducers/entries';
|
||||
import { selectCustomPath } from './reducers/entryDraft';
|
||||
selectMediaFolders,
|
||||
} from './lib/util/collection.util';
|
||||
import { selectMediaFilePath } from './lib/util/media.util';
|
||||
import { set } from './lib/util/object.util';
|
||||
import { selectIntegration } from './reducers/integrations';
|
||||
import { createEntry } from './valueObjects/Entry';
|
||||
import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate';
|
||||
|
||||
import type { Map } from 'immutable';
|
||||
import type { CmsConfig, ImplementationEntry } from './interface';
|
||||
import type {
|
||||
AsyncLock,
|
||||
BackendClass,
|
||||
BackendInitializer,
|
||||
Collection,
|
||||
CollectionFile,
|
||||
Config,
|
||||
Credentials,
|
||||
DataFile,
|
||||
DisplayURL,
|
||||
Implementation as BackendImplementation,
|
||||
User
|
||||
} from './lib/util';
|
||||
import type {
|
||||
Collection,
|
||||
CollectionFile,
|
||||
Entry,
|
||||
EntryData,
|
||||
EntryDraft,
|
||||
EntryField,
|
||||
EntryMap,
|
||||
Field,
|
||||
FilterRule,
|
||||
State
|
||||
} from './types/redux';
|
||||
ImplementationEntry,
|
||||
SearchQueryResponse,
|
||||
SearchResponse,
|
||||
User,
|
||||
} from './interface';
|
||||
import type { AllowedEvent } from './lib/registry';
|
||||
import type { AsyncLock } from './lib/util';
|
||||
import type { RootState } from './store';
|
||||
import type AssetProxy from './valueObjects/AssetProxy';
|
||||
import type { EntryValue } from './valueObjects/Entry';
|
||||
|
||||
const { extractTemplateVars, dateParsers, expandPath } = stringTemplate;
|
||||
|
||||
function updateAssetProxies(
|
||||
assetProxies: AssetProxy[],
|
||||
config: CmsConfig,
|
||||
config: Config,
|
||||
collection: Collection,
|
||||
entryDraft: EntryDraft,
|
||||
path: string,
|
||||
@ -78,13 +79,8 @@ function updateAssetProxies(
|
||||
assetProxies.map(asset => {
|
||||
// update media files path based on entry path
|
||||
const oldPath = asset.path;
|
||||
const newPath = selectMediaFilePath(
|
||||
config,
|
||||
collection,
|
||||
entryDraft.get('entry').set('path', path),
|
||||
oldPath,
|
||||
asset.field,
|
||||
);
|
||||
entryDraft.entry.path = path;
|
||||
const newPath = selectMediaFilePath(config, collection, entryDraft.entry, oldPath, asset.field);
|
||||
asset.path = newPath;
|
||||
});
|
||||
}
|
||||
@ -115,15 +111,15 @@ function getEntryBackupKey(collectionName?: string, slug?: string) {
|
||||
return `${baseKey}.${collectionName}${suffix}`;
|
||||
}
|
||||
|
||||
function getEntryField(field: string, entry: EntryValue) {
|
||||
function getEntryField(field: string, entry: Entry): string {
|
||||
const value = get(entry.data, field);
|
||||
if (value) {
|
||||
return String(value);
|
||||
} else {
|
||||
const firstFieldPart = field.split('.')[0];
|
||||
if (entry[firstFieldPart as keyof EntryValue]) {
|
||||
if (entry[firstFieldPart as keyof Entry]) {
|
||||
// allows searching using entry.slug/entry.path etc.
|
||||
return entry[firstFieldPart as keyof EntryValue];
|
||||
return String(entry[firstFieldPart as keyof Entry]);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
@ -131,7 +127,7 @@ function getEntryField(field: string, entry: EntryValue) {
|
||||
}
|
||||
|
||||
export function extractSearchFields(searchFields: string[]) {
|
||||
return (entry: EntryValue) =>
|
||||
return (entry: Entry) =>
|
||||
searchFields.reduce((acc, field) => {
|
||||
const value = getEntryField(field, entry);
|
||||
if (value) {
|
||||
@ -142,7 +138,7 @@ export function extractSearchFields(searchFields: string[]) {
|
||||
}, '');
|
||||
}
|
||||
|
||||
export function expandSearchEntries(entries: EntryValue[], searchFields: string[]) {
|
||||
export function expandSearchEntries(entries: Entry[], searchFields: string[]) {
|
||||
// expand the entries for the purpose of the search
|
||||
const expandedEntries = entries.reduce((acc, e) => {
|
||||
const expandedFields = searchFields.reduce((acc, f) => {
|
||||
@ -156,12 +152,12 @@ export function expandSearchEntries(entries: EntryValue[], searchFields: string[
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as (EntryValue & { field: string })[]);
|
||||
}, [] as (Entry & { field: string })[]);
|
||||
|
||||
return expandedEntries;
|
||||
}
|
||||
|
||||
export function mergeExpandedEntries(entries: (EntryValue & { field: string })[]) {
|
||||
export function mergeExpandedEntries(entries: (Entry & { field: string })[]) {
|
||||
// merge the search results by slug and only keep data that matched the search
|
||||
const fields = entries.map(f => f.field);
|
||||
const arrayPaths: Record<string, Set<string>> = {};
|
||||
@ -171,11 +167,12 @@ export function mergeExpandedEntries(entries: (EntryValue & { field: string })[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { field, ...rest } = e;
|
||||
acc[e.slug] = rest;
|
||||
arrayPaths[e.slug] = Set();
|
||||
arrayPaths[e.slug] = new Set();
|
||||
}
|
||||
|
||||
const nestedFields = e.field.split('.');
|
||||
let value = acc[e.slug].data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let value = acc[e.slug].data as any;
|
||||
for (let i = 0; i < nestedFields.length; i++) {
|
||||
value = value[nestedFields[i]];
|
||||
if (Array.isArray(value)) {
|
||||
@ -185,13 +182,13 @@ export function mergeExpandedEntries(entries: (EntryValue & { field: string })[]
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, EntryValue>);
|
||||
}, {} as Record<string, Entry>);
|
||||
|
||||
// this keeps the search score sorting order designated by the order in entries
|
||||
// and filters non matching items
|
||||
Object.keys(merged).forEach(slug => {
|
||||
const data = merged[slug].data;
|
||||
for (const path of arrayPaths[slug].toArray()) {
|
||||
let data = merged[slug].data ?? {};
|
||||
for (const path of arrayPaths[slug]) {
|
||||
const array = get(data, path) as unknown[];
|
||||
const filtered = array.filter((_, index) => {
|
||||
return fields.some(f => `${f}.`.startsWith(`${path}.${index}.`));
|
||||
@ -208,26 +205,19 @@ export function mergeExpandedEntries(entries: (EntryValue & { field: string })[]
|
||||
return matchingFieldIndexA - matchingFieldIndexB;
|
||||
});
|
||||
|
||||
set(data, path, filtered);
|
||||
data = set(data, path, filtered);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(merged);
|
||||
}
|
||||
|
||||
function sortByScore(a: fuzzy.FilterResult<EntryValue>, b: fuzzy.FilterResult<EntryValue>) {
|
||||
function sortByScore(a: fuzzy.FilterResult<Entry>, b: fuzzy.FilterResult<Entry>) {
|
||||
if (a.score > b.score) return -1;
|
||||
if (a.score < b.score) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function slugFromCustomPath(collection: Collection, customPath: string) {
|
||||
const folderPath = collection.get('folder', '') as string;
|
||||
const entryPath = customPath.toLowerCase().replace(folderPath.toLowerCase(), '');
|
||||
const slug = join(dirname(trim(entryPath, '/')), basename(entryPath, extname(customPath)));
|
||||
return slug;
|
||||
}
|
||||
|
||||
interface AuthStore {
|
||||
retrieve: () => User;
|
||||
store: (user: User) => void;
|
||||
@ -236,7 +226,7 @@ interface AuthStore {
|
||||
|
||||
interface BackendOptions {
|
||||
backendName: string;
|
||||
config: CmsConfig;
|
||||
config: Config;
|
||||
authStore?: AuthStore;
|
||||
}
|
||||
|
||||
@ -249,7 +239,10 @@ export interface MediaFile {
|
||||
draft?: boolean;
|
||||
url?: string;
|
||||
file?: File;
|
||||
field?: EntryField;
|
||||
field?: Field;
|
||||
queryOrder?: unknown;
|
||||
isViewableImage?: boolean;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface BackupEntry {
|
||||
@ -260,34 +253,17 @@ interface BackupEntry {
|
||||
}
|
||||
|
||||
interface PersistArgs {
|
||||
config: CmsConfig;
|
||||
config: Config;
|
||||
collection: Collection;
|
||||
entryDraft: EntryDraft;
|
||||
assetProxies: AssetProxy[];
|
||||
usedSlugs: List<string>;
|
||||
usedSlugs: string[];
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface ImplementationInitOptions {
|
||||
updateUserCredentials: (credentials: Credentials) => void;
|
||||
}
|
||||
|
||||
type Implementation = BackendImplementation & {
|
||||
init: (config: CmsConfig, options: ImplementationInitOptions) => Implementation;
|
||||
};
|
||||
|
||||
function prepareMetaPath(path: string, collection: Collection) {
|
||||
if (!selectHasMetaPath(collection)) {
|
||||
return path;
|
||||
}
|
||||
const dir = dirname(path);
|
||||
return dir.slice(collection.get('folder')!.length + 1) || '/';
|
||||
}
|
||||
|
||||
function collectionDepth(collection: Collection) {
|
||||
let depth;
|
||||
depth =
|
||||
collection.get('nested')?.get('depth') || getPathDepth(collection.get('path', '') as string);
|
||||
depth = collection.nested?.depth || getPathDepth(collection.path ?? '');
|
||||
|
||||
if (hasI18n(collection)) {
|
||||
depth = getI18nFilesDepth(collection, depth);
|
||||
@ -297,14 +273,17 @@ function collectionDepth(collection: Collection) {
|
||||
}
|
||||
|
||||
export class Backend {
|
||||
implementation: Implementation;
|
||||
implementation: BackendClass;
|
||||
backendName: string;
|
||||
config: CmsConfig;
|
||||
config: Config;
|
||||
authStore?: AuthStore;
|
||||
user?: User | null;
|
||||
backupSync: AsyncLock;
|
||||
|
||||
constructor(implementation: Implementation, { backendName, authStore, config }: BackendOptions) {
|
||||
constructor(
|
||||
implementation: BackendInitializer,
|
||||
{ backendName, authStore, config }: BackendOptions,
|
||||
) {
|
||||
// We can't reliably run this on exit, so we do cleanup on load.
|
||||
this.deleteAnonymousBackup();
|
||||
this.config = config;
|
||||
@ -411,18 +390,12 @@ export class Backend {
|
||||
|
||||
async generateUniqueSlug(
|
||||
collection: Collection,
|
||||
entryData: Map<string, unknown>,
|
||||
config: CmsConfig,
|
||||
usedSlugs: List<string>,
|
||||
customPath: string | undefined,
|
||||
entryData: EntryData,
|
||||
config: Config,
|
||||
usedSlugs: string[],
|
||||
) {
|
||||
const slugConfig = config.slug;
|
||||
let slug: string;
|
||||
if (customPath) {
|
||||
slug = slugFromCustomPath(collection, customPath);
|
||||
} else {
|
||||
slug = slugFormatter(collection, entryData, slugConfig);
|
||||
}
|
||||
const slug = slugFormatter(collection, entryData, slugConfig);
|
||||
let i = 1;
|
||||
let uniqueSlug = slug;
|
||||
|
||||
@ -436,10 +409,10 @@ export class Backend {
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
processEntries(loadedEntries: ImplementationEntry[], collection: Collection) {
|
||||
processEntries(loadedEntries: ImplementationEntry[], collection: Collection): Entry[] {
|
||||
const entries = loadedEntries.map(loadedEntry =>
|
||||
createEntry(
|
||||
collection.get('name'),
|
||||
collection.name,
|
||||
selectEntrySlug(collection, loadedEntry.file.path),
|
||||
loadedEntry.file.path,
|
||||
{
|
||||
@ -447,13 +420,12 @@ export class Backend {
|
||||
label: loadedEntry.file.label,
|
||||
author: loadedEntry.file.author,
|
||||
updatedOn: loadedEntry.file.updatedOn,
|
||||
meta: { path: prepareMetaPath(loadedEntry.file.path, collection) },
|
||||
},
|
||||
),
|
||||
);
|
||||
const formattedEntries = entries.map(this.entryWithFormat(collection));
|
||||
// If this collection has a "filter" property, filter entries accordingly
|
||||
const collectionFilter = collection.get('filter');
|
||||
const collectionFilter = collection.filter;
|
||||
const filteredEntries = collectionFilter
|
||||
? this.filterEntries({ entries: formattedEntries }, collectionFilter)
|
||||
: formattedEntries;
|
||||
@ -470,24 +442,17 @@ export class Backend {
|
||||
async listEntries(collection: Collection) {
|
||||
const extension = selectFolderEntryExtension(collection);
|
||||
let listMethod: () => Promise<ImplementationEntry[]>;
|
||||
const collectionType = collection.get('type');
|
||||
const collectionType = collection.type;
|
||||
if (collectionType === FOLDER) {
|
||||
listMethod = () => {
|
||||
const depth = collectionDepth(collection);
|
||||
return this.implementation.entriesByFolder(
|
||||
collection.get('folder') as string,
|
||||
extension,
|
||||
depth,
|
||||
);
|
||||
return this.implementation.entriesByFolder(collection.folder as string, extension, depth);
|
||||
};
|
||||
} else if (collectionType === FILES) {
|
||||
const files = collection
|
||||
.get('files')!
|
||||
.map(collectionFile => ({
|
||||
path: collectionFile!.get('file'),
|
||||
label: collectionFile!.get('label'),
|
||||
}))
|
||||
.toArray();
|
||||
const files = collection.files!.map(collectionFile => ({
|
||||
path: collectionFile!.file,
|
||||
label: collectionFile!.label,
|
||||
}));
|
||||
listMethod = () => this.implementation.entriesByFiles(files);
|
||||
} else {
|
||||
throw new Error(`Unknown collection type: ${collectionType}`);
|
||||
@ -506,7 +471,7 @@ export class Backend {
|
||||
});
|
||||
return {
|
||||
entries: this.processEntries(loadedEntries, collection),
|
||||
pagination: cursor.meta?.get('page'),
|
||||
pagination: cursor.meta?.page,
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
@ -517,18 +482,18 @@ export class Backend {
|
||||
// returns all the collected entries. Used to retrieve all entries
|
||||
// for local searches and queries.
|
||||
async listAllEntries(collection: Collection) {
|
||||
if (collection.get('folder') && this.implementation.allEntriesByFolder) {
|
||||
if (collection.folder && this.implementation.allEntriesByFolder) {
|
||||
const depth = collectionDepth(collection);
|
||||
const extension = selectFolderEntryExtension(collection);
|
||||
return this.implementation
|
||||
.allEntriesByFolder(collection.get('folder') as string, extension, depth)
|
||||
.allEntriesByFolder(collection.folder as string, extension, depth)
|
||||
.then(entries => this.processEntries(entries, collection));
|
||||
}
|
||||
|
||||
const response = await this.listEntries(collection);
|
||||
const { entries } = response;
|
||||
let { cursor } = response;
|
||||
while (cursor && cursor.actions!.includes('next')) {
|
||||
while (cursor && cursor.actions?.has('next')) {
|
||||
const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, 'next');
|
||||
entries.push(...newEntries);
|
||||
cursor = newCursor;
|
||||
@ -536,25 +501,22 @@ export class Backend {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async search(collections: Collection[], searchTerm: string) {
|
||||
async search(collections: Collection[], searchTerm: string): Promise<SearchResponse> {
|
||||
// Perform a local search by requesting all entries. For each
|
||||
// collection, load it, search, and call onCollectionResults with
|
||||
// its results.
|
||||
const errors: Error[] = [];
|
||||
const collectionEntriesRequests = collections
|
||||
.map(async collection => {
|
||||
const summary = collection.get('summary', '') as string;
|
||||
const summary = collection.summary ?? '';
|
||||
const summaryFields = extractTemplateVars(summary);
|
||||
|
||||
// TODO: pass search fields in as an argument
|
||||
let searchFields: (string | null | undefined)[] = [];
|
||||
|
||||
if (collection.get('type') === FILES) {
|
||||
collection.get('files')?.forEach(f => {
|
||||
const topLevelFields = f!
|
||||
.get('fields')
|
||||
.map(f => f!.get('name'))
|
||||
.toArray();
|
||||
if (collection.type === FILES) {
|
||||
collection.files?.forEach(f => {
|
||||
const topLevelFields = f!.fields.map(f => f!.name);
|
||||
searchFields = [...searchFields, ...topLevelFields];
|
||||
});
|
||||
} else {
|
||||
@ -579,7 +541,7 @@ export class Backend {
|
||||
.map(p =>
|
||||
p.catch(err => {
|
||||
errors.push(err);
|
||||
return [] as fuzzy.FilterResult<EntryValue>[];
|
||||
return [] as fuzzy.FilterResult<Entry>[];
|
||||
}),
|
||||
);
|
||||
|
||||
@ -592,10 +554,10 @@ export class Backend {
|
||||
}
|
||||
|
||||
const hits = entries
|
||||
.filter(({ score }: fuzzy.FilterResult<EntryValue>) => score > 5)
|
||||
.filter(({ score }: fuzzy.FilterResult<Entry>) => score > 5)
|
||||
.sort(sortByScore)
|
||||
.map((f: fuzzy.FilterResult<EntryValue>) => f.original);
|
||||
return { entries: hits };
|
||||
.map((f: fuzzy.FilterResult<Entry>) => f.original);
|
||||
return { entries: hits, pagination: 1 };
|
||||
}
|
||||
|
||||
async query(
|
||||
@ -604,7 +566,7 @@ export class Backend {
|
||||
searchTerm: string,
|
||||
file?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
): Promise<SearchQueryResponse> {
|
||||
let entries = await this.listAllEntries(collection);
|
||||
if (file) {
|
||||
entries = entries.filter(e => e.slug === file);
|
||||
@ -629,11 +591,11 @@ export class Backend {
|
||||
return { query: searchTerm, hits: merged };
|
||||
}
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
traverseCursor(cursor: Cursor, action: string): Promise<{ entries: Entry[]; cursor: Cursor }> {
|
||||
const [data, unwrappedCursor] = cursor.unwrapData();
|
||||
// TODO: stop assuming all cursors are for collections
|
||||
const collection = data.get('collection') as Collection;
|
||||
return this.implementation!.traverseCursor!(unwrappedCursor, action).then(
|
||||
const collection = data.collection as Collection;
|
||||
return this.implementation.traverseCursor!(unwrappedCursor, action).then(
|
||||
async ({ entries, cursor: newCursor }) => ({
|
||||
entries: this.processEntries(entries, collection),
|
||||
cursor: Cursor.create(newCursor).wrapData({
|
||||
@ -644,11 +606,14 @@ export class Backend {
|
||||
);
|
||||
}
|
||||
|
||||
async getLocalDraftBackup(collection: Collection, slug: string) {
|
||||
const key = getEntryBackupKey(collection.get('name'), slug);
|
||||
async getLocalDraftBackup(
|
||||
collection: Collection,
|
||||
slug: string,
|
||||
): Promise<{ entry: Entry | null }> {
|
||||
const key = getEntryBackupKey(collection.name, slug);
|
||||
const backup = await localForage.getItem<BackupEntry>(key);
|
||||
if (!backup || !backup.raw.trim()) {
|
||||
return {};
|
||||
return { entry: null };
|
||||
}
|
||||
const { raw, path } = backup;
|
||||
let { mediaFiles = [] } = backup;
|
||||
@ -665,16 +630,15 @@ export class Backend {
|
||||
|
||||
const formatRawData = (raw: string) => {
|
||||
return this.entryWithFormat(collection)(
|
||||
createEntry(collection.get('name'), slug, path, {
|
||||
createEntry(collection.name, slug, path, {
|
||||
raw,
|
||||
label,
|
||||
mediaFiles,
|
||||
meta: { path: prepareMetaPath(path, collection) },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const entry: EntryValue = formatRawData(raw);
|
||||
const entry: Entry = formatRawData(raw);
|
||||
if (hasI18n(collection) && backup.i18n) {
|
||||
const i18n = formatI18nBackup(backup.i18n, formatRawData);
|
||||
entry.i18n = i18n;
|
||||
@ -683,10 +647,10 @@ export class Backend {
|
||||
return { entry };
|
||||
}
|
||||
|
||||
async persistLocalDraftBackup(entry: EntryMap, collection: Collection) {
|
||||
async persistLocalDraftBackup(entry: Entry, collection: Collection) {
|
||||
try {
|
||||
await this.backupSync.acquire();
|
||||
const key = getEntryBackupKey(collection.get('name'), entry.get('slug'));
|
||||
const key = getEntryBackupKey(collection.name, entry.slug);
|
||||
const raw = this.entryToRaw(collection, entry);
|
||||
|
||||
if (!raw.trim()) {
|
||||
@ -694,17 +658,14 @@ export class Backend {
|
||||
}
|
||||
|
||||
const mediaFiles = await Promise.all<MediaFile>(
|
||||
entry
|
||||
.get('mediaFiles')
|
||||
.toJS()
|
||||
.map(async (file: MediaFile) => {
|
||||
// make sure to serialize the file
|
||||
if (file.url?.startsWith('blob:')) {
|
||||
const blob = await fetch(file.url as string).then(res => res.blob());
|
||||
return { ...file, file: blobToFileObj(file.name, blob) };
|
||||
}
|
||||
return file;
|
||||
}),
|
||||
entry.mediaFiles.map(async (file: MediaFile) => {
|
||||
// make sure to serialize the file
|
||||
if (file.url?.startsWith('blob:')) {
|
||||
const blob = await fetch(file.url as string).then(res => res.blob());
|
||||
return { ...file, file: blobToFileObj(file.name, blob) };
|
||||
}
|
||||
return file;
|
||||
}),
|
||||
);
|
||||
|
||||
let i18n;
|
||||
@ -714,7 +675,7 @@ export class Backend {
|
||||
|
||||
await localForage.setItem<BackupEntry>(key, {
|
||||
raw,
|
||||
path: entry.get('path'),
|
||||
path: entry.path,
|
||||
mediaFiles,
|
||||
...(i18n && { i18n }),
|
||||
});
|
||||
@ -730,9 +691,9 @@ export class Backend {
|
||||
async deleteLocalDraftBackup(collection: Collection, slug: string) {
|
||||
try {
|
||||
await this.backupSync.acquire();
|
||||
await localForage.removeItem(getEntryBackupKey(collection.get('name'), slug));
|
||||
await localForage.removeItem(getEntryBackupKey(collection.name, slug));
|
||||
// delete new entry backup if not deleted
|
||||
slug && (await localForage.removeItem(getEntryBackupKey(collection.get('name'))));
|
||||
slug && (await localForage.removeItem(getEntryBackupKey(collection.name)));
|
||||
const result = await this.deleteAnonymousBackup();
|
||||
return result;
|
||||
} catch (e) {
|
||||
@ -748,18 +709,17 @@ export class Backend {
|
||||
return localForage.removeItem(getEntryBackupKey());
|
||||
}
|
||||
|
||||
async getEntry(state: State, collection: Collection, slug: string) {
|
||||
async getEntry(state: RootState, collection: Collection, slug: string) {
|
||||
const path = selectEntryPath(collection, slug) as string;
|
||||
const label = selectFileEntryLabel(collection, slug);
|
||||
const extension = selectFolderEntryExtension(collection);
|
||||
|
||||
const getEntryValue = async (path: string) => {
|
||||
const loadedEntry = await this.implementation.getEntry(path);
|
||||
let entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, {
|
||||
let entry = createEntry(collection.name, slug, loadedEntry.file.path, {
|
||||
raw: loadedEntry.data,
|
||||
label,
|
||||
mediaFiles: [],
|
||||
meta: { path: prepareMetaPath(loadedEntry.file.path, collection) },
|
||||
});
|
||||
|
||||
entry = this.entryWithFormat(collection)(entry);
|
||||
@ -768,7 +728,7 @@ export class Backend {
|
||||
return entry;
|
||||
};
|
||||
|
||||
let entryValue: EntryValue;
|
||||
let entryValue: Entry;
|
||||
if (hasI18n(collection)) {
|
||||
entryValue = await getI18nEntry(collection, extension, path, slug, getEntryValue);
|
||||
} else {
|
||||
@ -798,27 +758,34 @@ export class Backend {
|
||||
}
|
||||
|
||||
entryWithFormat(collection: Collection) {
|
||||
return (entry: EntryValue): EntryValue => {
|
||||
return (entry: Entry): Entry => {
|
||||
const format = resolveFormat(collection, entry);
|
||||
if (entry && entry.raw !== undefined) {
|
||||
const data = (format && attempt(format.fromFile.bind(format, entry.raw))) || {};
|
||||
if (isError(data)) console.error(data);
|
||||
if (isError(data)) {
|
||||
console.error(data);
|
||||
}
|
||||
return Object.assign(entry, { data: isError(data) ? {} : data });
|
||||
}
|
||||
return format.fromFile(entry);
|
||||
};
|
||||
}
|
||||
|
||||
async processEntry(state: State, collection: Collection, entry: EntryValue) {
|
||||
async processEntry(state: RootState, collection: Collection, entry: Entry) {
|
||||
const configState = state.config;
|
||||
if (!configState.config) {
|
||||
throw new Error('Config not loaded');
|
||||
}
|
||||
|
||||
const integration = selectIntegration(state.integrations, null, 'assetStore');
|
||||
const mediaFolders = selectMediaFolders(state.config, collection, fromJS(entry));
|
||||
const mediaFolders = selectMediaFolders(configState.config, collection, entry);
|
||||
if (mediaFolders.length > 0 && !integration) {
|
||||
const files = await Promise.all(
|
||||
mediaFolders.map(folder => this.implementation.getMedia(folder)),
|
||||
);
|
||||
entry.mediaFiles = entry.mediaFiles.concat(...files);
|
||||
} else {
|
||||
entry.mediaFiles = entry.mediaFiles.concat(state.mediaLibrary.get('files') || []);
|
||||
entry.mediaFiles = entry.mediaFiles.concat(state.mediaLibrary.files || []);
|
||||
}
|
||||
|
||||
return entry;
|
||||
@ -832,12 +799,18 @@ export class Backend {
|
||||
usedSlugs,
|
||||
status,
|
||||
}: PersistArgs) {
|
||||
const modifiedData = await this.invokePreSaveEvent(draft.get('entry'));
|
||||
const entryDraft = (modifiedData && draft.setIn(['entry', 'data'], modifiedData)) || draft;
|
||||
const modifiedData = await this.invokePreSaveEvent(draft.entry);
|
||||
const entryDraft = modifiedData
|
||||
? {
|
||||
...draft,
|
||||
entry: {
|
||||
...draft.entry,
|
||||
data: modifiedData,
|
||||
},
|
||||
}
|
||||
: draft;
|
||||
|
||||
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
|
||||
|
||||
const customPath = selectCustomPath(collection, entryDraft);
|
||||
const newEntry = entryDraft.entry.newRecord ?? false;
|
||||
|
||||
let dataFile: DataFile;
|
||||
if (newEntry) {
|
||||
@ -846,26 +819,24 @@ export class Backend {
|
||||
}
|
||||
const slug = await this.generateUniqueSlug(
|
||||
collection,
|
||||
entryDraft.getIn(['entry', 'data']),
|
||||
entryDraft.entry.data,
|
||||
config,
|
||||
usedSlugs,
|
||||
customPath,
|
||||
);
|
||||
const path = customPath || (selectEntryPath(collection, slug) as string);
|
||||
const path = selectEntryPath(collection, slug) ?? '';
|
||||
dataFile = {
|
||||
path,
|
||||
slug,
|
||||
raw: this.entryToRaw(collection, entryDraft.get('entry')),
|
||||
raw: this.entryToRaw(collection, entryDraft.entry),
|
||||
};
|
||||
|
||||
updateAssetProxies(assetProxies, config, collection, entryDraft, path);
|
||||
} else {
|
||||
const slug = entryDraft.getIn(['entry', 'slug']);
|
||||
const slug = entryDraft.entry.slug;
|
||||
dataFile = {
|
||||
path: entryDraft.getIn(['entry', 'path']),
|
||||
slug: customPath ? slugFromCustomPath(collection, customPath) : slug,
|
||||
raw: this.entryToRaw(collection, entryDraft.get('entry')),
|
||||
newPath: customPath,
|
||||
path: entryDraft.entry.path,
|
||||
slug,
|
||||
raw: this.entryToRaw(collection, entryDraft.entry),
|
||||
};
|
||||
}
|
||||
|
||||
@ -877,8 +848,8 @@ export class Backend {
|
||||
dataFiles = getI18nFiles(
|
||||
collection,
|
||||
extension,
|
||||
entryDraft.get('entry'),
|
||||
(draftData: EntryMap) => this.entryToRaw(collection, draftData),
|
||||
entryDraft.entry,
|
||||
(draftData: Entry) => this.entryToRaw(collection, draftData),
|
||||
path,
|
||||
slug,
|
||||
newPath,
|
||||
@ -894,7 +865,7 @@ export class Backend {
|
||||
authorName: user.name,
|
||||
});
|
||||
|
||||
const collectionName = collection.get('name');
|
||||
const collectionName = collection.name;
|
||||
|
||||
const updatedOptions = { status };
|
||||
const opts = {
|
||||
@ -904,7 +875,7 @@ export class Backend {
|
||||
...updatedOptions,
|
||||
};
|
||||
|
||||
await this.invokePrePublishEvent(entryDraft.get('entry'));
|
||||
await this.invokePrePublishEvent(entryDraft.entry);
|
||||
|
||||
await this.implementation.persistEntry(
|
||||
{
|
||||
@ -914,34 +885,34 @@ export class Backend {
|
||||
opts,
|
||||
);
|
||||
|
||||
await this.invokePostSaveEvent(entryDraft.get('entry'));
|
||||
await this.invokePostPublishEvent(entryDraft.get('entry'));
|
||||
await this.invokePostSaveEvent(entryDraft.entry);
|
||||
await this.invokePostPublishEvent(entryDraft.entry);
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
async invokeEventWithEntry(event: string, entry: EntryMap) {
|
||||
const { login, name } = (await this.currentUser()) as User;
|
||||
async invokeEventWithEntry(event: AllowedEvent, entry: Entry) {
|
||||
const { login, name = '' } = (await this.currentUser()) as User;
|
||||
return await invokeEvent({ name: event, data: { entry, author: { login, name } } });
|
||||
}
|
||||
|
||||
async invokePrePublishEvent(entry: EntryMap) {
|
||||
async invokePrePublishEvent(entry: Entry) {
|
||||
await this.invokeEventWithEntry('prePublish', entry);
|
||||
}
|
||||
|
||||
async invokePostPublishEvent(entry: EntryMap) {
|
||||
async invokePostPublishEvent(entry: Entry) {
|
||||
await this.invokeEventWithEntry('postPublish', entry);
|
||||
}
|
||||
|
||||
async invokePreSaveEvent(entry: EntryMap) {
|
||||
async invokePreSaveEvent(entry: Entry) {
|
||||
return await this.invokeEventWithEntry('preSave', entry);
|
||||
}
|
||||
|
||||
async invokePostSaveEvent(entry: EntryMap) {
|
||||
async invokePostSaveEvent(entry: Entry) {
|
||||
await this.invokeEventWithEntry('postSave', entry);
|
||||
}
|
||||
|
||||
async persistMedia(config: CmsConfig, file: AssetProxy) {
|
||||
async persistMedia(config: Config, file: AssetProxy) {
|
||||
const user = (await this.currentUser()) as User;
|
||||
const options = {
|
||||
commitMessage: commitMessageFormatter('uploadMedia', config, {
|
||||
@ -953,8 +924,12 @@ export class Backend {
|
||||
return this.implementation.persistMedia(file, options);
|
||||
}
|
||||
|
||||
async deleteEntry(state: State, collection: Collection, slug: string) {
|
||||
const config = state.config;
|
||||
async deleteEntry(state: RootState, collection: Collection, slug: string) {
|
||||
const configState = state.config;
|
||||
if (!configState.config) {
|
||||
throw new Error('Config not loaded');
|
||||
}
|
||||
|
||||
const path = selectEntryPath(collection, slug) as string;
|
||||
const extension = selectFolderEntryExtension(collection) as string;
|
||||
|
||||
@ -963,7 +938,7 @@ export class Backend {
|
||||
}
|
||||
|
||||
const user = (await this.currentUser()) as User;
|
||||
const commitMessage = commitMessageFormatter('delete', config, {
|
||||
const commitMessage = commitMessageFormatter('delete', configState.config, {
|
||||
collection,
|
||||
slug,
|
||||
path,
|
||||
@ -978,7 +953,7 @@ export class Backend {
|
||||
await this.implementation.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
async deleteMedia(config: CmsConfig, path: string) {
|
||||
async deleteMedia(config: Config, path: string) {
|
||||
const user = (await this.currentUser()) as User;
|
||||
const commitMessage = commitMessageFormatter('deleteMedia', config, {
|
||||
path,
|
||||
@ -988,49 +963,43 @@ export class Backend {
|
||||
return this.implementation.deleteFiles([path], commitMessage);
|
||||
}
|
||||
|
||||
entryToRaw(collection: Collection, entry: EntryMap): string {
|
||||
const format = resolveFormat(collection, entry.toJS());
|
||||
entryToRaw(collection: Collection, entry: Entry): string {
|
||||
const format = resolveFormat(collection, entry);
|
||||
const fieldsOrder = this.fieldsOrder(collection, entry);
|
||||
const fieldsComments = selectFieldsComments(collection, entry);
|
||||
return format && format.toFile(entry.get('data').toJS(), fieldsOrder, fieldsComments);
|
||||
return format && format.toFile(entry.data, fieldsOrder, fieldsComments);
|
||||
}
|
||||
|
||||
fieldsOrder(collection: Collection, entry: EntryMap) {
|
||||
const fields = collection.get('fields');
|
||||
fieldsOrder(collection: Collection, entry: Entry) {
|
||||
const fields = collection.fields;
|
||||
if (fields) {
|
||||
return collection
|
||||
.get('fields')
|
||||
.map(f => f!.get('name'))
|
||||
.toArray();
|
||||
return collection.fields.map(f => f!.name);
|
||||
}
|
||||
|
||||
const files = collection.get('files');
|
||||
const file = (files || List<CollectionFile>())
|
||||
.filter(f => f!.get('name') === entry.get('slug'))
|
||||
.get(0);
|
||||
const files = collection.files;
|
||||
const file: CollectionFile | null =
|
||||
(files ?? []).filter(f => f!.name === entry.slug)?.[0] ?? null;
|
||||
|
||||
if (file == null) {
|
||||
throw new Error(`No file found for ${entry.get('slug')} in ${collection.get('name')}`);
|
||||
throw new Error(`No file found for ${entry.slug} in ${collection.name}`);
|
||||
}
|
||||
return file
|
||||
.get('fields')
|
||||
.map(f => f!.get('name'))
|
||||
.toArray();
|
||||
return file.fields.map(f => f.name);
|
||||
}
|
||||
|
||||
filterEntries(collection: { entries: EntryValue[] }, filterRule: FilterRule) {
|
||||
filterEntries(collection: { entries: Entry[] }, filterRule: FilterRule) {
|
||||
return collection.entries.filter(entry => {
|
||||
const fieldValue = entry.data[filterRule.get('field')];
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.includes(filterRule.get('value'));
|
||||
}
|
||||
return fieldValue === filterRule.get('value');
|
||||
const fieldValue = entry.data?.[filterRule.field];
|
||||
// TODO Investigate when the value could be a string array
|
||||
// if (Array.isArray(fieldValue)) {
|
||||
// return fieldValue.includes(filterRule.value);
|
||||
// }
|
||||
return fieldValue === filterRule.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBackend(config: CmsConfig) {
|
||||
if (!config.backend.name) {
|
||||
export function resolveBackend(config?: Config) {
|
||||
if (!config?.backend.name) {
|
||||
throw new Error('No backend defined in configuration');
|
||||
}
|
||||
|
||||
@ -1048,7 +1017,7 @@ export function resolveBackend(config: CmsConfig) {
|
||||
export const currentBackend = (function () {
|
||||
let backend: Backend;
|
||||
|
||||
return (config: CmsConfig) => {
|
||||
return (config: Config) => {
|
||||
if (backend) {
|
||||
return backend;
|
||||
}
|
||||
|
@ -1,14 +1,24 @@
|
||||
import { Base64 } from 'js-base64';
|
||||
import { partial, result, trim, trimStart } from 'lodash';
|
||||
import partial from 'lodash/partial';
|
||||
import result from 'lodash/result';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import { basename, dirname } from 'path';
|
||||
|
||||
import {
|
||||
APIError, localForage, readFile, readFileMetadata, requestWithBackoff,
|
||||
responseParser, unsentRequest
|
||||
APIError,
|
||||
localForage,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
requestWithBackoff,
|
||||
responseParser,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
|
||||
import type { Map } from 'immutable';
|
||||
import type { ApiRequest, AssetProxy, DataFile, PersistOptions } from '../../lib/util';
|
||||
import type { DataFile, PersistOptions } from '../../interface';
|
||||
import type { ApiRequest } from '../../lib/util';
|
||||
import type { ApiRequestObject } from '../../lib/util/API';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
export const API_NAME = 'Azure DevOps';
|
||||
|
||||
@ -28,23 +38,6 @@ type AzureGitItem = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
type AzurePullRequestCommit = { commitId: string };
|
||||
|
||||
enum AzureCommitStatusState {
|
||||
ERROR = 'error',
|
||||
FAILED = 'failed',
|
||||
NOT_APPLICABLE = 'notApplicable',
|
||||
NOT_SET = 'notSet',
|
||||
PENDING = 'pending',
|
||||
SUCCEEDED = 'succeeded',
|
||||
}
|
||||
|
||||
type AzureCommitStatus = {
|
||||
context: { genre?: string | null; name: string };
|
||||
state: AzureCommitStatusState;
|
||||
targetUrl: string;
|
||||
};
|
||||
|
||||
// This does not match Azure documentation, but it is what comes back from some calls
|
||||
// PullRequest as an example is documented as returning PullRequest[], but it actually
|
||||
// returns that inside of this value prop in the json
|
||||
@ -68,30 +61,6 @@ enum AzureObjectType {
|
||||
TREE = 'tree',
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/diffs/get?view=azure-devops-rest-6.1#gitcommitdiffs
|
||||
interface AzureGitCommitDiffs {
|
||||
changes: AzureGitChange[];
|
||||
}
|
||||
|
||||
// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/diffs/get?view=azure-devops-rest-6.1#gitchange
|
||||
interface AzureGitChange {
|
||||
changeId: number;
|
||||
item: AzureGitChangeItem;
|
||||
changeType: AzureCommitChangeType;
|
||||
originalPath: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface AzureGitChangeItem {
|
||||
objectId: string;
|
||||
originalObjectId: string;
|
||||
gitObjectType: string;
|
||||
commitId: string;
|
||||
path: string;
|
||||
isFolder: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type AzureRef = {
|
||||
name: string;
|
||||
objectId: string;
|
||||
@ -105,10 +74,6 @@ type AzureCommit = {
|
||||
};
|
||||
};
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getChangeItem(item: AzureCommitItem) {
|
||||
switch (item.action) {
|
||||
case AzureCommitChangeType.ADD:
|
||||
@ -186,10 +151,11 @@ export default class API {
|
||||
return withHeaders;
|
||||
};
|
||||
|
||||
withAzureFeatures = (req: Map<string, Map<string, string>>) => {
|
||||
if (req.hasIn(['params', API_VERSION])) {
|
||||
withAzureFeatures = (req: ApiRequestObject) => {
|
||||
if (API_VERSION in (req.params ?? {})) {
|
||||
return req;
|
||||
}
|
||||
|
||||
const withParams = unsentRequest.withParams(
|
||||
{
|
||||
[API_VERSION]: `${this.apiVersion}`,
|
||||
@ -203,7 +169,7 @@ export default class API {
|
||||
buildRequest = (req: ApiRequest) => {
|
||||
const withHeaders = this.withHeaders(req);
|
||||
const withAzureFeatures = this.withAzureFeatures(withHeaders);
|
||||
if (withAzureFeatures.has('cache')) {
|
||||
if ('cache' in withAzureFeatures) {
|
||||
return withAzureFeatures;
|
||||
} else {
|
||||
const withNoCache = unsentRequest.withNoCache(withAzureFeatures);
|
||||
@ -214,8 +180,12 @@ export default class API {
|
||||
request = (req: ApiRequest): Promise<Response> => {
|
||||
try {
|
||||
return requestWithBackoff(this, req);
|
||||
} catch (err: any) {
|
||||
throw new APIError(err.message, null, API_NAME);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new APIError(error.message, null, API_NAME);
|
||||
}
|
||||
|
||||
throw new APIError('Unknown api error', null, API_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
@ -261,7 +231,7 @@ export default class API {
|
||||
params: {
|
||||
'searchCriteria.itemPath': path,
|
||||
'searchCriteria.itemVersion.version': branch,
|
||||
'searchCriteria.$top': 1,
|
||||
'searchCriteria.$top': '1',
|
||||
},
|
||||
});
|
||||
const [commit] = value;
|
||||
|
@ -1,84 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { AuthenticationPage, Icon } from '../../ui';
|
||||
import { ImplicitAuthenticator } from '../../lib/auth';
|
||||
import alert from '../../components/UI/Alert';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
export default class AzureAuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
inProgress: PropTypes.bool,
|
||||
base_url: PropTypes.string,
|
||||
siteId: PropTypes.string,
|
||||
authEndpoint: PropTypes.string,
|
||||
config: PropTypes.object.isRequired,
|
||||
clearHash: PropTypes.func,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {};
|
||||
|
||||
componentDidMount() {
|
||||
this.auth = new ImplicitAuthenticator({
|
||||
base_url: `https://login.microsoftonline.com/${this.props.config.backend.tenant_id}`,
|
||||
auth_endpoint: 'oauth2/authorize',
|
||||
app_id: this.props.config.backend.app_id,
|
||||
clearHash: this.props.clearHash,
|
||||
});
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
this.auth.completeAuth((err, data) => {
|
||||
if (err) {
|
||||
alert({
|
||||
title: 'auth.errors.authTitle',
|
||||
body: { key: 'auth.errors.authBody', options: { details: err } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.props.onLogin(data);
|
||||
});
|
||||
}
|
||||
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
this.auth.authenticate(
|
||||
{
|
||||
scope: 'vso.code_full,user.read',
|
||||
resource: '499b84ac-1321-427f-aa17-267ca6975798',
|
||||
prompt: 'select_account',
|
||||
},
|
||||
(err, data) => {
|
||||
if (err) {
|
||||
this.setState({ loginError: err.toString() });
|
||||
return;
|
||||
}
|
||||
this.props.onLogin(data);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { inProgress, config, t } = this.props;
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={this.handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={this.state.loginError}
|
||||
logoUrl={config.logo_url}
|
||||
renderButtonContent={() => (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="azure" />
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.loginWithAzure')}
|
||||
</React.Fragment>
|
||||
)}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
80
src/backends/azure/AuthenticationPage.tsx
Normal file
80
src/backends/azure/AuthenticationPage.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import alert from '../../components/UI/Alert';
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
import { ImplicitAuthenticator } from '../../lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const AzureAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
clearHash,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const auth = useMemo(
|
||||
() =>
|
||||
new ImplicitAuthenticator({
|
||||
base_url: `https://login.microsoftonline.com/${config.backend.tenant_id}`,
|
||||
auth_endpoint: 'oauth2/authorize',
|
||||
app_id: config.backend.app_id,
|
||||
clearHash,
|
||||
}),
|
||||
[clearHash, config.backend.app_id, config.backend.tenant_id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
auth.completeAuth((err, data) => {
|
||||
if (err) {
|
||||
alert({
|
||||
title: 'auth.errors.authTitle',
|
||||
body: { key: 'auth.errors.authBody', options: { details: err } },
|
||||
});
|
||||
return;
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
}, [auth, onLogin]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
auth.authenticate(
|
||||
{
|
||||
scope: 'vso.code_full,user.read',
|
||||
resource: '499b84ac-1321-427f-aa17-267ca6975798',
|
||||
prompt: 'select_account',
|
||||
},
|
||||
(err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
[auth, onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
icon={<Icon type="azure" />}
|
||||
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithAzure')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AzureAuthenticationPage;
|
@ -1,19 +1,36 @@
|
||||
import { trim, trimStart } from 'lodash';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
import { BackendClass } from '../../interface';
|
||||
import {
|
||||
asyncLock, basename, entriesByFiles, entriesByFolder, filterByExtension, getBlobSHA, getMediaAsBlob, getMediaDisplayURL
|
||||
asyncLock,
|
||||
basename,
|
||||
entriesByFiles,
|
||||
entriesByFolder,
|
||||
filterByExtension,
|
||||
getBlobSHA,
|
||||
getMediaAsBlob,
|
||||
getMediaDisplayURL,
|
||||
} from '../../lib/util';
|
||||
import API, { API_NAME } from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type {
|
||||
AssetProxy, AsyncLock, Config, Credentials, DisplayURL,
|
||||
Entry, Implementation,
|
||||
BackendEntry,
|
||||
BackendInitializerOptions,
|
||||
Config,
|
||||
Credentials,
|
||||
DisplayURL,
|
||||
ImplementationEntry,
|
||||
ImplementationFile,
|
||||
ImplementationMediaFile, PersistOptions, User
|
||||
} from '../../lib/util';
|
||||
ImplementationMediaFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../interface';
|
||||
import type { AsyncLock, Cursor } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
@ -37,10 +54,10 @@ function parseAzureRepo(config: Config) {
|
||||
};
|
||||
}
|
||||
|
||||
export default class Azure implements Implementation {
|
||||
export default class Azure extends BackendClass {
|
||||
lock: AsyncLock;
|
||||
api?: API;
|
||||
options: {};
|
||||
options: BackendInitializerOptions;
|
||||
repo: {
|
||||
org: string;
|
||||
project: string;
|
||||
@ -54,7 +71,8 @@ export default class Azure implements Implementation {
|
||||
|
||||
_mediaDisplayURLSem?: Semaphore;
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
constructor(config: Config, options: BackendInitializerOptions) {
|
||||
super(config, options);
|
||||
this.options = {
|
||||
...options,
|
||||
};
|
||||
@ -72,7 +90,10 @@ export default class Azure implements Implementation {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
async status(): Promise<{
|
||||
auth: { status: boolean };
|
||||
api: { status: boolean; statusPage: string };
|
||||
}> {
|
||||
const auth =
|
||||
(await this.api!.user()
|
||||
.then(user => !!user)
|
||||
@ -196,7 +217,7 @@ export default class Azure implements Implementation {
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, options: PersistOptions): Promise<void> {
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions): Promise<void> {
|
||||
const mediaFiles: AssetProxy[] = entry.assets;
|
||||
await this.api!.persistFiles(entry.dataFiles, mediaFiles, options);
|
||||
}
|
||||
@ -229,4 +250,16 @@ export default class Azure implements Implementation {
|
||||
async deleteFiles(paths: string[], commitMessage: string) {
|
||||
await this.api!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
traverseCursor(): Promise<{ entries: ImplementationEntry[]; cursor: Cursor }> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
|
||||
allEntriesByFolder(
|
||||
_folder: string,
|
||||
_extension: string,
|
||||
_depth: number,
|
||||
): Promise<ImplementationEntry[]> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,3 @@
|
||||
import AzureBackend from './implementation';
|
||||
import API from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
export const StaticCmsBackendAzure = {
|
||||
AzureBackend,
|
||||
API,
|
||||
AuthenticationPage,
|
||||
};
|
||||
export { AzureBackend, API, AuthenticationPage };
|
||||
export { default as AzureBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
||||
|
@ -1,14 +1,25 @@
|
||||
import { flow, get } from 'lodash';
|
||||
import flow from 'lodash/flow';
|
||||
import get from 'lodash/get';
|
||||
import { dirname } from 'path';
|
||||
import { parse } from 'what-the-diff';
|
||||
|
||||
import {
|
||||
APIError, basename,
|
||||
Cursor, localForage, readFile, readFileMetadata, requestWithBackoff, responseParser,
|
||||
then, throwOnConflictingBranches, unsentRequest
|
||||
APIError,
|
||||
basename,
|
||||
Cursor,
|
||||
localForage,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
requestWithBackoff,
|
||||
responseParser,
|
||||
then,
|
||||
throwOnConflictingBranches,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
|
||||
import type { ApiRequest, AssetProxy, DataFile, FetchError, PersistOptions } from '../../lib/util';
|
||||
import type { DataFile, PersistOptions } from '../../interface';
|
||||
import type { ApiRequest, FetchError } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
interface Config {
|
||||
apiRoot?: string;
|
||||
@ -99,7 +110,7 @@ export default class API {
|
||||
|
||||
buildRequest = (req: ApiRequest) => {
|
||||
const withRoot = unsentRequest.withRoot(this.apiRoot)(req);
|
||||
if (withRoot.has('cache')) {
|
||||
if ('cache' in withRoot) {
|
||||
return withRoot;
|
||||
} else {
|
||||
const withNoCache = unsentRequest.withNoCache(withRoot);
|
||||
@ -110,8 +121,12 @@ export default class API {
|
||||
request = (req: ApiRequest): Promise<Response> => {
|
||||
try {
|
||||
return requestWithBackoff(this, req);
|
||||
} catch (err: any) {
|
||||
throw new APIError(err.message, null, API_NAME);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new APIError(error.message, null, API_NAME);
|
||||
}
|
||||
|
||||
throw new APIError('Unknown api error', null, API_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
@ -217,7 +232,7 @@ export default class API {
|
||||
async isShaExistsInBranch(branch: string, sha: string) {
|
||||
const { values }: { values: BitBucketCommit[] } = await this.requestJSON({
|
||||
url: `${this.repoURL}/commits`,
|
||||
params: { include: branch, pagelen: 100 },
|
||||
params: { include: branch, pagelen: '100' },
|
||||
}).catch(e => {
|
||||
console.info(`Failed getting commits for branch '${branch}'`, e);
|
||||
return [];
|
||||
@ -251,8 +266,8 @@ export default class API {
|
||||
const result: BitBucketSrcResult = await this.requestJSON({
|
||||
url: `${this.repoURL}/src/${node}/${path}`,
|
||||
params: {
|
||||
max_depth: depth,
|
||||
pagelen,
|
||||
max_depth: `${depth}`,
|
||||
pagelen: `${pagelen}`,
|
||||
},
|
||||
}).catch(replace404WithEmptyResponse);
|
||||
const { entries, cursor } = this.getEntriesAndCursor(result);
|
||||
@ -277,7 +292,7 @@ export default class API {
|
||||
cursor: newCursor,
|
||||
entries: this.processFiles(entries),
|
||||
})),
|
||||
])(cursor.data!.getIn(['links', action]));
|
||||
])((cursor.data?.links as Record<string, unknown>)[action]);
|
||||
|
||||
listAllFiles = async (path: string, depth: number, branch: string) => {
|
||||
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(
|
||||
@ -367,11 +382,13 @@ export default class API {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error.message || '';
|
||||
// very descriptive message from Bitbucket
|
||||
if (parentSha && message.includes('Something went wrong')) {
|
||||
await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message || '';
|
||||
// very descriptive message from Bitbucket
|
||||
if (parentSha && message.includes('Something went wrong')) {
|
||||
await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -379,7 +396,20 @@ export default class API {
|
||||
return files;
|
||||
}
|
||||
|
||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
async persistFiles(
|
||||
dataFiles: DataFile[],
|
||||
mediaFiles: (
|
||||
| {
|
||||
fileObj: File;
|
||||
size: number;
|
||||
sha: string;
|
||||
raw: string;
|
||||
path: string;
|
||||
}
|
||||
| AssetProxy
|
||||
)[],
|
||||
options: PersistOptions,
|
||||
) {
|
||||
const files = [...dataFiles, ...mediaFiles];
|
||||
return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch });
|
||||
}
|
||||
@ -391,7 +421,7 @@ export default class API {
|
||||
const rawDiff = await this.requestText({
|
||||
url: `${this.repoURL}/diff/${source}..${destination}`,
|
||||
params: {
|
||||
binary: false,
|
||||
binary: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
@ -424,8 +454,9 @@ export default class API {
|
||||
const { name, email } = this.commitAuthor;
|
||||
body.append('author', `${name} <${email}>`);
|
||||
}
|
||||
return flow([unsentRequest.withMethod('POST'), unsentRequest.withBody(body), this.request])(
|
||||
`${this.repoURL}/src`,
|
||||
|
||||
return this.request(
|
||||
unsentRequest.withBody(body, unsentRequest.withMethod('POST', `${this.repoURL}/src`)),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -1,95 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { AuthenticationPage, Icon } from '../../ui';
|
||||
import { NetlifyAuthenticator, ImplicitAuthenticator } from '../../lib/auth';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
export default class BitbucketAuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
inProgress: PropTypes.bool,
|
||||
base_url: PropTypes.string,
|
||||
siteId: PropTypes.string,
|
||||
authEndpoint: PropTypes.string,
|
||||
config: PropTypes.object.isRequired,
|
||||
clearHash: PropTypes.func,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {};
|
||||
|
||||
componentDidMount() {
|
||||
const { auth_type: authType = '' } = this.props.config.backend;
|
||||
|
||||
if (authType === 'implicit') {
|
||||
const {
|
||||
base_url = 'https://bitbucket.org',
|
||||
auth_endpoint = 'site/oauth2/authorize',
|
||||
app_id = '',
|
||||
} = this.props.config.backend;
|
||||
|
||||
this.auth = new ImplicitAuthenticator({
|
||||
base_url,
|
||||
auth_endpoint,
|
||||
app_id,
|
||||
clearHash: this.props.clearHash,
|
||||
});
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
this.auth.completeAuth((err, data) => {
|
||||
if (err) {
|
||||
this.setState({ loginError: err.toString() });
|
||||
return;
|
||||
}
|
||||
this.props.onLogin(data);
|
||||
});
|
||||
this.authSettings = { scope: 'repository:write' };
|
||||
} else {
|
||||
this.auth = new NetlifyAuthenticator({
|
||||
base_url: this.props.base_url,
|
||||
site_id:
|
||||
document.location.host.split(':')[0] === 'localhost'
|
||||
? 'cms.netlify.com'
|
||||
: this.props.siteId,
|
||||
auth_endpoint: this.props.authEndpoint,
|
||||
});
|
||||
this.authSettings = { provider: 'bitbucket', scope: 'repo' };
|
||||
}
|
||||
}
|
||||
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
this.auth.authenticate(this.authSettings, (err, data) => {
|
||||
if (err) {
|
||||
this.setState({ loginError: err.toString() });
|
||||
return;
|
||||
}
|
||||
this.props.onLogin(data);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { inProgress, config, t } = this.props;
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={this.handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={this.state.loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
renderButtonContent={() => (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="bitbucket" />
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.loginWithBitbucket')}
|
||||
</React.Fragment>
|
||||
)}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
96
src/backends/bitbucket/AuthenticationPage.tsx
Normal file
96
src/backends/bitbucket/AuthenticationPage.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
import { ImplicitAuthenticator, NetlifyAuthenticator } from '../../lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
const BitbucketAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
base_url,
|
||||
siteId,
|
||||
authEndpoint,
|
||||
clearHash,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const [auth, authSettings] = useMemo(() => {
|
||||
const { auth_type: authType = '' } = config.backend;
|
||||
|
||||
if (authType === 'implicit') {
|
||||
const {
|
||||
base_url = 'https://bitbucket.org',
|
||||
auth_endpoint = 'site/oauth2/authorize',
|
||||
app_id = '',
|
||||
} = config.backend;
|
||||
|
||||
const implicityAuth = new ImplicitAuthenticator({
|
||||
base_url,
|
||||
auth_endpoint,
|
||||
app_id,
|
||||
clearHash,
|
||||
});
|
||||
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
implicityAuth.completeAuth((err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
return;
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
|
||||
return [implicityAuth, { scope: 'repository:write' }];
|
||||
} else {
|
||||
return [
|
||||
new NetlifyAuthenticator({
|
||||
base_url,
|
||||
site_id:
|
||||
document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
|
||||
auth_endpoint: authEndpoint,
|
||||
}),
|
||||
{ provider: 'bitbucket', scope: 'repo' },
|
||||
] as const;
|
||||
}
|
||||
}, [authEndpoint, base_url, clearHash, config.backend, onLogin, siteId]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
auth.authenticate(authSettings, (err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
},
|
||||
[auth, authSettings, onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="bitbucket" />}
|
||||
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithBitbucket')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BitbucketAuthenticationPage;
|
@ -1,5 +1,5 @@
|
||||
import { stripIndent } from 'common-tags';
|
||||
import { trimStart } from 'lodash';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
import { NetlifyAuthenticator } from '../../lib/auth';
|
||||
@ -29,20 +29,17 @@ import { GitLfsClient } from './git-lfs-client';
|
||||
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type {
|
||||
ApiRequest,
|
||||
AssetProxy,
|
||||
AsyncLock,
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
Config,
|
||||
Credentials,
|
||||
Cursor,
|
||||
DisplayURL,
|
||||
Entry,
|
||||
FetchError,
|
||||
Implementation,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../lib/util';
|
||||
} from '../../interface';
|
||||
import type { ApiRequest, AsyncLock, Cursor, FetchError } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
@ -56,7 +53,7 @@ type BitbucketStatusComponent = {
|
||||
};
|
||||
|
||||
// Implementation wrapper class
|
||||
export default class BitbucketBackend implements Implementation {
|
||||
export default class BitbucketBackend implements BackendClass {
|
||||
lock: AsyncLock;
|
||||
api: API | null;
|
||||
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
|
||||
@ -71,7 +68,7 @@ export default class BitbucketBackend implements Implementation {
|
||||
baseUrl: string;
|
||||
siteId: string;
|
||||
token: string | null;
|
||||
mediaFolder: string;
|
||||
mediaFolder?: string;
|
||||
refreshToken?: string;
|
||||
refreshedTokenPromise?: Promise<string>;
|
||||
authenticator?: NetlifyAuthenticator;
|
||||
@ -231,7 +228,7 @@ export default class BitbucketBackend implements Implementation {
|
||||
this.refreshedTokenPromise = this.authenticator!.refresh({
|
||||
provider: 'bitbucket',
|
||||
refresh_token: this.refreshToken as string,
|
||||
}).then(({ token, refresh_token }) => {
|
||||
})?.then(({ token, refresh_token }: { token: string; refresh_token: string }) => {
|
||||
this.token = token;
|
||||
this.refreshToken = refresh_token;
|
||||
this.refreshedTokenPromise = undefined;
|
||||
@ -354,7 +351,10 @@ export default class BitbucketBackend implements Implementation {
|
||||
}));
|
||||
}
|
||||
|
||||
getMedia(mediaFolder = this.mediaFolder) {
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files =>
|
||||
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
|
||||
);
|
||||
@ -412,7 +412,7 @@ export default class BitbucketBackend implements Implementation {
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, options: PersistOptions) {
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
const client = await this.getLargeMediaClient();
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
@ -429,7 +429,18 @@ export default class BitbucketBackend implements Implementation {
|
||||
);
|
||||
}
|
||||
|
||||
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
||||
async persistMedia(
|
||||
mediaFile:
|
||||
| {
|
||||
fileObj: File;
|
||||
size: number;
|
||||
sha: string;
|
||||
raw: string;
|
||||
path: string;
|
||||
}
|
||||
| AssetProxy,
|
||||
options: PersistOptions,
|
||||
) {
|
||||
const { fileObj, path } = mediaFile;
|
||||
const displayURL = URL.createObjectURL(fileObj as Blob);
|
||||
const client = await this.getLargeMediaClient();
|
||||
@ -445,7 +456,18 @@ export default class BitbucketBackend implements Implementation {
|
||||
};
|
||||
}
|
||||
|
||||
async _persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
||||
async _persistMedia(
|
||||
mediaFile:
|
||||
| {
|
||||
fileObj: File;
|
||||
size: number;
|
||||
sha: string;
|
||||
raw: string;
|
||||
path: string;
|
||||
}
|
||||
| AssetProxy,
|
||||
options: PersistOptions,
|
||||
) {
|
||||
const fileObj = mediaFile.fileObj as File;
|
||||
|
||||
const [id] = await Promise.all([
|
||||
@ -472,7 +494,7 @@ export default class BitbucketBackend implements Implementation {
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => {
|
||||
const extension = cursor.meta?.get('extension');
|
||||
const extension = cursor.meta?.extension as string | undefined;
|
||||
if (extension) {
|
||||
entries = entries.filter(e => filterByExtension(e, extension));
|
||||
newCursor = newCursor.mergeMeta({ extension });
|
||||
|
@ -1,10 +1,3 @@
|
||||
import BitbucketBackend from './implementation';
|
||||
import API from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
export const StaticCmsBackendBitbucket = {
|
||||
BitbucketBackend,
|
||||
API,
|
||||
AuthenticationPage,
|
||||
};
|
||||
export { BitbucketBackend, API, AuthenticationPage };
|
||||
export { default as BitbucketBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
||||
|
@ -1,230 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { partial } from 'lodash';
|
||||
|
||||
import {
|
||||
AuthenticationPage,
|
||||
buttons,
|
||||
shadows,
|
||||
colors,
|
||||
colorsRaw,
|
||||
lengths,
|
||||
zIndex,
|
||||
} from '../../ui';
|
||||
|
||||
const LoginButton = styled.button`
|
||||
${buttons.button};
|
||||
${shadows.dropDeep};
|
||||
${buttons.default};
|
||||
${buttons.gray};
|
||||
|
||||
padding: 0 30px;
|
||||
display: block;
|
||||
margin-top: 20px;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const AuthForm = styled.form`
|
||||
width: 350px;
|
||||
margin-top: -80px;
|
||||
`;
|
||||
|
||||
const AuthInput = styled.input`
|
||||
background-color: ${colorsRaw.white};
|
||||
border-radius: ${lengths.borderRadius};
|
||||
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
margin-top: 6px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: ${zIndex.zIndex1};
|
||||
border: 1px solid ${colorsRaw.gray};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px ${colors.active};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.p`
|
||||
color: ${colors.errorText};
|
||||
`;
|
||||
|
||||
let component = null;
|
||||
|
||||
if (window.netlifyIdentity) {
|
||||
window.netlifyIdentity.on('login', user => {
|
||||
component && component.handleIdentityLogin(user);
|
||||
});
|
||||
window.netlifyIdentity.on('logout', () => {
|
||||
component && component.handleIdentityLogout();
|
||||
});
|
||||
window.netlifyIdentity.on('error', err => {
|
||||
component && component.handleIdentityError(err);
|
||||
});
|
||||
}
|
||||
|
||||
export default class GitGatewayAuthenticationPage extends React.Component {
|
||||
static authClient;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
component = this;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) {
|
||||
this.props.onLogin(window.netlifyIdentity.currentUser());
|
||||
window.netlifyIdentity.close();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
component = null;
|
||||
}
|
||||
|
||||
handleIdentityLogin = user => {
|
||||
this.props.onLogin(user);
|
||||
window.netlifyIdentity.close();
|
||||
};
|
||||
|
||||
handleIdentityLogout = () => {
|
||||
window.netlifyIdentity.open();
|
||||
};
|
||||
|
||||
handleIdentityError = err => {
|
||||
if (err?.message?.match(/^Failed to load settings from.+\.netlify\/identity$/)) {
|
||||
window.netlifyIdentity.close();
|
||||
this.setState({
|
||||
errors: { identity: this.props.t('auth.errors.identitySettings') },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleIdentity = () => {
|
||||
const user = window.netlifyIdentity.currentUser();
|
||||
if (user) {
|
||||
this.props.onLogin(user);
|
||||
} else {
|
||||
window.netlifyIdentity.open();
|
||||
}
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
inProgress: PropTypes.bool.isRequired,
|
||||
error: PropTypes.node,
|
||||
config: PropTypes.object.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = { email: '', password: '', errors: {} };
|
||||
|
||||
handleChange = (name, e) => {
|
||||
this.setState({ ...this.state, [name]: e.target.value });
|
||||
};
|
||||
|
||||
handleLogin = async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const { email, password } = this.state;
|
||||
const { t } = this.props;
|
||||
const errors = {};
|
||||
if (!email) {
|
||||
errors.email = t('auth.errors.email');
|
||||
}
|
||||
if (!password) {
|
||||
errors.password = t('auth.errors.password');
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
this.setState({ errors });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await GitGatewayAuthenticationPage.authClient();
|
||||
const user = await client.login(this.state.email, this.state.password, true);
|
||||
this.props.onLogin(user);
|
||||
} catch (error) {
|
||||
this.setState({
|
||||
errors: { server: error.description || error.msg || error },
|
||||
loggingIn: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { errors } = this.state;
|
||||
const { error, inProgress, config, t } = this.props;
|
||||
|
||||
if (window.netlifyIdentity) {
|
||||
if (errors.identity) {
|
||||
return (
|
||||
<AuthenticationPage
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
onLogin={this.handleIdentity}
|
||||
renderPageContent={() => (
|
||||
<a
|
||||
href="https://docs.netlify.com/visitor-access/git-gateway/#setup-and-settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{errors.identity}
|
||||
</a>
|
||||
)}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<AuthenticationPage
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
onLogin={this.handleIdentity}
|
||||
renderButtonContent={() => t('auth.loginWithNetlifyIdentity')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
renderPageContent={() => (
|
||||
<AuthForm onSubmit={this.handleLogin}>
|
||||
{!error ? null : <ErrorMessage>{error}</ErrorMessage>}
|
||||
{!errors.server ? null : <ErrorMessage>{String(errors.server)}</ErrorMessage>}
|
||||
<ErrorMessage>{errors.email || null}</ErrorMessage>
|
||||
<AuthInput
|
||||
type="text"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
value={this.state.email}
|
||||
onChange={partial(this.handleChange, 'email')}
|
||||
/>
|
||||
<ErrorMessage>{errors.password || null}</ErrorMessage>
|
||||
<AuthInput
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
value={this.state.password}
|
||||
onChange={partial(this.handleChange, 'password')}
|
||||
/>
|
||||
<LoginButton disabled={inProgress}>
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.login')}
|
||||
</LoginButton>
|
||||
</AuthForm>
|
||||
)}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
225
src/backends/git-gateway/AuthenticationPage.tsx
Normal file
225
src/backends/git-gateway/AuthenticationPage.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import { colors } from '../../components/UI/styles';
|
||||
|
||||
import type { ChangeEvent, FormEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps, User } from '../../interface';
|
||||
|
||||
const StyledAuthForm = styled('form')`
|
||||
width: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled('div')`
|
||||
color: ${colors.errorText};
|
||||
`;
|
||||
|
||||
function useNetlifyIdentifyEvent(eventName: 'login', callback: (login: User) => void): void;
|
||||
function useNetlifyIdentifyEvent(eventName: 'logout', callback: () => void): void;
|
||||
function useNetlifyIdentifyEvent(eventName: 'error', callback: (err: Error) => void): void;
|
||||
function useNetlifyIdentifyEvent(
|
||||
eventName: 'login' | 'logout' | 'error',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
callback: (input?: any) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
window.netlifyIdentity?.on(eventName, callback);
|
||||
}, [callback, eventName]);
|
||||
}
|
||||
|
||||
export interface GitGatewayAuthenticationPageProps
|
||||
extends TranslatedProps<AuthenticationPageProps> {
|
||||
handleAuth: (email: string, password: string) => Promise<User | string>;
|
||||
}
|
||||
|
||||
const GitGatewayAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
onLogin,
|
||||
handleAuth,
|
||||
t,
|
||||
}: GitGatewayAuthenticationPageProps) => {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [errors, setErrors] = useState<{
|
||||
identity?: string;
|
||||
server?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) {
|
||||
onLogin(window.netlifyIdentity.currentUser());
|
||||
window.netlifyIdentity.close();
|
||||
}
|
||||
}, [loggedIn, onLogin]);
|
||||
|
||||
const handleIdentityLogin = useCallback(
|
||||
(user: User) => {
|
||||
onLogin(user);
|
||||
window.netlifyIdentity?.close();
|
||||
},
|
||||
[onLogin],
|
||||
);
|
||||
|
||||
useNetlifyIdentifyEvent('login', handleIdentityLogin);
|
||||
|
||||
const handleIdentityLogout = useCallback(() => {
|
||||
window.netlifyIdentity?.open();
|
||||
}, []);
|
||||
|
||||
useNetlifyIdentifyEvent('logout', handleIdentityLogout);
|
||||
|
||||
const handleIdentityError = useCallback(
|
||||
(err: Error) => {
|
||||
if (err?.message?.match(/^Failed to load settings from.+\.netlify\/identity$/)) {
|
||||
window.netlifyIdentity?.close();
|
||||
setErrors({ identity: t('auth.errors.identitySettings') });
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
useNetlifyIdentifyEvent('error', handleIdentityError);
|
||||
|
||||
const handleIdentity = useCallback(() => {
|
||||
const user = window.netlifyIdentity?.currentUser();
|
||||
if (user) {
|
||||
onLogin(user);
|
||||
} else {
|
||||
window.netlifyIdentity?.open();
|
||||
}
|
||||
}, [onLogin]);
|
||||
|
||||
const handleEmailChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handlePasswordChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validationErrors: typeof errors = {};
|
||||
if (!email) {
|
||||
validationErrors.email = t('auth.errors.email');
|
||||
}
|
||||
if (!password) {
|
||||
validationErrors.password = t('auth.errors.password');
|
||||
}
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
let response: User | string;
|
||||
try {
|
||||
response = await handleAuth(email, password);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
response = e.message;
|
||||
} else {
|
||||
response = 'Unknown authentication error';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof response === 'string') {
|
||||
setErrors({ server: response });
|
||||
setLoggedIn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onLogin(response);
|
||||
},
|
||||
[email, handleAuth, onLogin, password, t],
|
||||
);
|
||||
|
||||
if (window.netlifyIdentity) {
|
||||
if (errors.identity) {
|
||||
return (
|
||||
<AuthenticationPage
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
onLogin={handleIdentity}
|
||||
pageContent={
|
||||
<a
|
||||
href="https://docs.netlify.com/visitor-access/git-gateway/#setup-and-settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{errors.identity}
|
||||
</a>
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<AuthenticationPage
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
onLogin={handleIdentity}
|
||||
buttonContent={t('auth.loginWithNetlifyIdentity')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
pageContent={
|
||||
<StyledAuthForm onSubmit={handleLogin}>
|
||||
{!errors.server ? null : <ErrorMessage>{String(errors.server)}</ErrorMessage>}
|
||||
<TextField
|
||||
type="text"
|
||||
name="email"
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email ?? undefined}
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={Boolean(errors.password)}
|
||||
helperText={errors.password ?? undefined}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={inProgress}
|
||||
sx={{ width: 120, alignSelf: 'center' }}
|
||||
>
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.login')}
|
||||
</Button>
|
||||
</StyledAuthForm>
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitGatewayAuthenticationPage;
|
@ -1,12 +1,21 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import GoTrue from 'gotrue-js';
|
||||
import ini from 'ini';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import { get, intersection, pick } from 'lodash';
|
||||
import get from 'lodash/get';
|
||||
import intersection from 'lodash/intersection';
|
||||
import pick from 'lodash/pick';
|
||||
|
||||
import {
|
||||
AccessTokenError, APIError, basename,
|
||||
entriesByFiles, getLargeMediaFilteredMediaFiles, getLargeMediaPatternsFromGitAttributesFile,
|
||||
getPointerFileForMediaFileObj, parsePointerFile, unsentRequest
|
||||
AccessTokenError,
|
||||
APIError,
|
||||
basename,
|
||||
entriesByFiles,
|
||||
getLargeMediaFilteredMediaFiles,
|
||||
getLargeMediaPatternsFromGitAttributesFile,
|
||||
getPointerFileForMediaFileObj,
|
||||
parsePointerFile,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
import { API as BitBucketAPI, BitbucketBackend } from '../bitbucket';
|
||||
import { GitHubBackend } from '../github';
|
||||
@ -16,11 +25,22 @@ import GitHubAPI from './GitHubAPI';
|
||||
import GitLabAPI from './GitLabAPI';
|
||||
import { getClient } from './netlify-lfs-client';
|
||||
|
||||
import type { ApiRequest, Cursor } from '../../lib/util';
|
||||
import type {
|
||||
ApiRequest,
|
||||
AssetProxy, Config, Credentials, Cursor, DisplayURL, DisplayURLObject, Entry, Implementation, ImplementationFile, PersistOptions, User
|
||||
} from '../../lib/util';
|
||||
Config,
|
||||
Credentials,
|
||||
DisplayURL,
|
||||
DisplayURLObject,
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
TranslatedProps,
|
||||
AuthenticationPageProps,
|
||||
} from '../../interface';
|
||||
import type { Client } from './netlify-lfs-client';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
const STATUS_PAGE = 'https://www.netlifystatus.com';
|
||||
const GIT_GATEWAY_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
|
||||
@ -34,15 +54,20 @@ type GitGatewayStatus = {
|
||||
type NetlifyIdentity = {
|
||||
logout: () => void;
|
||||
currentUser: () => User;
|
||||
on: (event: string, args: unknown) => void;
|
||||
on: (
|
||||
eventName: 'init' | 'login' | 'logout' | 'error',
|
||||
callback: (input?: unknown) => void,
|
||||
) => void;
|
||||
init: () => void;
|
||||
store: { user: unknown; modal: { page: string }; saving: boolean };
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type AuthClient = {
|
||||
logout: () => void;
|
||||
currentUser: () => unknown;
|
||||
login?(email: string, password: string, remember?: boolean): Promise<unknown>;
|
||||
login?: (email: string, password: string, remember?: boolean) => Promise<User>;
|
||||
clearStore: () => void;
|
||||
};
|
||||
|
||||
@ -109,11 +134,11 @@ interface NetlifyUser extends Credentials {
|
||||
user_metadata: { full_name: string; avatar_url: string };
|
||||
}
|
||||
|
||||
export default class GitGateway implements Implementation {
|
||||
export default class GitGateway implements BackendClass {
|
||||
config: Config;
|
||||
api?: GitHubAPI | GitLabAPI | BitBucketAPI;
|
||||
branch: string;
|
||||
mediaFolder: string;
|
||||
mediaFolder?: string;
|
||||
transformImages: boolean;
|
||||
gatewayUrl: string;
|
||||
netlifyLargeMediaURL: string;
|
||||
@ -158,7 +183,6 @@ export default class GitGateway implements Implementation {
|
||||
}
|
||||
|
||||
this.backend = null;
|
||||
AuthenticationPage.authClient = () => this.getAuthClient();
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
@ -227,7 +251,6 @@ export default class GitGateway implements Implementation {
|
||||
clearStore: () => undefined,
|
||||
};
|
||||
}
|
||||
return this.authClient;
|
||||
}
|
||||
|
||||
requestFunction = (req: ApiRequest) =>
|
||||
@ -244,8 +267,12 @@ export default class GitGateway implements Implementation {
|
||||
const func = user.jwt.bind(user);
|
||||
const token = await func();
|
||||
return token;
|
||||
} catch (error: any) {
|
||||
throw new AccessTokenError(`Failed getting access token: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new AccessTokenError(`Failed getting access token: ${error.message}`);
|
||||
}
|
||||
|
||||
throw new AccessTokenError('Failed getting access token');
|
||||
}
|
||||
};
|
||||
return this.tokenPromise!().then(async token => {
|
||||
@ -335,22 +362,48 @@ export default class GitGateway implements Implementation {
|
||||
}
|
||||
async restoreUser() {
|
||||
const client = await this.getAuthClient();
|
||||
const user = client.currentUser();
|
||||
if (!user) return Promise.reject();
|
||||
const user = client?.currentUser();
|
||||
if (!user) {
|
||||
return Promise.reject();
|
||||
}
|
||||
return this.authenticate(user as Credentials);
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
const WrappedAuthenticationPage = (props: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const handleAuth = useCallback(
|
||||
async (email: string, password: string): Promise<User | string> => {
|
||||
try {
|
||||
const authClient = await this.getAuthClient();
|
||||
if (!authClient) {
|
||||
return 'Auth client not started';
|
||||
}
|
||||
|
||||
if (!authClient.login) {
|
||||
return 'Auth client login function not found';
|
||||
}
|
||||
|
||||
return authClient.login(email, password, true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
return error.description || error.msg || error;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return <AuthenticationPage {...props} handleAuth={handleAuth} />;
|
||||
};
|
||||
WrappedAuthenticationPage.displayName = 'AuthenticationPage';
|
||||
return WrappedAuthenticationPage;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
const client = await this.getAuthClient();
|
||||
try {
|
||||
client.logout();
|
||||
client?.logout();
|
||||
} catch (e) {
|
||||
// due to a bug in the identity widget (gotrue-js actually) the store is not reset if logout fails
|
||||
// TODO: remove after https://github.com/netlify/gotrue-js/pull/83 is merged
|
||||
client.clearStore();
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
getToken() {
|
||||
@ -490,10 +543,10 @@ export default class GitGateway implements Implementation {
|
||||
return this.backend!.getMediaFile(path);
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, options: PersistOptions) {
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
const client = await this.getLargeMediaClient();
|
||||
if (client.enabled) {
|
||||
const assets = await getLargeMediaFilteredMediaFiles(client, entry.assets);
|
||||
const assets = (await getLargeMediaFilteredMediaFiles(client, entry.assets)) as any;
|
||||
return this.backend!.persistEntry({ ...entry, assets }, options);
|
||||
} else {
|
||||
return this.backend!.persistEntry(entry, options);
|
||||
@ -507,11 +560,11 @@ export default class GitGateway implements Implementation {
|
||||
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
const isLargeMedia = await this.isLargeMediaFile(fixedPath);
|
||||
if (isLargeMedia) {
|
||||
const persistMediaArgument = await getPointerFileForMediaFileObj(
|
||||
const persistMediaArgument = (await getPointerFileForMediaFileObj(
|
||||
client,
|
||||
fileObj as File,
|
||||
path,
|
||||
);
|
||||
)) as any;
|
||||
return {
|
||||
...(await this.backend!.persistMedia(persistMediaArgument, options)),
|
||||
displayURL,
|
@ -1,8 +1,2 @@
|
||||
import GitGatewayBackend from './implementation';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
export const StaticCmsBackendGitGateway = {
|
||||
GitGatewayBackend,
|
||||
AuthenticationPage,
|
||||
};
|
||||
export { GitGatewayBackend, AuthenticationPage };
|
||||
export { default as GitGatewayBackend } from './implementation';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { flow, fromPairs, map } from 'lodash/fp';
|
||||
import { isPlainObject, isEmpty } from 'lodash';
|
||||
import isPlainObject from 'lodash/isPlainObject';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import minimatch from 'minimatch';
|
||||
|
||||
import { unsentRequest } from '../../lib/util';
|
||||
@ -47,8 +48,7 @@ async function resourceExists(
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: what kind of error to throw here? APIError doesn't seem
|
||||
// to fit
|
||||
// TODO: what kind of error to throw here? APIError doesn't seem to fit
|
||||
}
|
||||
|
||||
function getTransofrmationsParams(t: boolean | ImageTransformations) {
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { Base64 } from 'js-base64';
|
||||
import { initial, last, partial, result, trim, trimStart } from 'lodash';
|
||||
import initial from 'lodash/initial';
|
||||
import last from 'lodash/last';
|
||||
import partial from 'lodash/partial';
|
||||
import result from 'lodash/result';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import { dirname } from 'path';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
@ -17,11 +21,12 @@ import {
|
||||
|
||||
import type { Octokit } from '@octokit/rest';
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type { ApiRequest, AssetProxy, DataFile, FetchError, PersistOptions } from '../../lib/util';
|
||||
import type { DataFile, PersistOptions } from '../../interface';
|
||||
import type { ApiRequest, FetchError } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
|
||||
type GitCreateTreeParamsTree = Octokit.GitCreateTreeParamsTree;
|
||||
type GitHubCompareCommit = Octokit.ReposCompareCommitsResponseCommitsItem;
|
||||
type GitHubAuthor = Octokit.GitCreateCommitResponseAuthor;
|
||||
type GitHubCommitter = Octokit.GitCreateCommitResponseCommitter;
|
||||
|
||||
@ -35,25 +40,10 @@ export interface Config {
|
||||
originRepo?: string;
|
||||
}
|
||||
|
||||
interface TreeFile {
|
||||
type: 'blob' | 'tree';
|
||||
sha: string;
|
||||
path: string;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
type Override<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
|
||||
|
||||
type TreeEntry = Override<GitCreateTreeParamsTree, { sha: string | null }>;
|
||||
|
||||
type GitHubCompareCommits = GitHubCompareCommit[];
|
||||
|
||||
type GitHubCompareFile = Octokit.ReposCompareCommitsResponseFilesItem & {
|
||||
previous_filename?: string;
|
||||
};
|
||||
|
||||
type GitHubCompareFiles = GitHubCompareFile[];
|
||||
|
||||
interface MetaDataObjects {
|
||||
entry: { path: string; sha: string };
|
||||
files: MediaFile[];
|
||||
@ -270,115 +260,6 @@ export default class API {
|
||||
return parseContentKey(contentKey);
|
||||
}
|
||||
|
||||
checkMetadataRef() {
|
||||
return this.request(`${this.repoURL}/git/refs/meta/_static_cms`)
|
||||
.then(response => response.object)
|
||||
.catch(() => {
|
||||
// Meta ref doesn't exist
|
||||
const readme = {
|
||||
raw: '# Static CMS\n\nThis tree is used by the Static CMS to store metadata information for specific files and branches.',
|
||||
};
|
||||
|
||||
return this.uploadBlob(readme)
|
||||
.then(item =>
|
||||
this.request(`${this.repoURL}/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.then(tree => this.commit('First Commit', tree))
|
||||
.then(response => this.createRef('meta', '_static_cms', response.sha))
|
||||
.then(response => response.object);
|
||||
});
|
||||
}
|
||||
|
||||
async storeMetadata(key: string, data: Metadata) {
|
||||
// semaphore ensures metadata updates are always ordered, even if
|
||||
// calls to storeMetadata are not. concurrent metadata updates
|
||||
// will result in the metadata branch being unable to update.
|
||||
if (!this._metadataSemaphore) {
|
||||
this._metadataSemaphore = semaphore(1);
|
||||
}
|
||||
return new Promise<void>((resolve, reject) =>
|
||||
this._metadataSemaphore?.take(async () => {
|
||||
try {
|
||||
const branchData = await this.checkMetadataRef();
|
||||
const file = { path: `${key}.json`, raw: JSON.stringify(data) };
|
||||
|
||||
await this.uploadBlob(file);
|
||||
const changeTree = await this.updateTree(branchData.sha, [file as TreeFile]);
|
||||
const { sha } = await this.commit(`Updating “${key}” metadata`, changeTree);
|
||||
await this.patchRef('meta', '_static_cms', sha);
|
||||
await localForage.setItem(`gh.meta.${key}`, {
|
||||
expires: Date.now() + 300000, // In 5 minutes
|
||||
data,
|
||||
});
|
||||
this._metadataSemaphore?.leave();
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
deleteMetadata(key: string) {
|
||||
if (!this._metadataSemaphore) {
|
||||
this._metadataSemaphore = semaphore(1);
|
||||
}
|
||||
return new Promise<void>(resolve =>
|
||||
this._metadataSemaphore?.take(async () => {
|
||||
try {
|
||||
const branchData = await this.checkMetadataRef();
|
||||
const file = { path: `${key}.json`, sha: null };
|
||||
|
||||
const changeTree = await this.updateTree(branchData.sha, [file]);
|
||||
const { sha } = await this.commit(`Deleting “${key}” metadata`, changeTree);
|
||||
await this.patchRef('meta', '_static_cms', sha);
|
||||
this._metadataSemaphore?.leave();
|
||||
resolve();
|
||||
} catch (err) {
|
||||
this._metadataSemaphore?.leave();
|
||||
resolve();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async retrieveMetadataOld(key: string): Promise<Metadata> {
|
||||
console.info(
|
||||
'%c Checking for MetaData files',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
);
|
||||
|
||||
const metadataRequestOptions = {
|
||||
params: { ref: 'refs/meta/_static_cms' },
|
||||
headers: { Accept: 'application/vnd.github.v3.raw' },
|
||||
};
|
||||
|
||||
function errorHandler(err: Error) {
|
||||
if (err.message === 'Not Found') {
|
||||
console.info(
|
||||
'%c %s does not have metadata',
|
||||
'line-height: 30px;text-align: center;font-weight: bold',
|
||||
key,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const result = await this.request(
|
||||
`${this.repoURL}/contents/${key}.json`,
|
||||
metadataRequestOptions,
|
||||
)
|
||||
.then((response: string) => JSON.parse(response))
|
||||
.catch(errorHandler);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async readFile(
|
||||
path: string,
|
||||
sha?: string | null,
|
||||
@ -479,14 +360,12 @@ export default class API {
|
||||
}
|
||||
|
||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = mediaFiles.concat(dataFiles);
|
||||
const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
|
||||
const uploadPromises = files.map(file => this.uploadBlob(file));
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
return this.getDefaultBranch()
|
||||
.then(branchData =>
|
||||
this.updateTree(branchData.commit.sha, files as { sha: string; path: string }[]),
|
||||
)
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, files as any))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(response => this.patchBranch(this.branch, response.sha));
|
||||
}
|
||||
|
@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { AuthenticationPage, Icon } from '../../ui';
|
||||
import { NetlifyAuthenticator } from '../../lib/auth';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
export default class GitHubAuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
inProgress: PropTypes.bool,
|
||||
base_url: PropTypes.string,
|
||||
siteId: PropTypes.string,
|
||||
authEndpoint: PropTypes.string,
|
||||
config: PropTypes.object.isRequired,
|
||||
clearHash: PropTypes.func,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {};
|
||||
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
const cfg = {
|
||||
base_url: this.props.base_url,
|
||||
site_id:
|
||||
document.location.host.split(':')[0] === 'localhost'
|
||||
? 'cms.netlify.com'
|
||||
: this.props.siteId,
|
||||
auth_endpoint: this.props.authEndpoint,
|
||||
};
|
||||
const auth = new NetlifyAuthenticator(cfg);
|
||||
|
||||
const { auth_scope: authScope = '' } =
|
||||
this.props.config.backend;
|
||||
|
||||
const scope = authScope || 'repo';
|
||||
auth.authenticate({ provider: 'github', scope }, (err, data) => {
|
||||
if (err) {
|
||||
this.setState({ loginError: err.toString() });
|
||||
return;
|
||||
}
|
||||
this.props.onLogin(data);
|
||||
});
|
||||
};
|
||||
|
||||
renderLoginButton = () => {
|
||||
const { inProgress, t } = this.props;
|
||||
return inProgress ? (
|
||||
t('auth.loggingIn')
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="github" />
|
||||
{t('auth.loginWithGitHub')}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
getAuthenticationPageRenderArgs() {
|
||||
return {
|
||||
renderButtonContent: this.renderLoginButton,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { inProgress, config, t } = this.props;
|
||||
const { loginError } = this.state;
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={this.handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
{...this.getAuthenticationPageRenderArgs()}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
64
src/backends/github/AuthenticationPage.tsx
Normal file
64
src/backends/github/AuthenticationPage.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
import { NetlifyAuthenticator } from '../../lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
const GitHubAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
base_url,
|
||||
siteId,
|
||||
authEndpoint,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
const cfg = {
|
||||
base_url,
|
||||
site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
|
||||
auth_endpoint: authEndpoint,
|
||||
};
|
||||
const auth = new NetlifyAuthenticator(cfg);
|
||||
|
||||
const { auth_scope: authScope = '' } = config.backend;
|
||||
|
||||
const scope = authScope || 'repo';
|
||||
auth.authenticate({ provider: 'github', scope }, (err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
},
|
||||
[authEndpoint, base_url, config.backend, onLogin, siteId],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="github" />}
|
||||
buttonContent={t('auth.loginWithGitHub')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHubAuthenticationPage;
|
@ -1,14 +1,11 @@
|
||||
import {
|
||||
InMemoryCache, IntrospectionFragmentMatcher
|
||||
} from 'apollo-cache-inmemory';
|
||||
import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
|
||||
import { ApolloClient } from 'apollo-client';
|
||||
import { setContext } from 'apollo-link-context';
|
||||
import { createHttpLink } from 'apollo-link-http';
|
||||
import { trim, trimStart } from 'lodash';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
|
||||
import {
|
||||
APIError, localForage, readFile, throwOnConflictingBranches
|
||||
} from '../../lib/util';
|
||||
import { APIError, localForage, readFile, throwOnConflictingBranches } from '../../lib/util';
|
||||
import API, { API_NAME } from './API';
|
||||
import introspectionQueryResultData from './fragmentTypes';
|
||||
import * as mutations from './mutations';
|
||||
@ -128,7 +125,7 @@ export default class GraphQLAPI extends API {
|
||||
// https://developer.github.com/v4/enum/repositorypermission/
|
||||
const { viewerPermission } = data.repository;
|
||||
return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(viewerPermission);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Problem fetching repo data from GitHub');
|
||||
throw error;
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { stripIndent } from 'common-tags';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import * as React from 'react';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
import {
|
||||
@ -25,17 +24,17 @@ import GraphQLAPI from './GraphQLAPI';
|
||||
import type { Octokit } from '@octokit/rest';
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type {
|
||||
AssetProxy,
|
||||
AsyncLock,
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
Config,
|
||||
Credentials,
|
||||
DisplayURL,
|
||||
Entry,
|
||||
Implementation,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../lib/util';
|
||||
} from '../../interface';
|
||||
import type { AsyncLock } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
|
||||
|
||||
@ -54,7 +53,7 @@ type GitHubStatusComponent = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export default class GitHub implements Implementation {
|
||||
export default class GitHub implements BackendClass {
|
||||
lock: AsyncLock;
|
||||
api: API | null;
|
||||
options: {
|
||||
@ -65,7 +64,7 @@ export default class GitHub implements Implementation {
|
||||
repo?: string;
|
||||
branch: string;
|
||||
apiRoot: string;
|
||||
mediaFolder: string;
|
||||
mediaFolder?: string;
|
||||
token: string | null;
|
||||
useGraphql: boolean;
|
||||
_currentUserPromise?: Promise<GitHubUser>;
|
||||
@ -136,11 +135,7 @@ export default class GitHub implements Implementation {
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
const wrappedAuthenticationPage = (props: Record<string, unknown>) => (
|
||||
<AuthenticationPage {...props} backend={this} />
|
||||
);
|
||||
wrappedAuthenticationPage.displayName = 'AuthenticationPage';
|
||||
return wrappedAuthenticationPage;
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
restoreUser(user: User) {
|
||||
@ -324,7 +319,10 @@ export default class GitHub implements Implementation {
|
||||
.catch(() => ({ file: { path, id: null }, data: '' }));
|
||||
}
|
||||
|
||||
getMedia(mediaFolder = this.mediaFolder) {
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
return this.api!.listFiles(mediaFolder).then(files =>
|
||||
files.map(({ id, name, size, path }) => {
|
||||
// load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls
|
||||
@ -362,7 +360,7 @@ export default class GitHub implements Implementation {
|
||||
);
|
||||
}
|
||||
|
||||
persistEntry(entry: Entry, options: PersistOptions) {
|
||||
persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
@ -394,8 +392,8 @@ export default class GitHub implements Implementation {
|
||||
}
|
||||
|
||||
async traverseCursor(cursor: Cursor, action: string) {
|
||||
const meta = cursor.meta!;
|
||||
const files = cursor.data!.get('files')!.toJS() as ApiFile[];
|
||||
const meta = cursor.meta;
|
||||
const files = (cursor.data?.files ?? []) as ApiFile[];
|
||||
|
||||
let result: { cursor: Cursor; files: ApiFile[] };
|
||||
switch (action) {
|
||||
@ -404,15 +402,15 @@ export default class GitHub implements Implementation {
|
||||
break;
|
||||
}
|
||||
case 'last': {
|
||||
result = this.getCursorAndFiles(files, meta.get('pageCount'));
|
||||
result = this.getCursorAndFiles(files, (meta?.['pageCount'] as number) ?? 1);
|
||||
break;
|
||||
}
|
||||
case 'next': {
|
||||
result = this.getCursorAndFiles(files, meta.get('page') + 1);
|
||||
result = this.getCursorAndFiles(files, (meta?.['page'] as number) + 1 ?? 1);
|
||||
break;
|
||||
}
|
||||
case 'prev': {
|
||||
result = this.getCursorAndFiles(files, meta.get('page') - 1);
|
||||
result = this.getCursorAndFiles(files, (meta?.['page'] as number) - 1 ?? 1);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
@ -1,10 +1,3 @@
|
||||
import GitHubBackend from './implementation';
|
||||
import API from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
export const StaticCmsBackendGithub = {
|
||||
GitHubBackend,
|
||||
API,
|
||||
AuthenticationPage,
|
||||
};
|
||||
export { GitHubBackend, API, AuthenticationPage };
|
||||
export { default as GitHubBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
||||
|
@ -1,6 +1,6 @@
|
||||
const fetch = require('node-fetch');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import path from 'path';
|
||||
|
||||
const API_HOST = process.env.GITHUB_HOST || 'https://api.github.com';
|
||||
const API_TOKEN = process.env.GITHUB_API_TOKEN;
|
||||
@ -32,7 +32,9 @@ fetch(`${API_HOST}/graphql`, {
|
||||
.then(result => result.json())
|
||||
.then(result => {
|
||||
// here we're filtering out any type information unrelated to unions or interfaces
|
||||
const filteredData = result.data.__schema.types.filter(type => type.possibleTypes !== null);
|
||||
const filteredData = result.data.__schema.types.filter(
|
||||
(type: { possibleTypes: string[] | null }) => type.possibleTypes !== null,
|
||||
);
|
||||
result.data.__schema.types = filteredData;
|
||||
fs.writeFile(
|
||||
path.join(__dirname, '..', 'src', 'fragmentTypes.js'),
|
5
src/backends/github/types/semaphore.d.ts
vendored
5
src/backends/github/types/semaphore.d.ts
vendored
@ -1,5 +0,0 @@
|
||||
declare module 'semaphore' {
|
||||
export type Semaphore = { take: (f: Function) => void; leave: () => void };
|
||||
const semaphore: (count: number) => Semaphore;
|
||||
export default semaphore;
|
||||
}
|
@ -2,36 +2,36 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||
import { ApolloClient } from 'apollo-client';
|
||||
import { setContext } from 'apollo-link-context';
|
||||
import { createHttpLink } from 'apollo-link-http';
|
||||
import { Map } from 'immutable';
|
||||
import { Base64 } from 'js-base64';
|
||||
import { flow, partial, result, trimStart } from 'lodash';
|
||||
import partial from 'lodash/partial';
|
||||
import result from 'lodash/result';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import {
|
||||
APIError, Cursor, localForage, parseLinkHeader, readFile,
|
||||
APIError,
|
||||
Cursor,
|
||||
localForage,
|
||||
parseLinkHeader,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
requestWithBackoff,
|
||||
responseParser, then,
|
||||
responseParser,
|
||||
throwOnConflictingBranches,
|
||||
unsentRequest
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
import * as queries from './queries';
|
||||
|
||||
const NO_CACHE = 'no-cache';
|
||||
|
||||
import type { NormalizedCacheObject } from 'apollo-cache-inmemory';
|
||||
import type { ApolloQueryResult } from 'apollo-client';
|
||||
import type {
|
||||
ApiRequest,
|
||||
AssetProxy,
|
||||
DataFile,
|
||||
FetchError,
|
||||
ImplementationFile,
|
||||
PersistOptions
|
||||
} from '../../lib/util';
|
||||
import type { DataFile, ImplementationFile, PersistOptions } from '../../interface';
|
||||
import type { ApiRequest, FetchError } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
export const API_NAME = 'GitLab';
|
||||
|
||||
const NO_CACHE = 'no-cache';
|
||||
|
||||
export interface Config {
|
||||
apiRoot?: string;
|
||||
graphQLAPIRoot?: string;
|
||||
@ -203,7 +203,7 @@ export default class API {
|
||||
const withRoot: ApiRequest = unsentRequest.withRoot(this.apiRoot)(req);
|
||||
const withAuthorizationHeaders = await this.withAuthorizationHeaders(withRoot);
|
||||
|
||||
if (withAuthorizationHeaders.has('cache')) {
|
||||
if ('cache' in withAuthorizationHeaders) {
|
||||
return withAuthorizationHeaders;
|
||||
} else {
|
||||
const withNoCache: ApiRequest = unsentRequest.withNoCache(withAuthorizationHeaders);
|
||||
@ -214,8 +214,11 @@ export default class API {
|
||||
request = async (req: ApiRequest): Promise<Response> => {
|
||||
try {
|
||||
return requestWithBackoff(this, req);
|
||||
} catch (err: any) {
|
||||
throw new APIError(err.message, null, API_NAME);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new APIError(error.message, null, API_NAME);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -310,16 +313,14 @@ export default class API {
|
||||
const pageSize = parseInt(headers.get('X-Per-Page') as string, 10);
|
||||
const count = parseInt(headers.get('X-Total') as string, 10);
|
||||
const links = parseLinkHeader(headers.get('Link'));
|
||||
const actions = Map(links)
|
||||
.keySeq()
|
||||
.flatMap(key =>
|
||||
(key === 'prev' && page > 1) ||
|
||||
(key === 'next' && page < pageCount) ||
|
||||
(key === 'first' && page > 1) ||
|
||||
(key === 'last' && page < pageCount)
|
||||
? [key]
|
||||
: [],
|
||||
);
|
||||
const actions = Object.keys(links).flatMap(key =>
|
||||
(key === 'prev' && page > 1) ||
|
||||
(key === 'next' && page < pageCount) ||
|
||||
(key === 'first' && page > 1) ||
|
||||
(key === 'last' && page < pageCount)
|
||||
? [key]
|
||||
: [],
|
||||
);
|
||||
return Cursor.create({
|
||||
actions,
|
||||
meta: { page, count, pageSize, pageCount },
|
||||
@ -329,38 +330,34 @@ export default class API {
|
||||
|
||||
getCursor = ({ headers }: { headers: Headers }) => this.getCursorFromHeaders(headers);
|
||||
|
||||
// Gets a cursor without retrieving the entries by using a HEAD
|
||||
// request
|
||||
// Gets a cursor without retrieving the entries by using a HEAD request
|
||||
fetchCursor = (req: ApiRequest) =>
|
||||
flow([unsentRequest.withMethod('HEAD'), this.request, then(this.getCursor)])(req);
|
||||
this.request(unsentRequest.withMethod('HEAD', req)).then(value => this.getCursor(value));
|
||||
|
||||
fetchCursorAndEntries = (
|
||||
req: ApiRequest,
|
||||
): Promise<{
|
||||
entries: FileEntry[];
|
||||
cursor: Cursor;
|
||||
}> =>
|
||||
flow([
|
||||
unsentRequest.withMethod('GET'),
|
||||
this.request,
|
||||
p =>
|
||||
Promise.all([
|
||||
p.then(this.getCursor),
|
||||
p.then(this.responseToJSON).catch((e: FetchError) => {
|
||||
if (e.status === 404) {
|
||||
return [];
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
]),
|
||||
then(([cursor, entries]: [Cursor, {}[]]) => ({ cursor, entries })),
|
||||
])(req);
|
||||
}> => {
|
||||
const request = this.request(unsentRequest.withMethod('GET', req));
|
||||
|
||||
return Promise.all([
|
||||
request.then(this.getCursor),
|
||||
request.then(this.responseToJSON).catch((e: FetchError) => {
|
||||
if (e.status === 404) {
|
||||
return [];
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
]).then(([cursor, entries]) => ({ cursor, entries }));
|
||||
};
|
||||
|
||||
listFiles = async (path: string, recursive = false) => {
|
||||
const { entries, cursor } = await this.fetchCursorAndEntries({
|
||||
url: `${this.repoURL}/repository/tree`,
|
||||
params: { path, ref: this.branch, recursive },
|
||||
params: { path, ref: this.branch, recursive: `${recursive}` },
|
||||
});
|
||||
return {
|
||||
files: entries.filter(({ type }) => type === 'blob'),
|
||||
@ -369,7 +366,7 @@ export default class API {
|
||||
};
|
||||
|
||||
traverseCursor = async (cursor: Cursor, action: string) => {
|
||||
const link = cursor.data!.getIn(['links', action]);
|
||||
const link = (cursor.data?.links as Record<string, ApiRequest>)[action];
|
||||
const { entries, cursor: newCursor } = await this.fetchCursorAndEntries(link);
|
||||
return {
|
||||
entries: entries.filter(({ type }) => type === 'blob'),
|
||||
@ -478,11 +475,11 @@ export default class API {
|
||||
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
|
||||
url: `${this.repoURL}/repository/tree`,
|
||||
// Get the maximum number of entries per page
|
||||
params: { path, ref: branch, per_page: 100, recursive },
|
||||
params: { path, ref: branch, per_page: '100', recursive: `${recursive}` },
|
||||
});
|
||||
entries.push(...initialEntries);
|
||||
while (cursor && cursor.actions!.has('next')) {
|
||||
const link = cursor.data!.getIn(['links', 'next']);
|
||||
const link = (cursor.data?.links as Record<string, ApiRequest>).next;
|
||||
const { cursor: newCursor, entries: newEntries } = await this.fetchCursorAndEntries(link);
|
||||
entries.push(...newEntries);
|
||||
cursor = newCursor;
|
||||
@ -533,10 +530,12 @@ export default class API {
|
||||
body: JSON.stringify(commitParams),
|
||||
});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message = error.message || '';
|
||||
if (newBranch && message.includes(`Could not update ${branch}`)) {
|
||||
await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message || '';
|
||||
if (newBranch && message.includes(`Could not update ${branch}`)) {
|
||||
await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -618,7 +617,7 @@ export default class API {
|
||||
params: { ref: branch },
|
||||
});
|
||||
|
||||
const blobId = request.headers.get('X-Gitlab-Blob-Id') as string;
|
||||
const blobId = request.headers.get('X - Gitlab - Blob - Id') as string;
|
||||
return blobId;
|
||||
}
|
||||
|
||||
|
@ -1,104 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { AuthenticationPage, Icon } from '../../ui';
|
||||
import {
|
||||
NetlifyAuthenticator,
|
||||
ImplicitAuthenticator,
|
||||
PkceAuthenticator,
|
||||
} from '../../lib/auth';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
const clientSideAuthenticators = {
|
||||
pkce: ({ base_url, auth_endpoint, app_id, auth_token_endpoint }) =>
|
||||
new PkceAuthenticator({ base_url, auth_endpoint, app_id, auth_token_endpoint }),
|
||||
|
||||
implicit: ({ base_url, auth_endpoint, app_id, clearHash }) =>
|
||||
new ImplicitAuthenticator({ base_url, auth_endpoint, app_id, clearHash }),
|
||||
};
|
||||
|
||||
export default class GitLabAuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
inProgress: PropTypes.bool,
|
||||
base_url: PropTypes.string,
|
||||
siteId: PropTypes.string,
|
||||
authEndpoint: PropTypes.string,
|
||||
config: PropTypes.object.isRequired,
|
||||
clearHash: PropTypes.func,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {};
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
auth_type: authType = '',
|
||||
base_url = 'https://gitlab.com',
|
||||
auth_endpoint = 'oauth/authorize',
|
||||
app_id = '',
|
||||
} = this.props.config.backend;
|
||||
|
||||
if (clientSideAuthenticators[authType]) {
|
||||
this.auth = clientSideAuthenticators[authType]({
|
||||
base_url,
|
||||
auth_endpoint,
|
||||
app_id,
|
||||
auth_token_endpoint: 'oauth/token',
|
||||
clearHash: this.props.clearHash,
|
||||
});
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
this.auth.completeAuth((err, data) => {
|
||||
if (err) {
|
||||
this.setState({ loginError: err.toString() });
|
||||
return;
|
||||
}
|
||||
this.props.onLogin(data);
|
||||
});
|
||||
} else {
|
||||
this.auth = new NetlifyAuthenticator({
|
||||
base_url: this.props.base_url,
|
||||
site_id:
|
||||
document.location.host.split(':')[0] === 'localhost'
|
||||
? 'cms.netlify.com'
|
||||
: this.props.siteId,
|
||||
auth_endpoint: this.props.authEndpoint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
this.auth.authenticate({ provider: 'gitlab', scope: 'api' }, (err, data) => {
|
||||
if (err) {
|
||||
this.setState({ loginError: err.toString() });
|
||||
return;
|
||||
}
|
||||
this.props.onLogin(data);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { inProgress, config, t } = this.props;
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={this.handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={this.state.loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
renderButtonContent={() => (
|
||||
<React.Fragment>
|
||||
<LoginButtonIcon type="gitlab" />{' '}
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.loginWithGitLab')}
|
||||
</React.Fragment>
|
||||
)}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
100
src/backends/gitlab/AuthenticationPage.tsx
Normal file
100
src/backends/gitlab/AuthenticationPage.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
import { ImplicitAuthenticator, NetlifyAuthenticator, PkceAuthenticator } from '../../lib/auth';
|
||||
import { isNotEmpty } from '../../lib/util/string.util';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type {
|
||||
AuthenticationPageProps,
|
||||
AuthenticatorConfig,
|
||||
TranslatedProps
|
||||
} from '../../interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
const clientSideAuthenticators = {
|
||||
pkce: (config: AuthenticatorConfig) => new PkceAuthenticator(config),
|
||||
implicit: (config: AuthenticatorConfig) => new ImplicitAuthenticator(config),
|
||||
} as const;
|
||||
|
||||
const GitLabAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
siteId,
|
||||
authEndpoint,
|
||||
clearHash,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const auth = useMemo(() => {
|
||||
const {
|
||||
auth_type: authType = '',
|
||||
base_url = 'https://gitlab.com',
|
||||
auth_endpoint = 'oauth/authorize',
|
||||
app_id = '',
|
||||
} = config.backend;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (isNotEmpty(authType) && authType in clientSideAuthenticators) {
|
||||
const clientSizeAuth = clientSideAuthenticators[
|
||||
authType as keyof typeof clientSideAuthenticators
|
||||
]({
|
||||
base_url,
|
||||
auth_endpoint,
|
||||
app_id,
|
||||
auth_token_endpoint: 'oauth/token',
|
||||
clearHash,
|
||||
});
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
clientSizeAuth.completeAuth((err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
return clientSizeAuth;
|
||||
} else {
|
||||
return new NetlifyAuthenticator({
|
||||
base_url,
|
||||
site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
|
||||
auth_endpoint: authEndpoint,
|
||||
});
|
||||
}
|
||||
}, [authEndpoint, clearHash, config.backend, onLogin, siteId]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
auth.authenticate({ provider: 'gitlab', scope: 'api' }, err => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
return;
|
||||
}
|
||||
});
|
||||
},
|
||||
[auth],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="gitlab" />}
|
||||
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithGitLab')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitLabAuthenticationPage;
|
@ -1,5 +1,5 @@
|
||||
import { stripIndent } from 'common-tags';
|
||||
import { trim } from 'lodash';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
@ -22,23 +22,22 @@ import API, { API_NAME } from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type { AsyncLock, Cursor } from '../../lib/util';
|
||||
import type {
|
||||
AssetProxy,
|
||||
AsyncLock,
|
||||
Config,
|
||||
Credentials,
|
||||
Cursor,
|
||||
DisplayURL,
|
||||
Entry,
|
||||
Implementation,
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../lib/util';
|
||||
} from '../../interface';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
export default class GitLab implements Implementation {
|
||||
export default class GitLab implements BackendClass {
|
||||
lock: AsyncLock;
|
||||
api: API | null;
|
||||
options: {
|
||||
@ -49,7 +48,7 @@ export default class GitLab implements Implementation {
|
||||
branch: string;
|
||||
apiRoot: string;
|
||||
token: string | null;
|
||||
mediaFolder: string;
|
||||
mediaFolder?: string;
|
||||
useGraphQL: boolean;
|
||||
graphQLAPIRoot: string;
|
||||
|
||||
@ -224,7 +223,10 @@ export default class GitLab implements Implementation {
|
||||
}));
|
||||
}
|
||||
|
||||
getMedia(mediaFolder = this.mediaFolder) {
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
return this.api!.listAllFiles(mediaFolder).then(files =>
|
||||
files.map(({ id, name, path }) => {
|
||||
return { id, name, path, displayURL: { id, name, path } };
|
||||
@ -259,7 +261,7 @@ export default class GitLab implements Implementation {
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, options: PersistOptions) {
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
@ -297,9 +299,9 @@ export default class GitLab implements Implementation {
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => {
|
||||
const [folder, depth, extension] = [
|
||||
cursor.meta?.get('folder') as string,
|
||||
cursor.meta?.get('depth') as number,
|
||||
cursor.meta?.get('extension') as string,
|
||||
cursor.meta?.folder as string,
|
||||
cursor.meta?.depth as number,
|
||||
cursor.meta?.extension as string,
|
||||
];
|
||||
if (folder && depth && extension) {
|
||||
entries = entries.filter(f => this.filterFile(folder, f, extension, depth));
|
||||
|
@ -1,10 +1,3 @@
|
||||
import GitLabBackend from './implementation';
|
||||
import API from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
export const StaticCmsBackendGitlab = {
|
||||
GitLabBackend,
|
||||
API,
|
||||
AuthenticationPage,
|
||||
};
|
||||
export { GitLabBackend, API, AuthenticationPage };
|
||||
export { default as GitLabBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
||||
|
@ -1,17 +1,7 @@
|
||||
import { AzureBackend } from './azure';
|
||||
import { BitbucketBackend } from './bitbucket';
|
||||
import { GitGatewayBackend } from './git-gateway';
|
||||
import { GitHubBackend } from './github';
|
||||
import { GitLabBackend } from './gitlab';
|
||||
import { ProxyBackend } from './proxy';
|
||||
import { TestBackend } from './test';
|
||||
|
||||
export {
|
||||
AzureBackend,
|
||||
BitbucketBackend,
|
||||
GitGatewayBackend,
|
||||
GitHubBackend,
|
||||
GitLabBackend,
|
||||
ProxyBackend,
|
||||
TestBackend,
|
||||
};
|
||||
export { AzureBackend } from './azure';
|
||||
export { BitbucketBackend } from './bitbucket';
|
||||
export { GitGatewayBackend } from './git-gateway';
|
||||
export { GitHubBackend } from './github';
|
||||
export { GitLabBackend } from './gitlab';
|
||||
export { ProxyBackend } from './proxy';
|
||||
export { TestBackend } from './test';
|
||||
|
@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Icon, buttons, shadows, GoBackButton } from '../../ui';
|
||||
|
||||
const StyledAuthenticationPage = styled.section`
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const PageLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
margin-top: -300px;
|
||||
`;
|
||||
|
||||
const LoginButton = styled.button`
|
||||
${buttons.button};
|
||||
${shadows.dropDeep};
|
||||
${buttons.default};
|
||||
${buttons.gray};
|
||||
|
||||
padding: 0 30px;
|
||||
margin-top: -40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
${Icon} {
|
||||
margin-right: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default class AuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
inProgress: PropTypes.bool,
|
||||
config: PropTypes.object.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
this.props.onLogin(this.state);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { config, inProgress, t } = this.props;
|
||||
|
||||
return (
|
||||
<StyledAuthenticationPage>
|
||||
<PageLogoIcon size="300px" type="static-cms" />
|
||||
<LoginButton disabled={inProgress} onClick={this.handleLogin}>
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.login')}
|
||||
</LoginButton>
|
||||
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
|
||||
</StyledAuthenticationPage>
|
||||
);
|
||||
}
|
||||
}
|
48
src/backends/proxy/AuthenticationPage.tsx
Normal file
48
src/backends/proxy/AuthenticationPage.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import GoBackButton from '../../components/UI/GoBackButton';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const StyledAuthenticationPage = styled('section')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const PageLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
|
||||
const AuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
onLogin({ token: 'fake_token' });
|
||||
},
|
||||
[onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAuthenticationPage>
|
||||
<PageLogoIcon width={300} height={150} type="static-cms" />
|
||||
<Button variant="contained" disabled={inProgress} onClick={handleLogin}>
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.login')}
|
||||
</Button>
|
||||
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
|
||||
</StyledAuthenticationPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticationPage;
|
@ -2,15 +2,17 @@ import { APIError, basename, blobToFileObj, unsentRequest } from '../../lib/util
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type {
|
||||
AssetProxy,
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
Config,
|
||||
Entry,
|
||||
Implementation,
|
||||
DisplayURL,
|
||||
ImplementationEntry,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../lib/util';
|
||||
} from '../../interface';
|
||||
import type { Cursor } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
async function serializeAsset(assetProxy: AssetProxy) {
|
||||
const base64content = await assetProxy.toBase64!();
|
||||
@ -42,9 +44,9 @@ function deserializeMediaFile({ id, content, encoding, path, name }: MediaFile)
|
||||
return { id, name, path, file, size: file.size, url, displayURL: url };
|
||||
}
|
||||
|
||||
export default class ProxyBackend implements Implementation {
|
||||
export default class ProxyBackend implements BackendClass {
|
||||
proxyUrl: string;
|
||||
mediaFolder: string;
|
||||
mediaFolder?: string;
|
||||
options: {};
|
||||
branch: string;
|
||||
|
||||
@ -124,7 +126,7 @@ export default class ProxyBackend implements Implementation {
|
||||
});
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry, options: PersistOptions) {
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
const assets = await Promise.all(entry.assets.map(serializeAsset));
|
||||
return this.request({
|
||||
action: 'persistEntry',
|
||||
@ -179,4 +181,16 @@ export default class ProxyBackend implements Implementation {
|
||||
params: { branch: this.branch, paths, options: { commitMessage } },
|
||||
});
|
||||
}
|
||||
|
||||
traverseCursor(): Promise<{ entries: ImplementationEntry[]; cursor: Cursor }> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
|
||||
allEntriesByFolder(
|
||||
_folder: string,
|
||||
_extension: string,
|
||||
_depth: number,
|
||||
): Promise<ImplementationEntry[]> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,2 @@
|
||||
import ProxyBackend from './implementation';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
export const StaticCmsBackendProxy = {
|
||||
ProxyBackend,
|
||||
AuthenticationPage,
|
||||
};
|
||||
export { ProxyBackend, AuthenticationPage };
|
||||
export { default as ProxyBackend } from './implementation';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
||||
|
@ -1,73 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Icon, buttons, shadows, GoBackButton } from '../../ui';
|
||||
|
||||
const StyledAuthenticationPage = styled.section`
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const PageLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
margin-top: -300px;
|
||||
`;
|
||||
|
||||
const LoginButton = styled.button`
|
||||
${buttons.button};
|
||||
${shadows.dropDeep};
|
||||
${buttons.default};
|
||||
${buttons.gray};
|
||||
|
||||
padding: 0 30px;
|
||||
margin-top: -40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
${Icon} {
|
||||
margin-right: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default class AuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
onLogin: PropTypes.func.isRequired,
|
||||
inProgress: PropTypes.bool,
|
||||
config: PropTypes.object.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
/**
|
||||
* Allow login screen to be skipped for demo purposes.
|
||||
*/
|
||||
const skipLogin = this.props.config.backend.login === false;
|
||||
if (skipLogin) {
|
||||
this.props.onLogin(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
handleLogin = e => {
|
||||
e.preventDefault();
|
||||
this.props.onLogin(this.state);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { config, inProgress, t } = this.props;
|
||||
|
||||
return (
|
||||
<StyledAuthenticationPage>
|
||||
<PageLogoIcon size="300px" type="static-cms" />
|
||||
<LoginButton disabled={inProgress} onClick={this.handleLogin}>
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.login')}
|
||||
</LoginButton>
|
||||
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
|
||||
</StyledAuthenticationPage>
|
||||
);
|
||||
}
|
||||
}
|
63
src/backends/test/AuthenticationPage.tsx
Normal file
63
src/backends/test/AuthenticationPage.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import GoBackButton from '../../components/UI/GoBackButton';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const StyledAuthenticationPage = styled('section')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const PageLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
|
||||
const AuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Allow login screen to be skipped for demo purposes.
|
||||
*/
|
||||
const skipLogin = config.backend.login === false;
|
||||
if (skipLogin) {
|
||||
onLogin({ token: 'fake_token' });
|
||||
}
|
||||
}, [config.backend.login, onLogin]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
onLogin({ token: 'fake_token' });
|
||||
},
|
||||
[onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAuthenticationPage>
|
||||
<PageLogoIcon width={300} height={150} type="static-cms" />
|
||||
<Button
|
||||
disabled={inProgress}
|
||||
onClick={handleLogin}
|
||||
variant="contained"
|
||||
sx={{ marginBottom: '32px' }}
|
||||
>
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.login')}
|
||||
</Button>
|
||||
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
|
||||
</StyledAuthenticationPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticationPage;
|
@ -1,19 +1,23 @@
|
||||
import { attempt, isError, take, unset } from 'lodash';
|
||||
import attempt from 'lodash/attempt';
|
||||
import isError from 'lodash/isError';
|
||||
import take from 'lodash/take';
|
||||
import unset from 'lodash/unset';
|
||||
import { extname } from 'path';
|
||||
import uuid from 'uuid/v4';
|
||||
|
||||
import { basename, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '../../lib/util';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type { ImplementationEntry } from '../../interface';
|
||||
import type {
|
||||
AssetProxy,
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
Config,
|
||||
Entry,
|
||||
Implementation,
|
||||
DisplayURL,
|
||||
ImplementationEntry,
|
||||
ImplementationFile,
|
||||
User,
|
||||
} from '../../lib/util';
|
||||
} from '../../interface';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
type RepoFile = { path: string; content: string | AssetProxy };
|
||||
type RepoTree = { [key: string]: RepoFile | RepoTree };
|
||||
@ -98,8 +102,8 @@ export function getFolderFiles(
|
||||
return files;
|
||||
}
|
||||
|
||||
export default class TestBackend implements Implementation {
|
||||
mediaFolder: string;
|
||||
export default class TestBackend implements BackendClass {
|
||||
mediaFolder?: string;
|
||||
options: {};
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
@ -136,7 +140,7 @@ export default class TestBackend implements Implementation {
|
||||
}
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
const { folder, extension, index, pageCount, depth } = cursor.data!.toObject() as {
|
||||
const { folder, extension, index, pageCount, depth } = cursor.data as {
|
||||
folder: string;
|
||||
extension: string;
|
||||
index: number;
|
||||
@ -177,6 +181,7 @@ export default class TestBackend implements Implementation {
|
||||
}));
|
||||
const cursor = getCursor(folder, extension, entries, 0, depth);
|
||||
const ret = take(entries, pageSize);
|
||||
// TODO Remove
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
ret[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
@ -199,7 +204,7 @@ export default class TestBackend implements Implementation {
|
||||
});
|
||||
}
|
||||
|
||||
async persistEntry(entry: Entry) {
|
||||
async persistEntry(entry: BackendEntry) {
|
||||
entry.dataFiles.forEach(dataFile => {
|
||||
const { path, raw } = dataFile;
|
||||
writeFile(path, raw, window.repoFiles);
|
||||
@ -210,12 +215,14 @@ export default class TestBackend implements Implementation {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
getMedia(mediaFolder = this.mediaFolder) {
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f =>
|
||||
f.path.startsWith(mediaFolder),
|
||||
);
|
||||
const assets = files.map(f => this.normalizeAsset(f.content as AssetProxy));
|
||||
return Promise.resolve(assets);
|
||||
return files.map(f => this.normalizeAsset(f.content as AssetProxy));
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
@ -270,4 +277,16 @@ export default class TestBackend implements Implementation {
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async allEntriesByFolder(
|
||||
folder: string,
|
||||
extension: string,
|
||||
depth: number,
|
||||
): Promise<ImplementationEntry[]> {
|
||||
return this.entriesByFolder(folder, extension, depth);
|
||||
}
|
||||
|
||||
getMediaDisplayURL(_displayURL: DisplayURL): Promise<string> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,2 @@
|
||||
import TestBackend from './implementation';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
export const StaticCmsBackendTest = {
|
||||
TestBackend,
|
||||
AuthenticationPage,
|
||||
};
|
||||
export { TestBackend, AuthenticationPage };
|
||||
export { default as TestBackend } from './implementation';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
||||
|
@ -1,46 +1,57 @@
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { Provider, connect } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { I18n } from 'react-polyglot';
|
||||
import 'symbol-observable';
|
||||
|
||||
import { GlobalStyles } from './ui';
|
||||
import { store } from './store';
|
||||
import { history } from './routing/history';
|
||||
import { loadConfig } from './actions/config';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18n } from 'react-polyglot';
|
||||
import { connect, Provider } from 'react-redux';
|
||||
import { HashRouter as Router } from 'react-router-dom';
|
||||
|
||||
import 'what-input';
|
||||
import { authenticateUser } from './actions/auth';
|
||||
import { getPhrases } from './lib/phrases';
|
||||
import { selectLocale } from './reducers/config';
|
||||
import { ErrorBoundary } from './components/UI';
|
||||
import { loadConfig } from './actions/config';
|
||||
import App from './components/App/App';
|
||||
import './components/EditorWidgets';
|
||||
import { ErrorBoundary } from './components/UI';
|
||||
import { addExtensions } from './extensions';
|
||||
import { getPhrases } from './lib/phrases';
|
||||
import './mediaLibrary';
|
||||
import 'what-input';
|
||||
import { selectLocale } from './reducers/config';
|
||||
import { store } from './store';
|
||||
|
||||
import type { AnyAction } from '@reduxjs/toolkit';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Config } from './interface';
|
||||
import type { RootState } from './store';
|
||||
|
||||
const ROOT_ID = 'nc-root';
|
||||
|
||||
function TranslatedApp({ locale, config }) {
|
||||
const TranslatedApp = ({ locale, config }: AppRootProps) => {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="cms">
|
||||
<I18n locale={locale} messages={getPhrases(locale)}>
|
||||
<ErrorBoundary showBackup config={config}>
|
||||
<Router history={history}>
|
||||
<App />
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
</I18n>
|
||||
</div>
|
||||
<I18n locale={locale} messages={getPhrases(locale)}>
|
||||
<ErrorBoundary showBackup config={config}>
|
||||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
function mapDispatchToProps(state: RootState) {
|
||||
return { locale: selectLocale(state.config.config), config: state.config.config };
|
||||
}
|
||||
|
||||
function mapDispatchToProps(state) {
|
||||
return { locale: selectLocale(state.config), config: state.config };
|
||||
}
|
||||
const connector = connect(mapDispatchToProps);
|
||||
export type AppRootProps = ConnectedProps<typeof connector>;
|
||||
|
||||
const ConnectedTranslatedApp = connect(mapDispatchToProps)(TranslatedApp);
|
||||
const ConnectedTranslatedApp = connector(TranslatedApp);
|
||||
|
||||
function bootstrap(opts = {}) {
|
||||
const { config } = opts;
|
||||
function bootstrap(opts?: { config?: Config; autoInitialize?: boolean }) {
|
||||
const { config, autoInitialize = true } = opts ?? {};
|
||||
|
||||
/**
|
||||
* Log the version number.
|
||||
@ -70,15 +81,14 @@ function bootstrap(opts = {}) {
|
||||
return newRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch config to store if received. This config will be merged into
|
||||
* config.yml if it exists, and any portion that produces a conflict will be
|
||||
* overwritten.
|
||||
*/
|
||||
if (autoInitialize) {
|
||||
addExtensions();
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
loadConfig(config, function onLoad() {
|
||||
store.dispatch(authenticateUser());
|
||||
}),
|
||||
store.dispatch(authenticateUser() as unknown as AnyAction);
|
||||
}) as AnyAction,
|
||||
);
|
||||
|
||||
/**
|
||||
@ -87,7 +97,6 @@ function bootstrap(opts = {}) {
|
||||
function Root() {
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<Provider store={store}>
|
||||
<ConnectedTranslatedApp />
|
||||
</Provider>
|
||||
@ -98,7 +107,8 @@ function bootstrap(opts = {}) {
|
||||
/**
|
||||
* Render application root.
|
||||
*/
|
||||
render(<Root />, getRoot());
|
||||
const root = createRoot(getRoot());
|
||||
root.render(<Root />);
|
||||
}
|
||||
|
||||
export default bootstrap;
|
@ -1,344 +0,0 @@
|
||||
import styled from '@emotion/styled';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { ScrollSync } from 'react-scroll-sync';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import { loginUser, logoutUser } from '../../actions/auth';
|
||||
import { createNewEntry } from '../../actions/collections';
|
||||
import { openMediaLibrary } from '../../actions/mediaLibrary';
|
||||
import { currentBackend } from '../../backend';
|
||||
import { history } from '../../routing/history';
|
||||
import { colors, Loader } from '../../ui';
|
||||
import Collection from '../Collection/Collection';
|
||||
import Editor from '../Editor/Editor';
|
||||
import MediaLibrary from '../MediaLibrary/MediaLibrary';
|
||||
import Page from '../page/Page';
|
||||
import Snackbars from '../snackbar/Snackbars';
|
||||
import { Alert } from '../UI/Alert';
|
||||
import { Confirm } from '../UI/Confirm';
|
||||
import Header from './Header';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const AppRoot = styled.div`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const AppWrapper = styled.div`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
const AppMainContainer = styled.div`
|
||||
min-width: 1200px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
margin: 20px;
|
||||
`;
|
||||
|
||||
const ErrorCodeBlock = styled.pre`
|
||||
margin-left: 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
function getDefaultPath(collections) {
|
||||
const first = collections
|
||||
.filter(
|
||||
collection =>
|
||||
collection.get('hide') !== true &&
|
||||
(!collection.has('files') || collection.get('files').size > 1),
|
||||
)
|
||||
.first();
|
||||
if (first) {
|
||||
return `/collections/${first.get('name')}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns default collection name if only one collection
|
||||
*
|
||||
* @param {Collection} collection
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDefaultCollectionPath(collection) {
|
||||
if (collection.has('files') && collection.get('files').size === 1) {
|
||||
return `/collections/${collection.get('name')}/entries/${collection
|
||||
.get('files')
|
||||
.first()
|
||||
.get('name')}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function RouteInCollectionDefault({ collections, render, ...props }) {
|
||||
const defaultPath = getDefaultPath(collections);
|
||||
return (
|
||||
<Route
|
||||
{...props}
|
||||
render={routeProps => {
|
||||
const collectionExists = collections.get(routeProps.match.params.name);
|
||||
if (!collectionExists) {
|
||||
return <Redirect to={defaultPath} />;
|
||||
}
|
||||
|
||||
const defaultCollectionPath = getDefaultCollectionPath(collectionExists);
|
||||
if (defaultCollectionPath !== null) {
|
||||
return <Redirect to={defaultCollectionPath} />;
|
||||
}
|
||||
|
||||
return render(routeProps);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteInCollection({ collections, render, ...props }) {
|
||||
const defaultPath = getDefaultPath(collections);
|
||||
return (
|
||||
<Route
|
||||
{...props}
|
||||
render={routeProps => {
|
||||
const collectionExists = collections.get(routeProps.match.params.name);
|
||||
return collectionExists ? render(routeProps) : <Redirect to={defaultPath} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
class App extends React.Component {
|
||||
static propTypes = {
|
||||
auth: PropTypes.object.isRequired,
|
||||
config: PropTypes.object.isRequired,
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
loginUser: PropTypes.func.isRequired,
|
||||
logoutUser: PropTypes.func.isRequired,
|
||||
user: PropTypes.object,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
siteId: PropTypes.string,
|
||||
useMediaLibrary: PropTypes.bool,
|
||||
openMediaLibrary: PropTypes.func.isRequired,
|
||||
showMediaButton: PropTypes.bool,
|
||||
scrollSyncEnabled: PropTypes.bool.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
configError(config) {
|
||||
const t = this.props.t;
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<h1>{t('app.app.errorHeader')}</h1>
|
||||
<div>
|
||||
<strong>{t('app.app.configErrors')}:</strong>
|
||||
<ErrorCodeBlock>{config.error}</ErrorCodeBlock>
|
||||
<span>{t('app.app.checkConfigYml')}</span>
|
||||
</div>
|
||||
</ErrorContainer>
|
||||
);
|
||||
}
|
||||
|
||||
handleLogin(credentials) {
|
||||
this.props.loginUser(credentials);
|
||||
}
|
||||
|
||||
authenticating() {
|
||||
const { auth, t } = this.props;
|
||||
const backend = currentBackend(this.props.config);
|
||||
|
||||
if (backend == null) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('app.app.waitingBackend')}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{React.createElement(backend.authComponent(), {
|
||||
onLogin: this.handleLogin.bind(this),
|
||||
error: auth.error,
|
||||
inProgress: auth.isFetching,
|
||||
siteId: this.props.config.backend.site_domain,
|
||||
base_url: this.props.config.backend.base_url,
|
||||
authEndpoint: this.props.config.backend.auth_endpoint,
|
||||
config: this.props.config,
|
||||
clearHash: () => history.replace('/'),
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleLinkClick(event, handler, ...args) {
|
||||
event.preventDefault();
|
||||
handler(...args);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
config,
|
||||
collections,
|
||||
logoutUser,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
openMediaLibrary,
|
||||
t,
|
||||
showMediaButton,
|
||||
scrollSyncEnabled,
|
||||
} = this.props;
|
||||
|
||||
if (config === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.error) {
|
||||
return this.configError(config);
|
||||
}
|
||||
|
||||
if (config.isFetching) {
|
||||
return <Loader active>{t('app.app.loadingConfig')}</Loader>;
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
return this.authenticating(t);
|
||||
}
|
||||
|
||||
const defaultPath = getDefaultPath(collections);
|
||||
|
||||
return (
|
||||
<ScrollSync enabled={scrollSyncEnabled}>
|
||||
<AppRoot id="cms-root">
|
||||
<AppWrapper className="cms-wrapper">
|
||||
<Snackbars />
|
||||
<Header
|
||||
user={user}
|
||||
collections={collections}
|
||||
onCreateEntryClick={createNewEntry}
|
||||
onLogoutClick={logoutUser}
|
||||
openMediaLibrary={openMediaLibrary}
|
||||
displayUrl={config.display_url}
|
||||
isTestRepo={config.backend.name === 'test-repo'}
|
||||
showMediaButton={showMediaButton}
|
||||
/>
|
||||
<AppMainContainer>
|
||||
{isFetching && <TopBarProgress />}
|
||||
<Switch>
|
||||
<Redirect exact from="/" to={defaultPath} />
|
||||
<Redirect exact from="/search/" to={defaultPath} />
|
||||
<RouteInCollection
|
||||
exact
|
||||
collections={collections}
|
||||
path="/collections/:name/search/"
|
||||
render={({ match }) => <Redirect to={`/collections/${match.params.name}`} />}
|
||||
/>
|
||||
<Redirect
|
||||
// This happens on Identity + Invite Only + External Provider email not matching
|
||||
// the registered user
|
||||
from="/error=access_denied&error_description=Signups+not+allowed+for+this+instance"
|
||||
to={defaultPath}
|
||||
/>
|
||||
<RouteInCollectionDefault
|
||||
exact
|
||||
collections={collections}
|
||||
path="/collections/:name"
|
||||
render={props => <Collection {...props} />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
path="/collections/:name/new"
|
||||
collections={collections}
|
||||
render={props => <Editor {...props} newRecord />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
path="/collections/:name/entries/*"
|
||||
collections={collections}
|
||||
render={props => <Editor {...props} />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
path="/collections/:name/search/:searchTerm"
|
||||
collections={collections}
|
||||
render={props => <Collection {...props} isSearchResults isSingleSearchResult />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
collections={collections}
|
||||
path="/collections/:name/filter/:filterTerm*"
|
||||
render={props => <Collection {...props} />}
|
||||
/>
|
||||
<Route
|
||||
path="/search/:searchTerm"
|
||||
render={props => <Collection {...props} isSearchResults />}
|
||||
/>
|
||||
<RouteInCollection
|
||||
path="/edit/:name/:entryName"
|
||||
collections={collections}
|
||||
render={({ match }) => {
|
||||
const { name, entryName } = match.params;
|
||||
return <Redirect to={`/collections/${name}/entries/${entryName}`} />;
|
||||
}}
|
||||
/>
|
||||
<Route path="/page/:id" render={props => <Page {...props} />} />
|
||||
<Route component={NotFoundPage} />
|
||||
</Switch>
|
||||
{useMediaLibrary ? <MediaLibrary /> : null}
|
||||
<Alert />
|
||||
<Confirm />
|
||||
</AppMainContainer>
|
||||
</AppWrapper>
|
||||
</AppRoot>
|
||||
</ScrollSync>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state;
|
||||
const user = auth.user;
|
||||
const isFetching = globalUI.isFetching;
|
||||
const useMediaLibrary = !mediaLibrary.get('externalLibrary');
|
||||
const showMediaButton = mediaLibrary.get('showMediaButton');
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
return {
|
||||
auth,
|
||||
config,
|
||||
collections,
|
||||
user,
|
||||
isFetching,
|
||||
showMediaButton,
|
||||
useMediaLibrary,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
openMediaLibrary,
|
||||
loginUser,
|
||||
logoutUser,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(translate()(App)));
|
264
src/components/App/App.tsx
Normal file
264
src/components/App/App.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import { ScrollSync } from 'react-scroll-sync';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import { loginUser as loginUserAction } from '../../actions/auth';
|
||||
import { currentBackend } from '../../backend';
|
||||
import { colors, GlobalStyles } from '../../components/UI/styles';
|
||||
import { history } from '../../routing/history';
|
||||
import CollectionRoute from '../Collection/CollectionRoute';
|
||||
import EditorRoute from '../Editor/EditorRoute';
|
||||
import MediaLibrary from '../MediaLibrary/MediaLibrary';
|
||||
import Snackbars from '../snackbar/Snackbars';
|
||||
import { Alert } from '../UI/Alert';
|
||||
import { Confirm } from '../UI/Confirm';
|
||||
import Loader from '../UI/Loader';
|
||||
import ScrollTop from '../UI/ScrollTop';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Collections, Credentials, TranslatedProps } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const AppRoot = styled('div')`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const AppWrapper = styled('div')`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled('div')`
|
||||
margin: 20px;
|
||||
`;
|
||||
|
||||
const ErrorCodeBlock = styled('pre')`
|
||||
margin-left: 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
function getDefaultPath(collections: Collections) {
|
||||
const options = Object.values(collections).filter(
|
||||
collection =>
|
||||
collection.hide !== true && (!('files' in collection) || (collection.files?.length ?? 0) > 1),
|
||||
);
|
||||
|
||||
if (options.length > 0) {
|
||||
return `/collections/${options[0].name}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
function CollectionSearchRedirect() {
|
||||
const { name } = useParams();
|
||||
return <Navigate to={`/collections/${name}`} />;
|
||||
}
|
||||
|
||||
function EditEntityRedirect() {
|
||||
const { name, entryName } = useParams();
|
||||
return <Navigate to={`/collections/${name}/entries/${entryName}`} />;
|
||||
}
|
||||
|
||||
history.listen(e => {
|
||||
console.log(e);
|
||||
});
|
||||
|
||||
const App = ({
|
||||
auth,
|
||||
user,
|
||||
config,
|
||||
collections,
|
||||
loginUser,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
t,
|
||||
scrollSyncEnabled,
|
||||
}: TranslatedProps<AppProps>) => {
|
||||
const configError = useCallback(() => {
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<h1>{t('app.app.errorHeader')}</h1>
|
||||
<div>
|
||||
<strong>{t('app.app.configErrors')}:</strong>
|
||||
<ErrorCodeBlock>{config.error}</ErrorCodeBlock>
|
||||
<span>{t('app.app.checkConfigYml')}</span>
|
||||
</div>
|
||||
</ErrorContainer>
|
||||
);
|
||||
}, [config.error, t]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(credentials: Credentials) => {
|
||||
loginUser(credentials);
|
||||
},
|
||||
[loginUser],
|
||||
);
|
||||
|
||||
const authenticating = useCallback(() => {
|
||||
if (!config.config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backend = currentBackend(config.config);
|
||||
|
||||
if (backend == null) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('app.app.waitingBackend')}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{React.createElement(backend.authComponent(), {
|
||||
onLogin: handleLogin,
|
||||
error: auth.error,
|
||||
inProgress: auth.isFetching,
|
||||
siteId: config.config.backend.site_domain,
|
||||
base_url: config.config.backend.base_url,
|
||||
authEndpoint: config.config.backend.auth_endpoint,
|
||||
config: config.config,
|
||||
clearHash: () => history.replace('/'),
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}, [auth.error, auth.isFetching, config.config, handleLogin, t]);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
if (!config.config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.error) {
|
||||
return configError();
|
||||
}
|
||||
|
||||
if (config.isFetching) {
|
||||
return <Loader>{t('app.app.loadingConfig')}</Loader>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return authenticating();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<ScrollSync enabled={scrollSyncEnabled}>
|
||||
<>
|
||||
<div id="back-to-top-anchor" />
|
||||
<AppRoot id="cms-root">
|
||||
<AppWrapper className="cms-wrapper">
|
||||
<Snackbars />
|
||||
{isFetching && <TopBarProgress />}
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={defaultPath} />} />
|
||||
<Route path="/search" element={<Navigate to={defaultPath} />} />
|
||||
<Route path="/collections/:name/search/" element={<CollectionSearchRedirect />} />
|
||||
<Route
|
||||
path="/error=access_denied&error_description=Signups+not+allowed+for+this+instance"
|
||||
element={<Navigate to={defaultPath} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/new"
|
||||
element={<EditorRoute collections={collections} newRecord />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/entries/:slug"
|
||||
element={<EditorRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/search/:searchTerm"
|
||||
element={
|
||||
<CollectionRoute
|
||||
collections={collections}
|
||||
isSearchResults
|
||||
isSingleSearchResult
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/filter/:filterTerm"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/search/:searchTerm"
|
||||
element={<CollectionRoute collections={collections} isSearchResults />}
|
||||
/>
|
||||
<Route path="/edit/:name/:entryName" element={<EditEntityRedirect />} />
|
||||
<Route element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
{useMediaLibrary ? <MediaLibrary /> : null}
|
||||
<Alert />
|
||||
<Confirm />
|
||||
</AppWrapper>
|
||||
</AppRoot>
|
||||
<ScrollTop>
|
||||
<Fab size="small" aria-label="scroll back to top">
|
||||
<KeyboardArrowUpIcon />
|
||||
</Fab>
|
||||
</ScrollTop>
|
||||
</>
|
||||
</ScrollSync>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state;
|
||||
const user = auth.user;
|
||||
const isFetching = globalUI.isFetching;
|
||||
const useMediaLibrary = !mediaLibrary.externalLibrary;
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
return {
|
||||
auth,
|
||||
config,
|
||||
collections,
|
||||
user,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loginUser: loginUserAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type AppProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(App) as ComponentType<AppProps>);
|
@ -1,226 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { css } from '@emotion/react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Icon,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
StyledDropdownButton,
|
||||
colors,
|
||||
lengths,
|
||||
shadows,
|
||||
buttons,
|
||||
zIndex,
|
||||
} from '../../ui';
|
||||
import { SettingsDropdown } from '../UI';
|
||||
import { checkBackendStatus } from '../../actions/status';
|
||||
|
||||
const styles = {
|
||||
buttonActive: css`
|
||||
color: ${colors.active};
|
||||
`,
|
||||
};
|
||||
|
||||
function AppHeader(props) {
|
||||
return (
|
||||
<header
|
||||
css={css`
|
||||
${shadows.dropMain};
|
||||
position: sticky;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
background-color: ${colors.foreground};
|
||||
z-index: ${zIndex.zIndex300};
|
||||
height: ${lengths.topBarHeight};
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AppHeaderContent = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-width: 1200px;
|
||||
max-width: 1440px;
|
||||
padding: 0 12px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const AppHeaderButton = styled.button`
|
||||
${buttons.button};
|
||||
background: none;
|
||||
color: #7b8290;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
padding: 16px 20px;
|
||||
align-items: center;
|
||||
|
||||
${Icon} {
|
||||
margin-right: 4px;
|
||||
color: #b3b9c4;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
${styles.buttonActive};
|
||||
|
||||
${Icon} {
|
||||
${styles.buttonActive};
|
||||
}
|
||||
}
|
||||
|
||||
${props => css`
|
||||
&.${props.activeClassName} {
|
||||
${styles.buttonActive};
|
||||
|
||||
${Icon} {
|
||||
${styles.buttonActive};
|
||||
}
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const AppHeaderNavLink = AppHeaderButton.withComponent(NavLink);
|
||||
|
||||
const AppHeaderActions = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const AppHeaderQuickNewButton = styled(StyledDropdownButton)`
|
||||
${buttons.button};
|
||||
${buttons.medium};
|
||||
${buttons.gray};
|
||||
margin-right: 8px;
|
||||
|
||||
&:after {
|
||||
top: 11px;
|
||||
}
|
||||
`;
|
||||
|
||||
const AppHeaderNavList = styled.ul`
|
||||
display: flex;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
`;
|
||||
|
||||
class Header extends React.Component {
|
||||
static propTypes = {
|
||||
user: PropTypes.object.isRequired,
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
onCreateEntryClick: PropTypes.func.isRequired,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
openMediaLibrary: PropTypes.func.isRequired,
|
||||
displayUrl: PropTypes.string,
|
||||
isTestRepo: PropTypes.bool,
|
||||
t: PropTypes.func.isRequired,
|
||||
checkBackendStatus: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
intervalId;
|
||||
|
||||
componentDidMount() {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.props.checkBackendStatus();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
handleCreatePostClick = collectionName => {
|
||||
const { onCreateEntryClick } = this.props;
|
||||
if (onCreateEntryClick) {
|
||||
onCreateEntryClick(collectionName);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
collections,
|
||||
onLogoutClick,
|
||||
openMediaLibrary,
|
||||
displayUrl,
|
||||
isTestRepo,
|
||||
t,
|
||||
showMediaButton,
|
||||
} = this.props;
|
||||
|
||||
const createableCollections = collections
|
||||
.filter(collection => collection.get('create'))
|
||||
.toList();
|
||||
|
||||
return (
|
||||
<AppHeader>
|
||||
<AppHeaderContent>
|
||||
<nav>
|
||||
<AppHeaderNavList>
|
||||
<li>
|
||||
<AppHeaderNavLink
|
||||
to="/"
|
||||
activeClassName="header-link-active"
|
||||
isActive={(match, location) => location.pathname.startsWith('/collections/')}
|
||||
>
|
||||
<Icon type="page" />
|
||||
{t('app.header.content')}
|
||||
</AppHeaderNavLink>
|
||||
</li>
|
||||
{showMediaButton && (
|
||||
<li>
|
||||
<AppHeaderButton onClick={openMediaLibrary}>
|
||||
<Icon type="media-alt" />
|
||||
{t('app.header.media')}
|
||||
</AppHeaderButton>
|
||||
</li>
|
||||
)}
|
||||
</AppHeaderNavList>
|
||||
</nav>
|
||||
<AppHeaderActions>
|
||||
{createableCollections.size > 0 && (
|
||||
<Dropdown
|
||||
renderButton={() => (
|
||||
<AppHeaderQuickNewButton> {t('app.header.quickAdd')}</AppHeaderQuickNewButton>
|
||||
)}
|
||||
dropdownTopOverlap="30px"
|
||||
dropdownWidth="160px"
|
||||
dropdownPosition="left"
|
||||
>
|
||||
{createableCollections.map(collection => (
|
||||
<DropdownItem
|
||||
key={collection.get('name')}
|
||||
label={collection.get('label_singular') || collection.get('label')}
|
||||
onClick={() => this.handleCreatePostClick(collection.get('name'))}
|
||||
/>
|
||||
))}
|
||||
</Dropdown>
|
||||
)}
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
isTestRepo={isTestRepo}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
</AppHeaderActions>
|
||||
</AppHeaderContent>
|
||||
</AppHeader>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
checkBackendStatus,
|
||||
};
|
||||
|
||||
export default connect(null, mapDispatchToProps)(translate()(Header));
|
212
src/components/App/Header.tsx
Normal file
212
src/components/App/Header.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Button from '@mui/material/Button';
|
||||
import Link from '@mui/material/Link';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { logoutUser as logoutUserAction } from '../../actions/auth';
|
||||
import { createNewEntry } from '../../actions/collections';
|
||||
import { openMediaLibrary as openMediaLibraryAction } from '../../actions/mediaLibrary';
|
||||
import { checkBackendStatus as checkBackendStatusAction } from '../../actions/status';
|
||||
import { buttons, colors } from '../../components/UI/styles';
|
||||
import { stripProtocol } from '../../lib/urlHelper';
|
||||
import NavLink from '../UI/NavLink';
|
||||
import SettingsDropdown from '../UI/SettingsDropdown';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { TranslatedProps } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
const StyledAppBar = styled(AppBar)`
|
||||
background-color: ${colors.foreground};
|
||||
`;
|
||||
|
||||
const StyledToolbar = styled(Toolbar)`
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
${buttons.button};
|
||||
background: none;
|
||||
color: #7b8290;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
gap: 2px;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
color: ${colors.active};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSpacer = styled('div')`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledAppHeaderActions = styled('div')`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Header = ({
|
||||
user,
|
||||
collections,
|
||||
logoutUser,
|
||||
openMediaLibrary,
|
||||
displayUrl,
|
||||
isTestRepo,
|
||||
t,
|
||||
showMediaButton,
|
||||
checkBackendStatus,
|
||||
}: TranslatedProps<HeaderProps>) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleCreatePostClick = useCallback((collectionName: string) => {
|
||||
createNewEntry(collectionName);
|
||||
}, []);
|
||||
|
||||
const createableCollections = useMemo(
|
||||
() => Object.values(collections).filter(collection => collection.create),
|
||||
[collections],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
checkBackendStatus();
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [checkBackendStatus]);
|
||||
|
||||
const handleMediaClick = useCallback(() => {
|
||||
openMediaLibrary();
|
||||
}, [openMediaLibrary]);
|
||||
|
||||
return (
|
||||
<StyledAppBar position="sticky">
|
||||
<StyledToolbar>
|
||||
<Link to="/collections" component={NavLink} activeClassName={'header-link-active'}>
|
||||
<DescriptionIcon />
|
||||
{t('app.header.content')}
|
||||
</Link>
|
||||
{showMediaButton ? (
|
||||
<StyledButton onClick={handleMediaClick}>
|
||||
<ImageIcon />
|
||||
{t('app.header.media')}
|
||||
</StyledButton>
|
||||
) : null}
|
||||
<StyledSpacer />
|
||||
<StyledAppHeaderActions>
|
||||
{createableCollections.length > 0 && (
|
||||
<div key="quick-create">
|
||||
<Button
|
||||
id="quick-create-button"
|
||||
aria-controls={open ? 'quick-create-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="contained"
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('app.header.quickAdd')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="quick-create-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'quick-create-button',
|
||||
}}
|
||||
>
|
||||
{createableCollections.map(collection => (
|
||||
<MenuItem
|
||||
key={collection.name}
|
||||
onClick={() => handleCreatePostClick(collection.name)}
|
||||
>
|
||||
{collection.label_singular || collection.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{isTestRepo && (
|
||||
<Button
|
||||
href="https://staticjscms.github.io/static-cms/docs/test-backend"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none' }}
|
||||
endIcon={<OpenInNewIcon />}
|
||||
>
|
||||
Test Backend
|
||||
</Button>
|
||||
)}
|
||||
{displayUrl ? (
|
||||
<Button
|
||||
href={displayUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none' }}
|
||||
endIcon={<OpenInNewIcon />}
|
||||
>
|
||||
{stripProtocol(displayUrl)}
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
isTestRepo={isTestRepo}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={logoutUser}
|
||||
/>
|
||||
</StyledAppHeaderActions>
|
||||
</StyledToolbar>
|
||||
</StyledAppBar>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { auth, config, collections, mediaLibrary } = state;
|
||||
const user = auth.user;
|
||||
const showMediaButton = mediaLibrary.showMediaButton;
|
||||
return {
|
||||
user,
|
||||
collections,
|
||||
displayUrl: config.config?.display_url,
|
||||
isTestRepo: config.config?.backend.name === 'test-repo',
|
||||
showMediaButton,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
checkBackendStatus: checkBackendStatusAction,
|
||||
openMediaLibrary: openMediaLibraryAction,
|
||||
logoutUser: logoutUserAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type HeaderProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(Header) as ComponentType<HeaderProps>);
|
49
src/components/App/MainView.tsx
Normal file
49
src/components/App/MainView.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import { colors } from '../../components/UI/styles';
|
||||
import Header from './Header';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const StyledMainContainerWrapper = styled('div')`
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
gap: 24px;
|
||||
`;
|
||||
|
||||
const StyledMainContainer = styled('div')`
|
||||
min-width: 1200px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
interface MainViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MainView = ({ children }: MainViewProps) => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<StyledMainContainerWrapper>
|
||||
<StyledMainContainer>{children}</StyledMainContainer>
|
||||
</StyledMainContainerWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainView;
|
@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { lengths } from '../../ui';
|
||||
|
||||
const NotFoundContainer = styled.div`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
||||
function NotFoundPage({ t }) {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<h2>{t('app.notFoundPage.header')}</h2>
|
||||
</NotFoundContainer>
|
||||
);
|
||||
}
|
||||
|
||||
NotFoundPage.propTypes = {
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(NotFoundPage);
|
22
src/components/App/NotFoundPage.tsx
Normal file
22
src/components/App/NotFoundPage.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { lengths } from '../../components/UI/styles';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
|
||||
const NotFoundContainer = styled('div')`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
||||
const NotFoundPage = ({ t }: TranslateProps) => {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<h2>{t('app.notFoundPage.header')}</h2>
|
||||
</NotFoundContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(NotFoundPage) as ComponentType<{}>;
|
@ -1,67 +1,52 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeViewStyle, filterByField, groupByField, sortByField } from '../../actions/entries';
|
||||
import {
|
||||
changeViewStyle as changeViewStyleAction,
|
||||
filterByField as filterByFieldAction,
|
||||
groupByField as groupByFieldAction,
|
||||
sortByField as sortByFieldAction,
|
||||
} from '../../actions/entries';
|
||||
import { components } from '../../components/UI/styles';
|
||||
import { SortDirection } from '../../interface';
|
||||
import { getNewEntryUrl } from '../../lib/urlHelper';
|
||||
import {
|
||||
selectSortableFields,
|
||||
selectViewFilters,
|
||||
selectViewGroups,
|
||||
} from '../../reducers/collections';
|
||||
} from '../../lib/util/collection.util';
|
||||
import {
|
||||
selectEntriesFilter,
|
||||
selectEntriesGroup,
|
||||
selectEntriesSort,
|
||||
selectViewStyle,
|
||||
} from '../../reducers/entries';
|
||||
import { components, lengths } from '../../ui';
|
||||
import CollectionControls from './CollectionControls';
|
||||
import CollectionTop from './CollectionTop';
|
||||
import EntriesCollection from './Entries/EntriesCollection';
|
||||
import EntriesSearch from './Entries/EntriesSearch';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
import type { RouteComponentProps } from 'react-router-dom';
|
||||
import type {
|
||||
CmsSortableFieldsDefault,
|
||||
TranslatedProps,
|
||||
ViewFilter,
|
||||
ViewGroup,
|
||||
} from '../../interface';
|
||||
import type { Collection, State } from '../../types/redux';
|
||||
import type { StaticallyTypedRecord } from '../../types/immutable';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Collection, TranslatedProps, ViewFilter, ViewGroup } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
const CollectionContainer = styled.div`
|
||||
margin: ${lengths.pageMargin};
|
||||
const CollectionMain = styled('main')`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CollectionMain = styled.main`
|
||||
padding-left: 280px;
|
||||
`;
|
||||
|
||||
const SearchResultContainer = styled.div`
|
||||
const SearchResultContainer = styled('div')`
|
||||
${components.cardTop};
|
||||
margin-bottom: 22px;
|
||||
`;
|
||||
|
||||
const SearchResultHeading = styled.h1`
|
||||
const SearchResultHeading = styled('h1')`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
interface CollectionRouterParams {
|
||||
name: string;
|
||||
searchTerm?: string;
|
||||
filterTerm?: string;
|
||||
}
|
||||
|
||||
interface CollectionViewProps extends RouteComponentProps<CollectionRouterParams> {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
}
|
||||
|
||||
const CollectionView = ({
|
||||
collection,
|
||||
collections,
|
||||
@ -71,27 +56,28 @@ const CollectionView = ({
|
||||
isSingleSearchResult,
|
||||
searchTerm,
|
||||
sortableFields,
|
||||
onSortClick,
|
||||
sortByField,
|
||||
sort,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
filterTerm,
|
||||
t,
|
||||
onFilterClick,
|
||||
onGroupClick,
|
||||
filterByField,
|
||||
groupByField,
|
||||
filter,
|
||||
group,
|
||||
onChangeViewStyle,
|
||||
changeViewStyle,
|
||||
viewStyle,
|
||||
}: ReturnType<typeof mergeProps>) => {
|
||||
}: TranslatedProps<CollectionViewProps>) => {
|
||||
const [readyToLoad, setReadyToLoad] = useState(false);
|
||||
const [preCollection, setPreCollection] = useState(collection);
|
||||
const [prevCollection, setPrevCollection] = useState<Collection | null>();
|
||||
|
||||
useEffect(() => {
|
||||
setPreCollection(collection);
|
||||
setPrevCollection(collection);
|
||||
}, [collection]);
|
||||
|
||||
const newEntryUrl = useMemo(() => {
|
||||
let url = collection.get('create') ? getNewEntryUrl(collectionName) : '';
|
||||
let url = collection.create ? getNewEntryUrl(collectionName) : '';
|
||||
if (url && filterTerm) {
|
||||
url = getNewEntryUrl(collectionName);
|
||||
if (filterTerm) {
|
||||
@ -106,50 +92,92 @@ const CollectionView = ({
|
||||
[isSingleSearchResult],
|
||||
);
|
||||
|
||||
const renderEntriesCollection = useCallback(() => {
|
||||
const entries = useMemo(() => {
|
||||
if (isSearchResults) {
|
||||
let searchCollections = collections;
|
||||
if (isSingleSearchResult) {
|
||||
const searchCollection = Object.values(collections).filter(c => c === collection);
|
||||
if (searchCollection.length === 1) {
|
||||
searchCollections = {
|
||||
[searchCollection[0].name]: searchCollection[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return <EntriesSearch collections={searchCollections} searchTerm={searchTerm} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EntriesCollection
|
||||
collection={collection}
|
||||
viewStyle={viewStyle}
|
||||
filterTerm={filterTerm}
|
||||
readyToLoad={readyToLoad && collection === preCollection}
|
||||
readyToLoad={readyToLoad && collection === prevCollection}
|
||||
/>
|
||||
);
|
||||
}, [collection, filterTerm, viewStyle, readyToLoad]);
|
||||
}, [
|
||||
collection,
|
||||
collections,
|
||||
filterTerm,
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
prevCollection,
|
||||
readyToLoad,
|
||||
searchTerm,
|
||||
viewStyle,
|
||||
]);
|
||||
|
||||
const renderEntriesSearch = useCallback(() => {
|
||||
return (
|
||||
<EntriesSearch
|
||||
collections={isSingleSearchResult ? collections.filter(c => c === collection) : collections}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
);
|
||||
}, [searchTerm, collections, collection, isSingleSearchResult]);
|
||||
const onSortClick = useCallback(
|
||||
async (key: string, direction?: SortDirection) => {
|
||||
await sortByField(collection, key, direction);
|
||||
},
|
||||
[collection, sortByField],
|
||||
);
|
||||
|
||||
const onFilterClick = useCallback(
|
||||
async (filter: ViewFilter) => {
|
||||
await filterByField(collection, filter);
|
||||
},
|
||||
[collection, filterByField],
|
||||
);
|
||||
|
||||
const onGroupClick = useCallback(
|
||||
async (group: ViewGroup) => {
|
||||
await groupByField(collection, group);
|
||||
},
|
||||
[collection, groupByField],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCollection === collection) {
|
||||
if (!readyToLoad) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sort?.[0]?.key) {
|
||||
if (!readyToLoad) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultSort = collection.sortable_fields.default;
|
||||
if (!defaultSort || !defaultSort.field) {
|
||||
if (!readyToLoad) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setReadyToLoad(false);
|
||||
|
||||
let alive = true;
|
||||
|
||||
const sortEntries = () => {
|
||||
setTimeout(async () => {
|
||||
if (sort?.first()?.get('key')) {
|
||||
setReadyToLoad(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultSort = collection.getIn(['sortable_fields', 'default']) as
|
||||
| StaticallyTypedRecord<CmsSortableFieldsDefault>
|
||||
| undefined;
|
||||
|
||||
if (!defaultSort || !defaultSort.get('field')) {
|
||||
setReadyToLoad(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await onSortClick(
|
||||
defaultSort.get('field'),
|
||||
defaultSort.get('direction') ?? SortDirection.Ascending,
|
||||
);
|
||||
await onSortClick(defaultSort.field, defaultSort.direction ?? SortDirection.Ascending);
|
||||
|
||||
if (alive) {
|
||||
setReadyToLoad(true);
|
||||
@ -162,10 +190,10 @@ const CollectionView = ({
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [collection]);
|
||||
}, [collection, onSortClick, prevCollection, readyToLoad, sort]);
|
||||
|
||||
return (
|
||||
<CollectionContainer>
|
||||
<>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
collection={(!isSearchResults || isSingleSearchResult) && collection}
|
||||
@ -174,59 +202,77 @@ const CollectionView = ({
|
||||
filterTerm={filterTerm}
|
||||
/>
|
||||
<CollectionMain>
|
||||
{isSearchResults ? (
|
||||
<SearchResultContainer>
|
||||
<SearchResultHeading>
|
||||
{t(searchResultKey, { searchTerm, collection: collection.get('label') })}
|
||||
</SearchResultHeading>
|
||||
</SearchResultContainer>
|
||||
) : (
|
||||
<>
|
||||
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
|
||||
<CollectionControls
|
||||
viewStyle={viewStyle}
|
||||
onChangeViewStyle={onChangeViewStyle}
|
||||
sortableFields={sortableFields}
|
||||
onSortClick={onSortClick}
|
||||
sort={sort}
|
||||
viewFilters={viewFilters}
|
||||
viewGroups={viewGroups}
|
||||
t={t}
|
||||
onFilterClick={onFilterClick}
|
||||
onGroupClick={onGroupClick}
|
||||
filter={filter}
|
||||
group={group}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isSearchResults ? renderEntriesSearch() : renderEntriesCollection()}
|
||||
<>
|
||||
{isSearchResults ? (
|
||||
<SearchResultContainer>
|
||||
<SearchResultHeading>
|
||||
{t(searchResultKey, { searchTerm, collection: collection.label })}
|
||||
</SearchResultHeading>
|
||||
</SearchResultContainer>
|
||||
) : (
|
||||
<>
|
||||
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
|
||||
<CollectionControls
|
||||
viewStyle={viewStyle}
|
||||
onChangeViewStyle={changeViewStyle}
|
||||
sortableFields={sortableFields}
|
||||
onSortClick={onSortClick}
|
||||
sort={sort}
|
||||
viewFilters={viewFilters}
|
||||
viewGroups={viewGroups}
|
||||
t={t}
|
||||
onFilterClick={onFilterClick}
|
||||
onGroupClick={onGroupClick}
|
||||
filter={filter}
|
||||
group={group}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{entries}
|
||||
</>
|
||||
</CollectionMain>
|
||||
</CollectionContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: State, ownProps: TranslatedProps<CollectionViewProps>) {
|
||||
interface CollectionViewOwnProps {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
name: string;
|
||||
searchTerm?: string;
|
||||
filterTerm?: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionViewOwnProps>) {
|
||||
const { collections } = state;
|
||||
const isSearchEnabled = state.config && state.config.search != false;
|
||||
const { isSearchResults, match, t } = ownProps;
|
||||
const { name, searchTerm = '', filterTerm = '' } = match.params;
|
||||
const collection: Collection = name ? collections.get(name) : collections.first();
|
||||
const sort = selectEntriesSort(state.entries, collection.get('name'));
|
||||
const isSearchEnabled = state.config.config && state.config.config.search != false;
|
||||
const {
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
name,
|
||||
searchTerm = '',
|
||||
filterTerm = '',
|
||||
t,
|
||||
} = ownProps;
|
||||
const collection: Collection = name ? collections[name] : collections[0];
|
||||
const sort = selectEntriesSort(state.entries, collection.name);
|
||||
const sortableFields = selectSortableFields(collection, t);
|
||||
const viewFilters = selectViewFilters(collection);
|
||||
const viewGroups = selectViewGroups(collection);
|
||||
const filter = selectEntriesFilter(state.entries, collection.get('name'));
|
||||
const group = selectEntriesGroup(state.entries, collection.get('name'));
|
||||
const filter = selectEntriesFilter(state.entries, collection.name);
|
||||
const group = selectEntriesGroup(state.entries, collection.name);
|
||||
const viewStyle = selectViewStyle(state.entries);
|
||||
|
||||
return {
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
name,
|
||||
searchTerm,
|
||||
filterTerm,
|
||||
collection,
|
||||
collections,
|
||||
collectionName: name,
|
||||
isSearchEnabled,
|
||||
isSearchResults,
|
||||
searchTerm,
|
||||
filterTerm,
|
||||
sort,
|
||||
sortableFields,
|
||||
viewFilters,
|
||||
@ -238,33 +284,13 @@ function mapStateToProps(state: State, ownProps: TranslatedProps<CollectionViewP
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
sortByField,
|
||||
filterByField,
|
||||
changeViewStyle,
|
||||
groupByField,
|
||||
sortByField: sortByFieldAction,
|
||||
filterByField: filterByFieldAction,
|
||||
changeViewStyle: changeViewStyleAction,
|
||||
groupByField: groupByFieldAction,
|
||||
};
|
||||
|
||||
function mergeProps(
|
||||
stateProps: ReturnType<typeof mapStateToProps>,
|
||||
dispatchProps: typeof mapDispatchToProps,
|
||||
ownProps: TranslatedProps<CollectionViewProps>,
|
||||
) {
|
||||
return {
|
||||
...stateProps,
|
||||
...ownProps,
|
||||
onSortClick: (key: string, direction: SortDirection) =>
|
||||
dispatchProps.sortByField(stateProps.collection, key, direction),
|
||||
onFilterClick: (filter: ViewFilter) =>
|
||||
dispatchProps.filterByField(stateProps.collection, filter),
|
||||
onGroupClick: (group: ViewGroup) => dispatchProps.groupByField(stateProps.collection, group),
|
||||
onChangeViewStyle: (viewStyle: string) => dispatchProps.changeViewStyle(viewStyle),
|
||||
};
|
||||
}
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type CollectionViewProps = ConnectedProps<typeof connector>;
|
||||
|
||||
const ConnectedCollection = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
mergeProps,
|
||||
)(CollectionView);
|
||||
|
||||
export default translate()(ConnectedCollection);
|
||||
export default translate()(connector(CollectionView)) as ComponentType<CollectionViewOwnProps>;
|
||||
|
@ -1,18 +1,28 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { lengths } from '../../ui';
|
||||
import ViewStyleControl from './ViewStyleControl';
|
||||
import SortControl from './SortControl';
|
||||
import FilterControl from './FilterControl';
|
||||
import GroupControl from './GroupControl';
|
||||
import SortControl from './SortControl';
|
||||
import ViewStyleControl from './ViewStyleControl';
|
||||
|
||||
const CollectionControlsContainer = styled.div`
|
||||
import type { CollectionViewStyle } from '../../constants/collectionViews';
|
||||
import type {
|
||||
FilterMap,
|
||||
GroupMap,
|
||||
SortableField,
|
||||
SortDirection,
|
||||
SortMap,
|
||||
TranslatedProps,
|
||||
ViewFilter,
|
||||
ViewGroup,
|
||||
} from '../../interface';
|
||||
|
||||
const CollectionControlsContainer = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: 22px;
|
||||
width: ${lengths.topCardWidth};
|
||||
max-width: 100%;
|
||||
|
||||
& > div {
|
||||
@ -20,7 +30,21 @@ const CollectionControlsContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
function CollectionControls({
|
||||
interface CollectionControlsProps {
|
||||
viewStyle: CollectionViewStyle;
|
||||
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void;
|
||||
sortableFields: SortableField[];
|
||||
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
|
||||
sort: SortMap | undefined;
|
||||
filter: Record<string, FilterMap>;
|
||||
viewFilters: ViewFilter[];
|
||||
onFilterClick: (filter: ViewFilter) => void;
|
||||
group: Record<string, GroupMap>;
|
||||
viewGroups: ViewGroup[];
|
||||
onGroupClick: (filter: ViewGroup) => void;
|
||||
}
|
||||
|
||||
const CollectionControls = ({
|
||||
viewStyle,
|
||||
onChangeViewStyle,
|
||||
sortableFields,
|
||||
@ -33,7 +57,7 @@ function CollectionControls({
|
||||
t,
|
||||
filter,
|
||||
group,
|
||||
}) {
|
||||
}: TranslatedProps<CollectionControlsProps>) => {
|
||||
return (
|
||||
<CollectionControlsContainer>
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
@ -53,6 +77,6 @@ function CollectionControls({
|
||||
)}
|
||||
</CollectionControlsContainer>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default CollectionControls;
|
60
src/components/Collection/CollectionRoute.tsx
Normal file
60
src/components/Collection/CollectionRoute.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
import MainView from '../App/MainView';
|
||||
import Collection from './Collection';
|
||||
|
||||
import type { Collections } from '../../interface';
|
||||
|
||||
function getDefaultPath(collections: Collections) {
|
||||
const first = Object.values(collections).filter(collection => collection.hide !== true)[0];
|
||||
if (first) {
|
||||
return `/collections/${first.name}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
interface CollectionRouteProps {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
const CollectionRoute = ({
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
collections,
|
||||
}: CollectionRouteProps) => {
|
||||
const { name, searchTerm, filterTerm } = useParams();
|
||||
const collection = useMemo(() => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return collections[name];
|
||||
}, [collections, name]);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
if (!name || !collection) {
|
||||
return <Navigate to={defaultPath} />;
|
||||
}
|
||||
|
||||
if ('files' in collection && collection.files?.length === 1) {
|
||||
return <Navigate to={`/collections/${collection.name}/entries/${collection.files[0].name}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MainView>
|
||||
<Collection
|
||||
name={name}
|
||||
searchTerm={searchTerm}
|
||||
filterTerm={filterTerm}
|
||||
isSearchResults={isSearchResults}
|
||||
isSingleSearchResult={isSingleSearchResult}
|
||||
/>
|
||||
</MainView>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionRoute;
|
@ -1,239 +0,0 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { colorsRaw, colors, Icon, lengths, zIndex } from '../../ui';
|
||||
|
||||
const SearchContainer = styled.div`
|
||||
margin: 0 12px;
|
||||
position: relative;
|
||||
|
||||
${Icon} {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 6px;
|
||||
z-index: ${zIndex.zIndex2};
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const InputContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
background-color: #eff0f4;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
font-size: 14px;
|
||||
padding: 10px 6px 10px 32px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: ${zIndex.zIndex1};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px ${colorsRaw.blue};
|
||||
}
|
||||
`;
|
||||
|
||||
const SuggestionsContainer = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Suggestions = styled.ul`
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background-color: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
border: 1px solid ${colors.textFieldBorder};
|
||||
z-index: ${zIndex.zIndex1};
|
||||
`;
|
||||
|
||||
const SuggestionHeader = styled.li`
|
||||
padding: 0 6px 6px 32px;
|
||||
font-size: 12px;
|
||||
color: ${colors.text};
|
||||
`;
|
||||
|
||||
const SuggestionItem = styled.li(
|
||||
({ isActive }) => `
|
||||
color: ${isActive ? colors.active : colorsRaw.grayDark};
|
||||
background-color: ${isActive ? colors.activeBackground : 'inherit'};
|
||||
padding: 6px 6px 6px 32px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const SuggestionDivider = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
class CollectionSearch extends React.Component {
|
||||
static propTypes = {
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
collection: ImmutablePropTypes.map,
|
||||
searchTerm: PropTypes.string.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
query: this.props.searchTerm,
|
||||
suggestionsVisible: false,
|
||||
// default to the currently selected
|
||||
selectedCollectionIdx: this.getSelectedSelectionBasedOnProps(),
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.collection !== this.props.collection) {
|
||||
const selectedCollectionIdx = this.getSelectedSelectionBasedOnProps();
|
||||
this.setState({ selectedCollectionIdx });
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedSelectionBasedOnProps() {
|
||||
const { collection, collections } = this.props;
|
||||
return collection ? collections.keySeq().indexOf(collection.get('name')) : -1;
|
||||
}
|
||||
|
||||
toggleSuggestions(visible) {
|
||||
this.setState({ suggestionsVisible: visible });
|
||||
}
|
||||
|
||||
selectNextSuggestion() {
|
||||
const { collections } = this.props;
|
||||
const { selectedCollectionIdx } = this.state;
|
||||
this.setState({
|
||||
selectedCollectionIdx: Math.min(selectedCollectionIdx + 1, collections.size - 1),
|
||||
});
|
||||
}
|
||||
|
||||
selectPreviousSuggestion() {
|
||||
const { selectedCollectionIdx } = this.state;
|
||||
this.setState({
|
||||
selectedCollectionIdx: Math.max(selectedCollectionIdx - 1, -1),
|
||||
});
|
||||
}
|
||||
|
||||
resetSelectedSuggestion() {
|
||||
this.setState({
|
||||
selectedCollectionIdx: -1,
|
||||
});
|
||||
}
|
||||
|
||||
submitSearch = () => {
|
||||
const { onSubmit, collections } = this.props;
|
||||
const { selectedCollectionIdx, query } = this.state;
|
||||
|
||||
this.toggleSuggestions(false);
|
||||
if (selectedCollectionIdx !== -1) {
|
||||
onSubmit(query, collections.toIndexedSeq().getIn([selectedCollectionIdx, 'name']));
|
||||
} else {
|
||||
onSubmit(query);
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = event => {
|
||||
const { suggestionsVisible } = this.state;
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
this.submitSearch();
|
||||
}
|
||||
|
||||
if (suggestionsVisible) {
|
||||
// allow closing of suggestions with escape key
|
||||
if (event.key === 'Escape') {
|
||||
this.toggleSuggestions(false);
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.selectNextSuggestion();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
this.selectPreviousSuggestion();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleQueryChange = query => {
|
||||
this.setState({ query });
|
||||
this.toggleSuggestions(query !== '');
|
||||
if (query === '') {
|
||||
this.resetSelectedSuggestion();
|
||||
}
|
||||
};
|
||||
|
||||
handleSuggestionClick = (event, idx) => {
|
||||
this.setState({ selectedCollectionIdx: idx }, this.submitSearch);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, t } = this.props;
|
||||
const { suggestionsVisible, selectedCollectionIdx, query } = this.state;
|
||||
return (
|
||||
<SearchContainer
|
||||
onBlur={() => this.toggleSuggestions(false)}
|
||||
onFocus={() => this.toggleSuggestions(query !== '')}
|
||||
>
|
||||
<InputContainer>
|
||||
<Icon type="search" />
|
||||
<SearchInput
|
||||
onChange={e => this.handleQueryChange(e.target.value)}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onClick={() => this.toggleSuggestions(true)}
|
||||
placeholder={t('collection.sidebar.searchAll')}
|
||||
value={query}
|
||||
/>
|
||||
</InputContainer>
|
||||
{suggestionsVisible && (
|
||||
<SuggestionsContainer>
|
||||
<Suggestions>
|
||||
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
|
||||
<SuggestionItem
|
||||
isActive={selectedCollectionIdx === -1}
|
||||
onClick={e => this.handleSuggestionClick(e, -1)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{t('collection.sidebar.allCollections')}
|
||||
</SuggestionItem>
|
||||
<SuggestionDivider />
|
||||
{collections.toIndexedSeq().map((collection, idx) => (
|
||||
<SuggestionItem
|
||||
key={idx}
|
||||
isActive={idx === selectedCollectionIdx}
|
||||
onClick={e => this.handleSuggestionClick(e, idx)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{collection.get('label')}
|
||||
</SuggestionItem>
|
||||
))}
|
||||
</Suggestions>
|
||||
</SuggestionsContainer>
|
||||
)}
|
||||
</SearchContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(CollectionSearch);
|
229
src/components/Collection/CollectionSearch.tsx
Normal file
229
src/components/Collection/CollectionSearch.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { transientOptions } from '../../lib';
|
||||
import { colors, colorsRaw, lengths, zIndex } from '../../components/UI/styles';
|
||||
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import type { Collection, Collections, TranslatedProps } from '../../interface';
|
||||
|
||||
const SearchContainer = styled('div')`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const SuggestionsContainer = styled('div')`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Suggestions = styled('ul')`
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background-color: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
border: 1px solid ${colors.textFieldBorder};
|
||||
z-index: ${zIndex.zIndex1};
|
||||
`;
|
||||
|
||||
const SuggestionHeader = styled('li')`
|
||||
padding: 0 6px 6px 32px;
|
||||
font-size: 12px;
|
||||
color: ${colors.text};
|
||||
`;
|
||||
|
||||
interface SuggestionItemProps {
|
||||
$isActive: boolean;
|
||||
}
|
||||
|
||||
const SuggestionItem = styled(
|
||||
'li',
|
||||
transientOptions,
|
||||
)<SuggestionItemProps>(
|
||||
({ $isActive }) => `
|
||||
color: ${$isActive ? colors.active : colorsRaw.grayDark};
|
||||
background-color: ${$isActive ? colors.activeBackground : 'inherit'};
|
||||
padding: 6px 6px 6px 32px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const SuggestionDivider = styled('div')`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
interface CollectionSearchProps {
|
||||
collections: Collections;
|
||||
collection?: Collection;
|
||||
searchTerm: string;
|
||||
onSubmit: (query: string, collection?: string) => void;
|
||||
}
|
||||
|
||||
const CollectionSearch = ({
|
||||
collections,
|
||||
collection,
|
||||
searchTerm,
|
||||
onSubmit,
|
||||
t,
|
||||
}: TranslatedProps<CollectionSearchProps>) => {
|
||||
const [query, setQuery] = useState(searchTerm);
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
|
||||
const getSelectedSelectionBasedOnProps = useCallback(() => {
|
||||
return collection ? Object.keys(collections).indexOf(collection.name) : -1;
|
||||
}, [collection, collections]);
|
||||
|
||||
const [selectedCollectionIdx, setSelectedCollectionIdx] = useState(
|
||||
getSelectedSelectionBasedOnProps(),
|
||||
);
|
||||
const [prevCollection, setPrevCollection] = useState(collection);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCollection !== collection) {
|
||||
setSelectedCollectionIdx(getSelectedSelectionBasedOnProps());
|
||||
}
|
||||
setPrevCollection(collection);
|
||||
}, [collection, getSelectedSelectionBasedOnProps, prevCollection]);
|
||||
|
||||
const toggleSuggestions = useCallback((visible: boolean) => {
|
||||
setSuggestionsVisible(visible);
|
||||
}, []);
|
||||
|
||||
const selectNextSuggestion = useCallback(() => {
|
||||
setSelectedCollectionIdx(
|
||||
Math.min(selectedCollectionIdx + 1, Object.keys(collections).length - 1),
|
||||
);
|
||||
}, [collections, selectedCollectionIdx]);
|
||||
|
||||
const selectPreviousSuggestion = useCallback(() => {
|
||||
setSelectedCollectionIdx(Math.max(selectedCollectionIdx - 1, -1));
|
||||
}, [selectedCollectionIdx]);
|
||||
|
||||
const resetSelectedSuggestion = useCallback(() => {
|
||||
setSelectedCollectionIdx(-1);
|
||||
}, []);
|
||||
|
||||
const submitSearch = useCallback(() => {
|
||||
toggleSuggestions(false);
|
||||
if (selectedCollectionIdx !== -1) {
|
||||
onSubmit(query, Object.values(collections)[selectedCollectionIdx]?.name);
|
||||
} else {
|
||||
onSubmit(query);
|
||||
}
|
||||
}, [collections, onSubmit, query, selectedCollectionIdx, toggleSuggestions]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
submitSearch();
|
||||
}
|
||||
|
||||
if (suggestionsVisible) {
|
||||
// allow closing of suggestions with escape key
|
||||
if (event.key === 'Escape') {
|
||||
toggleSuggestions(false);
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
selectNextSuggestion();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
selectPreviousSuggestion();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selectNextSuggestion,
|
||||
selectPreviousSuggestion,
|
||||
submitSearch,
|
||||
suggestionsVisible,
|
||||
toggleSuggestions,
|
||||
],
|
||||
);
|
||||
|
||||
const handleQueryChange = useCallback(
|
||||
(newQuery: string) => {
|
||||
setQuery(newQuery);
|
||||
toggleSuggestions(newQuery !== '');
|
||||
if (newQuery === '') {
|
||||
resetSelectedSuggestion();
|
||||
}
|
||||
},
|
||||
[resetSelectedSuggestion, toggleSuggestions],
|
||||
);
|
||||
|
||||
const handleSuggestionClick = useCallback(
|
||||
(event: MouseEvent, idx: number) => {
|
||||
setSelectedCollectionIdx(idx);
|
||||
submitSearch();
|
||||
event.preventDefault();
|
||||
},
|
||||
[submitSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchContainer>
|
||||
<TextField
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => toggleSuggestions(true)}
|
||||
placeholder={t('collection.sidebar.searchAll')}
|
||||
onBlur={() => toggleSuggestions(false)}
|
||||
onFocus={() => toggleSuggestions(query !== '')}
|
||||
value={query}
|
||||
onChange={e => handleQueryChange(e.target.value)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{suggestionsVisible && (
|
||||
<SuggestionsContainer>
|
||||
<Suggestions>
|
||||
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
|
||||
<SuggestionItem
|
||||
$isActive={selectedCollectionIdx === -1}
|
||||
onClick={e => handleSuggestionClick(e, -1)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{t('collection.sidebar.allCollections')}
|
||||
</SuggestionItem>
|
||||
<SuggestionDivider />
|
||||
{Object.values(collections).map((collection, idx) => (
|
||||
<SuggestionItem
|
||||
key={idx}
|
||||
$isActive={idx === selectedCollectionIdx}
|
||||
onClick={e => handleSuggestionClick(e, idx)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{collection.label}
|
||||
</SuggestionItem>
|
||||
))}
|
||||
</Suggestions>
|
||||
</SuggestionsContainer>
|
||||
)}
|
||||
</SearchContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(CollectionSearch);
|
@ -1,82 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { components, buttons, shadows } from '../../ui';
|
||||
|
||||
const CollectionTopContainer = styled.div`
|
||||
${components.cardTop};
|
||||
margin-bottom: 22px;
|
||||
`;
|
||||
|
||||
const CollectionTopRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const CollectionTopHeading = styled.h1`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
const CollectionTopNewButton = styled(Link)`
|
||||
${buttons.button};
|
||||
${shadows.dropDeep};
|
||||
${buttons.default};
|
||||
${buttons.gray};
|
||||
|
||||
padding: 0 30px;
|
||||
`;
|
||||
|
||||
const CollectionTopDescription = styled.p`
|
||||
${components.cardTopDescription};
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
function getCollectionProps(collection) {
|
||||
const collectionLabel = collection.get('label');
|
||||
const collectionLabelSingular = collection.get('label_singular');
|
||||
const collectionDescription = collection.get('description');
|
||||
|
||||
return {
|
||||
collectionLabel,
|
||||
collectionLabelSingular,
|
||||
collectionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
function CollectionTop({ collection, newEntryUrl, t }) {
|
||||
const { collectionLabel, collectionLabelSingular, collectionDescription } = getCollectionProps(
|
||||
collection,
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
<CollectionTopContainer>
|
||||
<CollectionTopRow>
|
||||
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
|
||||
{newEntryUrl ? (
|
||||
<CollectionTopNewButton to={newEntryUrl}>
|
||||
{t('collection.collectionTop.newButton', {
|
||||
collectionLabel: collectionLabelSingular || collectionLabel,
|
||||
})}
|
||||
</CollectionTopNewButton>
|
||||
) : null}
|
||||
</CollectionTopRow>
|
||||
{collectionDescription ? (
|
||||
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
|
||||
) : null}
|
||||
</CollectionTopContainer>
|
||||
);
|
||||
}
|
||||
|
||||
CollectionTop.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
newEntryUrl: PropTypes.string,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(CollectionTop);
|
78
src/components/Collection/CollectionTop.tsx
Normal file
78
src/components/Collection/CollectionTop.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import React, { useCallback } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { components } from '../../components/UI/styles';
|
||||
|
||||
import type { Collection, TranslatedProps } from '../../interface';
|
||||
|
||||
const CollectionTopRow = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const CollectionTopHeading = styled('h1')`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
const CollectionTopDescription = styled('p')`
|
||||
${components.cardTopDescription};
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
function getCollectionProps(collection: Collection) {
|
||||
const collectionLabel = collection.label;
|
||||
const collectionLabelSingular = collection.label_singular;
|
||||
const collectionDescription = collection.description;
|
||||
|
||||
return {
|
||||
collectionLabel,
|
||||
collectionLabelSingular,
|
||||
collectionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
interface CollectionTopProps {
|
||||
collection: Collection;
|
||||
newEntryUrl?: string;
|
||||
}
|
||||
|
||||
const CollectionTop = ({ collection, newEntryUrl, t }: TranslatedProps<CollectionTopProps>) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { collectionLabel, collectionLabelSingular, collectionDescription } =
|
||||
getCollectionProps(collection);
|
||||
|
||||
const onNewClick = useCallback(() => {
|
||||
if (newEntryUrl) {
|
||||
navigate(newEntryUrl);
|
||||
}
|
||||
}, [navigate, newEntryUrl]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CollectionTopRow>
|
||||
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
|
||||
{newEntryUrl ? (
|
||||
<Button onClick={onNewClick} variant="contained">
|
||||
{t('collection.collectionTop.newButton', {
|
||||
collectionLabel: collectionLabelSingular || collectionLabel,
|
||||
})}
|
||||
</Button>
|
||||
) : null}
|
||||
</CollectionTopRow>
|
||||
{collectionDescription ? (
|
||||
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(CollectionTop);
|
@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { buttons, StyledDropdownButton, colors } from '../../ui';
|
||||
|
||||
const Button = styled(StyledDropdownButton)`
|
||||
${buttons.button};
|
||||
${buttons.medium};
|
||||
${buttons.grayText};
|
||||
font-size: 14px;
|
||||
|
||||
&:after {
|
||||
top: 11px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ControlButton({ active, title }) {
|
||||
return (
|
||||
<Button
|
||||
css={css`
|
||||
color: ${active ? colors.active : undefined};
|
||||
`}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { Loader, lengths } from '../../../ui';
|
||||
import EntryListing from './EntryListing';
|
||||
|
||||
const PaginationMessage = styled.div`
|
||||
width: ${lengths.topCardWidth};
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const NoEntriesMessage = styled(PaginationMessage)`
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
function Entries({
|
||||
collections,
|
||||
entries,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
handleCursorActions,
|
||||
t,
|
||||
page,
|
||||
}) {
|
||||
const loadingMessages = [
|
||||
t('collection.entries.loadingEntries'),
|
||||
t('collection.entries.cachingEntries'),
|
||||
t('collection.entries.longerLoading'),
|
||||
];
|
||||
|
||||
if (isFetching && page === undefined) {
|
||||
return <Loader active>{loadingMessages}</Loader>;
|
||||
}
|
||||
|
||||
const hasEntries = (entries && entries.size > 0) || cursor?.actions?.has('append_next');
|
||||
if (hasEntries) {
|
||||
return (
|
||||
<>
|
||||
<EntryListing
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
{isFetching && page !== undefined && entries.size > 0 ? (
|
||||
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
|
||||
}
|
||||
|
||||
Entries.propTypes = {
|
||||
collections: ImmutablePropTypes.iterable.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
page: PropTypes.number,
|
||||
isFetching: PropTypes.bool,
|
||||
viewStyle: PropTypes.string,
|
||||
cursor: PropTypes.any.isRequired,
|
||||
handleCursorActions: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default translate()(Entries);
|
93
src/components/Collection/Entries/Entries.tsx
Normal file
93
src/components/Collection/Entries/Entries.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import Loader from '../../UI/Loader';
|
||||
import EntryListing from './EntryListing';
|
||||
|
||||
import type { CollectionViewStyle } from '../../../constants/collectionViews';
|
||||
import type { Collection, Collections, Entry, TranslatedProps } from '../../../interface';
|
||||
import type Cursor from '../../../lib/util/Cursor';
|
||||
|
||||
const PaginationMessage = styled('div')`
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const NoEntriesMessage = styled(PaginationMessage)`
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
export interface BaseEntriesProps {
|
||||
entries: Entry[];
|
||||
page?: number;
|
||||
isFetching: boolean;
|
||||
viewStyle: CollectionViewStyle;
|
||||
cursor: Cursor;
|
||||
handleCursorActions: (action: string) => void;
|
||||
}
|
||||
|
||||
export interface SingleCollectionEntriesProps extends BaseEntriesProps {
|
||||
collection: Collection;
|
||||
}
|
||||
|
||||
export interface MultipleCollectionEntriesProps extends BaseEntriesProps {
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
export type EntriesProps = SingleCollectionEntriesProps | MultipleCollectionEntriesProps;
|
||||
|
||||
const Entries = ({
|
||||
entries,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
handleCursorActions,
|
||||
t,
|
||||
page,
|
||||
...otherProps
|
||||
}: TranslatedProps<EntriesProps>) => {
|
||||
const loadingMessages = [
|
||||
t('collection.entries.loadingEntries'),
|
||||
t('collection.entries.cachingEntries'),
|
||||
t('collection.entries.longerLoading'),
|
||||
];
|
||||
|
||||
if (isFetching && page === undefined) {
|
||||
return <Loader>{loadingMessages}</Loader>;
|
||||
}
|
||||
|
||||
const hasEntries = (entries && entries.length > 0) || cursor?.actions?.has('append_next');
|
||||
if (hasEntries) {
|
||||
return (
|
||||
<>
|
||||
{'collection' in otherProps ? (
|
||||
<EntryListing
|
||||
collection={otherProps.collection}
|
||||
entries={entries}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
) : (
|
||||
<EntryListing
|
||||
collections={otherProps.collections}
|
||||
entries={entries}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
)}
|
||||
{isFetching && page !== undefined && entries.length > 0 ? (
|
||||
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
|
||||
};
|
||||
|
||||
export default translate()(Entries);
|
@ -1,166 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import styled from '@emotion/styled';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { partial } from 'lodash';
|
||||
|
||||
import { colors } from '../../../ui';
|
||||
import { Cursor } from '../../../lib/util';
|
||||
import {
|
||||
loadEntries as actionLoadEntries,
|
||||
traverseCollectionCursor as actionTraverseCollectionCursor,
|
||||
} from '../../../actions/entries';
|
||||
import {
|
||||
selectEntries,
|
||||
selectEntriesLoaded,
|
||||
selectIsFetching,
|
||||
selectGroups,
|
||||
} from '../../../reducers/entries';
|
||||
import { selectCollectionEntriesCursor } from '../../../reducers/cursors';
|
||||
import Entries from './Entries';
|
||||
|
||||
const GroupHeading = styled.h2`
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
color: ${colors.textLead};
|
||||
`;
|
||||
|
||||
const GroupContainer = styled.div``;
|
||||
|
||||
function getGroupEntries(entries, paths) {
|
||||
return entries.filter(entry => paths.has(entry.get('path')));
|
||||
}
|
||||
|
||||
function getGroupTitle(group, t) {
|
||||
const { label, value } = group;
|
||||
if (value === undefined) {
|
||||
return t('collection.groups.other');
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? label : t('collection.groups.negateLabel', { label });
|
||||
}
|
||||
return `${label} ${value}`.trim();
|
||||
}
|
||||
|
||||
function withGroups(groups, entries, EntriesToRender, t) {
|
||||
return groups.map(group => {
|
||||
const title = getGroupTitle(group, t);
|
||||
return (
|
||||
<GroupContainer key={group.id} id={group.id}>
|
||||
<GroupHeading>{title}</GroupHeading>
|
||||
<EntriesToRender entries={getGroupEntries(entries, group.paths)} />
|
||||
</GroupContainer>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class EntriesCollection extends React.Component {
|
||||
static propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
page: PropTypes.number,
|
||||
entries: ImmutablePropTypes.list,
|
||||
groups: PropTypes.array,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
viewStyle: PropTypes.string,
|
||||
cursor: PropTypes.object.isRequired,
|
||||
loadEntries: PropTypes.func.isRequired,
|
||||
traverseCollectionCursor: PropTypes.func.isRequired,
|
||||
entriesLoaded: PropTypes.bool,
|
||||
readyToLoad: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { collection, entriesLoaded, loadEntries, readyToLoad } = this.props;
|
||||
if (collection && !entriesLoaded && readyToLoad) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { collection, entriesLoaded, loadEntries, readyToLoad } = this.props;
|
||||
if (!entriesLoaded && readyToLoad && (!prevProps.readyToLoad || prevProps.collection !== collection)) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
}
|
||||
|
||||
handleCursorActions = (cursor, action) => {
|
||||
const { collection, traverseCollectionCursor } = this.props;
|
||||
traverseCollectionCursor(collection, action);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, entries, groups, isFetching, viewStyle, cursor, page, t } = this.props;
|
||||
|
||||
const EntriesToRender = ({ entries }) => {
|
||||
return (
|
||||
<Entries
|
||||
collections={collection}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
collectionName={collection.get('label')}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={partial(this.handleCursorActions, cursor)}
|
||||
page={page}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (groups && groups.length > 0) {
|
||||
return withGroups(groups, entries, EntriesToRender, t);
|
||||
}
|
||||
|
||||
return <EntriesToRender entries={entries} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterNestedEntries(path, collectionFolder, entries) {
|
||||
const filtered = entries.filter(e => {
|
||||
const entryPath = e.get('path').slice(collectionFolder.length + 1);
|
||||
if (!entryPath.startsWith(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// only show immediate children
|
||||
if (path) {
|
||||
// non root path
|
||||
const trimmed = entryPath.slice(path.length + 1);
|
||||
return trimmed.split('/').length === 2;
|
||||
} else {
|
||||
// root path
|
||||
return entryPath.split('/').length <= 2;
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collection, viewStyle, filterTerm } = ownProps;
|
||||
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
|
||||
|
||||
let entries = selectEntries(state.entries, collection);
|
||||
const groups = selectGroups(state.entries, collection);
|
||||
|
||||
if (collection.has('nested')) {
|
||||
const collectionFolder = collection.get('folder');
|
||||
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
|
||||
}
|
||||
const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name'));
|
||||
const isFetching = selectIsFetching(state.entries, collection.get('name'));
|
||||
|
||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
|
||||
const cursor = Cursor.create(rawCursor).clearData();
|
||||
|
||||
return { collection, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadEntries: actionLoadEntries,
|
||||
traverseCollectionCursor: actionTraverseCollectionCursor,
|
||||
};
|
||||
|
||||
const ConnectedEntriesCollection = connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
|
||||
|
||||
export default translate()(ConnectedEntriesCollection);
|
191
src/components/Collection/Entries/EntriesCollection.tsx
Normal file
191
src/components/Collection/Entries/EntriesCollection.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
loadEntries as loadEntriesAction,
|
||||
traverseCollectionCursor as traverseCollectionCursorAction,
|
||||
} from '../../../actions/entries';
|
||||
import { colors } from '../../../components/UI/styles';
|
||||
import { Cursor } from '../../../lib/util';
|
||||
import { selectCollectionEntriesCursor } from '../../../reducers/cursors';
|
||||
import {
|
||||
selectEntries,
|
||||
selectEntriesLoaded,
|
||||
selectGroups,
|
||||
selectIsFetching,
|
||||
} from '../../../reducers/entries';
|
||||
import Entries from './Entries';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { t } from 'react-polyglot';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { CollectionViewStyle } from '../../../constants/collectionViews';
|
||||
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '../../../interface';
|
||||
import type { RootState } from '../../../store';
|
||||
|
||||
const GroupHeading = styled('h2')`
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
color: ${colors.textLead};
|
||||
`;
|
||||
|
||||
const GroupContainer = styled('div')``;
|
||||
|
||||
function getGroupEntries(entries: Entry[], paths: Set<string>) {
|
||||
return entries.filter(entry => paths.has(entry.path));
|
||||
}
|
||||
|
||||
function getGroupTitle(group: GroupOfEntries, t: t) {
|
||||
const { label, value } = group;
|
||||
if (value === undefined) {
|
||||
return t('collection.groups.other');
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? label : t('collection.groups.negateLabel', { label });
|
||||
}
|
||||
return `${label} ${value}`.trim();
|
||||
}
|
||||
|
||||
function withGroups(
|
||||
groups: GroupOfEntries[],
|
||||
entries: Entry[],
|
||||
EntriesToRender: ComponentType<EntriesToRenderProps>,
|
||||
t: t,
|
||||
) {
|
||||
return groups.map(group => {
|
||||
const title = getGroupTitle(group, t);
|
||||
return (
|
||||
<GroupContainer key={group.id} id={group.id}>
|
||||
<GroupHeading>{title}</GroupHeading>
|
||||
<EntriesToRender entries={getGroupEntries(entries, group.paths)} />
|
||||
</GroupContainer>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface EntriesToRenderProps {
|
||||
entries: Entry[];
|
||||
}
|
||||
|
||||
const EntriesCollection = ({
|
||||
collection,
|
||||
entries,
|
||||
groups,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
page,
|
||||
traverseCollectionCursor,
|
||||
t,
|
||||
entriesLoaded,
|
||||
readyToLoad,
|
||||
loadEntries,
|
||||
}: TranslatedProps<EntriesCollectionProps>) => {
|
||||
const [prevReadyToLoad, setPrevReadyToLoad] = useState(false);
|
||||
const [prevCollection, setPrevCollection] = useState(collection);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
collection &&
|
||||
!entriesLoaded &&
|
||||
readyToLoad &&
|
||||
(!prevReadyToLoad || prevCollection !== collection)
|
||||
) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
|
||||
setPrevReadyToLoad(readyToLoad);
|
||||
setPrevCollection(collection);
|
||||
}, [collection, entriesLoaded, loadEntries, prevCollection, prevReadyToLoad, readyToLoad]);
|
||||
|
||||
const handleCursorActions = useCallback(
|
||||
(action: string) => {
|
||||
traverseCollectionCursor(collection, action);
|
||||
},
|
||||
[collection, traverseCollectionCursor],
|
||||
);
|
||||
|
||||
const EntriesToRender = useCallback(
|
||||
({ entries }: EntriesToRenderProps) => {
|
||||
return (
|
||||
<Entries
|
||||
collection={collection}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
collectionName={collection.label}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[collection, cursor, handleCursorActions, isFetching, page, viewStyle],
|
||||
);
|
||||
|
||||
if (groups && groups.length > 0) {
|
||||
return <>{withGroups(groups, entries, EntriesToRender, t)}</>;
|
||||
}
|
||||
|
||||
return <EntriesToRender entries={entries} />;
|
||||
};
|
||||
|
||||
export function filterNestedEntries(path: string, collectionFolder: string, entries: Entry[]) {
|
||||
const filtered = entries.filter(e => {
|
||||
const entryPath = e.path.slice(collectionFolder.length + 1);
|
||||
if (!entryPath.startsWith(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// only show immediate children
|
||||
if (path) {
|
||||
// non root path
|
||||
const trimmed = entryPath.slice(path.length + 1);
|
||||
return trimmed.split('/').length === 2;
|
||||
} else {
|
||||
// root path
|
||||
return entryPath.split('/').length <= 2;
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
interface EntriesCollectionOwnProps {
|
||||
collection: Collection;
|
||||
viewStyle: CollectionViewStyle;
|
||||
readyToLoad: boolean;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntriesCollectionOwnProps) {
|
||||
const { collection, viewStyle, filterTerm } = ownProps;
|
||||
const page = state.entries.pages[collection.name]?.page;
|
||||
|
||||
let entries = selectEntries(state.entries, collection);
|
||||
const groups = selectGroups(state.entries, collection);
|
||||
|
||||
if ('nested' in collection) {
|
||||
const collectionFolder = collection.folder ?? '';
|
||||
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
|
||||
}
|
||||
|
||||
const entriesLoaded = selectEntriesLoaded(state.entries, collection.name);
|
||||
const isFetching = selectIsFetching(state.entries, collection.name);
|
||||
|
||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.name);
|
||||
const cursor = Cursor.create(rawCursor).clearData();
|
||||
|
||||
return { ...ownProps, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadEntries: loadEntriesAction,
|
||||
traverseCollectionCursor: traverseCollectionCursorAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EntriesCollectionProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(EntriesCollection) as ComponentType<EntriesCollectionProps>);
|
@ -1,91 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { Cursor } from '../../../lib/util';
|
||||
import { selectSearchedEntries } from '../../../reducers';
|
||||
import {
|
||||
searchEntries as actionSearchEntries,
|
||||
clearSearch as actionClearSearch,
|
||||
} from '../../../actions/search';
|
||||
import Entries from './Entries';
|
||||
|
||||
class EntriesSearch extends React.Component {
|
||||
static propTypes = {
|
||||
isFetching: PropTypes.bool,
|
||||
searchEntries: PropTypes.func.isRequired,
|
||||
clearSearch: PropTypes.func.isRequired,
|
||||
searchTerm: PropTypes.string.isRequired,
|
||||
collections: ImmutablePropTypes.seq,
|
||||
collectionNames: PropTypes.array,
|
||||
entries: ImmutablePropTypes.list,
|
||||
page: PropTypes.number,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { searchTerm, searchEntries, collectionNames } = this.props;
|
||||
searchEntries(searchTerm, collectionNames);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { searchTerm, collectionNames } = this.props;
|
||||
|
||||
// check if the search parameters are the same
|
||||
if (prevProps.searchTerm === searchTerm && isEqual(prevProps.collectionNames, collectionNames))
|
||||
return;
|
||||
|
||||
const { searchEntries } = prevProps;
|
||||
searchEntries(searchTerm, collectionNames);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.clearSearch();
|
||||
}
|
||||
|
||||
getCursor = () => {
|
||||
const { page } = this.props;
|
||||
return Cursor.create({
|
||||
actions: isNaN(page) ? [] : ['append_next'],
|
||||
});
|
||||
};
|
||||
|
||||
handleCursorActions = action => {
|
||||
const { page, searchTerm, searchEntries, collectionNames } = this.props;
|
||||
if (action === 'append_next') {
|
||||
const nextPage = page + 1;
|
||||
searchEntries(searchTerm, collectionNames, nextPage);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, entries, isFetching } = this.props;
|
||||
return (
|
||||
<Entries
|
||||
cursor={this.getCursor()}
|
||||
handleCursorActions={this.handleCursorActions}
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { searchTerm } = ownProps;
|
||||
const collections = ownProps.collections.toIndexedSeq();
|
||||
const collectionNames = ownProps.collections.keySeq().toArray();
|
||||
const isFetching = state.search.isFetching;
|
||||
const page = state.search.page;
|
||||
const entries = selectSearchedEntries(state, collectionNames);
|
||||
return { isFetching, page, collections, collectionNames, entries, searchTerm };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
searchEntries: actionSearchEntries,
|
||||
clearSearch: actionClearSearch,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);
|
101
src/components/Collection/Entries/EntriesSearch.tsx
Normal file
101
src/components/Collection/Entries/EntriesSearch.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
clearSearch as clearSearchAction,
|
||||
searchEntries as searchEntriesAction,
|
||||
} from '../../../actions/search';
|
||||
import { Cursor } from '../../../lib/util';
|
||||
import { selectSearchedEntries } from '../../../reducers';
|
||||
import Entries from './Entries';
|
||||
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Collections } from '../../../interface';
|
||||
import type { RootState } from '../../../store';
|
||||
|
||||
const EntriesSearch = ({
|
||||
collections,
|
||||
entries,
|
||||
isFetching,
|
||||
page,
|
||||
searchTerm,
|
||||
searchEntries,
|
||||
collectionNames,
|
||||
clearSearch,
|
||||
}: EntriesSearchProps) => {
|
||||
const getCursor = useCallback(() => {
|
||||
return Cursor.create({
|
||||
actions: Number.isNaN(page) ? [] : ['append_next'],
|
||||
});
|
||||
}, [page]);
|
||||
|
||||
const handleCursorActions = useCallback(
|
||||
(action: string) => {
|
||||
if (action === 'append_next') {
|
||||
const nextPage = page + 1;
|
||||
searchEntries(searchTerm, collectionNames, nextPage);
|
||||
}
|
||||
},
|
||||
[collectionNames, page, searchEntries, searchTerm],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
searchEntries(searchTerm, collectionNames);
|
||||
}, [collectionNames, searchEntries, searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSearch();
|
||||
};
|
||||
}, [clearSearch]);
|
||||
|
||||
const [prevSearch, setPrevSearch] = useState('');
|
||||
const [prevCollectionNames, setPrevCollectionNames] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
// check if the search parameters are the same
|
||||
if (prevSearch === searchTerm && isEqual(prevCollectionNames, collectionNames)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPrevSearch(searchTerm);
|
||||
setPrevCollectionNames(collectionNames);
|
||||
|
||||
searchEntries(searchTerm, collectionNames);
|
||||
}, [collectionNames, prevCollectionNames, prevSearch, searchEntries, searchTerm]);
|
||||
|
||||
return (
|
||||
<Entries
|
||||
cursor={getCursor()}
|
||||
handleCursorActions={handleCursorActions}
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface EntriesSearchOwnProps {
|
||||
searchTerm: string;
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
|
||||
const { searchTerm } = ownProps;
|
||||
const collections = Object.values(ownProps.collections);
|
||||
const collectionNames = Object.keys(ownProps.collections);
|
||||
const isFetching = state.search.isFetching;
|
||||
const page = state.search.page;
|
||||
const entries = selectSearchedEntries(state, collectionNames);
|
||||
return { isFetching, page, collections, collectionNames, entries, searchTerm };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
searchEntries: searchEntriesAction,
|
||||
clearSearch: clearSearchAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EntriesSearchProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EntriesSearch);
|
@ -1,167 +0,0 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { colors, colorsRaw, components, lengths, zIndex } from '../../../ui';
|
||||
import { boundGetAsset } from '../../../actions/media';
|
||||
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from '../../../constants/collectionViews';
|
||||
import { selectIsLoadingAsset } from '../../../reducers/medias';
|
||||
import { selectEntryCollectionTitle } from '../../../reducers/collections';
|
||||
|
||||
const ListCard = styled.li`
|
||||
${components.card};
|
||||
width: ${lengths.topCardWidth};
|
||||
margin-left: 12px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ListCardLink = styled(Link)`
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 16px 22px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${colors.foreground};
|
||||
}
|
||||
`;
|
||||
|
||||
const GridCard = styled.li`
|
||||
${components.card};
|
||||
flex: 0 0 335px;
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
margin-left: 12px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const GridCardLink = styled(Link)`
|
||||
display: block;
|
||||
height: 100%;
|
||||
outline-offset: -2px;
|
||||
|
||||
&,
|
||||
&:hover {
|
||||
background-color: ${colors.foreground};
|
||||
color: ${colors.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const CollectionLabel = styled.h2`
|
||||
font-size: 12px;
|
||||
color: ${colors.textLead};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const ListCardTitle = styled.h2`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const CardHeading = styled.h2`
|
||||
margin: 0 0 2px;
|
||||
`;
|
||||
|
||||
const CardBody = styled.div`
|
||||
padding: 16px 22px;
|
||||
height: 90px;
|
||||
position: relative;
|
||||
margin-bottom: ${props => props.hasImage && 0};
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
z-index: ${zIndex.zIndex1};
|
||||
bottom: 0;
|
||||
left: -20%;
|
||||
height: 140%;
|
||||
width: 140%;
|
||||
box-shadow: inset 0 -15px 24px ${colorsRaw.white};
|
||||
}
|
||||
`;
|
||||
|
||||
const CardImage = styled.div`
|
||||
background-image: url(${props => props.src});
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
height: 150px;
|
||||
`;
|
||||
|
||||
function EntryCard({
|
||||
path,
|
||||
summary,
|
||||
image,
|
||||
imageField,
|
||||
collectionLabel,
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
getAsset,
|
||||
}) {
|
||||
if (viewStyle === VIEW_STYLE_LIST) {
|
||||
return (
|
||||
<ListCard>
|
||||
<ListCardLink to={path}>
|
||||
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
|
||||
<ListCardTitle>{summary}</ListCardTitle>
|
||||
</ListCardLink>
|
||||
</ListCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewStyle === VIEW_STYLE_GRID) {
|
||||
return (
|
||||
<GridCard>
|
||||
<GridCardLink to={path}>
|
||||
<CardBody hasImage={image}>
|
||||
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
|
||||
<CardHeading>{summary}</CardHeading>
|
||||
</CardBody>
|
||||
{image ? <CardImage src={getAsset(image, imageField).toString()} /> : null}
|
||||
</GridCardLink>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { entry, inferedFields, collection } = ownProps;
|
||||
const entryData = entry.get('data');
|
||||
const summary = selectEntryCollectionTitle(collection, entry);
|
||||
|
||||
let image = entryData.get(inferedFields.imageField);
|
||||
if (image) {
|
||||
image = encodeURI(image);
|
||||
}
|
||||
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
|
||||
return {
|
||||
summary,
|
||||
path: `/collections/${collection.get('name')}/entries/${entry.get('slug')}`,
|
||||
image,
|
||||
imageFolder: collection
|
||||
.get('fields')
|
||||
?.find(f => f.get('name') === inferedFields.imageField && f.get('widget') === 'image'),
|
||||
isLoadingAsset,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProps(stateProps, dispatchProps, ownProps) {
|
||||
return {
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
...ownProps,
|
||||
getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry),
|
||||
};
|
||||
}
|
||||
|
||||
const ConnectedEntryCard = connect(mapStateToProps, mapDispatchToProps, mergeProps)(EntryCard);
|
||||
|
||||
export default ConnectedEntryCard;
|
102
src/components/Collection/Entries/EntryCard.tsx
Normal file
102
src/components/Collection/Entries/EntryCard.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import Card from '@mui/material/Card';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getAsset as getAssetAction } from '../../../actions/media';
|
||||
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '../../../constants/collectionViews';
|
||||
import { selectEntryCollectionTitle } from '../../../lib/util/collection.util';
|
||||
import { selectIsLoadingAsset } from '../../../reducers/medias';
|
||||
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { CollectionViewStyle } from '../../../constants/collectionViews';
|
||||
import type { Field, Collection, Entry } from '../../../interface';
|
||||
import type { RootState } from '../../../store';
|
||||
|
||||
const EntryCard = ({
|
||||
collection,
|
||||
entry,
|
||||
path,
|
||||
image,
|
||||
imageField,
|
||||
collectionLabel,
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
getAsset,
|
||||
}: NestedCollectionProps) => {
|
||||
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea component={Link} to={path}>
|
||||
{viewStyle === VIEW_STYLE_GRID && image && imageField ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="140"
|
||||
image={getAsset(collection, entry, image, imageField).toString()}
|
||||
/>
|
||||
) : null}
|
||||
<CardContent>
|
||||
{collectionLabel ? (
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{collectionLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography gutterBottom variant="h6" component="div" sx={{ margin: 0 }}>
|
||||
{summary}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface EntryCardOwnProps {
|
||||
entry: Entry;
|
||||
inferedFields: {
|
||||
titleField?: string | null | undefined;
|
||||
descriptionField?: string | null | undefined;
|
||||
imageField?: string | null | undefined;
|
||||
remainingFields?: Field[] | undefined;
|
||||
};
|
||||
collection: Collection;
|
||||
imageField?: Field;
|
||||
collectionLabel?: string;
|
||||
viewStyle?: CollectionViewStyle;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntryCardOwnProps) {
|
||||
const { entry, inferedFields, collection } = ownProps;
|
||||
const entryData = entry.data;
|
||||
|
||||
let image = inferedFields.imageField
|
||||
? (entryData?.[inferedFields.imageField] as string | undefined)
|
||||
: undefined;
|
||||
if (image) {
|
||||
image = encodeURI(image);
|
||||
}
|
||||
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
|
||||
return {
|
||||
...ownProps,
|
||||
path: `/collections/${collection.name}/entries/${entry.slug}`,
|
||||
image,
|
||||
imageField: collection.fields?.find(
|
||||
f => f.name === inferedFields.imageField && f.widget === 'image',
|
||||
),
|
||||
isLoadingAsset,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
getAsset: getAssetAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type NestedCollectionProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EntryCard);
|
@ -1,87 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
import { Map } from 'immutable';
|
||||
|
||||
import { selectFields, selectInferedField } from '../../../reducers/collections';
|
||||
import EntryCard from './EntryCard';
|
||||
|
||||
const CardsGrid = styled.ul`
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
list-style-type: none;
|
||||
margin-left: -12px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 0;
|
||||
`;
|
||||
|
||||
export default class EntryListing extends React.Component {
|
||||
static propTypes = {
|
||||
collections: ImmutablePropTypes.iterable.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
viewStyle: PropTypes.string,
|
||||
cursor: PropTypes.any.isRequired,
|
||||
handleCursorActions: PropTypes.func.isRequired,
|
||||
page: PropTypes.number,
|
||||
};
|
||||
|
||||
hasMore = () => {
|
||||
const hasMore = this.props.cursor?.actions?.has('append_next');
|
||||
return hasMore;
|
||||
};
|
||||
|
||||
handleLoadMore = () => {
|
||||
if (this.hasMore()) {
|
||||
this.props.handleCursorActions('append_next');
|
||||
}
|
||||
};
|
||||
|
||||
inferFields = collection => {
|
||||
const titleField = selectInferedField(collection, 'title');
|
||||
const descriptionField = selectInferedField(collection, 'description');
|
||||
const imageField = selectInferedField(collection, 'image');
|
||||
const fields = selectFields(collection);
|
||||
const inferedFields = [titleField, descriptionField, imageField];
|
||||
const remainingFields =
|
||||
fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
|
||||
return { titleField, descriptionField, imageField, remainingFields };
|
||||
};
|
||||
|
||||
renderCardsForSingleCollection = () => {
|
||||
const { collections, entries, viewStyle } = this.props;
|
||||
const inferedFields = this.inferFields(collections);
|
||||
const entryCardProps = { collection: collections, inferedFields, viewStyle };
|
||||
return entries.map((entry, idx) => <EntryCard {...entryCardProps} entry={entry} key={idx} />);
|
||||
};
|
||||
|
||||
renderCardsForMultipleCollections = () => {
|
||||
const { collections, entries } = this.props;
|
||||
const isSingleCollectionInList = collections.size === 1;
|
||||
return entries.map((entry, idx) => {
|
||||
const collectionName = entry.get('collection');
|
||||
const collection = collections.find(coll => coll.get('name') === collectionName);
|
||||
const collectionLabel = !isSingleCollectionInList && collection.get('label');
|
||||
const inferedFields = this.inferFields(collection);
|
||||
const entryCardProps = { collection, entry, inferedFields, collectionLabel };
|
||||
return <EntryCard {...entryCardProps} key={idx} />;
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, page } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardsGrid>
|
||||
{Map.isMap(collections)
|
||||
? this.renderCardsForSingleCollection()
|
||||
: this.renderCardsForMultipleCollections()}
|
||||
{this.hasMore() && <Waypoint key={page} onEnter={this.handleLoadMore} />}
|
||||
</CardsGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
147
src/components/Collection/Entries/EntryListing.tsx
Normal file
147
src/components/Collection/Entries/EntryListing.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
|
||||
import { VIEW_STYLE_LIST } from '../../../constants/collectionViews';
|
||||
import { transientOptions } from '../../../lib';
|
||||
import { selectFields, selectInferedField } from '../../../lib/util/collection.util';
|
||||
import EntryCard from './EntryCard';
|
||||
|
||||
import type { CollectionViewStyle } from '../../../constants/collectionViews';
|
||||
import type { Field, Collection, Collections, Entry } from '../../../interface';
|
||||
import type Cursor from '../../../lib/util/Cursor';
|
||||
|
||||
interface CardsGridProps {
|
||||
$layout: CollectionViewStyle;
|
||||
}
|
||||
|
||||
const CardsGrid = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<CardsGridProps>(
|
||||
({ $layout }) => `
|
||||
${
|
||||
$layout === VIEW_STYLE_LIST
|
||||
? `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
: `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
`
|
||||
}
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
gap: 16px;
|
||||
`,
|
||||
);
|
||||
|
||||
export interface BaseEntryListingProps {
|
||||
entries: Entry[];
|
||||
viewStyle: CollectionViewStyle;
|
||||
cursor?: Cursor;
|
||||
handleCursorActions: (action: string) => void;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export interface SingleCollectionEntryListingProps extends BaseEntryListingProps {
|
||||
collection: Collection;
|
||||
}
|
||||
|
||||
export interface MultipleCollectionEntryListingProps extends BaseEntryListingProps {
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
export type EntryListingProps =
|
||||
| SingleCollectionEntryListingProps
|
||||
| MultipleCollectionEntryListingProps;
|
||||
|
||||
const EntryListing = ({
|
||||
entries,
|
||||
page,
|
||||
cursor,
|
||||
viewStyle,
|
||||
handleCursorActions,
|
||||
...otherProps
|
||||
}: EntryListingProps) => {
|
||||
const hasMore = useCallback(() => {
|
||||
const hasMore = cursor?.actions?.has('append_next');
|
||||
return hasMore;
|
||||
}, [cursor?.actions]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (hasMore()) {
|
||||
handleCursorActions('append_next');
|
||||
}
|
||||
}, [handleCursorActions, hasMore]);
|
||||
|
||||
const inferFields = useCallback(
|
||||
(
|
||||
collection?: Collection,
|
||||
): {
|
||||
titleField?: string | null;
|
||||
descriptionField?: string | null;
|
||||
imageField?: string | null;
|
||||
remainingFields?: Field[];
|
||||
} => {
|
||||
if (!collection) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const titleField = selectInferedField(collection, 'title');
|
||||
const descriptionField = selectInferedField(collection, 'description');
|
||||
const imageField = selectInferedField(collection, 'image');
|
||||
const fields = selectFields(collection);
|
||||
const inferedFields = [titleField, descriptionField, imageField];
|
||||
const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.name) === -1);
|
||||
return { titleField, descriptionField, imageField, remainingFields };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderedCards = useMemo(() => {
|
||||
if ('collection' in otherProps) {
|
||||
const inferedFields = inferFields(otherProps.collection);
|
||||
return entries.map((entry, idx) => (
|
||||
<EntryCard
|
||||
collection={otherProps.collection}
|
||||
inferedFields={inferedFields}
|
||||
viewStyle={viewStyle}
|
||||
entry={entry}
|
||||
key={idx}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1;
|
||||
return entries.map((entry, idx) => {
|
||||
const collectionName = entry.collection;
|
||||
const collection = Object.values(otherProps.collections).find(
|
||||
coll => coll.name === collectionName,
|
||||
);
|
||||
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
|
||||
const inferedFields = inferFields(collection);
|
||||
return collection ? (
|
||||
<EntryCard
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
inferedFields={inferedFields}
|
||||
collectionLabel={collectionLabel}
|
||||
key={idx}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
}, [entries, inferFields, otherProps, viewStyle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardsGrid $layout={viewStyle}>
|
||||
{renderedCards}
|
||||
{hasMore() && <Waypoint key={page} onEnter={handleLoadMore} />}
|
||||
</CardsGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryListing;
|
@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { Dropdown, DropdownCheckedItem } from '../../ui';
|
||||
import { ControlButton } from './ControlButton';
|
||||
|
||||
function FilterControl({ viewFilters, t, onFilterClick, filter }) {
|
||||
const hasActiveFilter = filter
|
||||
?.valueSeq()
|
||||
.toJS()
|
||||
.some(f => f.active === true);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
renderButton={() => {
|
||||
return (
|
||||
<ControlButton active={hasActiveFilter} title={t('collection.collectionTop.filterBy')} />
|
||||
);
|
||||
}}
|
||||
closeOnSelection={false}
|
||||
dropdownTopOverlap="30px"
|
||||
dropdownPosition="left"
|
||||
>
|
||||
{viewFilters.map(viewFilter => {
|
||||
return (
|
||||
<DropdownCheckedItem
|
||||
key={viewFilter.id}
|
||||
label={viewFilter.label}
|
||||
id={viewFilter.id}
|
||||
checked={filter.getIn([viewFilter.id, 'active'], false)}
|
||||
onClick={() => onFilterClick(viewFilter)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate()(FilterControl);
|
85
src/components/Collection/FilterControl.tsx
Normal file
85
src/components/Collection/FilterControl.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { FilterMap, TranslatedProps, ViewFilter } from '../../interface';
|
||||
|
||||
interface FilterControlProps {
|
||||
filter: Record<string, FilterMap>;
|
||||
viewFilters: ViewFilter[];
|
||||
onFilterClick: (viewFilter: ViewFilter) => void;
|
||||
}
|
||||
|
||||
const FilterControl = ({
|
||||
viewFilters,
|
||||
t,
|
||||
onFilterClick,
|
||||
filter,
|
||||
}: TranslatedProps<FilterControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={anyActive ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.filterBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{viewFilters.map(viewFilter => {
|
||||
const checked = filter[viewFilter.id]?.active ?? false;
|
||||
const labelId = `filter-list-label-${viewFilter.label}`;
|
||||
return (
|
||||
<MenuItem
|
||||
key={viewFilter.id}
|
||||
onClick={() => onFilterClick(viewFilter)}
|
||||
sx={{ height: '36px' }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={checked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText id={labelId} primary={viewFilter.label} />
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(FilterControl);
|
@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { Dropdown, DropdownItem } from '../../ui';
|
||||
import { ControlButton } from './ControlButton';
|
||||
|
||||
function GroupControl({ viewGroups, t, onGroupClick, group }) {
|
||||
const hasActiveGroup = group
|
||||
?.valueSeq()
|
||||
.toJS()
|
||||
.some(f => f.active === true);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
renderButton={() => {
|
||||
return (
|
||||
<ControlButton active={hasActiveGroup} title={t('collection.collectionTop.groupBy')} />
|
||||
);
|
||||
}}
|
||||
closeOnSelection={false}
|
||||
dropdownTopOverlap="30px"
|
||||
dropdownWidth="160px"
|
||||
dropdownPosition="left"
|
||||
>
|
||||
{viewGroups.map(viewGroup => {
|
||||
return (
|
||||
<DropdownItem
|
||||
key={viewGroup.id}
|
||||
label={viewGroup.label}
|
||||
onClick={() => onGroupClick(viewGroup)}
|
||||
isActive={group.getIn([viewGroup.id, 'active'], false)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate()(GroupControl);
|
79
src/components/Collection/GroupControl.tsx
Normal file
79
src/components/Collection/GroupControl.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
import type { GroupMap, TranslatedProps, ViewGroup } from '../../interface';
|
||||
|
||||
const StyledMenuIconWrapper = styled('div')`
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
interface GroupControlProps {
|
||||
group: Record<string, GroupMap>;
|
||||
viewGroups: ViewGroup[];
|
||||
onGroupClick: (viewGroup: ViewGroup) => void;
|
||||
}
|
||||
|
||||
const GroupControl = ({
|
||||
viewGroups,
|
||||
group,
|
||||
t,
|
||||
onGroupClick,
|
||||
}: TranslatedProps<GroupControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={activeGroup ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.groupBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{viewGroups.map(viewGroup => (
|
||||
<MenuItem key={viewGroup.id} onClick={() => onGroupClick(viewGroup)}>
|
||||
<ListItemText>{viewGroup.label}</ListItemText>
|
||||
<StyledMenuIconWrapper>
|
||||
{viewGroup.id === activeGroup?.id ? <CheckIcon fontSize="small" /> : null}
|
||||
</StyledMenuIconWrapper>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(GroupControl);
|
@ -1,309 +0,0 @@
|
||||
import React from 'react';
|
||||
import { List } from 'immutable';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { connect } from 'react-redux';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { dirname, sep } from 'path';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
import { Icon, colors, components } from '../../ui';
|
||||
import { stringTemplate } from '../../lib/widgets';
|
||||
import { selectEntries } from '../../reducers/entries';
|
||||
import { selectEntryCollectionTitle } from '../../reducers/collections';
|
||||
|
||||
const { addFileTemplateFields } = stringTemplate;
|
||||
|
||||
const NodeTitleContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const NodeTitle = styled.div`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const Caret = styled.div`
|
||||
position: relative;
|
||||
top: 2px;
|
||||
`;
|
||||
|
||||
const CaretDown = styled(Caret)`
|
||||
${components.caretDown};
|
||||
color: currentColor;
|
||||
`;
|
||||
|
||||
const CaretRight = styled(Caret)`
|
||||
${components.caretRight};
|
||||
color: currentColor;
|
||||
left: 2px;
|
||||
`;
|
||||
|
||||
const TreeNavLink = styled(NavLink)`
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding-left: ${props => props.depth * 20 + 12}px;
|
||||
border-left: 2px solid #fff;
|
||||
|
||||
${Icon} {
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
${props => css`
|
||||
&:hover,
|
||||
&:active,
|
||||
&.${props.activeClassName} {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
border-left-color: #4863c6;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
function getNodeTitle(node) {
|
||||
const title = node.isRoot
|
||||
? node.title
|
||||
: node.children.find(c => !c.isDir && c.title)?.title || node.title;
|
||||
return title;
|
||||
}
|
||||
|
||||
function TreeNode(props) {
|
||||
const { collection, treeData, depth = 0, onToggle } = props;
|
||||
const collectionName = collection.get('name');
|
||||
|
||||
const sortedData = sortBy(treeData, getNodeTitle);
|
||||
return sortedData.map(node => {
|
||||
const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0;
|
||||
if (leaf) {
|
||||
return null;
|
||||
}
|
||||
let to = `/collections/${collectionName}`;
|
||||
if (depth > 0) {
|
||||
to = `${to}/filter${node.path}`;
|
||||
}
|
||||
const title = getNodeTitle(node);
|
||||
|
||||
const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir));
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.path}>
|
||||
<TreeNavLink
|
||||
exact
|
||||
to={to}
|
||||
activeClassName="sidebar-active"
|
||||
onClick={() => onToggle({ node, expanded: !node.expanded })}
|
||||
depth={depth}
|
||||
data-testid={node.path}
|
||||
>
|
||||
<Icon type="write" />
|
||||
<NodeTitleContainer>
|
||||
<NodeTitle>{title}</NodeTitle>
|
||||
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)}
|
||||
</NodeTitleContainer>
|
||||
</TreeNavLink>
|
||||
{node.expanded && (
|
||||
<TreeNode
|
||||
collection={collection}
|
||||
depth={depth + 1}
|
||||
treeData={node.children}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
TreeNode.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
depth: PropTypes.number,
|
||||
treeData: PropTypes.array.isRequired,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export function walk(treeData, callback) {
|
||||
function traverse(children) {
|
||||
for (const child of children) {
|
||||
callback(child);
|
||||
traverse(child.children);
|
||||
}
|
||||
}
|
||||
|
||||
return traverse(treeData);
|
||||
}
|
||||
|
||||
export function getTreeData(collection, entries) {
|
||||
const collectionFolder = collection.get('folder');
|
||||
const rootFolder = '/';
|
||||
const entriesObj = entries
|
||||
.toJS()
|
||||
.map(e => ({ ...e, path: e.path.slice(collectionFolder.length) }));
|
||||
|
||||
const dirs = entriesObj.reduce((acc, entry) => {
|
||||
let dir = dirname(entry.path);
|
||||
while (!acc[dir] && dir && dir !== rootFolder) {
|
||||
const parts = dir.split(sep);
|
||||
acc[dir] = parts.pop();
|
||||
dir = parts.length && parts.join(sep);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (collection.getIn(['nested', 'summary'])) {
|
||||
collection = collection.set('summary', collection.getIn(['nested', 'summary']));
|
||||
} else {
|
||||
collection = collection.delete('summary');
|
||||
}
|
||||
|
||||
const flatData = [
|
||||
{
|
||||
title: collection.get('label'),
|
||||
path: rootFolder,
|
||||
isDir: true,
|
||||
isRoot: true,
|
||||
},
|
||||
...Object.entries(dirs).map(([key, value]) => ({
|
||||
title: value,
|
||||
path: key,
|
||||
isDir: true,
|
||||
isRoot: false,
|
||||
})),
|
||||
...entriesObj.map((e, index) => {
|
||||
let entryMap = entries.get(index);
|
||||
entryMap = entryMap.set(
|
||||
'data',
|
||||
addFileTemplateFields(entryMap.get('path'), entryMap.get('data')),
|
||||
);
|
||||
const title = selectEntryCollectionTitle(collection, entryMap);
|
||||
return {
|
||||
...e,
|
||||
title,
|
||||
isDir: false,
|
||||
isRoot: false,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const parentsToChildren = flatData.reduce((acc, node) => {
|
||||
const parent = node.path === rootFolder ? '' : dirname(node.path);
|
||||
if (acc[parent]) {
|
||||
acc[parent].push(node);
|
||||
} else {
|
||||
acc[parent] = [node];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
function reducer(acc, value) {
|
||||
const node = value;
|
||||
let children = [];
|
||||
if (parentsToChildren[node.path]) {
|
||||
children = parentsToChildren[node.path].reduce(reducer, []);
|
||||
}
|
||||
|
||||
acc.push({ ...node, children });
|
||||
return acc;
|
||||
}
|
||||
|
||||
const treeData = parentsToChildren[''].reduce(reducer, []);
|
||||
|
||||
return treeData;
|
||||
}
|
||||
|
||||
export function updateNode(treeData, node, callback) {
|
||||
let stop = false;
|
||||
|
||||
function updater(nodes) {
|
||||
if (stop) {
|
||||
return nodes;
|
||||
}
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].path === node.path) {
|
||||
nodes[i] = callback(node);
|
||||
stop = true;
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
nodes.forEach(node => updater(node.children));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return updater([...treeData]);
|
||||
}
|
||||
|
||||
export class NestedCollection extends React.Component {
|
||||
static propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entries: ImmutablePropTypes.list.isRequired,
|
||||
filterTerm: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
treeData: getTreeData(this.props.collection, this.props.entries),
|
||||
selected: null,
|
||||
useFilter: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { collection, entries, filterTerm } = this.props;
|
||||
if (
|
||||
collection !== prevProps.collection ||
|
||||
entries !== prevProps.entries ||
|
||||
filterTerm !== prevProps.filterTerm
|
||||
) {
|
||||
const expanded = {};
|
||||
walk(this.state.treeData, node => {
|
||||
if (node.expanded) {
|
||||
expanded[node.path] = true;
|
||||
}
|
||||
});
|
||||
const treeData = getTreeData(collection, entries);
|
||||
|
||||
const path = `/${filterTerm}`;
|
||||
walk(treeData, node => {
|
||||
if (expanded[node.path] || (this.state.useFilter && path.startsWith(node.path))) {
|
||||
node.expanded = true;
|
||||
}
|
||||
});
|
||||
this.setState({ treeData });
|
||||
}
|
||||
}
|
||||
|
||||
onToggle = ({ node, expanded }) => {
|
||||
if (!this.state.selected || this.state.selected.path === node.path || expanded) {
|
||||
const treeData = updateNode(this.state.treeData, node, node => ({
|
||||
...node,
|
||||
expanded,
|
||||
}));
|
||||
this.setState({ treeData, selected: node, useFilter: false });
|
||||
} else {
|
||||
// don't collapse non selected nodes when clicked
|
||||
this.setState({ selected: node, useFilter: false });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { treeData } = this.state;
|
||||
const { collection } = this.props;
|
||||
|
||||
return <TreeNode collection={collection} treeData={treeData} onToggle={this.onToggle} />;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collection } = ownProps;
|
||||
const entries = selectEntries(state.entries, collection) || List();
|
||||
return { entries };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null)(NestedCollection);
|
354
src/components/Collection/NestedCollection.tsx
Normal file
354
src/components/Collection/NestedCollection.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { dirname, sep } from 'path';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { colors, components } from '../../components/UI/styles';
|
||||
import { transientOptions } from '../../lib';
|
||||
import { selectEntryCollectionTitle } from '../../lib/util/collection.util';
|
||||
import { stringTemplate } from '../../lib/widgets';
|
||||
import { selectEntries } from '../../reducers/entries';
|
||||
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Collection, Entry } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
const { addFileTemplateFields } = stringTemplate;
|
||||
|
||||
const NodeTitleContainer = styled('div')`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const NodeTitle = styled('div')`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const Caret = styled('div')`
|
||||
position: relative;
|
||||
top: 2px;
|
||||
`;
|
||||
|
||||
const CaretDown = styled(Caret)`
|
||||
${components.caretDown};
|
||||
color: currentColor;
|
||||
`;
|
||||
|
||||
const CaretRight = styled(Caret)`
|
||||
${components.caretRight};
|
||||
color: currentColor;
|
||||
left: 2px;
|
||||
`;
|
||||
|
||||
interface TreeNavLinkProps {
|
||||
$activeClassName: string;
|
||||
$depth: number;
|
||||
}
|
||||
|
||||
const TreeNavLink = styled(
|
||||
NavLink,
|
||||
transientOptions,
|
||||
)<TreeNavLinkProps>(
|
||||
({ $activeClassName, $depth }) => `
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding-left: ${$depth * 20 + 12}px;
|
||||
border-left: 2px solid #fff;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&.${$activeClassName} {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
border-left-color: #4863c6;
|
||||
|
||||
.MuiListItemIcon-root {
|
||||
color: ${colors.active};
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
interface BaseTreeNodeData {
|
||||
title: string | undefined;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
isRoot: boolean;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
type SingleTreeNodeData = BaseTreeNodeData | (Entry & BaseTreeNodeData);
|
||||
|
||||
type TreeNodeData = SingleTreeNodeData & {
|
||||
children: TreeNodeData[];
|
||||
};
|
||||
|
||||
function getNodeTitle(node: TreeNodeData) {
|
||||
const title = node.isRoot
|
||||
? node.title
|
||||
: node.children.find(c => !c.isDir && c.title)?.title || node.title;
|
||||
return title;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
collection: Collection;
|
||||
treeData: TreeNodeData[];
|
||||
depth?: number;
|
||||
onToggle: ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => void;
|
||||
}
|
||||
|
||||
const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps) => {
|
||||
const collectionName = collection.name;
|
||||
|
||||
const sortedData = sortBy(treeData, getNodeTitle);
|
||||
return (
|
||||
<>
|
||||
{sortedData.map(node => {
|
||||
const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0;
|
||||
if (leaf) {
|
||||
return null;
|
||||
}
|
||||
let to = `/collections/${collectionName}`;
|
||||
if (depth > 0) {
|
||||
to = `${to}/filter${node.path}`;
|
||||
}
|
||||
const title = getNodeTitle(node);
|
||||
|
||||
const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir));
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.path}>
|
||||
<TreeNavLink
|
||||
to={to}
|
||||
$activeClassName="sidebar-active"
|
||||
onClick={() => onToggle({ node, expanded: !node.expanded })}
|
||||
$depth={depth}
|
||||
data-testid={node.path}
|
||||
>
|
||||
<ArticleIcon />
|
||||
<NodeTitleContainer>
|
||||
<NodeTitle>{title}</NodeTitle>
|
||||
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)}
|
||||
</NodeTitleContainer>
|
||||
</TreeNavLink>
|
||||
{node.expanded && (
|
||||
<TreeNode
|
||||
collection={collection}
|
||||
depth={depth + 1}
|
||||
treeData={node.children}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function walk(treeData: TreeNodeData[], callback: (node: TreeNodeData) => void) {
|
||||
function traverse(children: TreeNodeData[]) {
|
||||
for (const child of children) {
|
||||
callback(child);
|
||||
traverse(child.children);
|
||||
}
|
||||
}
|
||||
|
||||
return traverse(treeData);
|
||||
}
|
||||
|
||||
export function getTreeData(collection: Collection, entries: Entry[]): TreeNodeData[] {
|
||||
const collectionFolder = collection.folder ?? '';
|
||||
const rootFolder = '/';
|
||||
const entriesObj = entries.map(e => ({ ...e, path: e.path.slice(collectionFolder.length) }));
|
||||
|
||||
const dirs = entriesObj.reduce((acc, entry) => {
|
||||
let dir: string | undefined = dirname(entry.path);
|
||||
while (dir && !acc[dir] && dir !== rootFolder) {
|
||||
const parts: string[] = dir.split(sep);
|
||||
acc[dir] = parts.pop();
|
||||
dir = parts.length ? parts.join(sep) : undefined;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string | undefined>);
|
||||
|
||||
if ('nested' in collection && collection.nested?.summary) {
|
||||
collection = {
|
||||
...collection,
|
||||
summary: collection.nested.summary,
|
||||
};
|
||||
} else {
|
||||
collection = {
|
||||
...collection,
|
||||
};
|
||||
delete collection.summary;
|
||||
}
|
||||
|
||||
const flatData = [
|
||||
{
|
||||
title: collection.label,
|
||||
path: rootFolder,
|
||||
isDir: true,
|
||||
isRoot: true,
|
||||
},
|
||||
...Object.entries(dirs).map(([key, value]) => ({
|
||||
title: value,
|
||||
path: key,
|
||||
isDir: true,
|
||||
isRoot: false,
|
||||
})),
|
||||
...entriesObj.map((e, index) => {
|
||||
let entryMap = entries[index];
|
||||
entryMap = {
|
||||
...entryMap,
|
||||
data: addFileTemplateFields(entryMap.path, entryMap.data as Record<string, string>),
|
||||
};
|
||||
const title = selectEntryCollectionTitle(collection, entryMap);
|
||||
return {
|
||||
...e,
|
||||
title,
|
||||
isDir: false,
|
||||
isRoot: false,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const parentsToChildren = flatData.reduce((acc, node) => {
|
||||
const parent = node.path === rootFolder ? '' : dirname(node.path);
|
||||
if (acc[parent]) {
|
||||
acc[parent].push(node);
|
||||
} else {
|
||||
acc[parent] = [node];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, BaseTreeNodeData[]>);
|
||||
|
||||
function reducer(acc: TreeNodeData[], value: BaseTreeNodeData) {
|
||||
const node = value;
|
||||
let children: TreeNodeData[] = [];
|
||||
if (parentsToChildren[node.path]) {
|
||||
children = parentsToChildren[node.path].reduce(reducer, []);
|
||||
}
|
||||
|
||||
acc.push({ ...node, children });
|
||||
return acc;
|
||||
}
|
||||
|
||||
const treeData = parentsToChildren[''].reduce(reducer, []);
|
||||
|
||||
return treeData;
|
||||
}
|
||||
|
||||
export function updateNode(
|
||||
treeData: TreeNodeData[],
|
||||
node: TreeNodeData,
|
||||
callback: (node: TreeNodeData) => TreeNodeData,
|
||||
) {
|
||||
let stop = false;
|
||||
|
||||
function updater(nodes: TreeNodeData[]) {
|
||||
if (stop) {
|
||||
return nodes;
|
||||
}
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].path === node.path) {
|
||||
nodes[i] = callback(node);
|
||||
stop = true;
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
nodes.forEach(node => updater(node.children));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return updater([...treeData]);
|
||||
}
|
||||
|
||||
const NestedCollection = ({ collection, entries, filterTerm }: NestedCollectionProps) => {
|
||||
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
|
||||
const [selected, setSelected] = useState<TreeNodeData | null>(null);
|
||||
const [useFilter, setUseFilter] = useState(true);
|
||||
|
||||
const [prevCollection, setPrevCollection] = useState(collection);
|
||||
const [prevEntries, setPrevEntries] = useState(entries);
|
||||
const [prevFilterTerm, setPrevFilterTerm] = useState(filterTerm);
|
||||
|
||||
useEffect(() => {
|
||||
if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) {
|
||||
const expanded: Record<string, boolean> = {};
|
||||
walk(treeData, node => {
|
||||
if (node.expanded) {
|
||||
expanded[node.path] = true;
|
||||
}
|
||||
});
|
||||
const newTreeData = getTreeData(collection, entries);
|
||||
|
||||
const path = `/${filterTerm}`;
|
||||
walk(newTreeData, node => {
|
||||
if (expanded[node.path] || (useFilter && path.startsWith(node.path))) {
|
||||
node.expanded = true;
|
||||
}
|
||||
});
|
||||
|
||||
setTreeData(newTreeData);
|
||||
}
|
||||
|
||||
setPrevCollection(collection);
|
||||
setPrevEntries(entries);
|
||||
setPrevFilterTerm(filterTerm);
|
||||
}, [
|
||||
collection,
|
||||
entries,
|
||||
filterTerm,
|
||||
prevCollection,
|
||||
prevEntries,
|
||||
prevFilterTerm,
|
||||
treeData,
|
||||
useFilter,
|
||||
]);
|
||||
|
||||
const onToggle = useCallback(
|
||||
({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => {
|
||||
if (!selected || selected.path === node.path || expanded) {
|
||||
setTreeData(
|
||||
updateNode(treeData, node, node => ({
|
||||
...node,
|
||||
expanded,
|
||||
})),
|
||||
);
|
||||
setSelected(node);
|
||||
setUseFilter(false);
|
||||
} else {
|
||||
// don't collapse non selected nodes when clicked
|
||||
setSelected(node);
|
||||
setUseFilter(false);
|
||||
}
|
||||
},
|
||||
[selected, treeData],
|
||||
);
|
||||
|
||||
return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />;
|
||||
};
|
||||
|
||||
interface NestedCollectionOwnProps {
|
||||
collection: Collection;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: NestedCollectionOwnProps) {
|
||||
const { collection } = ownProps;
|
||||
const entries = selectEntries(state.entries, collection) ?? [];
|
||||
return { ...ownProps, entries };
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, {});
|
||||
export type NestedCollectionProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(NestedCollection);
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user