Feature/typescript conversion (#44)

This commit is contained in:
Daniel Lautzenheiser 2022-10-20 11:57:30 -04:00 committed by GitHub
parent 7fe23baf0b
commit e60e1fa755
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
517 changed files with 27034 additions and 27191 deletions

View File

@ -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],

View File

@ -84,7 +84,7 @@ function plugins() {
}
if (!isProduction) {
return [...defaultPlugins, 'react-hot-loader/babel'];
return [...defaultPlugins];
}
return defaultPlugins;

View 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

View 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>

View 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

View 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>

View 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

View 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>

View 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

View 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>

View 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

View 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>

View 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

View 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>

View File

@ -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

View File

@ -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
View File

@ -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>;
}

View File

@ -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"

View File

@ -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());
});

View File

@ -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 {

View File

@ -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

View File

@ -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) {

View File

@ -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));
}

View 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());
};
}

View File

@ -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));
}
}
};
}

View File

@ -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));
}
}
};
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View File

@ -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}
/>
);
}
}

View 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;

View File

@ -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');
}
}

View File

@ -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';

View File

@ -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`)),
);
};
}

View File

@ -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}
/>
);
}
}

View 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;

View File

@ -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 });

View File

@ -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';

View File

@ -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}
/>
);
}
}

View 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;

View File

@ -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,

View File

@ -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';

View File

@ -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) {

View File

@ -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));
}

View File

@ -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}
/>
);
}
}

View 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;

View File

@ -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;
}

View File

@ -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: {

View File

@ -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';

View File

@ -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'),

View File

@ -1,5 +0,0 @@
declare module 'semaphore' {
export type Semaphore = { take: (f: Function) => void; leave: () => void };
const semaphore: (count: number) => Semaphore;
export default semaphore;
}

View File

@ -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;
}

View File

@ -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}
/>
);
}
}

View 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;

View File

@ -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));

View File

@ -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';

View File

@ -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';

View File

@ -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>
);
}
}

View 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;

View File

@ -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');
}
}

View File

@ -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';

View File

@ -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>
);
}
}

View 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;

View File

@ -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');
}
}

View File

@ -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';

View File

@ -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;

View File

@ -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
View 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>);

View File

@ -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));

View 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>);

View 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;

View File

@ -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);

View 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<{}>;

View File

@ -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>;

View File

@ -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;

View 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;

View File

@ -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);

View 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);

View File

@ -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);

View 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);

View File

@ -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>
);
}

View File

@ -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);

View 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);

View File

@ -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);

View 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>);

View File

@ -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);

View 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);

View File

@ -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;

View 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);

View File

@ -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>
);
}
}

View 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;

View File

@ -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);

View 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);

View File

@ -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);

View 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);

View File

@ -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);

View 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