diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e4006cc..0dfc0929 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: packages/app/yarn.lock packages/core/yarn.lock packages/docs/yarn.lock - node-version: 16 + node-version: 18 - name: Install run: | @@ -50,7 +50,7 @@ jobs: packages/app/yarn.lock packages/core/yarn.lock packages/docs/yarn.lock - node-version: 16 + node-version: 18 - name: Install run: | @@ -75,7 +75,7 @@ jobs: packages/app/yarn.lock packages/core/yarn.lock packages/docs/yarn.lock - node-version: 16 + node-version: 18 - name: Install run: | @@ -105,7 +105,7 @@ jobs: packages/app/yarn.lock packages/core/yarn.lock packages/docs/yarn.lock - node-version: 16 + node-version: 18 - name: Install run: | diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml new file mode 100644 index 00000000..9c313848 --- /dev/null +++ b/.github/workflows/cypress.yml @@ -0,0 +1,49 @@ +name: Cypress Tests + +on: + workflow_dispatch: + push: + branches: ['main', 'next'] + pull_request: + branches: ['main', 'next'] + +jobs: + cypress-run: + runs-on: ubuntu-latest + + strategy: + fail-fast: false # https://github.com/cypress-io/github-action/issues/48 + matrix: + containers: [1, 2] # Uses 2 parallel instances + + steps: + - uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + cache: yarn + cache-dependency-path: | + yarn.lock + packages/app/yarn.lock + packages/core/yarn.lock + packages/docs/yarn.lock + node-version: 18 + + - name: Install + run: | + yarn install --frozen-lockfile + + - name: Run Cypress Tests + uses: cypress-io/github-action@v6 + with: + start: yarn dev + wait-on: 'http://localhost:8080' + record: true # Records to Cypress Cloud + parallel: true # Runs test in parallel using settings above + env: + # For recording and parallelization to work you must set your CYPRESS_RECORD_KEY + # in GitHub repo → Settings → Secrets → Actions + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + # Creating a token https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token + GITHUB_TOKEN: ${{ secrets.CYPRESS_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7c4e60e7..d9379e20 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ /node_modules *.log .vscode +cypress/screenshots +cypress/downloads +.env +.temp diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..863642f5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist/ +bin/ +public/ +.cache/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..30e01f34 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "arrowParens": "avoid", + "trailingComma": "all", + "singleQuote": true, + "printWidth": 100 +} diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 00000000..e1066657 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "cypress"; +import setupNodeEvents from "./cypress/plugins"; + +export default defineConfig({ + projectId: "wvw3x3", + retries: { + runMode: 2, + openMode: 0, + }, + chromeWebSecurity: false, + e2e: { + video: false, + // We've imported your old cypress plugins here. + // You may want to clean this up later by importing these. + setupNodeEvents, + baseUrl: "http://localhost:8080", + specPattern: "cypress/e2e/*.spec.ts", + }, +}); diff --git a/cypress/README.md b/cypress/README.md new file mode 100644 index 00000000..a1516ffc --- /dev/null +++ b/cypress/README.md @@ -0,0 +1,72 @@ +# Cypress Tests Guide + +## Introduction + +[Cypress](https://www.cypress.io/) is a JavaScript End to End Testing Framework that runs in the browser. + +Cypress tests run with a [local version](../packages/core/dev-test) of the CMS. + +During the setup of a spec file, the relevant `index.html` and `config.yml` are copied from `packages/core/dev-test/backends/` to `dev-test`. + +Tests for the `test` backend use mock data generated in `dev-test/backends/test/index.html`. + +Tests for the other backends use previously [recorded data](fixtures) and stub `fetch` [calls](support/commands.js#L52). See more about recording tests data [here](#recording-tests-data). + +## Run Tests Locally + +```bash +yarn test:e2e # builds the demo site and runs Cypress in headless mode with mock data +``` + +## Debug Tests + +```bash +yarn develop # starts a local dev server with the demo site +yarn test:e2e:exec # runs Cypress in non-headless mode with mock data +``` + +## Recording Tests Data + +When recording tests, access to the relevant backend API is required, thus one must set up a `.env` file in the root project directory in the following format: + +```bash +GITHUB_REPO_OWNER=owner +GITHUB_REPO_NAME=repo +GITHUB_REPO_TOKEN=tokenWithWritePermissions +GITHUB_OPEN_AUTHORING_OWNER=forkOwner +GITHUB_OPEN_AUTHORING_TOKEN=tokenWithWritePermissions + +GITLAB_REPO_OWNER=owner +GITLAB_REPO_NAME=repo +GITLAB_REPO_TOKEN=tokenWithWritePermissions + +BITBUCKET_REPO_OWNER=owner +BITBUCKET_REPO_NAME=repo +BITBUCKET_OUATH_CONSUMER_KEY=ouathConsumerKey +BITBUCKET_OUATH_CONSUMER_SECRET=ouathConsumerSecret + +NETLIFY_API_TOKEN=netlifyApiToken +NETLIFY_INSTALLATION_ID=netlifyGitHubInstallationId +``` + +> The structure of the relevant repo should match the settings in [`config.yml`](../packages/core/dev-test/backends//config.yml#L1) + +To start a recording run the following commands: + +```bash +yarn develop # starts a local dev server with the demo site +yarn mock:server:start # starts the recording proxy +yarn test:e2e:record-fixtures:dev # runs Cypress in non-headless and pass data through the recording proxy +yarn mock:server:stop # stops the recording proxy +``` + +> During the recorded process a clone of the relevant repo will be created under `.temp` and reset between tests. + +Recordings are sanitized from any possible sensitive data and [transformed](plugins/common.js#L34) into an easier to process format. + +To avoid recording all the tests over and over again, a recommended process is to: + +1. Mark the specific test as `only` by changing `it("some test...` to `it.only("some test...` for the relevant test. +2. Run the test in recording mode. +3. Exit Cypress and stop the proxy. +4. Run the test normally (with mock data) to verify the recording works. diff --git a/cypress/cypress.d.ts b/cypress/cypress.d.ts new file mode 100644 index 00000000..4a495f9e --- /dev/null +++ b/cypress/cypress.d.ts @@ -0,0 +1,82 @@ +/// + +import type { + SetupBackendProps, + SetupBackendTestProps, + SeedRepoProps, + TeardownBackendTestProps, + TeardownBackendProps, +} from './interface'; +import type { Config as CMSConfig, DeepPartial } from '@staticcms/core/interface'; + +interface KeyProps { + shift?: boolean; + times?: number; +} + +declare global { + namespace Cypress { + interface Chainable { + task(event: 'setupBackend', props: SetupBackendProps): Chainable; + task(event: 'setupBackendTest', props: SetupBackendTestProps): Chainable>; + task(event: 'seedRepo', props: SeedRepoProps): Chainable>; + task(event: 'teardownBackendTest', props: TeardownBackendTestProps): Chainable>; + task(event: 'teardownBackend', props: TeardownBackendProps): Chainable>; + task(event: 'updateConfig', props: DeepPartial): Chainable>; + + login(): Chainable; + loginAndNewPost(): Chainable; + + dragTo(selector: string, options?: { delay?: number }): Chainable; + + getMarkdownEditor(): Chainable; + confirmMarkdownEditorContent(expected: string): Chainable; + clearMarkdownEditorContent(): Chainable; + confirmRawEditorContent(expected: string): Chainable; + + enter(props?: KeyProps): Chainable; + backspace(props?: KeyProps): Chainable; + selectAll(props?: KeyProps): Chainable; + up(props?: KeyProps): Chainable; + down(props?: KeyProps): Chainable; + left(props?: KeyProps): Chainable; + right(props?: KeyProps): Chainable; + tabkey(props?: KeyProps): Chainable; + + selection( + fn: (this: Cypress.ObjectLike, currentSubject: JQuery) => Chainable, + ): Chainable; + setSelection( + query: + | string + | { + anchorQuery: string; + anchorOffset?: number; + focusQuery: string; + focusOffset?: number; + }, + endQuery: string, + ): Chainable; + + setCursor(query: string, atStart?: boolean): Chainable; + setCursorBefore(query: string): Chainable; + setCursorAfter(query: string): Chainable; + + print(message: string): Chainable; + + insertCodeBlock(): Chainable; + insertEditorComponent(title: string): Chainable; + + clickToolbarButton(title: string, opts: { times: number }): Chainable; + clickHeadingOneButton(opts: { times: number }): Chainable; + clickHeadingTwoButton(opts: { times: number }): Chainable; + clickOrderedListButton(opts: { times: number }): Chainable; + clickUnorderedListButton(opts: { times: number }): Chainable; + clickCodeButton(opts: { times: number }): Chainable; + clickItalicButton(opts: { times: number }): Chainable; + clickQuoteButton(opts: { times: number }): Chainable; + clickLinkButton(opts: { times: number }): Chainable; + clickModeToggle(): Chainable; + } + } +} diff --git a/cypress/e2e/_old/editorial_workflow_spec_bitbucket_backend.js b/cypress/e2e/_old/editorial_workflow_spec_bitbucket_backend.js new file mode 100644 index 00000000..e706c53a --- /dev/null +++ b/cypress/e2e/_old/editorial_workflow_spec_bitbucket_backend.js @@ -0,0 +1,30 @@ +import fixture from '../common/editorial_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'bitbucket'; + +describe('BitBucket Backend Editorial Workflow', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'editorial_workflow' }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/editorial_workflow_spec_git-gateway_github_backend.js b/cypress/e2e/_old/editorial_workflow_spec_git-gateway_github_backend.js new file mode 100644 index 00000000..a4c7d500 --- /dev/null +++ b/cypress/e2e/_old/editorial_workflow_spec_git-gateway_github_backend.js @@ -0,0 +1,31 @@ +import fixture from '../common/editorial_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'git-gateway'; +const provider = 'github'; + +describe('Git Gateway (GitHub) Backend Editorial Workflow', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'editorial_workflow', provider }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/editorial_workflow_spec_git-gateway_gitlab_backend.js b/cypress/e2e/_old/editorial_workflow_spec_git-gateway_gitlab_backend.js new file mode 100644 index 00000000..baa36591 --- /dev/null +++ b/cypress/e2e/_old/editorial_workflow_spec_git-gateway_gitlab_backend.js @@ -0,0 +1,31 @@ +import fixture from '../common/editorial_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'git-gateway'; +const provider = 'gitlab'; + +describe('Git Gateway (GitLab) Backend Editorial Workflow', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'editorial_workflow', provider }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/editorial_workflow_spec_github_backend_graphql.js b/cypress/e2e/_old/editorial_workflow_spec_github_backend_graphql.js new file mode 100644 index 00000000..15745c79 --- /dev/null +++ b/cypress/e2e/_old/editorial_workflow_spec_github_backend_graphql.js @@ -0,0 +1,37 @@ +import fixture from '../common/editorial_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'github'; + +describe('Github Backend Editorial Workflow - GraphQL API', () => { + const taskResult = { data: {} }; + + before(() => { + specUtils.before( + taskResult, + { + backend: { use_graphql: true, open_authoring: false }, + publish_mode: 'editorial_workflow', + }, + backend, + ); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/editorial_workflow_spec_github_backend_graphql_open_authoring.js b/cypress/e2e/_old/editorial_workflow_spec_github_backend_graphql_open_authoring.js new file mode 100644 index 00000000..40b4354c --- /dev/null +++ b/cypress/e2e/_old/editorial_workflow_spec_github_backend_graphql_open_authoring.js @@ -0,0 +1,38 @@ +import fixture from '../common/open_authoring'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'github'; + +describe('Github Backend Editorial Workflow - GraphQL API - Open Authoring', () => { + const taskResult = { data: {} }; + + before(() => { + specUtils.before( + taskResult, + { + backend: { use_graphql: true, open_authoring: true }, + publish_mode: 'editorial_workflow', + }, + backend, + ); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + getForkUser: () => taskResult.data.forkUser, + }); +}); diff --git a/cypress/e2e/_old/editorial_workflow_spec_github_backend_rest.js b/cypress/e2e/_old/editorial_workflow_spec_github_backend_rest.js new file mode 100644 index 00000000..46d9bfe0 --- /dev/null +++ b/cypress/e2e/_old/editorial_workflow_spec_github_backend_rest.js @@ -0,0 +1,37 @@ +import fixture from '../common/editorial_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'github'; + +describe('Github Backend Editorial Workflow - REST API', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before( + taskResult, + { + backend: { use_graphql: false, open_authoring: false }, + publish_mode: 'editorial_workflow', + }, + backend, + ); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/editorial_workflow_spec_github_backend_rest_open_authoring.js b/cypress/e2e/_old/editorial_workflow_spec_github_backend_rest_open_authoring.js new file mode 100644 index 00000000..3feeb5bf --- /dev/null +++ b/cypress/e2e/_old/editorial_workflow_spec_github_backend_rest_open_authoring.js @@ -0,0 +1,38 @@ +import fixture from '../common/open_authoring'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'github'; + +describe('Github Backend Editorial Workflow - REST API - Open Authoring', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before( + taskResult, + { + backend: { use_graphql: false, open_authoring: true }, + publish_mode: 'editorial_workflow', + }, + backend, + ); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + getForkUser: () => taskResult.data.forkUser, + }); +}); diff --git a/cypress/e2e/_old/editorial_workflow_spec_gitlab_backend.js b/cypress/e2e/_old/editorial_workflow_spec_gitlab_backend.js new file mode 100644 index 00000000..836064a6 --- /dev/null +++ b/cypress/e2e/_old/editorial_workflow_spec_gitlab_backend.js @@ -0,0 +1,36 @@ +import fixture from '../common/editorial_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'gitlab'; + +describe('GitLab Backend Editorial Workflow', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'editorial_workflow' }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + if ( + Cypress.mocha.getRunner().suite.ctx.currentTest.title === + 'can change status on and publish multiple entries' + ) { + Cypress.mocha.getRunner().suite.ctx.currentTest.skip(); + } + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/editorial_workflow_spec_proxy_git_backend.js b/cypress/e2e/_old/editorial_workflow_spec_proxy_git_backend.js new file mode 100644 index 00000000..64796b1a --- /dev/null +++ b/cypress/e2e/_old/editorial_workflow_spec_proxy_git_backend.js @@ -0,0 +1,32 @@ +import fixture from '../common/editorial_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'proxy'; +const mode = 'git'; + +describe.skip(`Proxy Backend Editorial Workflow - '${mode}' mode`, () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'editorial_workflow', mode }, backend); + Cypress.config('defaultCommandTimeout', 5 * 1000); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/field_validations_spec.js b/cypress/e2e/_old/field_validations_spec.js new file mode 100644 index 00000000..98201220 --- /dev/null +++ b/cypress/e2e/_old/field_validations_spec.js @@ -0,0 +1,54 @@ +import { + login, + validateObjectFieldsAndExit, + validateNestedObjectFieldsAndExit, + validateListFieldsAndExit, + validateNestedListFieldsAndExit, +} from '../../utils/steps'; +import { setting1, setting2 } from '../../utils/constants'; + +describe('Test Backend Editorial Workflow', () => { + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + }); + + beforeEach(() => { + cy.task('setupBackend', { backend: 'test' }); + }); + + it('can validate object fields', () => { + login({ editorialWorkflow: true }); + + cy.contains('a', 'Posts').click(); + + validateObjectFieldsAndExit(setting1); + }); + + it('can validate fields nested in an object field', () => { + login({ editorialWorkflow: true }); + + cy.contains('a', 'Posts').click(); + + validateNestedObjectFieldsAndExit(setting1); + }); + + it('can validate list fields', () => { + login({ editorialWorkflow: true }); + + cy.contains('a', 'Posts').click(); + + validateListFieldsAndExit(setting2); + }); + + it('can validate deeply nested list fields', () => { + login({ editorialWorkflow: true }); + + cy.contains('a', 'Posts').click(); + + validateNestedListFieldsAndExit(setting2); + }); +}); diff --git a/cypress/e2e/_old/i18n_editorial_workflow_spec_test_backend.js b/cypress/e2e/_old/i18n_editorial_workflow_spec_test_backend.js new file mode 100644 index 00000000..9e138cf7 --- /dev/null +++ b/cypress/e2e/_old/i18n_editorial_workflow_spec_test_backend.js @@ -0,0 +1,38 @@ +import fixture from '../common/i18n_editorial_workflow_spec'; + +const backend = 'test'; + +describe(`I18N Test Backend Editorial Workflow`, () => { + const taskResult = { data: {} }; + + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { + backend, + options: { + publish_mode: 'editorial_workflow', + i18n: { + locales: ['en', 'de', 'fr'], + }, + collections: [ + { + folder: 'content/i18n', + i18n: true, + fields: [{ i18n: true }, {}, { i18n: 'duplicate' }], + }, + ], + }, + }); + }); + + after(() => { + cy.task('teardownBackend', { backend }); + }); + + const entry = { + Title: 'first title', + Body: 'first body', + }; + + fixture({ entry, getUser: () => taskResult.data.user }); +}); diff --git a/cypress/e2e/_old/i18n_simple_workflow_spec_proxy_fs_backend.js b/cypress/e2e/_old/i18n_simple_workflow_spec_proxy_fs_backend.js new file mode 100644 index 00000000..da717b8f --- /dev/null +++ b/cypress/e2e/_old/i18n_simple_workflow_spec_proxy_fs_backend.js @@ -0,0 +1,148 @@ +import * as specUtils from '../common/spec_utils'; +import { login } from '../../utils/steps'; +import { createEntryTranslateAndPublish } from '../common/i18n'; + +const backend = 'proxy'; +const mode = 'fs'; + +const expectedEnContent = `--- +template: post +title: first title +date: 1970-01-01T00:00:00.000Z +description: first description +category: first category +tags: + - tag1 +--- +`; + +const expectedDeContent = `--- +title: de +date: 1970-01-01T00:00:00.000Z +--- +`; + +const expectedFrContent = `--- +title: fr +date: 1970-01-01T00:00:00.000Z +--- +`; + +const contentSingleFile = `--- +en: + template: post + date: 1970-01-01T00:00:00.000Z + title: first title + body: first body + description: first description + category: first category + tags: + - tag1 +de: + date: 1970-01-01T00:00:00.000Z + title: de +fr: + date: 1970-01-01T00:00:00.000Z + title: fr +--- +`; + +describe(`I18N Proxy Backend Simple Workflow - '${mode}' mode`, () => { + const taskResult = { data: {} }; + + const entry = { + Title: 'first title', + Body: 'first body', + Description: 'first description', + Category: 'first category', + Tags: 'tag1', + }; + + before(() => { + specUtils.before( + taskResult, + { + mode, + publish_mode: 'simple', + i18n: { + locales: ['en', 'de', 'fr'], + }, + collections: [{ i18n: true, fields: [{}, { i18n: true }, {}, { i18n: 'duplicate' }] }], + }, + backend, + ); + Cypress.config('taskTimeout', 15 * 1000); + Cypress.config('defaultCommandTimeout', 5 * 1000); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + it('can create entry with translation in locale_folders mode', () => { + cy.task('updateConfig', { i18n: { structure: 'multiple_folders' } }); + + login({ user: taskResult.data.user }); + + createEntryTranslateAndPublish(entry); + + cy.readFile(`${taskResult.data.tempDir}/content/posts/en/1970-01-01-first-title.md`).should( + 'contain', + expectedEnContent, + ); + + cy.readFile(`${taskResult.data.tempDir}/content/posts/de/1970-01-01-first-title.md`).should( + 'eq', + expectedDeContent, + ); + + cy.readFile(`${taskResult.data.tempDir}/content/posts/fr/1970-01-01-first-title.md`).should( + 'eq', + expectedFrContent, + ); + }); + + it('can create entry with translation in single_file mode', () => { + cy.task('updateConfig', { i18n: { structure: 'multiple_files' } }); + + login({ user: taskResult.data.user }); + + createEntryTranslateAndPublish(entry); + + cy.readFile(`${taskResult.data.tempDir}/content/posts/1970-01-01-first-title.en.md`).should( + 'contain', + expectedEnContent, + ); + + cy.readFile(`${taskResult.data.tempDir}/content/posts/1970-01-01-first-title.de.md`).should( + 'eq', + expectedDeContent, + ); + + cy.readFile(`${taskResult.data.tempDir}/content/posts/1970-01-01-first-title.fr.md`).should( + 'eq', + expectedFrContent, + ); + }); + + it('can create entry with translation in locale_file_extensions mode', () => { + cy.task('updateConfig', { i18n: { structure: 'single_file' } }); + + login({ user: taskResult.data.user }); + + createEntryTranslateAndPublish(entry); + + cy.readFile(`${taskResult.data.tempDir}/content/posts/1970-01-01-first-title.md`).should( + 'eq', + contentSingleFile, + ); + }); +}); diff --git a/cypress/e2e/_old/markdown_widget_backspace_spec.js b/cypress/e2e/_old/markdown_widget_backspace_spec.js new file mode 100644 index 00000000..b15a04bf --- /dev/null +++ b/cypress/e2e/_old/markdown_widget_backspace_spec.js @@ -0,0 +1,76 @@ +describe('Markdown widget', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + // describe('pressing backspace', () => { + it('sets non-default block to default when empty', () => { + cy.focused().clickHeadingOneButton().backspace().confirmMarkdownEditorContent(` +

+ `); + }); + it('moves to previous block when no character left to delete', () => { + cy.focused().type('foo').enter().clickHeadingOneButton().type('a').backspace({ times: 2 }) + .confirmMarkdownEditorContent(` +

foo

+ `); + }); + it('does nothing at start of first block in document when non-empty and non-default', () => { + cy.focused().clickHeadingOneButton().type('foo').setCursorBefore('foo').backspace({ times: 4 }) + .confirmMarkdownEditorContent(` +

foo

+ `); + }); + it('deletes individual characters in middle of non-empty non-default block in document', () => { + cy.focused().clickHeadingOneButton().type('foo').setCursorAfter('fo').backspace({ times: 3 }) + .confirmMarkdownEditorContent(` +

o

+ `); + }); + it('at beginning of non-first block, moves default block content to previous block', () => { + cy + .focused() + .clickHeadingOneButton() + .type('foo') + .enter() + .type('bar') + .setCursorBefore('bar') + .backspace().confirmMarkdownEditorContent(` +

foobar

+ `); + }); + it('at beginning of non-first block, moves non-default block content to previous block', () => { + cy + .focused() + .type('foo') + .enter() + .clickHeadingOneButton() + .type('bar') + .enter() + .clickHeadingTwoButton() + .type('baz') + .setCursorBefore('baz') + .backspace() + .confirmMarkdownEditorContent( + ` +

foo

+

barbaz

+ `, + ) + .setCursorBefore('bar') + .backspace().confirmMarkdownEditorContent(` +

foobarbaz

+ `); + // }); + }); +}); diff --git a/cypress/e2e/_old/markdown_widget_code_block_spec.js b/cypress/e2e/_old/markdown_widget_code_block_spec.js new file mode 100644 index 00000000..5fee16e7 --- /dev/null +++ b/cypress/e2e/_old/markdown_widget_code_block_spec.js @@ -0,0 +1,134 @@ +import { oneLineTrim, stripIndent } from 'common-tags'; + +describe('Markdown widget code block', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + describe('code block', () => { + it('outputs code', () => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy + .insertCodeBlock() + .type('foo') + .enter() + .type('bar') + .confirmMarkdownEditorContent( + ` + ${codeBlock(` + foo + bar + `)} + `, + ) + .wait(500) + .clickModeToggle().confirmMarkdownEditorContent(` + ${codeBlockRaw(` + foo + bar + `)} + `); + }); + }); +}); + +function codeBlockRaw(content) { + return ['```', ...stripIndent(content).split('\n'), '```'] + .map( + line => oneLineTrim` +
+ + + ${line} + + +
+ `, + ) + .join(''); +} + +function codeBlock(content) { + const lines = stripIndent(content) + .split('\n') + .map( + (line, idx) => ` +
+
+
${idx + 1}
+
+
${line}
+
+ `, + ) + .join(''); + + return oneLineTrim` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
xxxxxxxxxx
+
+
+
+
+
 
+
+
+ ${lines} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +  + + +
+
+
+
+ `; +} diff --git a/cypress/e2e/_old/markdown_widget_enter_spec.js b/cypress/e2e/_old/markdown_widget_enter_spec.js new file mode 100644 index 00000000..4ca26ce7 --- /dev/null +++ b/cypress/e2e/_old/markdown_widget_enter_spec.js @@ -0,0 +1,80 @@ +describe('Markdown widget breaks', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + describe('pressing enter', () => { + it('creates new default block from empty block', () => { + cy.focused().enter().confirmMarkdownEditorContent(` +

+

+ `); + }); + it('creates new default block when selection collapsed at end of block', () => { + cy.focused().type('foo').enter().confirmMarkdownEditorContent(` +

foo

+

+ `); + }); + it('creates new default block when selection collapsed at end of non-default block', () => { + cy.clickHeadingOneButton().type('foo').enter().confirmMarkdownEditorContent(` +

foo

+

+ `); + }); + it('creates new default block when selection collapsed in empty non-default block', () => { + cy.clickHeadingOneButton().enter().confirmMarkdownEditorContent(` +

+

+ `); + }); + it('splits block into two same-type blocks when collapsed selection at block start', () => { + cy.clickHeadingOneButton().type('foo').setCursorBefore('foo').enter() + .confirmMarkdownEditorContent(` +

+

foo

+ `); + }); + it('splits block into two same-type blocks when collapsed in middle of selection at block start', () => { + cy.clickHeadingOneButton().type('foo').setCursorBefore('oo').enter() + .confirmMarkdownEditorContent(` +

f

+

oo

+ `); + }); + it('deletes selected content and splits to same-type block when selection is expanded', () => { + cy.clickHeadingOneButton().type('foo bar').setSelection('o b').enter() + .confirmMarkdownEditorContent(` +

fo

+

ar

+ `); + }); + }); + + describe('pressing shift+enter', () => { + it('creates line break', () => { + cy.focused().enter({ shift: true }).confirmMarkdownEditorContent(` +

+ +

+ `); + }); + it('creates consecutive line break', () => { + cy.focused().enter({ shift: true, times: 4 }).confirmMarkdownEditorContent(` +

+ +

+ `); + }); + }); +}); diff --git a/cypress/e2e/_old/markdown_widget_hotkeys_spec.js b/cypress/e2e/_old/markdown_widget_hotkeys_spec.js new file mode 100644 index 00000000..35288775 --- /dev/null +++ b/cypress/e2e/_old/markdown_widget_hotkeys_spec.js @@ -0,0 +1,109 @@ +import { HOT_KEY_MAP } from '../../utils/constants'; +const headingNumberToWord = ['', 'one', 'two', 'three', 'four', 'five', 'six']; +const isMac = Cypress.platform === 'darwin'; +const modifierKey = isMac ? '{meta}' : '{ctrl}'; +// eslint-disable-next-line func-style +const replaceMod = str => str.replace(/mod\+/g, modifierKey).replace(/shift\+/g, '{shift}'); + +describe('Markdown widget hotkeys', () => { + describe('hot keys', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + cy.focused().type('foo').setSelection('foo').as('selection'); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + describe('bold', () => { + it('pressing mod+b bolds the text', () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP['bold'])) + .confirmMarkdownEditorContent( + ` +

+ foo +

+ `, + ) + .type(replaceMod(HOT_KEY_MAP['bold'])); + }); + }); + + describe('italic', () => { + it('pressing mod+i italicizes the text', () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP['italic'])) + .confirmMarkdownEditorContent( + ` +

+ foo +

+ `, + ) + .type(replaceMod(HOT_KEY_MAP['italic'])); + }); + }); + + describe('strikethrough', () => { + it('pressing mod+shift+s displays a strike through the text', () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP['strikethrough'])) + .confirmMarkdownEditorContent( + ` +

+ foo +

+ `, + ) + .type(replaceMod(HOT_KEY_MAP['strikethrough'])); + }); + }); + + describe('code', () => { + it('pressing mod+shift+c displays a code block around the text', () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP['code'])) + .confirmMarkdownEditorContent( + ` +

+ foo +

+ `, + ) + .type(replaceMod(HOT_KEY_MAP['code'])); + }); + }); + + describe('link', () => { + before(() => {}); + it('pressing mod+k transforms the text to a link', () => { + cy.window().then(win => { + cy.get('@selection').type(replaceMod(HOT_KEY_MAP['link'])); + cy.stub(win, 'prompt').returns('https://google.com'); + cy.confirmMarkdownEditorContent('

foo

').type( + replaceMod(HOT_KEY_MAP['link']), + ); + }); + }); + }); + + describe('headings', () => { + for (let i = 1; i <= 6; i++) { + it(`pressing mod+${i} transforms the text to a heading`, () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP[`heading-${headingNumberToWord[i]}`])) + .confirmMarkdownEditorContent(`foo`) + .type(replaceMod(HOT_KEY_MAP[`heading-${headingNumberToWord[i]}`])); + }); + } + }); + }); +}); diff --git a/cypress/e2e/_old/markdown_widget_link_spec.js b/cypress/e2e/_old/markdown_widget_link_spec.js new file mode 100644 index 00000000..79ee57f3 --- /dev/null +++ b/cypress/e2e/_old/markdown_widget_link_spec.js @@ -0,0 +1,64 @@ +describe('Markdown widget link', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + describe('link', () => { + it('can add a new valid link', () => { + const link = 'https://www.staticcms.org/'; + cy.window().then(win => { + cy.stub(win, 'prompt').returns(link); + }); + cy.focused().clickLinkButton(); + + cy.confirmMarkdownEditorContent(`

${link}

`); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.clickModeToggle(); + + cy.confirmRawEditorContent(`<${link}>`); + }); + + it('can add a new invalid link', () => { + const link = 'www.staticcms.org'; + cy.window().then(win => { + cy.stub(win, 'prompt').returns(link); + }); + cy.focused().clickLinkButton(); + + cy.confirmMarkdownEditorContent(`

${link}

`); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.clickModeToggle(); + + cy.confirmRawEditorContent(`[${link}](${link})`); + }); + + it('can select existing text as link', () => { + const link = 'https://www.staticcms.org'; + cy.window().then(win => { + cy.stub(win, 'prompt').returns(link); + }); + + const text = 'Static CMS'; + cy.focused().getMarkdownEditor().type(text).setSelection(text).clickLinkButton(); + + cy.confirmMarkdownEditorContent(`

${text}

`); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.clickModeToggle(); + + cy.confirmRawEditorContent(`[${text}](${link})`); + }); + }); +}); diff --git a/cypress/e2e/_old/markdown_widget_list_spec.js b/cypress/e2e/_old/markdown_widget_list_spec.js new file mode 100644 index 00000000..f54e352c --- /dev/null +++ b/cypress/e2e/_old/markdown_widget_list_spec.js @@ -0,0 +1,734 @@ +describe('Markdown widget', () => { + describe('list', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + // describe('toolbar buttons', () => { + it('creates and focuses empty list', () => { + cy.clickUnorderedListButton().confirmMarkdownEditorContent(` +
    +
  • +

    +
  • +
+ `); + }); + + it('removes list', () => { + cy.clickUnorderedListButton().clickUnorderedListButton().confirmMarkdownEditorContent(` +

+ `); + }); + + it('converts a list item to a paragraph block which is a sibling of the parent list', () => { + cy.clickUnorderedListButton().type('foo').enter().clickUnorderedListButton() + .confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
  • +
+

+ `); + }); + + it('converts empty nested list item to empty paragraph block in parent list item', () => { + cy + .clickUnorderedListButton() + .type('foo') + .enter() + .tabkey() + .type('bar') + .enter() + .tabkey() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
      +
    • +

      bar

      +
        +
      • +

        +
      • +
      +
    • +
    +
  • +
+ `, + ) + .clickUnorderedListButton() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
      +
    • +

      bar

      +

      +
    • +
    +
  • +
+ `, + ) + .backspace({ times: 4 }) + .clickUnorderedListButton().confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +

    +
  • +
+ `); + }); + + it('moves nested list item content to parent list item when in first block', () => { + cy + .clickUnorderedListButton() + .type('foo') + .enter() + .tabkey() + .type('bar') + .enter() + .tabkey() + .type('baz') + .clickUnorderedListButton() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
      +
    • +

      bar

      +

      baz

      +
    • +
    +
  • +
+ `, + ) + .up() + .clickUnorderedListButton() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +

    bar

    +

    baz

    +
  • +
+ `, + ) + .up() + .clickUnorderedListButton().confirmMarkdownEditorContent(` +

foo

+

bar

+

baz

+ `); + }); + + it('affects only the current block with collapsed selection', () => { + cy + .focused() + .type('foo') + .enter() + .type('bar') + .enter() + .type('baz') + .up() + .clickUnorderedListButton().confirmMarkdownEditorContent(` +

foo

+
    +
  • +

    bar

    +
  • +
+

baz

+ `); + }); + + it('wrap each bottom-most block in a selection with a list item block', () => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy + .focused() + .type('foo') + .enter() + .type('bar') + .enter() + .type('baz') + .setSelection('foo', 'baz') + .wait(500) + .clickUnorderedListButton().confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
  • +
  • +

    bar

    +
  • +
  • +

    baz

    +
  • +
+ `); + }); + + it('unwraps list item block from each selected list item and unwraps all of them from the outer list block', () => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy + .clickUnorderedListButton() + .type('foo') + .enter() + .type('bar') + .enter() + .type('baz') + .setSelection('foo', 'baz') + .wait(500) + .clickUnorderedListButton().confirmMarkdownEditorContent(` +

foo

+

bar

+

baz

+ `); + }); + + it('combines adjacent same-typed lists, not differently typed lists', () => { + cy.focused() + .type('foo') + .enter() + .type('bar') + .enter() + .type('baz') + .up() + .clickUnorderedListButton() + .up() + .clickUnorderedListButton() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
  • +
  • +

    bar

    +
  • +
+

baz

+ `, + ) + .down({ times: 2 }) + .focused() + .clickUnorderedListButton() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
  • +
  • +

    bar

    +
  • +
  • +

    baz

    +
  • +
+ `, + ) + .up() + .enter() + .type('qux') + .tabkey() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
  • +
  • +

    bar

    +
      +
    • +

      qux

      +
    • +
    +
  • +
  • +

    baz

    +
  • +
+ `, + ) + .up() + .enter() + .type('quux') + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
  • +
  • +

    bar

    +
      +
    • +

      quux

      +
    • +
    • +

      qux

      +
    • +
    +
  • +
  • +

    baz

    +
  • +
+ `, + ) + .clickOrderedListButton() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
  • +
  • +

    bar

    +
      +
    1. +

      quux

      +
    2. +
    +
      +
    • +

      qux

      +
    • +
    +
  • +
  • +

    baz

    +
  • +
+ `, + ) + .setSelection({ + anchorQuery: 'ul > li > ol p', + anchorOffset: 1, + focusQuery: 'ul > li > ul:last-child p', + focusOffset: 2, + }); + }); + + // while this works on dev environemnt, it will always fail in cypress - has something to do with text selection + // it('affects only selected list items', () => { + // cy + // .clickUnorderedListButton() + // .type('foo') + // .enter() + // .type('bar') + // .enter() + // .type('baz') + // .setSelection('bar') + // .clickUnorderedListButton() + // .confirmMarkdownEditorContent( + // ` + //
    + //
  • + //

    foo

    + //
  • + //
+ //

bar

+ //
    + //
  • + //

    baz

    + //
  • + //
+ // `, + // ) + // .clickUnorderedListButton() + // .setSelection('bar', 'baz') + // .clickUnorderedListButton() + // .confirmMarkdownEditorContent( + // ` + //
    + //
  • + //

    foo

    + //
  • + //
+ //

bar

+ //

baz

+ // `, + // ) + // .clickUnorderedListButton() + // .confirmMarkdownEditorContent( + // ` + //
    + //
  • + //

    foo

    + //
  • + //
  • + //

    bar

    + //
  • + //
  • + //

    baz

    + //
  • + //
+ // `, + // ) + // .setSelection('baz') + // .clickUnorderedListButton() + // .confirmMarkdownEditorContent( + // ` + //
    + //
  • + //

    foo

    + //
  • + //
  • + //

    bar

    + //
  • + //
+ //

baz

+ // `, + // ) + // .clickUnorderedListButton() + // .tabkey() + // .setCursorAfter('baz') + // .enter() + // .tabkey() + // .type('qux') + // .confirmMarkdownEditorContent( + // ` + //
    + //
  • + //

    foo

    + //
  • + //
  • + //

    bar

    + //
      + //
    • + //

      baz

      + //
        + //
      • + //

        qux

        + //
      • + //
      + //
    • + //
    + //
  • + //
+ // `, + // ) + // .setSelection('baz') + // .clickOrderedListButton() + // .confirmMarkdownEditorContent( + // ` + //
    + //
  • + //

    foo

    + //
  • + //
  • + //

    bar

    + //
      + //
    1. + //

      baz

      + //
        + //
      • + //

        qux

        + //
      • + //
      + //
    2. + //
    + //
  • + //
+ // `, + // ) + // .setCursorAfter('qux') + // .enter({ times: 2 }) + // .clickUnorderedListButton() + // .confirmMarkdownEditorContent(` + //
    + //
  • + //

    foo

    + //
  • + //
  • + //

    bar

    + //
      + //
    1. + //

      baz

      + //
        + //
      • + //

        qux

        + //
      • + //
      + //
    2. + //
    + //
      + //
    • + //

      + //
    • + //
    + //
  • + //
+ // `); + // }); + // }); + // }); + + // describe('on Enter', () => { + it('removes the list item and list if empty', () => { + cy.clickUnorderedListButton().enter().confirmMarkdownEditorContent(` +

+ `); + }); + + it('creates a new list item in a non-empty list', () => { + cy + .clickUnorderedListButton() + .type('foo') + .enter() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
  • +
  • +

    +
  • +
+ `, + ) + .type('bar') + .enter().confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
  • +
  • +

    bar

    +
  • +
  • +

    +
  • +
+ `); + }); + + it('creates a new default block below a list when hitting Enter twice on an empty list item of the list', () => { + cy.clickUnorderedListButton().type('foo').enter({ times: 2 }).confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
  • +
+

+ `); + }); + // }); + + // describe('on Backspace', () => { + it('removes the list item and list if empty', () => { + cy.clickUnorderedListButton().backspace().confirmMarkdownEditorContent(` +

+ `); + }); + + it('removes the list item if list not empty', () => { + cy.clickUnorderedListButton().type('foo').enter().backspace().confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +

    +
  • +
+ `); + }); + + it('does not remove list item if empty with non-default block', () => { + cy.clickUnorderedListButton().clickHeadingOneButton().backspace() + .confirmMarkdownEditorContent(` +
    +
  • +

    +
  • +
+ `); + }); + // }); + + // describe('on Tab', () => { + it('does nothing in top level list', () => { + cy + .clickUnorderedListButton() + .tabkey() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    +
  • +
+ `, + ) + .type('foo') + .tabkey().confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
  • +
+ `); + }); + + it('indents nested list items', () => { + cy + .clickUnorderedListButton() + .type('foo') + .enter() + .type('bar') + .tabkey() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
      +
    • +

      bar

      +
    • +
    +
  • +
+ `, + ) + .enter() + .tabkey().confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
      +
    • +

      bar

      +
        +
      • +

        +
      • +
      +
    • +
    +
  • +
+ `); + }); + + it('only nests up to one level down from the parent list', () => { + cy.clickUnorderedListButton().type('foo').enter().tabkey().confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
      +
    • +

      +
    • +
    +
  • +
+ `); + }); + + it('unindents nested list items with shift', () => { + cy.clickUnorderedListButton().type('foo').enter().tabkey().tabkey({ shift: true }) + .confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
  • +
  • +

    +
  • +
+ `); + }); + + it('indents and unindents from one level below parent back to document root', () => { + cy + .clickUnorderedListButton() + .type('foo') + .enter() + .tabkey() + .type('bar') + .enter() + .tabkey() + .type('baz') + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
      +
    • +

      bar

      +
        +
      • +

        baz

        +
      • +
      +
    • +
    +
  • +
+ `, + ) + .tabkey({ shift: true }) + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
      +
    • +

      bar

      +
    • +
    • +

      baz

      +
    • +
    +
  • +
+ `, + ) + .tabkey({ shift: true }).confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
      +
    • +

      bar

      +
    • +
    +
  • +
  • +

    baz

    +
  • +
+ `); + }); + // }); + }); +}); diff --git a/cypress/e2e/_old/markdown_widget_marks_spec.js b/cypress/e2e/_old/markdown_widget_marks_spec.js new file mode 100644 index 00000000..b6ad15ab --- /dev/null +++ b/cypress/e2e/_old/markdown_widget_marks_spec.js @@ -0,0 +1,31 @@ +describe('Markdown widget', () => { + describe('code mark', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + describe('toolbar button', () => { + it('can combine code mark with other marks', () => { + cy.clickItalicButton().type('foo').setSelection('oo').clickCodeButton() + .confirmMarkdownEditorContent(` +

+ f + + oo + +

+ `); + }); + }); + }); +}); diff --git a/cypress/e2e/_old/markdown_widget_quote_spec.js b/cypress/e2e/_old/markdown_widget_quote_spec.js new file mode 100644 index 00000000..1b138e44 --- /dev/null +++ b/cypress/e2e/_old/markdown_widget_quote_spec.js @@ -0,0 +1,370 @@ +describe('Markdown widget', () => { + describe('quote block', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + // describe('toggle quote', () => { + it('toggles empty quote block on and off in empty editor', () => { + cy + .clickQuoteButton() + .confirmMarkdownEditorContent( + ` +
+

+
+ `, + ) + .clickQuoteButton().confirmMarkdownEditorContent(` +

+ `); + }); + it('toggles empty quote block on and off for current block', () => { + cy + .focused() + .type('foo') + .clickQuoteButton() + .confirmMarkdownEditorContent( + ` +
+

foo

+
+ `, + ) + .clickQuoteButton().confirmMarkdownEditorContent(` +

foo

+ `); + }); + it('toggles entire quote block without expanded selection', () => { + cy.clickQuoteButton().type('foo').enter().type('bar').clickQuoteButton() + .confirmMarkdownEditorContent(` +

foo

+

bar

+ `); + }); + it('toggles entire quote block with complex content', () => { + cy + .clickQuoteButton() + .clickUnorderedListButton() + .clickHeadingOneButton() + .type('foo') + .enter({ times: 2 }) // First Enter creates new list item. Second Enter turns that list item into a default block. + .clickQuoteButton() // Unwrap the quote block. + .confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
  • +
+

+ `); + }); + it('toggles empty quote block on and off for selected blocks', () => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy + .focused() + .type('foo') + .enter() + .type('bar') + .setSelection('foo', 'bar') + .wait(500) + .clickQuoteButton() + .confirmMarkdownEditorContent( + ` +
+

foo

+

bar

+
+ `, + ) + .clickQuoteButton() + .confirmMarkdownEditorContent( + ` +

foo

+

bar

+ `, + ) + .clickQuoteButton().confirmMarkdownEditorContent(` +
+

foo

+

bar

+
+ `); + }); + it('toggles empty quote block on and off for partially selected blocks', () => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy + .focused() + .type('foo') + .enter() + .type('bar') + .setSelection('oo', 'ba') + .wait(500) + .clickQuoteButton() + .confirmMarkdownEditorContent( + ` +
+

foo

+

bar

+
+ `, + ) + .clickQuoteButton() + .confirmMarkdownEditorContent( + ` +

foo

+

bar

+ `, + ) + .clickQuoteButton().confirmMarkdownEditorContent(` +
+

foo

+

bar

+
+ `); + }); + it('toggles quote block on and off for multiple selected list items', () => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy + .focused() + .clickUnorderedListButton() + .type('foo') + .enter() + .type('bar') + .setSelection('foo', 'bar') + .wait(500) + .clickQuoteButton() + .confirmMarkdownEditorContent( + ` +
+
    +
  • +

    foo

    +
  • +
  • +

    bar

    +
  • +
+
+ `, + ) + .clickQuoteButton() + .confirmMarkdownEditorContent( + ` +
    +
  • +

    foo

    +
  • +
  • +

    bar

    +
  • +
+ `, + ) + .setCursorAfter('bar') + .wait(500) + .enter() + .type('baz') + .setSelection('bar', 'baz') + .wait(500) + .clickQuoteButton().confirmMarkdownEditorContent(` +
    +
  • +

    foo

    +
  • +
+
+
    +
  • +

    bar

    +
  • +
  • +

    baz

    +
  • +
+
+ `); + }); + it('creates new quote block if parent is not a quote, can deeply nest', () => { + cy.clickQuoteButton() + .clickUnorderedListButton() + .clickQuoteButton() + .clickUnorderedListButton() + .clickQuoteButton() + .clickUnorderedListButton() + .clickQuoteButton() + .type('foo') + // Content should contains 4
tags and 3
    tags + .confirmMarkdownEditorContent( + ` +
    +
      +
    • +
      +
        +
      • +
        +
          +
        • +
          +

          foo

          +
          +
        • +
        +
        +
      • +
      +
      +
    • +
    +
    + `, + ) + /* + * First Enter creates new paragraph within the innermost block quote. + * Second Enter moves that paragraph one level up to become sibling of the previous quote block and direct child of a list item. + * Third Enter to turn that paragraph into a list item and move it one level up. + * Repeat the circle for three more times to reach the second list item of the outermost list block. + * Then Enter again to turn that list item into a paragraph and move it one level up to become sibling of the outermost list and + * direct child of the outermost block quote. + */ + .enter({ times: 10 }) + .type('bar') + .confirmMarkdownEditorContent( + ` +
    +
      +
    • +
      +
        +
      • +
        +
          +
        • +
          +

          foo

          +
          +
        • +
        +
        +
      • +
      +
      +
    • +
    +

    bar

    +
    + `, + ) + /* The GOAL is to delete all the text content inside this deeply nested block quote and turn it into a default paragraph block on top level. + * We need: + * 3 Backspace to delete the word “bar”. + * 1 Backspace to remove the paragraph that contains bar and bring cursor to the end of the unordered list which is direct child of the outermost block quote. + * 3 Backspace to remove the word “foo”. + * 1 Backspace to remove the current block quote that the cursor is on, 1 Backspace to remove the list that wraps the block quote. Repeat this step for three times for a total of 6 Backspace until the cursor is on the outermost block quote. + * 1 Backspace to remove to toggle off the outermost block quote and turn it into a default paragraph. + * Total Backspaces required: 3 + 1 + 3 + ((1 + 1) * 3) + 1 = 14 + */ + .backspace({ times: 14 }); + }); + // }); + + // describe('backspace inside quote', () => { + it('joins two paragraphs', () => { + cy.clickQuoteButton().type('foo').enter().type('bar').setCursorBefore('bar').backspace() + .confirmMarkdownEditorContent(` +
    +

    foobar

    +
    + `); + }); + it('joins quote with previous quote', () => { + cy + .clickQuoteButton() + .type('foo') + .enter({ times: 2 }) + .clickQuoteButton() + .type('bar') + .confirmMarkdownEditorContent( + ` +
    +

    foo

    +
    +
    +

    bar

    +
    + `, + ) + .setCursorBefore('bar') + .backspace().confirmMarkdownEditorContent(` +
    +

    foo

    +

    bar

    +
    + `); + }); + it('removes first block from quote when focused at first block at start', () => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy + .clickQuoteButton() + .type('foo') + .enter() + .type('bar') + .setCursorBefore('foo') + .wait(500) + .backspace().confirmMarkdownEditorContent(` +

    foo

    +
    +

    bar

    +
    + `); + }); + // }); + + // describe('enter inside quote', () => { + it('creates new block inside quote', () => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy + .clickQuoteButton() + .type('foo') + .enter() + .confirmMarkdownEditorContent( + ` +
    +

    foo

    +

    +
    + `, + ) + .type('bar') + .setCursorAfter('ba') + .wait(500) + .enter().confirmMarkdownEditorContent(` +
    +

    foo

    +

    ba

    +

    r

    +
    + `); + }); + it('creates new block after quote from empty last block', () => { + cy.clickQuoteButton().type('foo').enter().enter().confirmMarkdownEditorContent(` +
    +

    foo

    +
    +

    + `); + }); + // }); + }); +}); diff --git a/cypress/e2e/_old/media_library_spec_bitbucket_backend.js b/cypress/e2e/_old/media_library_spec_bitbucket_backend.js new file mode 100644 index 00000000..9de5c93b --- /dev/null +++ b/cypress/e2e/_old/media_library_spec_bitbucket_backend.js @@ -0,0 +1,27 @@ +import fixture from '../common/media_library'; +import { entry1 } from '../common/entries'; +import * as specUtils from '../common/spec_utils'; + +const backend = 'bitbucket'; + +describe('BitBucket Backend Media Library - REST API', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, {}, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ entries: [entry1], getUser: () => taskResult.data.user }); +}); diff --git a/cypress/e2e/_old/media_library_spec_bitbucket_backend_large_media.js b/cypress/e2e/_old/media_library_spec_bitbucket_backend_large_media.js new file mode 100644 index 00000000..fefee09d --- /dev/null +++ b/cypress/e2e/_old/media_library_spec_bitbucket_backend_large_media.js @@ -0,0 +1,28 @@ +import fixture from '../common/media_library'; +import { entry1 } from '../common/entries'; +import * as specUtils from '../common/spec_utils'; + +const backend = 'bitbucket'; +const lfs = true; + +describe('BitBucket Backend Media Library - Large Media', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { lfs }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ entries: [entry1], getUser: () => taskResult.data.user }); +}); diff --git a/cypress/e2e/_old/media_library_spec_git-gateway_github_backend_large_media.js b/cypress/e2e/_old/media_library_spec_git-gateway_github_backend_large_media.js new file mode 100644 index 00000000..71b811b9 --- /dev/null +++ b/cypress/e2e/_old/media_library_spec_git-gateway_github_backend_large_media.js @@ -0,0 +1,28 @@ +import fixture from '../common/media_library'; +import { entry1 } from '../common/entries'; +import * as specUtils from '../common/spec_utils'; + +const backend = 'git-gateway'; +const provider = 'github'; + +describe('Git Gateway (GitHub) Backend Media Library - Large Media', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'editorial_workflow', provider }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ entries: [entry1], getUser: () => taskResult.data.user }); +}); diff --git a/cypress/e2e/_old/media_library_spec_git-gateway_gitlab_backend_large_media.js b/cypress/e2e/_old/media_library_spec_git-gateway_gitlab_backend_large_media.js new file mode 100644 index 00000000..6a6073f7 --- /dev/null +++ b/cypress/e2e/_old/media_library_spec_git-gateway_gitlab_backend_large_media.js @@ -0,0 +1,28 @@ +import fixture from '../common/media_library'; +import { entry1 } from '../common/entries'; +import * as specUtils from '../common/spec_utils'; + +const backend = 'git-gateway'; +const provider = 'gitlab'; + +describe('Git Gateway (GitLab) Backend Media Library - Large Media', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'editorial_workflow', provider }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ entries: [entry1], getUser: () => taskResult.data.user }); +}); diff --git a/cypress/e2e/_old/media_library_spec_github_backend_graphql.js b/cypress/e2e/_old/media_library_spec_github_backend_graphql.js new file mode 100644 index 00000000..5968442d --- /dev/null +++ b/cypress/e2e/_old/media_library_spec_github_backend_graphql.js @@ -0,0 +1,34 @@ +import fixture from '../common/media_library'; +import { entry1 } from '../common/entries'; +import * as specUtils from '../common/spec_utils'; + +const backend = 'github'; + +describe('GitHub Backend Media Library - GraphQL API', () => { + const taskResult = { data: {} }; + + before(() => { + specUtils.before( + taskResult, + { + backend: { use_graphql: true }, + publish_mode: 'editorial_workflow', + }, + backend, + ); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ entries: [entry1], getUser: () => taskResult.data.user }); +}); diff --git a/cypress/e2e/_old/media_library_spec_github_backend_rest.js b/cypress/e2e/_old/media_library_spec_github_backend_rest.js new file mode 100644 index 00000000..31daf0fe --- /dev/null +++ b/cypress/e2e/_old/media_library_spec_github_backend_rest.js @@ -0,0 +1,34 @@ +import fixture from '../common/media_library'; +import { entry1 } from '../common/entries'; +import * as specUtils from '../common/spec_utils'; + +const backend = 'github'; + +describe('GitHub Backend Media Library - REST API', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before( + taskResult, + { + backend: { use_graphql: false }, + publish_mode: 'editorial_workflow', + }, + backend, + ); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ entries: [entry1], getUser: () => taskResult.data.user }); +}); diff --git a/cypress/e2e/_old/media_library_spec_gitlab_backend.js b/cypress/e2e/_old/media_library_spec_gitlab_backend.js new file mode 100644 index 00000000..6cfe3c61 --- /dev/null +++ b/cypress/e2e/_old/media_library_spec_gitlab_backend.js @@ -0,0 +1,27 @@ +import fixture from '../common/media_library'; +import { entry1 } from '../common/entries'; +import * as specUtils from '../common/spec_utils'; + +const backend = 'gitlab'; + +describe('GitLab Backend Media Library - REST API', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'editorial_workflow' }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ entries: [entry1], getUser: () => taskResult.data.user }); +}); diff --git a/cypress/e2e/_old/media_library_spec_proxy_git_backend.js b/cypress/e2e/_old/media_library_spec_proxy_git_backend.js new file mode 100644 index 00000000..95a0ff18 --- /dev/null +++ b/cypress/e2e/_old/media_library_spec_proxy_git_backend.js @@ -0,0 +1,29 @@ +import fixture from '../common/media_library'; +import * as specUtils from '../common/spec_utils'; +import { entry1 } from '../common/entries'; + +const backend = 'proxy'; +const mode = 'git'; + +describe(`Proxy Backend Media Library - '${mode}' mode`, () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'editorial_workflow', mode }, backend); + Cypress.config('defaultCommandTimeout', 5 * 1000); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ entries: [entry1], getUser: () => taskResult.data.user }); +}); diff --git a/cypress/e2e/_old/media_library_spec_test_backend.js b/cypress/e2e/_old/media_library_spec_test_backend.js new file mode 100644 index 00000000..6e16209a --- /dev/null +++ b/cypress/e2e/_old/media_library_spec_test_backend.js @@ -0,0 +1,21 @@ +import fixture from '../common/media_library'; + +const entries = [ + { + Title: 'first title', + Body: 'first body', + }, +]; + +describe('Test Backend Media Library', () => { + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + fixture({ entries }); +}); diff --git a/cypress/e2e/_old/search_suggestion_spec.js b/cypress/e2e/_old/search_suggestion_spec.js new file mode 100644 index 00000000..69452070 --- /dev/null +++ b/cypress/e2e/_old/search_suggestion_spec.js @@ -0,0 +1,71 @@ +import { login } from '../../utils/steps'; + +const search = (term, collection) => { + cy.get('[class*=SearchInput]').clear({ force: true }); + cy.get('[class*=SearchInput]').type(term, { force: true }); + cy.get('[class*=SuggestionsContainer]').within(() => { + cy.contains(collection).click(); + }); +}; + +const assertSearchHeading = title => { + cy.get('[class*=SearchResultHeading]').should('have.text', title); +}; + +const assertSearchResult = (text, collection) => { + cy.get('[class*=ListCardLink] h2').contains(collection ?? text); +}; + +const assertNotInSearch = text => { + cy.get('[class*=ListCardLink] h2').contains(text).should('not.exist'); +}; + +describe('Search Suggestion', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + beforeEach(() => { + login(); + }); + + it('can search in all collections', () => { + search('this', 'All Collections'); + + assertSearchHeading('Search Results for "this"'); + + assertSearchResult('This is post # 20', 'Posts'); + assertSearchResult('This is a TOML front matter post', 'Posts'); + assertSearchResult('This is a JSON front matter post', 'Posts'); + assertSearchResult('This is a YAML front matter post', 'Posts'); + assertSearchResult('This FAQ item # 5', 'FAQ'); + }); + + it('can search in posts collection', () => { + search('this', 'Posts'); + + assertSearchHeading('Search Results for "this" in Posts'); + + assertSearchResult('This is post # 20'); + assertSearchResult('This is a TOML front matter post'); + assertSearchResult('This is a JSON front matter post'); + assertSearchResult('This is a YAML front matter post'); + + assertNotInSearch('This FAQ item # 5'); + }); + + it('can search in faq collection', () => { + search('this', 'FAQ'); + + assertSearchHeading('Search Results for "this" in FAQ'); + + assertSearchResult('This FAQ item # 5'); + + assertNotInSearch('This is post # 20'); + }); +}); diff --git a/cypress/e2e/_old/simple_workflow_spec_bitbucket_backend.ts b/cypress/e2e/_old/simple_workflow_spec_bitbucket_backend.ts new file mode 100644 index 00000000..48f8b859 --- /dev/null +++ b/cypress/e2e/_old/simple_workflow_spec_bitbucket_backend.ts @@ -0,0 +1,30 @@ +import fixture from '../common/simple_workflow'; +import * as specUtils from '../common/spec_utils'; + +import type { TaskResult } from '../../interface'; + +const backend = 'bitbucket'; + +describe('BitBucket Backend Simple Workflow', () => { + let taskResult: TaskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'simple' }, backend); + }); + + after(() => { + specUtils.after(backend); + }); + + beforeEach(() => { + specUtils.beforeEach(backend); + }); + + afterEach(() => { + specUtils.afterEach(backend); + }); + + fixture({ + getUser: () => taskResult.data?.user, + }); +}); diff --git a/cypress/e2e/_old/simple_workflow_spec_git-gateway_github_backend.js b/cypress/e2e/_old/simple_workflow_spec_git-gateway_github_backend.js new file mode 100644 index 00000000..2c64273e --- /dev/null +++ b/cypress/e2e/_old/simple_workflow_spec_git-gateway_github_backend.js @@ -0,0 +1,31 @@ +import fixture from '../common/simple_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'git-gateway'; +const provider = 'github'; + +describe('Git Gateway (GitHub) Backend Simple Workflow', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'simple', provider }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/simple_workflow_spec_git-gateway_gitlab_backend.js b/cypress/e2e/_old/simple_workflow_spec_git-gateway_gitlab_backend.js new file mode 100644 index 00000000..5fd2f902 --- /dev/null +++ b/cypress/e2e/_old/simple_workflow_spec_git-gateway_gitlab_backend.js @@ -0,0 +1,31 @@ +import fixture from '../common/simple_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'git-gateway'; +const provider = 'gitlab'; + +describe('Git Gateway (GitLab) Backend Simple Workflow', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'simple', provider }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/simple_workflow_spec_github_backend_graphql.js b/cypress/e2e/_old/simple_workflow_spec_github_backend_graphql.js new file mode 100644 index 00000000..489c67f7 --- /dev/null +++ b/cypress/e2e/_old/simple_workflow_spec_github_backend_graphql.js @@ -0,0 +1,37 @@ +import fixture from '../common/simple_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'github'; + +describe('GitHub Backend Simple Workflow - GraphQL API', () => { + const taskResult = { data: {} }; + + before(() => { + specUtils.before( + taskResult, + { + backend: { use_graphql: true }, + publish_mode: 'simple', + }, + backend, + ); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/simple_workflow_spec_github_backend_rest.js b/cypress/e2e/_old/simple_workflow_spec_github_backend_rest.js new file mode 100644 index 00000000..884dee8b --- /dev/null +++ b/cypress/e2e/_old/simple_workflow_spec_github_backend_rest.js @@ -0,0 +1,37 @@ +import fixture from '../common/simple_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'github'; + +describe('GitHub Backend Simple Workflow - REST API', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before( + taskResult, + { + backend: { use_graphql: false }, + publish_mode: 'simple', + }, + backend, + ); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/simple_workflow_spec_gitlab_backend.js b/cypress/e2e/_old/simple_workflow_spec_gitlab_backend.js new file mode 100644 index 00000000..a519cdd2 --- /dev/null +++ b/cypress/e2e/_old/simple_workflow_spec_gitlab_backend.js @@ -0,0 +1,30 @@ +import fixture from '../common/simple_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'gitlab'; + +describe('GitLab Backend Simple Workflow', () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'simple' }, backend); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/simple_workflow_spec_proxy_fs_backend.js b/cypress/e2e/_old/simple_workflow_spec_proxy_fs_backend.js new file mode 100644 index 00000000..7ef5a27a --- /dev/null +++ b/cypress/e2e/_old/simple_workflow_spec_proxy_fs_backend.js @@ -0,0 +1,32 @@ +import fixture from '../common/simple_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'proxy'; +const mode = 'fs'; + +describe(`Proxy Backend Simple Workflow - '${mode}' mode`, () => { + const taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'simple', mode }, backend); + Cypress.config('defaultCommandTimeout', 5 * 1000); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/_old/simple_workflow_spec_proxy_git_backend.js b/cypress/e2e/_old/simple_workflow_spec_proxy_git_backend.js new file mode 100644 index 00000000..81093685 --- /dev/null +++ b/cypress/e2e/_old/simple_workflow_spec_proxy_git_backend.js @@ -0,0 +1,35 @@ +import fixture from '../common/simple_workflow'; +import * as specUtils from '../common/spec_utils'; +import { entry1, entry2, entry3 } from '../common/entries'; + +const backend = 'proxy'; +const mode = 'git'; + +describe(`Proxy Backend Simple Workflow - '${mode}' mode`, () => { + let taskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'simple', mode }, backend); + Cypress.config('defaultCommandTimeout', 5 * 1000); + }); + + after(() => { + specUtils.after(taskResult, backend); + }); + + beforeEach(() => { + if (Cypress.mocha.getRunner().suite.ctx.currentTest.title === 'can create an entry') { + Cypress.mocha.getRunner().suite.ctx.currentTest.skip(); + } + specUtils.beforeEach(taskResult, backend); + }); + + afterEach(() => { + specUtils.afterEach(taskResult, backend); + }); + + fixture({ + entries: [entry1, entry2, entry3], + getUser: () => taskResult.data.user, + }); +}); diff --git a/cypress/e2e/common/editorial_workflow.js b/cypress/e2e/common/editorial_workflow.js new file mode 100644 index 00000000..587f17ce --- /dev/null +++ b/cypress/e2e/common/editorial_workflow.js @@ -0,0 +1,92 @@ +import { + login, + createPost, + createPostAndExit, + updateExistingPostAndExit, + exitEditor, + goToWorkflow, + goToCollections, + updateWorkflowStatus, + publishWorkflowEntry, + assertWorkflowStatusInEditor, + assertPublishedEntry, + deleteEntryInEditor, + assertOnCollectionsPage, + assertEntryDeleted, + assertWorkflowStatus, + updateWorkflowStatusInEditor, +} from '../../utils/steps'; +import { workflowStatus, editorStatus } from '../../utils/constants'; + +export default function ({ entries, getUser }) { + it('successfully loads', () => { + login({ user: getUser() }); + }); + + it('can create an entry', () => { + login({ user: getUser() }); + createPostAndExit(entries[0]); + }); + + it('can update an entry', () => { + login({ user: getUser() }); + createPostAndExit(entries[0]); + updateExistingPostAndExit(entries[0], entries[1]); + }); + + it('can publish an editorial workflow entry', () => { + login({ user: getUser() }); + createPostAndExit(entries[0]); + goToWorkflow(); + updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entries[0]); + }); + + it('can change workflow status', () => { + login({ user: getUser() }); + createPostAndExit(entries[0]); + goToWorkflow(); + updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.review); + updateWorkflowStatus(entries[0], workflowStatus.review, workflowStatus.ready); + updateWorkflowStatus(entries[0], workflowStatus.ready, workflowStatus.review); + updateWorkflowStatus(entries[0], workflowStatus.review, workflowStatus.draft); + updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready); + }); + + it('can change status on and publish multiple entries', () => { + login({ user: getUser() }); + createPostAndExit(entries[0]); + createPostAndExit(entries[1]); + createPostAndExit(entries[2]); + goToWorkflow(); + updateWorkflowStatus(entries[2], workflowStatus.draft, workflowStatus.ready); + updateWorkflowStatus(entries[1], workflowStatus.draft, workflowStatus.ready); + updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entries[2]); + publishWorkflowEntry(entries[1]); + publishWorkflowEntry(entries[0]); + goToCollections(); + assertPublishedEntry([entries[2], entries[1], entries[0]]); + }); + + it('can delete an entry', () => { + login({ user: getUser() }); + createPost(entries[0]); + deleteEntryInEditor(); + assertOnCollectionsPage(); + assertEntryDeleted(entries[0]); + }); + + it('can update workflow status from within the editor', () => { + login({ user: getUser() }); + createPost(entries[0]); + assertWorkflowStatusInEditor(editorStatus.draft); + updateWorkflowStatusInEditor(editorStatus.review); + assertWorkflowStatusInEditor(editorStatus.review); + updateWorkflowStatusInEditor(editorStatus.ready); + assertWorkflowStatusInEditor(editorStatus.ready); + exitEditor(); + goToWorkflow(); + assertWorkflowStatus(entries[0], workflowStatus.ready); + }); +} diff --git a/cypress/e2e/common/editorial_workflow_migrations.js b/cypress/e2e/common/editorial_workflow_migrations.js new file mode 100644 index 00000000..d178d860 --- /dev/null +++ b/cypress/e2e/common/editorial_workflow_migrations.js @@ -0,0 +1,50 @@ +import { + login, + createPostAndExit, + goToWorkflow, + goToCollections, + updateWorkflowStatus, + publishWorkflowEntry, + assertPublishedEntry, +} from '../../utils/steps'; +import { workflowStatus } from '../../utils/constants'; + +const versions = ['2.9.7', '2.10.24']; + +export default function ({ entries, getUser }) { + versions.forEach(version => { + it(`migrate from ${version} to latest`, () => { + cy.task('switchToVersion', { + version, + }); + cy.reload(); + + login({ user: getUser() }); + createPostAndExit(entries[0]); + createPostAndExit(entries[1]); + createPostAndExit(entries[2]); + goToWorkflow(); + updateWorkflowStatus(entries[2], workflowStatus.draft, workflowStatus.ready); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1500); // older versions of the CMS didn't wait fully for the update to be resolved + updateWorkflowStatus(entries[1], workflowStatus.draft, workflowStatus.ready); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1500); // older versions of the CMS didn't wait fully for the update to be resolved + updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1500); // older versions of the CMS didn't wait fully for the update to be resolved + + cy.task('switchToVersion', { + version: 'latest', + }); + cy.reload(); + + // allow migration code to run for 5 minutes + publishWorkflowEntry(entries[2], 5 * 60 * 1000); + publishWorkflowEntry(entries[1]); + publishWorkflowEntry(entries[0]); + goToCollections(); + assertPublishedEntry([entries[2], entries[1], entries[0]]); + }); + }); +} diff --git a/cypress/e2e/common/entries.ts b/cypress/e2e/common/entries.ts new file mode 100644 index 00000000..aca43f5a --- /dev/null +++ b/cypress/e2e/common/entries.ts @@ -0,0 +1,121 @@ +import type { Post } from '@staticcms/cypress/interface'; + +export const entry1: Post = { + Title: 'first title', + Body: 'first body', + Description: 'first description', + Category: 'first category', + Tags: 'tag1', +}; + +export const entry2: Post = { + Title: 'second title', + Body: 'second body', + Description: 'second description', + Category: 'second category', + Tags: 'tag2', +}; + +export const entry3: Post = { + Title: 'third title', + Body: 'third body', + Description: 'third description', + Category: 'third category', + Tags: 'tag3', +}; + +export const entry4: Post = { + Title: 'fourth title', + Body: 'fourth body', + Description: 'fourth description', + Category: 'fourth category', + Tags: 'tag4', +}; + +export const entry5: Post = { + Title: 'fifth title', + Body: 'fifth body', + Description: 'fifth description', + Category: 'fifth category', + Tags: 'tag5', +}; + +export const entry6: Post = { + Title: 'sixth title', + Body: 'sixth body', + Description: 'sixth description', + Category: 'sixth category', + Tags: 'tag6', +}; + +export const entry7: Post = { + Title: 'seventh title', + Body: 'seventh body', + Description: 'seventh description', + Category: 'seventh category', + Tags: 'tag7', +}; + +export const entry8: Post = { + Title: 'eighth title', + Body: 'eighth body', + Description: 'eighth description', + Category: 'eighth category', + Tags: 'tag8', +}; + +export const entry9: Post = { + Title: 'nineth title', + Body: 'nineth body', + Description: 'nineth description', + Category: 'nineth category', + Tags: 'tag9', +}; + +export const entry10: Post = { + Title: 'tenth title', + Body: 'tenth body', + Description: 'tenth description', + Category: 'tenth category', + Tags: 'tag10', +}; + +export const entry11: Post = { + Title: 'eleventh title', + Body: 'eleventh body', + Description: 'eleventh description', + Category: 'eleventh category', + Tags: 'tag11', +}; + +export const entry12: Post = { + Title: 'twelveth title', + Body: 'twelveth body', + Description: 'twelveth description', + Category: 'twelveth category', + Tags: 'tag12', +}; + +export const entry13: Post = { + Title: 'thirteenth title', + Body: 'thirteenth body', + Description: 'thirteenth description', + Category: 'thirteenth category', + Tags: 'tag13', +}; + +export const entry14: Post = { + Title: 'fourteenth title', + Body: 'fourteenth body', + Description: 'fourteenth description', + Category: 'fourteenth category', + Tags: 'tag14', +}; + +export const entry15: Post = { + Title: 'fifteenth title', + Body: 'fifteenth body', + Description: 'fifteenth description', + Category: 'fifteenth category', + Tags: 'tag15', +}; diff --git a/cypress/e2e/common/i18n.js b/cypress/e2e/common/i18n.js new file mode 100644 index 00000000..130bcbbf --- /dev/null +++ b/cypress/e2e/common/i18n.js @@ -0,0 +1,55 @@ +import { newPost, populateEntry, publishEntry, flushClockAndSave } from '../../utils/steps'; + +const enterTranslation = str => { + cy.get('[data-testid="field-Title"]').first().clear({ force: true }); + cy.get('[data-testid="field-Title"]').first().type(str, { force: true }); +}; + +const createAndTranslate = entry => { + newPost(); + // fill the main entry + populateEntry(entry, () => undefined); + + // fill the translation + cy.get('.Pane2').within(() => { + enterTranslation('de'); + + cy.contains('span', 'Writing in DE').click(); + cy.contains('span', 'fr').click(); + + enterTranslation('fr'); + }); +}; + +export const updateTranslation = () => { + cy.get('.Pane2').within(() => { + enterTranslation('fr fr'); + + cy.contains('span', 'Writing in FR').click(); + cy.contains('span', 'de').click(); + + enterTranslation('de de'); + }); + cy.contains('button', 'Save').click(); +}; + +export const assertTranslation = () => { + cy.get('.Pane2').within(() => { + cy.get('[data-testid="field-Title"]').should('have.value', 'de'); + + cy.contains('span', 'Writing in DE').click(); + cy.contains('span', 'fr').click(); + + cy.get('[data-testid="field-Title"]').should('have.value', 'fr'); + }); +}; + +export const createEntryTranslateAndPublish = entry => { + createAndTranslate(entry); + publishEntry(); +}; + +export const createEntryTranslateAndSave = entry => { + createAndTranslate(entry); + flushClockAndSave(); +}; diff --git a/cypress/e2e/common/i18n_editorial_workflow_spec.js b/cypress/e2e/common/i18n_editorial_workflow_spec.js new file mode 100644 index 00000000..094cd297 --- /dev/null +++ b/cypress/e2e/common/i18n_editorial_workflow_spec.js @@ -0,0 +1,53 @@ +import { + login, + goToWorkflow, + updateWorkflowStatus, + exitEditor, + publishWorkflowEntry, + goToEntry, + updateWorkflowStatusInEditor, + publishEntryInEditor, + assertPublishedEntryInEditor, + assertUnpublishedEntryInEditor, + assertUnpublishedChangesInEditor, +} from '../../utils/steps'; +import { createEntryTranslateAndSave, assertTranslation, updateTranslation } from './i18n'; +import { workflowStatus, editorStatus, publishTypes } from '../../utils/constants'; + +export default function ({ entry, getUser }) { + const structures = ['multiple_folders', 'multiple_files', 'single_file']; + structures.forEach(structure => { + it(`can create and publish entry with translation in ${structure} mode`, () => { + cy.task('updateConfig', { i18n: { structure } }); + + login({ user: getUser() }); + + createEntryTranslateAndSave(entry); + assertUnpublishedEntryInEditor(); + exitEditor(); + goToWorkflow(); + updateWorkflowStatus(entry, workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entry); + goToEntry(entry); + assertTranslation(); + assertPublishedEntryInEditor(); + }); + + it(`can update translated entry in ${structure} mode`, () => { + cy.task('updateConfig', { i18n: { structure: 'multiple_folders' } }); + + login({ user: getUser() }); + + createEntryTranslateAndSave(entry); + assertUnpublishedEntryInEditor(); + updateWorkflowStatusInEditor(editorStatus.ready); + publishEntryInEditor(publishTypes.publishNow); + exitEditor(); + goToEntry(entry); + assertTranslation(); + assertPublishedEntryInEditor(); + updateTranslation(); + assertUnpublishedChangesInEditor(); + }); + }); +} diff --git a/cypress/e2e/common/media_library.js b/cypress/e2e/common/media_library.js new file mode 100644 index 00000000..e495523f --- /dev/null +++ b/cypress/e2e/common/media_library.js @@ -0,0 +1,162 @@ +import { + login, + goToMediaLibrary, + newPost, + populateEntry, + exitEditor, + goToWorkflow, + updateWorkflowStatus, + publishWorkflowEntry, + goToEntry, + goToCollections, +} from '../../utils/steps'; +import { workflowStatus } from '../../utils/constants'; + +function uploadMediaFile() { + assertNoImagesInLibrary(); + + const fixture = 'cypress/fixtures/media/netlify.png'; + cy.get('input[type="file"]').selectFile(fixture, { force: true }); + cy.contains('span', 'Uploading...').should('not.exist'); + + assertImagesInLibrary(); +} + +function assertImagesInLibrary() { + cy.get('img[class*="CardImage"]').should('exist'); +} + +function assertNoImagesInLibrary() { + cy.get('h1').contains('Loading...').should('not.exist'); + cy.get('img[class*="CardImage"]').should('not.exist'); +} + +function deleteImage() { + cy.get('img[class*="CardImage"]').click(); + cy.contains('button', 'Delete selected').click(); + assertNoImagesInLibrary(); +} + +function chooseSelectedMediaFile() { + cy.contains('button', 'Choose selected').should('not.be.disabled'); + cy.contains('button', 'Choose selected').click(); +} + +function chooseAnImage() { + cy.contains('button', 'Choose an image').click(); +} + +function waitForEntryToLoad() { + cy.contains('button', 'Saving...').should('not.exist'); + cy.contains('div', 'Loading entry...').should('not.exist'); +} + +function matchImageSnapshot() { + // cy.matchImageSnapshot(); +} + +function newPostAndUploadImage() { + newPost(); + chooseAnImage(); + uploadMediaFile(); +} + +function newPostWithImage(entry) { + newPostAndUploadImage(); + chooseSelectedMediaFile(); + populateEntry(entry); + waitForEntryToLoad(); +} + +function publishPostWithImage(entry) { + newPostWithImage(entry); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + exitEditor(); + goToWorkflow(); + updateWorkflowStatus(entry, workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entry); +} + +function closeMediaLibrary() { + cy.get('button[class*="CloseButton"]').click(); +} + +function switchToGridView() { + cy.get('div[class*="ViewControls"]').within(() => { + cy.get('button').last().click(); + }); +} + +function assertGridEntryImage(entry) { + cy.contains('li', entry.title).within(() => { + cy.get('div[class*="CardImage"]').should('be.visible'); + }); +} + +export default function ({ entries, getUser }) { + beforeEach(() => { + login(getUser && getUser()); + }); + + it('can upload image from global media library', () => { + goToMediaLibrary(); + uploadMediaFile(); + matchImageSnapshot(); + closeMediaLibrary(); + }); + + it('can delete image from global media library', () => { + goToMediaLibrary(); + uploadMediaFile(); + closeMediaLibrary(); + goToMediaLibrary(); + deleteImage(); + matchImageSnapshot(); + closeMediaLibrary(); + }); + + it('can upload image from entry media library', () => { + newPostAndUploadImage(); + matchImageSnapshot(); + closeMediaLibrary(); + exitEditor(); + }); + + it('can save entry with image', () => { + newPostWithImage(entries[0]); + matchImageSnapshot(); + exitEditor(); + }); + + it('can publish entry with image', () => { + publishPostWithImage(entries[0]); + goToEntry(entries[0]); + waitForEntryToLoad(); + matchImageSnapshot(); + }); + + it('should not show draft entry image in global media library', () => { + newPostWithImage(entries[0]); + exitEditor(); + goToMediaLibrary(); + assertNoImagesInLibrary(); + matchImageSnapshot(); + }); + + it('should show published entry image in global media library', () => { + publishPostWithImage(entries[0]); + goToMediaLibrary(); + assertImagesInLibrary(); + matchImageSnapshot(); + }); + + it('should show published entry image in grid view', () => { + publishPostWithImage(entries[0]); + goToCollections(); + switchToGridView(); + assertGridEntryImage(entries[0]); + + matchImageSnapshot(); + }); +} diff --git a/cypress/e2e/common/open_authoring.js b/cypress/e2e/common/open_authoring.js new file mode 100644 index 00000000..cedfeb80 --- /dev/null +++ b/cypress/e2e/common/open_authoring.js @@ -0,0 +1,75 @@ +import { + login, + createPostAndExit, + updateExistingPostAndExit, + goToWorkflow, + deleteWorkflowEntry, + updateWorkflowStatus, + publishWorkflowEntry, +} from '../../utils/steps'; +import { workflowStatus } from '../../utils/constants'; + +export default function ({ entries, getUser, getForkUser }) { + it('successfully loads', () => { + login({ user: getUser() }); + }); + + it('can create an entry', () => { + login({ user: getUser() }); + createPostAndExit(entries[0]); + }); + + it('can update an entry', () => { + login({ user: getUser() }); + createPostAndExit(entries[0]); + updateExistingPostAndExit(entries[0], entries[1]); + }); + + it('can publish an editorial workflow entry', () => { + login({ user: getUser() }); + createPostAndExit(entries[0]); + goToWorkflow(); + updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entries[0]); + }); + + it('successfully forks repository and loads', () => { + login({ user: getForkUser() }); + }); + + it('can create an entry on fork', () => { + login({ user: getForkUser() }); + createPostAndExit(entries[0]); + }); + + it('can update a draft entry on fork', () => { + login({ user: getForkUser() }); + createPostAndExit(entries[0]); + updateExistingPostAndExit(entries[0], entries[1]); + }); + + it('can change entry status from fork', () => { + login({ user: getForkUser() }); + createPostAndExit(entries[0]); + goToWorkflow(); + updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.review); + }); + + it('can delete review entry from fork', () => { + login({ user: getForkUser() }); + createPostAndExit(entries[0]); + goToWorkflow(); + updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.review); + deleteWorkflowEntry(entries[0]); + }); + + it('can return entry to draft and delete it', () => { + login({ user: getForkUser() }); + createPostAndExit(entries[0]); + goToWorkflow(); + updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.review); + + updateWorkflowStatus(entries[0], workflowStatus.review, workflowStatus.draft); + deleteWorkflowEntry(entries[0]); + }); +} diff --git a/cypress/e2e/common/simple_workflow.ts b/cypress/e2e/common/simple_workflow.ts new file mode 100644 index 00000000..4eb50c9c --- /dev/null +++ b/cypress/e2e/common/simple_workflow.ts @@ -0,0 +1,85 @@ +import { + assertPublishedEntry, + createPostAndPublish, + createPostPublishAndCreateNew, + createPostPublishAndDuplicate, + duplicatePostAndPublish, + editPostAndPublish, + editPostPublishAndCreateNew, + editPostPublishAndDuplicate, + login, +} from '../../utils/steps'; +import { + entry1, + entry10, + entry2, + entry3, + entry4, + entry5, + entry6, + entry7, + entry8, + entry9, +} from './entries'; + +import type { User } from '@staticcms/cypress/interface'; + +export interface SimpleWorkflowProps { + getUser?: () => User | undefined; +} + +export default function ({ getUser }: SimpleWorkflowProps = {}) { + it('successfully loads', () => { + login({ user: getUser?.() }); + }); + + it('can create an entry', () => { + login({ user: getUser?.() }); + createPostAndPublish(entry1); + assertPublishedEntry(entry1); + }); + + it('can publish a new entry and create new', () => { + login(); + createPostPublishAndCreateNew(entry2); + assertPublishedEntry(entry2); + }); + + it('can publish a new entry and duplicate', () => { + login(); + createPostPublishAndDuplicate(entry3); + assertPublishedEntry(entry3); + }); + + it('can edit an existing entry and publish', () => { + login(); + createPostAndPublish(entry4); + assertPublishedEntry(entry4); + + editPostAndPublish(entry4, entry5); + }); + + it('can edit an existing entry, publish and create new', () => { + login(); + createPostAndPublish(entry6); + assertPublishedEntry(entry6); + + editPostPublishAndCreateNew(entry6, entry7); + }); + + it('can edit an existing entry, publish and duplicate', () => { + login(); + createPostAndPublish(entry8); + assertPublishedEntry(entry8); + + editPostPublishAndDuplicate(entry8, entry9); + }); + + it('can duplicate an existing entry', () => { + login(); + createPostAndPublish(entry10); + assertPublishedEntry(entry10); + + duplicatePostAndPublish(entry10); + }); +} diff --git a/cypress/e2e/common/spec_utils.ts b/cypress/e2e/common/spec_utils.ts new file mode 100644 index 00000000..59e3dcef --- /dev/null +++ b/cypress/e2e/common/spec_utils.ts @@ -0,0 +1,47 @@ +import type { Config } from '@staticcms/core/interface'; +import type { TaskResult } from 'cypress/interface'; + +export const before = (taskResult: TaskResult, options: Partial, backend: string) => { + Cypress.config('taskTimeout', 7 * 60 * 1000); + cy.task('setupBackend', { backend, options }).then(data => { + taskResult.data = data; + }); +}; + +export const after = (backend: string) => { + cy.task('teardownBackend', { + backend, + }); +}; + +export const beforeEach = (backend: string) => { + cy.task('setupBackendTest', { + backend, + testName: Cypress.currentTest.title, + }); +}; + +export const afterEach = (backend: string) => { + cy.task('teardownBackendTest', { + backend, + testName: Cypress.currentTest.title, + }); + + const { + suite: { + ctx: { + currentTest: { state, _retries: retries, _currentRetry: currentRetry }, + }, + }, + } = (Cypress as any).mocha.getRunner(); + + if (state === 'failed' && retries === currentRetry) { + (Cypress as any).runner.stop(); + } +}; + +export const seedRepo = (backend: string) => { + cy.task('seedRepo', { + backend, + }); +}; diff --git a/cypress/e2e/editorial_workflow_test_backend.spec.ts b/cypress/e2e/editorial_workflow_test_backend.spec.ts new file mode 100644 index 00000000..fd3d2949 --- /dev/null +++ b/cypress/e2e/editorial_workflow_test_backend.spec.ts @@ -0,0 +1,358 @@ +import format from 'date-fns/format'; + +import { + login, + createPost, + createPostAndExit, + exitEditor, + goToWorkflow, + updateWorkflowStatus, + publishWorkflowEntry, + assertWorkflowStatusInEditor, + assertPublishedEntry, + deleteEntryInEditor, + assertOnCollectionsPage, + assertEntryDeleted, + assertWorkflowStatus, + updateWorkflowStatusInEditor, + unpublishEntry, + publishEntryInEditor, + duplicateEntry, + goToEntry, + populateEntry, + publishAndCreateNewEntryInEditor, + publishAndDuplicateEntryInEditor, + assertNotification, + assertFieldValidationError, +} from '../utils/steps'; +import { editorStatus, publishTypes, notifications } from '../utils/constants'; +import { + entry1, + entry10, + entry11, + entry12, + entry13, + entry14, + entry15, + entry2, + entry3, + entry4, + entry5, + entry6, + entry7, + entry8, + entry9, +} from './common/entries'; +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; + +describe('Test Backend Editorial Workflow', () => { + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + cy.task('updateConfig', { + collections: [{ publish: true }], + publish_mode: 'editorial_workflow', + }); + }); + + it('successfully loads', () => { + login({ editorialWorkflow: true }); + }); + + it('can create an entry', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPost(entry1); + + // new entry should show 'Delete unpublished entry' + cy.wait(1000); + cy.get('[data-testid="editor-extra-menu"]', { timeout: 5000 }).should('be.enabled'); + cy.get('[data-testid="editor-extra-menu"]').click(); + cy.contains('[data-testid="delete-button"]', 'Delete unpublished entry'); + cy.url().should( + 'eq', + `http://localhost:8080/#/collections/posts/entries/${format( + new Date(), + 'yyyy-MM-dd', + )}-${entry1.Title.toLowerCase().replace(/\s/, '-')}`, + ); + exitEditor(); + }); + + it.only('can publish an editorial workflow entry', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPostAndExit(entry2); + goToWorkflow(); + updateWorkflowStatus(entry2, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH); + publishWorkflowEntry(entry2); + }); + + it('can update an entry', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPostAndExit(entry3); + goToWorkflow(); + updateWorkflowStatus(entry3, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH); + publishWorkflowEntry(entry3); + + goToEntry(entry3); + populateEntry(entry4); + // existing entry should show 'Delete unpublished changes' + cy.contains('button', 'Delete unpublished changes'); + // existing entry slug should remain the same after save' + cy.url().should( + 'eq', + `http://localhost:8080/#/collections/posts/entries/${format( + new Date(), + 'yyyy-MM-dd', + )}-${entry3.Title.toLowerCase().replace(/\s/, '-')}`, + ); + exitEditor(); + }); + + it('can change workflow status', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPostAndExit(entry5); + goToWorkflow(); + updateWorkflowStatus(entry5, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_REVIEW); + updateWorkflowStatus(entry5, WorkflowStatus.PENDING_REVIEW, WorkflowStatus.PENDING_PUBLISH); + updateWorkflowStatus(entry5, WorkflowStatus.PENDING_PUBLISH, WorkflowStatus.PENDING_REVIEW); + updateWorkflowStatus(entry5, WorkflowStatus.PENDING_REVIEW, WorkflowStatus.DRAFT); + updateWorkflowStatus(entry5, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH); + }); + + it('can change status on and publish multiple entries', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPostAndExit(entry6); + createPostAndExit(entry7); + createPostAndExit(entry8); + goToWorkflow(); + updateWorkflowStatus(entry8, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH); + updateWorkflowStatus(entry7, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH); + updateWorkflowStatus(entry6, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH); + publishWorkflowEntry(entry8); + publishWorkflowEntry(entry7); + publishWorkflowEntry(entry6); + assertPublishedEntry([entry8, entry7, entry6]); + }); + + it('can delete an entry', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPost(entry9); + deleteEntryInEditor(); + assertOnCollectionsPage(); + assertEntryDeleted(entry9); + }); + + it('can update workflow status from within the editor', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPost(entry10); + assertWorkflowStatusInEditor(editorStatus.draft); + updateWorkflowStatusInEditor(editorStatus.review); + assertWorkflowStatusInEditor(editorStatus.review); + updateWorkflowStatusInEditor(editorStatus.ready); + assertWorkflowStatusInEditor(editorStatus.ready); + exitEditor(); + goToWorkflow(); + assertWorkflowStatus(entry10, WorkflowStatus.PENDING_PUBLISH); + }); + + it('can unpublish an existing entry', () => { + // first publish an entry + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPostAndExit(entry11); + goToWorkflow(); + updateWorkflowStatus(entry11, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH); + publishWorkflowEntry(entry11); + // then unpublish it + unpublishEntry(entry11); + }); + + it('can duplicate an existing entry', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPost(entry12); + updateWorkflowStatusInEditor(editorStatus.ready); + publishEntryInEditor(publishTypes.publishNow); + duplicateEntry(entry12); + }); + + it('cannot publish when "publish" is false', () => { + cy.task('updateConfig', { collections: [{ publish: false }] }); + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPost(entry13); + cy.contains('span', 'Publish').should('not.exist'); + exitEditor(); + goToWorkflow(); + updateWorkflowStatus(entry13, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH); + cy.contains('button', 'Publish new entry').should('not.exist'); + }); + + it('can create a new entry, publish and create new', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPost(entry14); + updateWorkflowStatusInEditor(editorStatus.ready); + + publishAndCreateNewEntryInEditor(); + }); + + it('can create a new entry, publish and duplicate', () => { + login({ editorialWorkflow: true }); + + cy.get('[data-testid="sidebar-collection-nav-Posts').click(); + + createPost(entry15); + updateWorkflowStatusInEditor(editorStatus.ready); + publishAndDuplicateEntryInEditor(entry15); + }); + + const inSidebar = (func: (currentSubject: JQuery) => void) => { + cy.get('[class*=SidebarNavList]').within(func); + }; + + const inGrid = (func: (currentSubject: JQuery) => void) => { + cy.get('[class*=CardsGrid]').within(func); + }; + + it('can access nested collection items', () => { + login(); + + inSidebar(() => cy.contains('a', 'Pages').click()); + inSidebar(() => cy.contains('a', 'Directory')); + inGrid(() => cy.contains('a', 'Root Page')); + inGrid(() => cy.contains('a', 'Directory')); + + inSidebar(() => cy.contains('a', 'Directory').click()); + + inGrid(() => cy.contains('a', 'Sub Directory')); + inGrid(() => cy.contains('a', 'Another Sub Directory')); + + inSidebar(() => cy.contains('a', 'Sub Directory').click()); + inGrid(() => cy.contains('a', 'Nested Directory')); + cy.url().should( + 'eq', + 'http://localhost:8080/#/collections/pages/filter/directory/sub-directory', + ); + + inSidebar(() => cy.contains('a', 'Pages').click()); + inSidebar(() => cy.contains('a', 'Pages').click()); + + inGrid(() => cy.contains('a', 'Another Sub Directory').should('not.exist')); + }); + + it('can navigate to nested entry', () => { + login(); + + inSidebar(() => cy.contains('a', 'Pages').click()); + inSidebar(() => cy.contains('a', 'Directory').click()); + inGrid(() => cy.contains('a', 'Another Sub Directory').click()); + + cy.url().should( + 'eq', + 'http://localhost:8080/#/collections/pages/entries/directory/another-sub-directory/index', + ); + }); + + it(`can create a new entry with custom path`, () => { + login(); + + inSidebar(() => cy.contains('a', 'Pages').click()); + inSidebar(() => cy.contains('a', 'Directory').click()); + inSidebar(() => cy.contains('a', 'Sub Directory').click()); + cy.contains('a', 'New Page').click(); + + cy.get('[data-testid="field-Path"]').should('have.value', 'directory/sub-directory'); + cy.get('[data-testid="field-Path"]').type('/new-path'); + cy.get('[data-testid="field-Title"]').type('New Path Title'); + cy.wait(150); + cy.contains('button', 'Save').click(); + assertNotification(notifications.saved); + updateWorkflowStatusInEditor(editorStatus.ready); + publishEntryInEditor(publishTypes.publishNow); + exitEditor(); + + inGrid(() => cy.contains('a', 'New Path Title')); + inSidebar(() => cy.contains('a', 'Directory').click()); + inSidebar(() => cy.contains('a', 'Directory').click()); + inGrid(() => cy.contains('a', 'New Path Title').should('not.exist')); + }); + + it(`can't create an entry with an existing path`, () => { + login(); + + inSidebar(() => cy.contains('a', 'Pages').click()); + inSidebar(() => cy.contains('a', 'Directory').click()); + inSidebar(() => cy.contains('a', 'Sub Directory').click()); + + cy.contains('a', 'New Page').click(); + cy.get('[data-testid="field-Title"]').type('New Path Title'); + cy.wait(150); + cy.contains('button', 'Save').click(); + + assertFieldValidationError({ + message: `Path 'directory/sub-directory' already exists`, + fieldLabel: 'Path', + }); + }); + + it('can move an existing entry to a new path', () => { + login(); + + inSidebar(() => cy.contains('a', 'Pages').click()); + inGrid(() => cy.contains('a', 'Directory').click()); + + cy.get('[data-testid="field-Path"]').should('have.value', 'directory'); + cy.get('[data-testid="field-Path"]').clear(); + cy.get('[data-testid="field-Path"]').type('new-directory'); + cy.get('[data-testid="field-Title"]').clear(); + cy.get('[data-testid="field-Title"]').type('New Directory'); + cy.wait(150); + cy.contains('button', 'Save').click(); + assertNotification(notifications.saved); + updateWorkflowStatusInEditor(editorStatus.ready); + publishEntryInEditor(publishTypes.publishNow); + exitEditor(); + + inSidebar(() => cy.contains('a', 'New Directory').click()); + + inGrid(() => cy.contains('a', 'Sub Directory')); + inGrid(() => cy.contains('a', 'Another Sub Directory')); + }); +}); diff --git a/cypress/e2e/simple_workflow_test_backend.spec.ts b/cypress/e2e/simple_workflow_test_backend.spec.ts new file mode 100644 index 00000000..63de638e --- /dev/null +++ b/cypress/e2e/simple_workflow_test_backend.spec.ts @@ -0,0 +1,28 @@ +import fixture from './common/simple_workflow'; +import * as specUtils from './common/spec_utils'; + +import type { TaskResult } from '../interface'; + +const backend = 'test'; + +describe('Test Backend Simple Workflow', () => { + const taskResult: TaskResult = { data: {} }; + + before(() => { + specUtils.before(taskResult, { publish_mode: 'simple' }, backend); + }); + + after(() => { + specUtils.after(backend); + }); + + beforeEach(() => { + specUtils.beforeEach(backend); + }); + + afterEach(() => { + specUtils.afterEach(backend); + }); + + fixture(); +}); diff --git a/cypress/e2e/view_filters.spec.ts b/cypress/e2e/view_filters.spec.ts new file mode 100644 index 00000000..70e3e969 --- /dev/null +++ b/cypress/e2e/view_filters.spec.ts @@ -0,0 +1,60 @@ +import { login } from '../utils/steps'; + +const filter = (term: string) => { + cy.get('[data-testid="filter-by"]').click(); + cy.get(`[data-testid="filter-by-option-${term}"]`).click(); +}; + +const assertEntriesCount = (count: number) => { + cy.get('[class*=CMS_Entries_entry-listing-table-row]').should('have.length', count); +}; + +describe('View Filter', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + beforeEach(() => { + login(); + }); + + it('can apply string filter', () => { + // enable filter + filter('Posts With Index'); + + assertEntriesCount(20); + + // disable filter + filter('Posts With Index'); + }); + + it('can apply boolean filter', () => { + // enable filter + filter('Drafts'); + + assertEntriesCount(10); + + // disable filter + filter('Drafts'); + + assertEntriesCount(23); + }); + + it('can apply multiple filters', () => { + // enable filter + filter('Posts Without Index'); + + assertEntriesCount(3); + + filter('Drafts'); + + assertEntriesCount(0); + + cy.contains('div', 'No Entries'); + }); +}); diff --git a/cypress/e2e/view_groups.spec.ts b/cypress/e2e/view_groups.spec.ts new file mode 100644 index 00000000..83622c3f --- /dev/null +++ b/cypress/e2e/view_groups.spec.ts @@ -0,0 +1,58 @@ +import { login } from '../utils/steps'; + +const group = (term: string) => { + cy.get('[data-testid="group-by"]').click(); + cy.get(`[data-testid="group-by-option-${term}"]`).click(); +}; + +const assertGroupsCount = (count: number) => { + cy.get('[class*=CMS_Entries_group-button]').should('have.length', count); +}; + +const assertEachGroupCount = (name: string, count: number) => { + cy.get(`[data-testid="group-by-${name}"]`).click(); + assertEntriesCount(count); +}; + +const assertEntriesCount = (count: number) => { + cy.get('[class*=CMS_Entries_entry-listing-table-row]').should('have.length', count); +}; + +describe('View Group', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + beforeEach(() => { + login(); + }); + + it('can apply string group', () => { + // enable group + group('Year'); + + assertGroupsCount(2); + const year = new Date().getFullYear(); + assertEachGroupCount(`Year ${year}`, 20); + assertEachGroupCount('Year 2015', 3); + + //disable group + group('Year'); + + assertEntriesCount(23); + + //enable group + group('Drafts'); + + assertGroupsCount(3); + + assertEachGroupCount('Drafts', 10); + assertEachGroupCount('Not Drafts', 10); + assertEachGroupCount('Other', 3); + }); +}); diff --git a/cypress/fixtures/media/decap.png b/cypress/fixtures/media/decap.png new file mode 100644 index 00000000..31d2f867 Binary files /dev/null and b/cypress/fixtures/media/decap.png differ diff --git a/cypress/fixtures/media/netlify.png b/cypress/fixtures/media/netlify.png new file mode 100644 index 00000000..13692e65 Binary files /dev/null and b/cypress/fixtures/media/netlify.png differ diff --git a/cypress/interface.ts b/cypress/interface.ts new file mode 100644 index 00000000..b248769e --- /dev/null +++ b/cypress/interface.ts @@ -0,0 +1,55 @@ +import type { Config } from '@staticcms/core/interface'; + +export interface TaskResult { + data: SetupBackendResponse; +} + +export interface SetupBackendProps { + backend: string; + options: Partial; +} + +export interface User { + email: string; + password: string; + netlifySiteURL?: string; +} + +export type SetupBackendResponse = null | { + user?: User; +}; + +export interface SetupBackendTestProps { + backend: string; + testName: string; +} + +export interface TeardownBackendTestProps { + backend: string; + testName: string; +} + +export interface SeedRepoProps { + backend: string; +} + +export interface TeardownBackendProps { + backend: string; +} + +export interface Post { + Title: string; + Body: string; + Description: string; + Category: string; + Tags: string; +} + +export interface Author { + name: string; + description: string; +} + +export interface Authors { + authors: Author[]; +} diff --git a/cypress/plugins/bitbucket.js b/cypress/plugins/bitbucket.js new file mode 100644 index 00000000..dbcf7e1c --- /dev/null +++ b/cypress/plugins/bitbucket.js @@ -0,0 +1,310 @@ +const fs = require('fs-extra'); +const fetch = require('node-fetch'); +const path = require('path'); +const { updateConfig } = require('../utils/config'); +const { escapeRegExp } = require('../utils/regexp'); +const { + getExpectationsFilename, + transformRecordedData: transformData, + getGitClient, +} = require('./common'); +const { merge } = require('lodash'); + +const BITBUCKET_REPO_OWNER_SANITIZED_VALUE = 'owner'; +const BITBUCKET_REPO_NAME_SANITIZED_VALUE = 'repo'; +const BITBUCKET_REPO_TOKEN_SANITIZED_VALUE = 'fakeToken'; + +const FAKE_OWNER_USER = { + name: 'owner', + display_name: 'owner', + links: { + avatar: { + href: 'https://avatars1.githubusercontent.com/u/7892489?v=4', + }, + }, + nickname: 'owner', +}; + +async function getEnvs() { + const { + BITBUCKET_REPO_OWNER: owner, + BITBUCKET_REPO_NAME: repo, + BITBUCKET_OUATH_CONSUMER_KEY: consumerKey, + BITBUCKET_OUATH_CONSUMER_SECRET: consumerSecret, + } = process.env; + if (!owner || !repo || !consumerKey || !consumerSecret) { + throw new Error( + 'Please set BITBUCKET_REPO_OWNER, BITBUCKET_REPO_NAME, BITBUCKET_OUATH_CONSUMER_KEY, BITBUCKET_OUATH_CONSUMER_SECRET environment variables', + ); + } + + const params = new URLSearchParams(); + params.append('grant_type', 'client_credentials'); + + const { access_token: token } = await fetch( + `https://${consumerKey}:${consumerSecret}@bitbucket.org/site/oauth2/access_token`, + { method: 'POST', body: params }, + ).then(r => r.json()); + + return { owner, repo, token }; +} + +const API_URL = 'https://api.bitbucket.org/2.0/'; + +function get(token, path) { + return fetch(`${API_URL}${path}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }).then(r => r.json()); +} + +function post(token, path, body) { + return fetch(`${API_URL}${path}`, { + method: 'POST', + ...(body ? { body } : {}), + headers: { + 'Content-Type': 'application/json', + ...(body ? { 'Content-Type': 'application/json' } : {}), + Authorization: `Bearer ${token}`, + }, + }); +} + +function del(token, path) { + return fetch(`${API_URL}${path}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); +} + +async function prepareTestBitBucketRepo({ lfs }) { + const { owner, repo, token } = await getEnvs(); + + // postfix a random string to avoid collisions + const postfix = Math.random().toString(32).slice(2); + const testRepoName = `${repo}-${Date.now()}-${postfix}`; + + console.info('Creating repository', testRepoName, token); + const response = await post( + token, + `repositories/${owner}/${testRepoName}`, + JSON.stringify({ scm: 'git' }), + ); + if (!response.ok) { + throw new Error(`Unable to create repository. ${response.statusText}`); + } + + const tempDir = path.join('.temp', testRepoName); + await fs.remove(tempDir); + let git = getGitClient(); + + const repoUrl = `git@bitbucket.org:${owner}/${repo}.git`; + + console.info('Cloning repository', repoUrl); + await git.clone(repoUrl, tempDir); + git = getGitClient(tempDir); + + console.info('Pushing to new repository', testRepoName); + + console.info('Updating remote...'); + await git.removeRemote('origin'); + await git.addRemote('origin', `git@bitbucket.org:${owner}/${testRepoName}`); + + console.info('Pushing...'); + await git.push(['-u', 'origin', 'main']); + + console.info('Pushed to new repository', testRepoName); + + if (lfs) { + console.info(`Enabling LFS for repo ${owner}/${repo}`); + await git.addConfig('commit.gpgsign', 'false'); + await git.raw(['lfs', 'track', '*.png', '*.jpg']); + await git.add('.gitattributes'); + await git.commit('chore: track images files under LFS'); + await git.push('origin', 'main'); + } + + return { owner, repo: testRepoName, tempDir }; +} + +async function getUser() { + const { token } = await getEnvs(); + const user = await get(token, 'user'); + return { ...user, token, backendName: 'bitbucket' }; +} + +async function deleteRepositories({ owner, repo, tempDir }) { + const { token } = await getEnvs(); + + console.info('Deleting repository', `${owner}/${repo}`); + await fs.remove(tempDir); + + await del(token, `repositories/${owner}/${repo}`); +} + +async function resetOriginRepo({ owner, repo, tempDir }) { + console.info('Resetting origin repo:', `${owner}/${repo}`); + + const { token } = await getEnvs(); + + const pullRequests = await get(token, `repositories/${owner}/${repo}/pullrequests`); + const ids = pullRequests.values.map(mr => mr.id); + + console.info('Closing pull requests:', ids); + await Promise.all( + ids.map(id => post(token, `repositories/${owner}/${repo}/pullrequests/${id}/decline`)), + ); + const branches = await get(token, `repositories/${owner}/${repo}/refs/branches`); + const toDelete = branches.values.filter(b => b.name !== 'main').map(b => b.name); + + console.info('Deleting branches', toDelete); + await Promise.all( + toDelete.map(branch => del(token, `repositories/${owner}/${repo}/refs/branches/${branch}`)), + ); + + console.info('Resetting main'); + const git = getGitClient(tempDir); + await git.push(['--force', 'origin', 'main']); + console.info('Done resetting origin repo:', `${owner}/${repo}`); +} + +async function resetRepositories({ owner, repo, tempDir }) { + await resetOriginRepo({ owner, repo, tempDir }); +} + +async function setupBitBucket(options) { + const { lfs = false, ...rest } = options; + + console.info('Running tests - live data will be used!'); + const [user, repoData] = await Promise.all([getUser(), prepareTestBitBucketRepo({ lfs })]); + + console.info('Updating config...'); + await updateConfig(config => { + merge(config, rest, { + backend: { + repo: `${repoData.owner}/${repoData.repo}`, + }, + }); + }); + + return { ...repoData, user }; +} + +async function teardownBitBucket(taskData) { + await deleteRepositories(taskData); + + return null; +} + +async function setupBitBucketTest(taskData) { + await resetRepositories(taskData); + + return null; +} + +const sanitizeString = (str, { owner, repo, token, ownerName }) => { + let replaced = str + .replace(new RegExp(escapeRegExp(owner), 'g'), BITBUCKET_REPO_OWNER_SANITIZED_VALUE) + .replace(new RegExp(escapeRegExp(repo), 'g'), BITBUCKET_REPO_NAME_SANITIZED_VALUE) + .replace(new RegExp(escapeRegExp(token), 'g'), BITBUCKET_REPO_TOKEN_SANITIZED_VALUE) + .replace( + new RegExp('https://secure.gravatar.+?/u/.+?v=\\d', 'g'), + `${FAKE_OWNER_USER.links.avatar.href}`, + ) + .replace(new RegExp(/\?token=.+?&/g), 'token=fakeToken&') + .replace(new RegExp(/&client=.+?&/g), 'client=fakeClient&'); + + if (ownerName) { + replaced = replaced.replace( + new RegExp(escapeRegExp(ownerName), 'g'), + FAKE_OWNER_USER.display_name, + ); + } + + return replaced; +}; + +const transformRecordedData = (expectation, toSanitize) => { + const requestBodySanitizer = httpRequest => { + let body; + if (httpRequest.body && httpRequest.body.type === 'JSON' && httpRequest.body.json) { + const bodyObject = JSON.parse(httpRequest.body.json); + if (bodyObject.encoding === 'base64') { + // sanitize encoded data + const decodedBody = Buffer.from(bodyObject.content, 'base64').toString('binary'); + const sanitizedContent = sanitizeString(decodedBody, toSanitize); + const sanitizedEncodedContent = Buffer.from(sanitizedContent, 'binary').toString('base64'); + bodyObject.content = sanitizedEncodedContent; + body = JSON.stringify(bodyObject); + } else { + body = httpRequest.body.json; + } + } else if (httpRequest.body && httpRequest.body.type === 'STRING' && httpRequest.body.string) { + body = httpRequest.body.string; + } else if ( + httpRequest.body && + httpRequest.body.type === 'BINARY' && + httpRequest.body.base64Bytes + ) { + body = { + encoding: 'base64', + content: httpRequest.body.base64Bytes, + contentType: httpRequest.body.contentType, + }; + } + return body; + }; + + const responseBodySanitizer = (httpRequest, httpResponse) => { + let responseBody = null; + if (httpResponse.body && httpResponse.body.string) { + responseBody = httpResponse.body.string; + } else if ( + httpResponse.body && + httpResponse.body.type === 'BINARY' && + httpResponse.body.base64Bytes + ) { + responseBody = { + encoding: 'base64', + content: httpResponse.body.base64Bytes, + }; + } else if (httpResponse.body) { + if (httpResponse.body && httpResponse.body.type === 'JSON' && httpResponse.body.json) { + responseBody = JSON.stringify(httpResponse.body.json); + } else { + responseBody = httpResponse.body; + } + } + + // replace recorded user with fake one + if ( + responseBody && + httpRequest.path === '/2.0/user' && + httpRequest.headers.host.includes('api.bitbucket.org') + ) { + responseBody = JSON.stringify(FAKE_OWNER_USER); + } + + return responseBody; + }; + + return transformData(expectation, requestBodySanitizer, responseBodySanitizer); +}; + +async function teardownBitBucketTest(taskData) { + await resetRepositories(taskData); + + return null; +} + +module.exports = { + setupBitBucket, + teardownBitBucket, + setupBitBucketTest, + teardownBitBucketTest, +}; diff --git a/cypress/plugins/common.js b/cypress/plugins/common.js new file mode 100644 index 00000000..d3f5c5b9 --- /dev/null +++ b/cypress/plugins/common.js @@ -0,0 +1,80 @@ +const path = require('path'); +const { default: simpleGit } = require('simple-git'); + +const GIT_SSH_COMMAND = 'ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no'; +const GIT_SSL_NO_VERIFY = true; + +const getExpectationsFilename = taskData => { + const { spec, testName } = taskData; + const basename = `${spec}__${testName}`; + const fixtures = path.join(__dirname, '..', 'fixtures'); + const filename = path.join(fixtures, `${basename}.json`); + + return filename; +}; + +const HEADERS_TO_IGNORE = [ + 'Date', + 'X-RateLimit-Remaining', + 'X-RateLimit-Reset', + 'ETag', + 'Last-Modified', + 'X-GitHub-Request-Id', + 'X-NF-Request-ID', + 'X-Request-Id', + 'X-Runtime', + 'RateLimit-Limit', + 'RateLimit-Observed', + 'RateLimit-Remaining', + 'RateLimit-Reset', + 'RateLimit-ResetTime', + 'GitLab-LB', +].map(h => h.toLowerCase()); + +const transformRecordedData = (expectation, requestBodySanitizer, responseBodySanitizer) => { + const { httpRequest, httpResponse } = expectation; + + const responseHeaders = { + 'Content-Type': 'application/json; charset=utf-8', + }; + + Object.keys(httpResponse.headers) + .filter(key => !HEADERS_TO_IGNORE.includes(key.toLowerCase())) + .forEach(key => { + responseHeaders[key] = httpResponse.headers[key][0]; + }); + + let queryString; + if (httpRequest.queryStringParameters) { + const { queryStringParameters } = httpRequest; + + queryString = Object.keys(queryStringParameters) + .map(key => `${key}=${queryStringParameters[key]}`) + .join('&'); + } + + const body = requestBodySanitizer(httpRequest); + const responseBody = responseBodySanitizer(httpRequest, httpResponse); + + const cypressRouteOptions = { + body, + method: httpRequest.method, + url: queryString ? `${httpRequest.path}?${queryString}` : httpRequest.path, + headers: responseHeaders, + response: responseBody, + status: httpResponse.statusCode, + }; + + return cypressRouteOptions; +}; + +function getGitClient(repoDir) { + const git = simpleGit(repoDir).env({ ...process.env, GIT_SSH_COMMAND, GIT_SSL_NO_VERIFY }); + return git; +} + +module.exports = { + getExpectationsFilename, + transformRecordedData, + getGitClient, +}; diff --git a/cypress/plugins/gitGateway.js b/cypress/plugins/gitGateway.js new file mode 100644 index 00000000..ec1ea747 --- /dev/null +++ b/cypress/plugins/gitGateway.js @@ -0,0 +1,284 @@ +const fetch = require('node-fetch'); +const { + transformRecordedData: transformGitHub, + setupGitHub, + teardownGitHub, + setupGitHubTest, + teardownGitHubTest, +} = require('./github'); +const { + transformRecordedData: transformGitLab, + setupGitLab, + teardownGitLab, + setupGitLabTest, + teardownGitLabTest, +} = require('./gitlab'); +const { getGitClient } = require('./common'); + +function getEnvs() { + const { + NETLIFY_API_TOKEN: netlifyApiToken, + GITHUB_REPO_TOKEN: githubToken, + GITLAB_REPO_TOKEN: gitlabToken, + NETLIFY_INSTALLATION_ID: installationId, + } = process.env; + if (!netlifyApiToken) { + throw new Error( + 'Please set NETLIFY_API_TOKEN, GITHUB_REPO_TOKEN, GITLAB_REPO_TOKEN, NETLIFY_INSTALLATION_ID environment variables', + ); + } + return { netlifyApiToken, githubToken, gitlabToken, installationId }; +} + +const apiRoot = 'https://api.netlify.com/api/v1/'; + +async function get(netlifyApiToken, path) { + const response = await fetch(`${apiRoot}${path}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${netlifyApiToken}`, + }, + }).then(res => res.json()); + + return response; +} + +async function post(netlifyApiToken, path, payload) { + const response = await fetch(`${apiRoot}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${netlifyApiToken}`, + }, + ...(payload ? { body: JSON.stringify(payload) } : {}), + }).then(res => res.json()); + + return response; +} + +async function del(netlifyApiToken, path) { + const response = await fetch(`${apiRoot}${path}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${netlifyApiToken}`, + }, + }).then(res => res.text()); + + return response; +} + +async function createSite(netlifyApiToken, payload) { + return post(netlifyApiToken, 'sites', payload); +} + +async function enableIdentity(netlifyApiToken, siteId) { + return post(netlifyApiToken, `sites/${siteId}/identity`, {}); +} + +async function enableGitGateway(netlifyApiToken, siteId, provider, token, repo) { + return post(netlifyApiToken, `sites/${siteId}/services/git/instances`, { + [provider]: { + repo, + access_token: token, + }, + }); +} + +async function enableLargeMedia(netlifyApiToken, siteId) { + return post(netlifyApiToken, `sites/${siteId}/services/large-media/instances`, {}); +} + +async function waitForDeploys(netlifyApiToken, siteId) { + for (let i = 0; i < 10; i++) { + const deploys = await get(netlifyApiToken, `sites/${siteId}/deploys`); + if (deploys.some(deploy => deploy.state === 'ready')) { + console.info('Deploy finished for site:', siteId); + return; + } + console.info('Waiting on deploy of site:', siteId); + await new Promise(resolve => setTimeout(resolve, 30 * 1000)); + } + console.info('Timed out waiting on deploy of site:', siteId); +} + +async function createUser(netlifyApiToken, siteUrl, email, password) { + const response = await fetch(`${siteUrl}/.netlify/functions/create-user`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${netlifyApiToken}`, + }, + body: JSON.stringify({ email, password }), + }); + + if (response.ok) { + console.info('User created successfully'); + } else { + throw new Error('Failed to create user'); + } +} + +const netlifySiteURL = 'https://fake-site-url.netlify.com/'; +const email = 'static@p-m.si'; +const password = '12345678'; +const backendName = 'git-gateway'; + +const methods = { + github: { + setup: setupGitHub, + teardown: teardownGitHub, + setupTest: setupGitHubTest, + teardownTest: teardownGitHubTest, + transformData: transformGitHub, + createSite: (netlifyApiToken, result) => { + const { installationId } = getEnvs(); + return createSite(netlifyApiToken, { + repo: { + provider: 'github', + installation_id: installationId, + repo: `${result.owner}/${result.repo}`, + }, + }); + }, + token: () => getEnvs().githubToken, + }, + gitlab: { + setup: setupGitLab, + teardown: teardownGitLab, + setupTest: setupGitLabTest, + teardownTest: teardownGitLabTest, + transformData: transformGitLab, + createSite: async (netlifyApiToken, result) => { + const { id, public_key } = await post(netlifyApiToken, 'deploy_keys'); + const { gitlabToken } = getEnvs(); + const project = `${result.owner}/${result.repo}`; + await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(project)}/deploy_keys`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${gitlabToken}`, + }, + body: JSON.stringify({ title: 'Netlify Deploy Key', key: public_key, can_push: false }), + }).then(res => res.json()); + + const site = await createSite(netlifyApiToken, { + account_slug: result.owner, + repo: { + provider: 'gitlab', + repo: `${result.owner}/${result.repo}`, + deploy_key_id: id, + }, + }); + + await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(project)}/hooks`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${gitlabToken}`, + }, + body: JSON.stringify({ + url: 'https://api.netlify.com/hooks/gitlab', + push_events: true, + merge_requests_events: true, + enable_ssl_verification: true, + }), + }).then(res => res.json()); + + return site; + }, + token: () => getEnvs().gitlabToken, + }, +}; + +async function setupGitGateway(options) { + const { provider, ...rest } = options; + const result = await methods[provider].setup(rest); + + const { netlifyApiToken } = getEnvs(); + + console.info(`Creating Netlify Site for provider: ${provider}`); + let site_id, ssl_url; + try { + ({ site_id, ssl_url } = await methods[provider].createSite(netlifyApiToken, result)); + } catch (e) { + console.error(e); + throw e; + } + + console.info('Enabling identity for site:', site_id); + await enableIdentity(netlifyApiToken, site_id); + + console.info('Enabling git gateway for site:', site_id); + const token = methods[provider].token(); + await enableGitGateway( + netlifyApiToken, + site_id, + provider, + token, + `${result.owner}/${result.repo}`, + ); + + console.info('Enabling large media for site:', site_id); + await enableLargeMedia(netlifyApiToken, site_id); + + const git = getGitClient(result.tempDir); + await git.raw([ + 'config', + '-f', + '.lfsconfig', + 'lfs.url', + `https://${site_id}.netlify.com/.netlify/large-media`, + ]); + await git.addConfig('commit.gpgsign', 'false'); + await git.add('.lfsconfig'); + await git.commit('add .lfsconfig'); + await git.push('origin', 'main'); + + await waitForDeploys(netlifyApiToken, site_id); + console.info('Creating user for site:', site_id, 'with email:', email); + + try { + await createUser(netlifyApiToken, ssl_url, email, password); + } catch (e) { + console.error(e); + } + + return { + ...result, + user: { + ...result.user, + backendName, + netlifySiteURL: ssl_url, + email, + password, + }, + site_id, + ssl_url, + provider, + }; +} + +async function teardownGitGateway(taskData) { + const { netlifyApiToken } = getEnvs(); + const { site_id } = taskData; + console.info('Deleting Netlify site:', site_id); + await del(netlifyApiToken, `sites/${site_id}`); + + return methods[taskData.provider].teardown(taskData); +} + +async function setupGitGatewayTest(taskData) { + return methods[taskData.provider].setupTest(taskData); +} + +async function teardownGitGatewayTest(taskData) { + return methods[taskData.provider].teardownTest(taskData, {}); +} + +module.exports = { + setupGitGateway, + teardownGitGateway, + setupGitGatewayTest, + teardownGitGatewayTest, +}; diff --git a/cypress/plugins/github.js b/cypress/plugins/github.js new file mode 100644 index 00000000..de81aed8 --- /dev/null +++ b/cypress/plugins/github.js @@ -0,0 +1,426 @@ +const { Octokit } = require('@octokit/rest'); +const fs = require('fs-extra'); +const path = require('path'); +const { + getExpectationsFilename, + transformRecordedData: transformData, + getGitClient, +} = require('./common'); +const { updateConfig } = require('../utils/config'); +const { escapeRegExp } = require('../utils/regexp'); +const { merge } = require('lodash'); + +const GITHUB_REPO_OWNER_SANITIZED_VALUE = 'owner'; +const GITHUB_REPO_NAME_SANITIZED_VALUE = 'repo'; +const GITHUB_REPO_TOKEN_SANITIZED_VALUE = 'fakeToken'; +const GITHUB_OPEN_AUTHORING_OWNER_SANITIZED_VALUE = 'forkOwner'; +const GITHUB_OPEN_AUTHORING_TOKEN_SANITIZED_VALUE = 'fakeForkToken'; + +const FAKE_OWNER_USER = { + login: 'owner', + id: 1, + avatar_url: 'https://avatars1.githubusercontent.com/u/7892489?v=4', + name: 'owner', +}; + +const FAKE_FORK_OWNER_USER = { + login: 'forkOwner', + id: 2, + avatar_url: 'https://avatars1.githubusercontent.com/u/9919?s=200&v=4', + name: 'forkOwner', +}; + +function getGitHubClient(token) { + const client = new Octokit({ + auth: `token ${token}`, + baseUrl: 'https://api.github.com', + }); + return client; +} + +function getEnvs() { + const { + GITHUB_REPO_OWNER: owner, + GITHUB_REPO_NAME: repo, + GITHUB_REPO_TOKEN: token, + GITHUB_OPEN_AUTHORING_OWNER: forkOwner, + GITHUB_OPEN_AUTHORING_TOKEN: forkToken, + } = process.env; + if (!owner || !repo || !token || !forkOwner || !forkToken) { + throw new Error( + 'Please set GITHUB_REPO_OWNER, GITHUB_REPO_NAME, GITHUB_REPO_TOKEN, GITHUB_OPEN_AUTHORING_OWNER, GITHUB_OPEN_AUTHORING_TOKEN environment variables', + ); + } + return { owner, repo, token, forkOwner, forkToken }; +} + +async function prepareTestGitHubRepo() { + const { owner, repo, token } = getEnvs(); + + // postfix a random string to avoid collisions + const postfix = Math.random().toString(32).slice(2); + const testRepoName = `${repo}-${Date.now()}-${postfix}`; + + const client = getGitHubClient(token); + + console.info('Creating repository', testRepoName); + await client.repos.createForAuthenticatedUser({ + name: testRepoName, + }); + + const tempDir = path.join('.temp', testRepoName); + await fs.remove(tempDir); + let git = getGitClient(); + + const repoUrl = `git@github.com:${owner}/${repo}.git`; + + console.info('Cloning repository', repoUrl); + await git.clone(repoUrl, tempDir); + git = getGitClient(tempDir); + + console.info('Pushing to new repository', testRepoName); + + await git.removeRemote('origin'); + await git.addRemote( + 'origin', + `https://${token}:x-oauth-basic@github.com/${owner}/${testRepoName}`, + ); + await git.push(['-u', 'origin', 'main']); + + return { owner, repo: testRepoName, tempDir }; +} + +async function getAuthenticatedUser(token) { + const client = getGitHubClient(token); + const { data: user } = await client.users.getAuthenticated(); + return { ...user, token, backendName: 'github' }; +} + +async function getUser() { + const { token } = getEnvs(); + return getAuthenticatedUser(token); +} + +async function getForkUser() { + const { forkToken } = getEnvs(); + return getAuthenticatedUser(forkToken); +} + +async function deleteRepositories({ owner, repo, tempDir }) { + const { forkOwner, token, forkToken } = getEnvs(); + + const errorHandler = e => { + if (e.status !== 404) { + throw e; + } + }; + + console.info('Deleting repository', `${owner}/${repo}`); + await fs.remove(tempDir); + + let client = getGitHubClient(token); + await client.repos + .delete({ + owner, + repo, + }) + .catch(errorHandler); + + console.info('Deleting forked repository', `${forkOwner}/${repo}`); + client = getGitHubClient(forkToken); + await client.repos + .delete({ + owner: forkOwner, + repo, + }) + .catch(errorHandler); +} + +async function batchRequests(items, batchSize, func) { + while (items.length > 0) { + const batch = items.splice(0, batchSize); + await Promise.all(batch.map(func)); + await new Promise(resolve => setTimeout(resolve, 2500)); + } +} + +async function resetOriginRepo({ owner, repo, tempDir }) { + console.info('Resetting origin repo:', `${owner}/${repo}`); + const { token } = getEnvs(); + const client = getGitHubClient(token); + + const { data: prs } = await client.pulls.list({ + repo, + owner, + state: 'open', + }); + const numbers = prs.map(pr => pr.number); + console.info('Closing prs:', numbers); + + await batchRequests(numbers, 10, async pull_number => { + await client.pulls.update({ + owner, + repo, + pull_number, + state: 'closed', + }); + }); + + const { data: branches } = await client.repos.listBranches({ owner, repo }); + const refs = branches.filter(b => b.name !== 'main').map(b => `heads/${b.name}`); + + console.info('Deleting refs', refs); + + await batchRequests(refs, 10, async ref => { + await client.git.deleteRef({ + owner, + repo, + ref, + }); + }); + + console.info('Resetting main'); + const git = getGitClient(tempDir); + await git.push(['--force', 'origin', 'main']); + console.info('Done resetting origin repo:', `${owner}/${repo}`); +} + +async function resetForkedRepo({ repo }) { + const { forkToken, forkOwner } = getEnvs(); + const client = getGitHubClient(forkToken); + + const { data: repos } = await client.repos.list(); + if (repos.some(r => r.name === repo)) { + console.info('Resetting forked repo:', `${forkOwner}/${repo}`); + const { data: branches } = await client.repos.listBranches({ owner: forkOwner, repo }); + const refs = branches.filter(b => b.name !== 'main').map(b => `heads/${b.name}`); + + console.info('Deleting refs', refs); + await Promise.all( + refs.map(ref => + client.git.deleteRef({ + owner: forkOwner, + repo, + ref, + }), + ), + ); + console.info('Done resetting forked repo:', `${forkOwner}/${repo}`); + } +} + +async function resetRepositories({ owner, repo, tempDir }) { + await resetOriginRepo({ owner, repo, tempDir }); + await resetForkedRepo({ repo }); +} + +async function setupGitHub(options) { + console.info('Running tests - live data will be used!'); + const [user, forkUser, repoData] = await Promise.all([ + getUser(), + getForkUser(), + prepareTestGitHubRepo(), + ]); + + await updateConfig(config => { + merge(config, options, { + backend: { + repo: `${repoData.owner}/${repoData.repo}`, + }, + }); + }); + + return { ...repoData, user, forkUser }; +} + +async function teardownGitHub(taskData) { + await deleteRepositories(taskData); + + return null; +} + +async function setupGitHubTest(taskData) { + await resetRepositories(taskData); + + return null; +} + +const sanitizeString = ( + str, + { owner, repo, token, forkOwner, forkToken, ownerName, forkOwnerName }, +) => { + let replaced = str + .replace(new RegExp(escapeRegExp(forkOwner), 'g'), GITHUB_OPEN_AUTHORING_OWNER_SANITIZED_VALUE) + .replace(new RegExp(escapeRegExp(forkToken), 'g'), GITHUB_OPEN_AUTHORING_TOKEN_SANITIZED_VALUE) + .replace(new RegExp(escapeRegExp(owner), 'g'), GITHUB_REPO_OWNER_SANITIZED_VALUE) + .replace(new RegExp(escapeRegExp(repo), 'g'), GITHUB_REPO_NAME_SANITIZED_VALUE) + .replace(new RegExp(escapeRegExp(token), 'g'), GITHUB_REPO_TOKEN_SANITIZED_VALUE) + .replace( + new RegExp('https://avatars\\d+\\.githubusercontent\\.com/u/\\d+?\\?v=\\d', 'g'), + `${FAKE_OWNER_USER.avatar_url}`, + ); + + if (ownerName) { + replaced = replaced.replace(new RegExp(escapeRegExp(ownerName), 'g'), FAKE_OWNER_USER.name); + } + + if (forkOwnerName) { + replaced = replaced.replace( + new RegExp(escapeRegExp(forkOwnerName), 'g'), + FAKE_FORK_OWNER_USER.name, + ); + } + + return replaced; +}; + +const transformRecordedData = (expectation, toSanitize) => { + const requestBodySanitizer = httpRequest => { + let body; + if (httpRequest.body && httpRequest.body.type === 'JSON' && httpRequest.body.json) { + const bodyObject = + typeof httpRequest.body.json === 'string' + ? JSON.parse(httpRequest.body.json) + : httpRequest.body.json; + + if (bodyObject.encoding === 'base64') { + // sanitize encoded data + const decodedBody = Buffer.from(bodyObject.content, 'base64').toString('binary'); + const sanitizedContent = sanitizeString(decodedBody, toSanitize); + const sanitizedEncodedContent = Buffer.from(sanitizedContent, 'binary').toString('base64'); + bodyObject.content = sanitizedEncodedContent; + body = JSON.stringify(bodyObject); + } else { + body = JSON.stringify(bodyObject); + } + } else if (httpRequest.body && httpRequest.body.type === 'STRING' && httpRequest.body.string) { + body = httpRequest.body.string; + } else if (httpRequest.body) { + const str = + typeof httpRequest.body !== 'string' ? JSON.stringify(httpRequest.body) : httpRequest.body; + body = sanitizeString(str, toSanitize); + } + return body; + }; + + const responseBodySanitizer = (httpRequest, httpResponse) => { + let responseBody = null; + if (httpResponse.body && httpResponse.body.string) { + responseBody = httpResponse.body.string; + } else if ( + httpResponse.body && + httpResponse.body.type === 'BINARY' && + httpResponse.body.base64Bytes + ) { + responseBody = { + encoding: 'base64', + content: httpResponse.body.base64Bytes, + }; + } else if (httpResponse.body && httpResponse.body.json) { + responseBody = JSON.stringify(httpResponse.body.json); + } else { + responseBody = + typeof httpResponse.body === 'string' + ? httpResponse.body + : httpResponse.body && JSON.stringify(httpResponse.body); + } + + // replace recorded user with fake one + if ( + responseBody && + httpRequest.path === '/user' && + httpRequest.headers.host.includes('api.github.com') + ) { + const parsed = JSON.parse(responseBody); + if (parsed.login === toSanitize.forkOwner) { + responseBody = JSON.stringify(FAKE_FORK_OWNER_USER); + } else { + responseBody = JSON.stringify(FAKE_OWNER_USER); + } + } + return responseBody; + }; + + const cypressRouteOptions = transformData( + expectation, + requestBodySanitizer, + responseBodySanitizer, + ); + + return cypressRouteOptions; +}; + +const defaultOptions = { + transformRecordedData, +}; + +async function teardownGitHubTest(taskData, { transformRecordedData } = defaultOptions) { + await resetRepositories(taskData); + + return null; +} + +async function seedGitHubRepo(taskData) { + const { owner, token } = getEnvs(); + + const client = getGitHubClient(token); + const repo = taskData.repo; + + try { + console.info('Getting main branch'); + const { data: main } = await client.repos.getBranch({ + owner, + repo, + branch: 'main', + }); + + const prCount = 120; + const prs = new Array(prCount).fill(0).map((v, i) => i); + const batchSize = 5; + await batchRequests(prs, batchSize, async i => { + const branch = `seed_branch_${i}`; + console.info(`Creating branch ${branch}`); + await client.git.createRef({ + owner, + repo, + ref: `refs/heads/${branch}`, + sha: main.commit.sha, + }); + + const path = `seed/file_${i}`; + console.info(`Creating file ${path}`); + await client.repos.createOrUpdateFile({ + owner, + repo, + branch, + content: Buffer.from(`Seed File ${i}`).toString('base64'), + message: `Create seed file ${i}`, + path, + }); + + const title = `Non CMS Pull Request ${i}`; + console.info(`Creating PR ${title}`); + await client.pulls.create({ + owner, + repo, + base: 'main', + head: branch, + title, + }); + }); + } catch (e) { + console.error(e); + throw e; + } + + return null; +} + +module.exports = { + transformRecordedData, + setupGitHub, + teardownGitHub, + setupGitHubTest, + teardownGitHubTest, + seedGitHubRepo, +}; diff --git a/cypress/plugins/gitlab.js b/cypress/plugins/gitlab.js new file mode 100644 index 00000000..c650a335 --- /dev/null +++ b/cypress/plugins/gitlab.js @@ -0,0 +1,275 @@ +const { Gitlab } = require('gitlab'); +const fs = require('fs-extra'); +const path = require('path'); +const { updateConfig } = require('../utils/config'); +const { escapeRegExp } = require('../utils/regexp'); +const { + getExpectationsFilename, + transformRecordedData: transformData, + getGitClient, +} = require('./common'); +const { merge } = require('lodash'); + +const GITLAB_REPO_OWNER_SANITIZED_VALUE = 'owner'; +const GITLAB_REPO_NAME_SANITIZED_VALUE = 'repo'; +const GITLAB_REPO_TOKEN_SANITIZED_VALUE = 'fakeToken'; + +const FAKE_OWNER_USER = { + id: 1, + name: 'owner', + username: 'owner', + avatar_url: 'https://avatars1.githubusercontent.com/u/7892489?v=4', + email: 'owner@email.com', + login: 'owner', +}; + +function getGitLabClient(token) { + const client = new Gitlab({ + token, + }); + + return client; +} + +function getEnvs() { + const { + GITLAB_REPO_OWNER: owner, + GITLAB_REPO_NAME: repo, + GITLAB_REPO_TOKEN: token, + } = process.env; + if (!owner || !repo || !token) { + throw new Error( + 'Please set GITLAB_REPO_OWNER, GITLAB_REPO_NAME, GITLAB_REPO_TOKEN environment variables', + ); + } + return { owner, repo, token }; +} + +async function prepareTestGitLabRepo() { + const { owner, repo, token } = getEnvs(); + + // postfix a random string to avoid collisions + const postfix = Math.random().toString(32).slice(2); + const testRepoName = `${repo}-${Date.now()}-${postfix}`; + + const client = getGitLabClient(token); + + console.info('Creating repository', testRepoName); + await client.Projects.create({ + name: testRepoName, + lfs_enabled: false, + }); + + const tempDir = path.join('.temp', testRepoName); + await fs.remove(tempDir); + let git = getGitClient(); + + const repoUrl = `git@gitlab.com:${owner}/${repo}.git`; + + console.info('Cloning repository', repoUrl); + await git.clone(repoUrl, tempDir); + git = getGitClient(tempDir); + + console.info('Pushing to new repository', testRepoName); + + await git.removeRemote('origin'); + await git.addRemote('origin', `https://oauth2:${token}@gitlab.com/${owner}/${testRepoName}`); + await git.push(['-u', 'origin', 'main']); + + await client.ProtectedBranches.unprotect(`${owner}/${testRepoName}`, 'main'); + + return { owner, repo: testRepoName, tempDir }; +} + +async function getAuthenticatedUser(token) { + const client = getGitLabClient(token); + const user = await client.Users.current(); + return { ...user, token, backendName: 'gitlab' }; +} + +async function getUser() { + const { token } = getEnvs(); + return getAuthenticatedUser(token); +} + +async function deleteRepositories({ owner, repo, tempDir }) { + const { token } = getEnvs(); + + const errorHandler = e => { + if (e.status !== 404) { + throw e; + } + }; + + console.info('Deleting repository', `${owner}/${repo}`); + await fs.remove(tempDir); + + const client = getGitLabClient(token); + await client.Projects.remove(`${owner}/${repo}`).catch(errorHandler); +} + +async function resetOriginRepo({ owner, repo, tempDir }) { + console.info('Resetting origin repo:', `${owner}/${repo}`); + + const { token } = getEnvs(); + const client = getGitLabClient(token); + + const projectId = `${owner}/${repo}`; + const mergeRequests = await client.MergeRequests.all({ + projectId, + state: 'opened', + }); + const ids = mergeRequests.map(mr => mr.iid); + console.info('Closing merge requests:', ids); + await Promise.all( + ids.map(id => client.MergeRequests.edit(projectId, id, { state_event: 'close' })), + ); + const branches = await client.Branches.all(projectId); + const toDelete = branches.filter(b => b.name !== 'main').map(b => b.name); + + console.info('Deleting branches', toDelete); + await Promise.all(toDelete.map(branch => client.Branches.remove(projectId, branch))); + + console.info('Resetting main'); + const git = getGitClient(tempDir); + await git.push(['--force', 'origin', 'main']); + console.info('Done resetting origin repo:', `${owner}/${repo}`); +} + +async function resetRepositories({ owner, repo, tempDir }) { + await resetOriginRepo({ owner, repo, tempDir }); +} + +async function setupGitLab(options) { + console.info('Running tests - live data will be used!'); + const [user, repoData] = await Promise.all([getUser(), prepareTestGitLabRepo()]); + + await updateConfig(config => { + merge(config, options, { + backend: { + repo: `${repoData.owner}/${repoData.repo}`, + }, + }); + }); + + return { ...repoData, user }; +} + +async function teardownGitLab(taskData) { + await deleteRepositories(taskData); + + return null; +} + +async function setupGitLabTest(taskData) { + await resetRepositories(taskData); + + return null; +} + +const sanitizeString = (str, { owner, repo, token, ownerName }) => { + let replaced = str + .replace(new RegExp(escapeRegExp(owner), 'g'), GITLAB_REPO_OWNER_SANITIZED_VALUE) + .replace(new RegExp(escapeRegExp(repo), 'g'), GITLAB_REPO_NAME_SANITIZED_VALUE) + .replace(new RegExp(escapeRegExp(token), 'g'), GITLAB_REPO_TOKEN_SANITIZED_VALUE) + .replace( + new RegExp('https://secure.gravatar.+?/u/.+?v=\\d', 'g'), + `${FAKE_OWNER_USER.avatar_url}`, + ); + + if (ownerName) { + replaced = replaced.replace(new RegExp(escapeRegExp(ownerName), 'g'), FAKE_OWNER_USER.name); + } + + return replaced; +}; + +const transformRecordedData = (expectation, toSanitize) => { + const requestBodySanitizer = httpRequest => { + let body; + if (httpRequest.body && httpRequest.body.type === 'JSON' && httpRequest.body.json) { + const bodyObject = + typeof httpRequest.body.json === 'string' + ? JSON.parse(httpRequest.body.json) + : httpRequest.body.json; + + if (bodyObject.encoding === 'base64') { + // sanitize encoded data + const decodedBody = Buffer.from(bodyObject.content, 'base64').toString('binary'); + const sanitizedContent = sanitizeString(decodedBody, toSanitize); + const sanitizedEncodedContent = Buffer.from(sanitizedContent, 'binary').toString('base64'); + bodyObject.content = sanitizedEncodedContent; + body = JSON.stringify(bodyObject); + } else { + body = JSON.stringify(bodyObject); + } + } else if (httpRequest.body && httpRequest.body.type === 'STRING' && httpRequest.body.string) { + body = sanitizeString(httpRequest.body.string, toSanitize); + } else if (httpRequest.body) { + const str = + typeof httpRequest.body !== 'string' ? JSON.stringify(httpRequest.body) : httpRequest.body; + body = sanitizeString(str, toSanitize); + } + return body; + }; + + const responseBodySanitizer = (httpRequest, httpResponse) => { + let responseBody = null; + if (httpResponse.body && httpResponse.body.string) { + responseBody = httpResponse.body.string; + } else if ( + httpResponse.body && + httpResponse.body.type === 'BINARY' && + httpResponse.body.base64Bytes + ) { + responseBody = { + encoding: 'base64', + content: httpResponse.body.base64Bytes, + }; + } else if (httpResponse.body && httpResponse.body.json) { + responseBody = JSON.stringify(httpResponse.body.json); + } else { + responseBody = + typeof httpResponse.body === 'string' + ? httpResponse.body + : httpResponse.body && JSON.stringify(httpResponse.body); + } + + // replace recorded user with fake one + if ( + responseBody && + httpRequest.path === '/api/v4/user' && + httpRequest.headers.host.includes('gitlab.com') + ) { + responseBody = JSON.stringify(FAKE_OWNER_USER); + } + + return responseBody; + }; + + const cypressRouteOptions = transformData( + expectation, + requestBodySanitizer, + responseBodySanitizer, + ); + + return cypressRouteOptions; +}; + +const defaultOptions = { + transformRecordedData, +}; + +async function teardownGitLabTest(taskData, { transformRecordedData } = defaultOptions) { + await resetRepositories(taskData); + + return null; +} + +module.exports = { + transformRecordedData, + setupGitLab, + teardownGitLab, + setupGitLabTest, + teardownGitLabTest, +}; diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts new file mode 100644 index 00000000..45dda08b --- /dev/null +++ b/cypress/plugins/index.ts @@ -0,0 +1,178 @@ +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) +import 'dotenv/config'; + +import { addMatchImageSnapshotPlugin } from '@simonsmith/cypress-image-snapshot/plugin'; +import merge from 'lodash/merge'; + +// const { setupGitHub, teardownGitHub, setupGitHubTest, teardownGitHubTest, seedGitHubRepo } = require("./github"); +// const { setupGitGateway, teardownGitGateway, setupGitGatewayTest, teardownGitGatewayTest } = require("./gitGateway"); +// const { setupGitLab, teardownGitLab, setupGitLabTest, teardownGitLabTest } = require("./gitlab"); +// const { setupBitBucket, teardownBitBucket, setupBitBucketTest, teardownBitBucketTest } = require("./bitbucket"); +// const { setupProxy, teardownProxy, setupProxyTest, teardownProxyTest } = require("./proxy"); +import { setupTestBackend } from './testBackend'; + +import { copyBackendFiles, switchVersion, updateConfig } from '../utils/config'; + +import type { Config } from '@staticcms/core/interface'; +import type { + SeedRepoProps, + SetupBackendProps, + SetupBackendResponse, + SetupBackendTestProps, + TeardownBackendProps, + TeardownBackendTestProps, +} from '../interface'; + +export default async (on: Cypress.PluginEvents) => { + // `on` is used to hook into various events Cypress emits + on('task', { + async setupBackend({ backend, options }: SetupBackendProps): Promise { + console.info('Preparing environment for backend', backend); + await copyBackendFiles(backend); + + let result = null; + switch (backend) { + // case "github": + // result = await setupGitHub(options); + // break; + // case "git-gateway": + // result = await setupGitGateway(options); + // break; + // case "gitlab": + // result = await setupGitLab(options); + // break; + // case "bitbucket": + // result = await setupBitBucket(options); + // break; + // case "proxy": + // result = await setupProxy(options); + // break; + case 'test': + result = await setupTestBackend(options); + break; + } + + return result; + }, + async teardownBackend({ backend }: TeardownBackendProps): Promise { + console.info('Tearing down backend', backend); + + switch ( + backend + // case "github": + // await teardownGitHub(taskData); + // break; + // case "git-gateway": + // await teardownGitGateway(taskData); + // break; + // case "gitlab": + // await teardownGitLab(taskData); + // break; + // case "bitbucket": + // await teardownBitBucket(taskData); + // break; + // case "proxy": + // await teardownProxy(taskData); + // break; + ) { + } + + console.info('Restoring defaults'); + await copyBackendFiles('test'); + + return null; + }, + async setupBackendTest({ backend, testName }: SetupBackendTestProps): Promise { + console.info(`Setting up single test '${testName}' for backend`, backend); + + switch ( + backend + // case "github": + // await setupGitHubTest(taskData); + // break; + // case "git-gateway": + // await setupGitGatewayTest(taskData); + // break; + // case "gitlab": + // await setupGitLabTest(taskData); + // break; + // case "bitbucket": + // await setupBitBucketTest(taskData); + // break; + // case "proxy": + // await setupProxyTest(taskData); + // break; + ) { + } + + return null; + }, + async teardownBackendTest({ backend, testName }: TeardownBackendTestProps): Promise { + console.info(`Tearing down single test '${testName}' for backend`, backend); + + switch ( + backend + // case "github": + // await teardownGitHubTest(taskData); + // break; + // case "git-gateway": + // await teardownGitGatewayTest(taskData); + // break; + // case "gitlab": + // await teardownGitLabTest(taskData); + // break; + // case "bitbucket": + // await teardownBitBucketTest(taskData); + // break; + // case "proxy": + // await teardownProxyTest(taskData); + // break; + ) { + } + + return null; + }, + async seedRepo({ backend }: SeedRepoProps): Promise { + console.info(`Seeding repository for backend`, backend); + + switch ( + backend + // case "github": + // await seedGitHubRepo(taskData); + // break; + ) { + } + + return null; + }, + async switchToVersion(taskData) { + const { version } = taskData; + + console.info(`Switching CMS to version '${version}'`); + + await switchVersion(version); + + return null; + }, + async updateConfig(config: Partial) { + await updateConfig(current => { + merge(current, config); + }); + + return null; + }, + }); + + addMatchImageSnapshotPlugin(on); +}; diff --git a/cypress/plugins/proxy.js b/cypress/plugins/proxy.js new file mode 100644 index 00000000..6ca7025b --- /dev/null +++ b/cypress/plugins/proxy.js @@ -0,0 +1,105 @@ +const fs = require('fs-extra'); +const path = require('path'); +const { spawn } = require('child_process'); +const { merge } = require('lodash'); + +const { updateConfig } = require('../utils/config'); +const { getGitClient } = require('./common'); + +const initRepo = async dir => { + await fs.remove(dir); + await fs.mkdirp(dir); + const git = getGitClient(dir); + await git.init({ '--initial-branch': 'main' }); + await git.addConfig('user.email', 'cms-cypress-test@netlify.com'); + await git.addConfig('user.name', 'cms-cypress-test'); + + const readme = 'README.md'; + await fs.writeFile(path.join(dir, readme), ''); + await git.add(readme); + await git.commit('initial commit', readme, { '--no-verify': true, '--no-gpg-sign': true }); +}; + +const startServer = async (repoDir, mode) => { + const tsNode = path.join(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node'); + const serverDir = path.join(__dirname, '..', '..', 'packages', 'static-server'); + const distIndex = path.join(serverDir, 'dist', 'index.js'); + const tsIndex = path.join(serverDir, 'src', 'index.ts'); + + const port = 8082; + const env = { + ...process.env, + GIT_REPO_DIRECTORY: path.resolve(repoDir), + PORT: port, + MODE: mode, + }; + + console.info(`Starting proxy server on port '${port}' with mode ${mode}`); + if (await fs.pathExists(distIndex)) { + serverProcess = spawn('node', [distIndex], { env, cwd: serverDir }); + } else { + serverProcess = spawn(tsNode, ['--files', tsIndex], { env, cwd: serverDir }); + } + + return new Promise((resolve, reject) => { + serverProcess.stdout.on('data', data => { + const message = data.toString().trim(); + console.info(`server:stdout: ${message}`); + if (message.includes('Static CMS Proxy Server listening on port')) { + resolve(serverProcess); + } + }); + + serverProcess.stderr.on('data', data => { + console.error(`server:stderr: ${data.toString().trim()}`); + reject(data.toString()); + }); + }); +}; + +let serverProcess; + +async function setupProxy(options) { + const postfix = Math.random().toString(32).slice(2); + + const testRepoName = `proxy-test-repo-${Date.now()}-${postfix}`; + const tempDir = path.join('.temp', testRepoName); + + const { mode, ...rest } = options; + + await updateConfig(config => { + merge(config, rest); + }); + + return { tempDir, mode }; +} + +async function teardownProxy(taskData) { + if (serverProcess) { + serverProcess.kill(); + } + await fs.remove(taskData.tempDir); + + return null; +} + +async function setupProxyTest(taskData) { + await initRepo(taskData.tempDir); + serverProcess = await startServer(taskData.tempDir, taskData.mode); + return null; +} + +async function teardownProxyTest(taskData) { + if (serverProcess) { + serverProcess.kill(); + } + await fs.remove(taskData.tempDir); + return null; +} + +module.exports = { + setupProxy, + teardownProxy, + setupProxyTest, + teardownProxyTest, +}; diff --git a/cypress/plugins/testBackend.ts b/cypress/plugins/testBackend.ts new file mode 100644 index 00000000..c7ccf62e --- /dev/null +++ b/cypress/plugins/testBackend.ts @@ -0,0 +1,13 @@ +import merge from 'lodash/merge'; +import { updateConfig } from '../utils/config'; + +import type { Config } from '@staticcms/core/interface'; +import type { SetupBackendResponse } from '../interface'; + +export async function setupTestBackend(options: Partial): Promise { + await updateConfig(current => { + merge(current, options); + }); + + return null; +} diff --git a/cypress/run.mjs b/cypress/run.mjs new file mode 100644 index 00000000..9e77c678 --- /dev/null +++ b/cypress/run.mjs @@ -0,0 +1,41 @@ +import execa from 'execa'; +import { globby } from 'globby'; + +async function runCypress() { + if (process.env.IS_FORK === 'true') { + const machineIndex = parseInt(process.env.MACHINE_INDEX); + const machineCount = parseInt(process.env.MACHINE_COUNT); + const specs = await globby(['cypress/integration/*spec*.js']); + const specsPerMachine = Math.floor(specs.length / machineCount); + const start = (machineIndex - 1) * specsPerMachine; + const machineSpecs = + machineIndex === machineCount + ? specs.slice(start) + : specs.slice(start, start + specsPerMachine); + + await execa( + 'cypress', + ['run', '--browser', 'chrome', '--headless', '--spec', machineSpecs.join(',')], + { stdio: 'inherit', preferLocal: true }, + ); + } else { + await execa( + 'cypress', + [ + 'run', + '--browser', + 'chrome', + '--headless', + '--record', + '--parallel', + '--ci-build-id', + process.env.GITHUB_SHA, + '--group', + 'GitHub CI', + ], + { stdio: 'inherit', preferLocal: true }, + ); + } +} + +runCypress(); diff --git a/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can delete image from global media library.snap.png b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can delete image from global media library.snap.png new file mode 100644 index 00000000..d64a7a56 Binary files /dev/null and b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can delete image from global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can publish entry with image.snap.png b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can publish entry with image.snap.png new file mode 100644 index 00000000..bfe883f7 Binary files /dev/null and b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can publish entry with image.snap.png differ diff --git a/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can save entry with image.snap.png b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can save entry with image.snap.png new file mode 100644 index 00000000..2850e691 Binary files /dev/null and b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can save entry with image.snap.png differ diff --git a/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can upload image from entry media library.snap.png b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can upload image from entry media library.snap.png new file mode 100644 index 00000000..f7b064d2 Binary files /dev/null and b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can upload image from entry media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can upload image from global media library.snap.png b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can upload image from global media library.snap.png new file mode 100644 index 00000000..c45b810d Binary files /dev/null and b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- can upload image from global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- should not show draft entry image in global media library.snap.png b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- should not show draft entry image in global media library.snap.png new file mode 100644 index 00000000..3d4b65a9 Binary files /dev/null and b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- should not show draft entry image in global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- should show published entry image in global media library.snap.png b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- should show published entry image in global media library.snap.png new file mode 100644 index 00000000..ebf2054d Binary files /dev/null and b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- should show published entry image in global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- should show published entry image in grid view.snap.png b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- should show published entry image in grid view.snap.png new file mode 100644 index 00000000..77a5b8ca Binary files /dev/null and b/cypress/snapshots/media_library_spec_git-gateway_backend_large_media.js/Git Gateway Backend Media Library - Large Media -- should show published entry image in grid view.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can delete image from global media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can delete image from global media library.snap.png new file mode 100644 index 00000000..849eb72c Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can delete image from global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can publish entry with image.snap.png b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can publish entry with image.snap.png new file mode 100644 index 00000000..acc62dd4 Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can publish entry with image.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can save entry with image.snap.png b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can save entry with image.snap.png new file mode 100644 index 00000000..7c6fabbd Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can save entry with image.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can upload image from entry media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can upload image from entry media library.snap.png new file mode 100644 index 00000000..af11607e Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can upload image from entry media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can upload image from global media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can upload image from global media library.snap.png new file mode 100644 index 00000000..20eee39d Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- can upload image from global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- should not show draft entry image in global media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- should not show draft entry image in global media library.snap.png new file mode 100644 index 00000000..b73df63b Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- should not show draft entry image in global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- should show published entry image in global media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- should show published entry image in global media library.snap.png new file mode 100644 index 00000000..22981d6f Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- should show published entry image in global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- should show published entry image in grid view.snap.png b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- should show published entry image in grid view.snap.png new file mode 100644 index 00000000..db978aec Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_graphql.js/GitHub Backend Media Library - GraphQL API -- should show published entry image in grid view.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can delete image from global media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can delete image from global media library.snap.png new file mode 100644 index 00000000..33a6187e Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can delete image from global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can publish entry with image.snap.png b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can publish entry with image.snap.png new file mode 100644 index 00000000..1bd9410a Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can publish entry with image.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can save entry with image.snap.png b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can save entry with image.snap.png new file mode 100644 index 00000000..80d85269 Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can save entry with image.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can upload image from entry media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can upload image from entry media library.snap.png new file mode 100644 index 00000000..1c7fe29a Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can upload image from entry media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can upload image from global media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can upload image from global media library.snap.png new file mode 100644 index 00000000..20eee39d Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- can upload image from global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- should not show draft entry image in global media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- should not show draft entry image in global media library.snap.png new file mode 100644 index 00000000..73c77b36 Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- should not show draft entry image in global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- should show published entry image in global media library.snap.png b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- should show published entry image in global media library.snap.png new file mode 100644 index 00000000..93ca0454 Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- should show published entry image in global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- should show published entry image in grid view.snap.png b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- should show published entry image in grid view.snap.png new file mode 100644 index 00000000..8c95b623 Binary files /dev/null and b/cypress/snapshots/media_library_spec_github_backend_rest.js/GitHub Backend Media Library - REST API -- should show published entry image in grid view.snap.png differ diff --git a/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can delete image from global media library.snap.png b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can delete image from global media library.snap.png new file mode 100644 index 00000000..0df4524c Binary files /dev/null and b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can delete image from global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can publish entry with image.snap.png b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can publish entry with image.snap.png new file mode 100644 index 00000000..7804e216 Binary files /dev/null and b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can publish entry with image.snap.png differ diff --git a/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can save entry with image.snap.png b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can save entry with image.snap.png new file mode 100644 index 00000000..9a23d143 Binary files /dev/null and b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can save entry with image.snap.png differ diff --git a/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can upload image from entry media library.snap.png b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can upload image from entry media library.snap.png new file mode 100644 index 00000000..157eb756 Binary files /dev/null and b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can upload image from entry media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can upload image from global media library.snap.png b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can upload image from global media library.snap.png new file mode 100644 index 00000000..3db31625 Binary files /dev/null and b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- can upload image from global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- should not show draft entry image in global media library.snap.png b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- should not show draft entry image in global media library.snap.png new file mode 100644 index 00000000..dff9602a Binary files /dev/null and b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- should not show draft entry image in global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- should show published entry image in global media library.snap.png b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- should show published entry image in global media library.snap.png new file mode 100644 index 00000000..479f0a2e Binary files /dev/null and b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- should show published entry image in global media library.snap.png differ diff --git a/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- should show published entry image in grid view.snap.png b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- should show published entry image in grid view.snap.png new file mode 100644 index 00000000..b7a92f56 Binary files /dev/null and b/cypress/snapshots/media_library_spec_test_backend.js/Test Backend Media Library -- should show published entry image in grid view.snap.png differ diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 00000000..da23d60c --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,322 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This is will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) +import { oneLineTrim } from 'common-tags'; +import { rehype } from 'rehype'; +import { visit } from 'unist-util-visit'; + +function runTimes( + cyInstance: Cypress.Chainable, + fn: (chain: Cypress.Chainable) => Cypress.Chainable, + count = 1, +) { + let chain = cyInstance, + i = count; + while (i) { + i -= 1; + chain = fn(chain); + } + return chain; +} + +( + [ + 'enter', + 'backspace', + ['selectAll', 'selectall'], + ['up', 'upArrow'], + ['down', 'downArrow'], + ['left', 'leftArrow'], + ['right', 'rightArrow'], + ] as const +).forEach(key => { + const [cmd, keyName] = typeof key === 'object' ? key : [key, key]; + Cypress.Commands.add(cmd, { prevSubject: true }, (subject, { shift, times = 1 } = {}) => { + const fn = (chain: Cypress.Chainable) => + chain.type(`${shift ? '{shift}' : ''}{${keyName}}`, { delay: 50 }); + return runTimes(cy.wrap(subject), fn, times); + }); +}); + +// Convert `tab` command from plugin to a child command with `times` support +Cypress.Commands.add('tabkey', { prevSubject: true }, (subject, { shift, times } = {}) => { + const fn = (chain: Cypress.Chainable) => chain.tab({ shift }); + cy.wait(100); + return runTimes(cy, fn, times).wrap(subject); +}); + +Cypress.Commands.add('selection', { prevSubject: true }, (subject, fn) => { + cy.wrap(subject).trigger('mousedown').then(fn).trigger('mouseup'); + + cy.document().trigger('selectionchange'); + return cy.wrap(subject); +}); + +Cypress.Commands.add('print', { prevSubject: 'optional' }, (subject, str) => { + cy.log(str); + console.info(`cy.log: ${str}`); + return cy.wrap(subject); +}); + +Cypress.Commands.add('setSelection', { prevSubject: true }, (subject, query, endQuery) => { + return cy.wrap(subject).selection($el => { + if (typeof query === 'string') { + const anchorNode = getTextNode($el[0], query); + const focusNode = endQuery ? getTextNode($el[0], endQuery) : anchorNode; + const anchorOffset = anchorNode?.wholeText.indexOf(query) ?? 0; + const focusOffset = endQuery + ? (focusNode?.wholeText ?? '').indexOf(endQuery) + endQuery.length + : anchorOffset + query.length; + setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); + } else if (typeof query === 'object') { + const el = $el[0]; + const anchorNode = getTextNode(el.querySelector(query.anchorQuery)); + const anchorOffset = query.anchorOffset || 0; + const focusNode = query.focusQuery + ? getTextNode(el.querySelector(query.focusQuery)) + : anchorNode; + const focusOffset = query.focusOffset || 0; + setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); + } + return cy.wrap(subject); + }); +}); + +Cypress.Commands.add('setCursor', { prevSubject: true }, (subject, query, atStart) => { + return cy.wrap(subject).selection($el => { + const node = getTextNode($el[0], query); + const offset = (node?.wholeText.indexOf(query) ?? 0) + (atStart ? 0 : query.length); + const document = node?.ownerDocument; + document?.getSelection()?.removeAllRanges(); + document?.getSelection()?.collapse(node, offset); + + return cy.wrap(subject); + }); +}); + +Cypress.Commands.add('setCursorBefore', { prevSubject: true }, (subject, query) => { + cy.wrap(subject).setCursor(query, true); +}); + +Cypress.Commands.add('setCursorAfter', { prevSubject: true }, (subject, query) => { + cy.wrap(subject).setCursor(query); +}); + +Cypress.Commands.add('login', () => { + cy.viewport(1200, 1200); + cy.visit('/'); + cy.contains('button', 'Login').click(); +}); + +Cypress.Commands.add('loginAndNewPost', () => { + cy.login(); + cy.contains('a', 'New Post').click(); +}); + +Cypress.Commands.add( + 'dragTo', + { + prevSubject: 'element', + }, + (subject, selector, options) => { + cy.wrap(subject, { log: false }).then(subject => { + return cy + .document() + .its('body') + .find(selector) + .then($el => { + const target = $el[0].getBoundingClientRect(); + const x = Math.floor(target.x + target.width / 2); + const y = target.y + 200; + + cy.wrap(subject) + .trigger('pointerdown', { force: true }) + .wait(options?.delay || 100, { log: Boolean(options?.delay) }); + + cy.wrap(subject) + .trigger('pointermove', { + force: true, + clientX: x, + clientY: y, + }) + .wait(options?.delay || 100, { log: Boolean(options?.delay) }); + + cy.wrap(subject) + .trigger('pointermove', { + force: true, + clientX: x, + clientY: y, + }) + .wait(options?.delay || 100, { log: Boolean(options?.delay) }); + + cy.document().its('body').find(selector).trigger('pointerup', { force: true }).wait(250); + }); + }); + }, +); + +Cypress.Commands.add('clickToolbarButton', (title, { times } = {}) => { + const isHeading = title.startsWith('Heading'); + if (isHeading) { + cy.get('button[title="Headings"]').click(); + } + const instance = isHeading ? cy.contains('div', title) : cy.get(`button[title="${title}"]`); + const fn = (chain: Cypress.Chainable) => chain.click(); + // this seems to be the only thing that makes cypress stable(ish) + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(100); + return runTimes(instance, fn, times).focused(); +}); + +Cypress.Commands.add('insertEditorComponent', (title: string) => { + cy.get('button[title="Add Component"]').click(); + cy.contains('div', title).click().focused(); +}); + +( + [ + ['clickHeadingOneButton', 'Heading 1'], + ['clickHeadingTwoButton', 'Heading 2'], + ['clickOrderedListButton', 'Numbered List'], + ['clickUnorderedListButton', 'Bulleted List'], + ['clickCodeButton', 'Code'], + ['clickItalicButton', 'Italic'], + ['clickQuoteButton', 'Quote'], + ['clickLinkButton', 'Link'], + ] as const +).forEach(([commandName, toolbarButtonName]) => { + Cypress.Commands.add(commandName, opts => { + return cy.clickToolbarButton(toolbarButtonName, opts); + }); +}); + +Cypress.Commands.add('clickModeToggle', () => { + cy.get('.cms-editor-visual').within(() => { + cy.get('button[role="switch"]').click().focused(); + }); +}); + +([['insertCodeBlock', 'Code Block']] as const).forEach(([commandName, componentTitle]) => { + Cypress.Commands.add(commandName, () => { + return cy.insertEditorComponent(componentTitle); + }); +}); + +Cypress.Commands.add('getMarkdownEditor', () => { + return cy.get('[data-slate-editor="true"]'); +}); + +Cypress.Commands.add('confirmMarkdownEditorContent', (expectedDomString: string) => { + return cy.getMarkdownEditor().should(([element]) => { + // Slate makes the following representations: + // - blank line: 2 BOM's +
    + // - blank element (placed inside empty elements): 1 BOM +
    + // - inline element (e.g. link tag ) are wrapped with BOM characters (https://github.com/ianstormtaylor/slate/issues/2722) + // We replace to represent a blank line as a single
    , remove the + // contents of elements that are actually empty, and remove BOM characters wrapping
    tags + const actualDomString = toPlainTree(element.innerHTML) + .replace(/\uFEFF\uFEFF
    /g, '
    ') + .replace(/\uFEFF
    /g, '') + .replace(/\uFEFF
    /g, '') + .replace(/<\/a>\uFEFF/g, ''); + expect(actualDomString).equals(oneLineTrim(expectedDomString)); + }); +}); + +Cypress.Commands.add('clearMarkdownEditorContent', () => { + return cy.getMarkdownEditor().selectAll().backspace({ times: 2 }); +}); + +Cypress.Commands.add('confirmRawEditorContent', expectedDomString => { + cy.get('.cms-editor-raw').within(() => { + cy.contains('span', expectedDomString); + }); +}); + +function toPlainTree(domString: string) { + return rehype() + .use(removeSlateArtifacts) + .data('settings', { fragment: true }) + .processSync(domString) + .toString(); +} + +function getActualBlockChildren(node: any) { + if (node.tagName === 'span') { + return node.children.flatMap(getActualBlockChildren); + } + if (node.children) { + return { + ...node, + children: node.children.flatMap(getActualBlockChildren), + }; + } + return node; +} + +function removeSlateArtifacts() { + return function transform(tree: any) { + visit(tree, 'element', node => { + // remove all element attributes + delete node.properties; + + // remove slate padding spans to simplify test cases + if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'].includes(node.tagName)) { + node.children = node.children.flatMap(getActualBlockChildren); + } + }); + }; +} + +function getTextNode(el: Node, match?: string): Text | null { + const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); + if (!match) { + return walk.nextNode() as Text; + } + + let node: Text; + while ((node = walk.nextNode() as Text)) { + if (node.wholeText.includes(match)) { + return node; + } + } + + return null; +} + +function setBaseAndExtent( + anchorNode: Node | null, + anchorOffset: number, + focusNode: Node | null, + focusOffset: number, +) { + if (!anchorNode || !focusNode) { + return; + } + + const document = anchorNode?.ownerDocument; + document?.getSelection()?.removeAllRanges(); + document?.getSelection()?.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); +} diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 00000000..2a0db543 --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,27 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** +import 'cypress-plugin-tab'; +import { addMatchImageSnapshotCommand } from '@simonsmith/cypress-image-snapshot/command'; + +addMatchImageSnapshotCommand({ + failureThreshold: 0.01, + failureThresholdType: 'percent', + customDiffConfig: { threshold: 0.09 }, + capture: 'viewport', +}); + +Cypress.on('uncaught:exception', () => false); + +import './commands'; diff --git a/cypress/utils/README.md b/cypress/utils/README.md new file mode 100644 index 00000000..9eaf3394 --- /dev/null +++ b/cypress/utils/README.md @@ -0,0 +1,5 @@ +## Utilities for integration tests + +Utils in this dir must be explicitly included in each spec file. + +For routines to be executed on all tests, please use the `cypress/plugins.index.js` file instead: https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests.html#Plugin-files diff --git a/cypress/utils/config.ts b/cypress/utils/config.ts new file mode 100644 index 00000000..51aa2e56 --- /dev/null +++ b/cypress/utils/config.ts @@ -0,0 +1,42 @@ +import fs from 'fs-extra'; +import path from 'path'; +import yaml from 'js-yaml'; + +import type { Config } from '@staticcms/core/interface'; + +const devTestDirectory = path.join(__dirname, '..', '..', 'packages', 'core', 'dev-test'); +const backendsDirectory = path.join(devTestDirectory, 'backends'); + +export async function copyBackendFiles(backend: string) { + await Promise.all( + ['config.yml', 'index.html'].map(file => { + return fs.copyFile( + path.join(backendsDirectory, backend, file), + path.join(devTestDirectory, file), + ); + }), + ); +} + +export async function updateConfig(configModifier: (config: Config) => void) { + const configFile = path.join(devTestDirectory, 'config.yml'); + const configContent = await fs.readFile(configFile, 'utf-8'); + const config = yaml.load(configContent) as Config; + await configModifier(config); + await fs.writeFileSync(configFile, yaml.dump(config)); +} + +export async function switchVersion(version: string) { + const htmlFile = path.join(devTestDirectory, 'index.html'); + const content = await fs.readFile(htmlFile); + + const replaceString = + version === 'latest' + ? '' + : ``; + + await fs.writeFile( + htmlFile, + content.toString().replace(/ + + + + diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index 785df0d0..236f4f8d 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -1,8 +1,7 @@ backend: name: test-repo site_url: 'https://example.com' -media_folder: assets/uploads -public_folder: /assets/uploads +media_folder: /assets/uploads media_library: folder_support: true locale: en @@ -18,7 +17,7 @@ i18n: # Optional, defaults to the first item in locales. # The locale to be used for fields validation and as a baseline for the entry. - defaultLocale: en + default_locale: en collections: - name: posts label: Posts @@ -31,7 +30,6 @@ collections: summary_fields: - title - date - - draft sortable_fields: fields: - title @@ -40,28 +38,35 @@ collections: 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 + filters: + - name: posts-with-index + label: Posts With Index + field: title + pattern: 'This is post #' + - name: posts-without-index + label: Posts Without Index + field: title + pattern: front matter post + - name: draft + label: Drafts + field: draft + pattern: true view_groups: - - label: Year - field: date - pattern: '\d{4}' - - label: Drafts - field: draft + groups: + - name: by-year + label: Year + field: date + pattern: '\d{4}' + - name: draft + label: Drafts + field: draft fields: - label: Title name: title widget: string - - label: Draft - name: draft - widget: boolean + - label: 'Draft' + name: 'draft' + widget: 'boolean' default: false - label: Publish Date name: date @@ -73,10 +78,19 @@ collections: name: image widget: image required: false + - label: Description + name: description + widget: text + - label: Category + name: category + widget: string - label: Body name: body widget: markdown - hint: "*Main* __content__ __*goes*__ [here](https://example.com/)." + hint: '*Main* __content__ __*goes*__ [here](https://example.com/).' + - label: Tags + name: tags + widget: list - name: faq label: FAQ folder: _faqs @@ -150,6 +164,19 @@ collections: widget: boolean pattern: ['true', 'Must be true'] required: false + - name: prefix + label: With Prefix + widget: boolean + prefix: "I'm a prefix" + - name: suffix + label: With Suffix + widget: boolean + suffix: "I'm a suffix" + - name: prefix_and_suffix + label: With Prefix and Suffix + widget: boolean + prefix: "I'm a prefix" + suffix: "I'm a suffix" - name: code label: Code file: _widgets/code.json @@ -571,6 +598,7 @@ collections: - label: Type 2 Object name: type_2_object widget: object + summary: "{{datetime | date('yyyy-MM-dd')}}" fields: - label: Number name: number @@ -778,6 +806,19 @@ collections: widget: number pattern: ['[0-9]{3,}', 'Must be at least 3 digits'] required: false + - name: prefix + label: With Prefix + widget: number + prefix: '$' + - name: suffix + label: With Suffix + widget: number + suffix: '%' + - name: prefix_and_suffix + label: With Prefix and Suffix + widget: number + prefix: '$' + suffix: '%' - name: object label: Object file: _widgets/object.json @@ -1055,6 +1096,19 @@ collections: widget: string pattern: ['.{12,}', 'Must have at least 12 characters'] required: false + - name: prefix + label: With Prefix + widget: string + prefix: '$' + - name: suffix + label: With Suffix + widget: string + suffix: '%' + - name: prefix_and_suffix + label: With Prefix and Suffix + widget: string + prefix: '$' + suffix: '%' - name: text label: Text file: _widgets/text.json @@ -1099,11 +1153,6 @@ collections: 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 @@ -1142,6 +1191,34 @@ collections: - label: Description name: description widget: text + - name: hotels + label: Hotel Locations + file: _data/hotel_locations.yml + fields: + - name: country + label: Country + widget: string + - name: hotel_locations + label: Hotel Locations + widget: list + fields: + - name: cities + label: Cities + widget: list + fields: + - name: city + label: City + widget: string + - name: number_of_hotels_in_city + label: Number of Hotels in City + widget: number + - name: city_locations + label: City Locations + widget: list + fields: + - name: hotel_name + label: Hotel Name + widget: string - name: kitchenSink label: Kitchen Sink folder: _sink diff --git a/packages/core/dev-test/data.js b/packages/core/dev-test/data.js new file mode 100644 index 00000000..d1a1dcdf --- /dev/null +++ b/packages/core/dev-test/data.js @@ -0,0 +1,308 @@ +window.repoFiles = { + assets: { + uploads: { + 'moby-dick.jpg': { + content: '', + }, + 'lobby.jpg': { + content: '', + }, + 'Other Pics': { + 'moby-dick.jpg': { + content: '', + }, + 'lobby.jpg': { + content: '', + }, + }, + }, + }, + _posts: { + '2015-02-14-this-is-a-post.md': { + content: + '---\ntitle: This is a YAML front matter post\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-13T00:00:00.000Z\ndescription: YAML front matter post\ncategory: yaml\ntags:\n - yaml\n---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n', + }, + '2015-02-15-this-is-a-json-frontmatter-post.md': { + content: + '{\n"title": "This is a JSON front matter post",\n"image": "/assets/uploads/moby-dick.jpg",\n"date": "2015-02-14T00:00:00.000Z",\n"description": "JSON front matter post",\n"category": "json",\n"tags": ["json"]\n}\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n', + }, + '2015-02-15-this-is-a-toml-frontmatter-post.md': { + content: + '+++\ntitle = "This is a TOML front matter post"\nimage = "/assets/uploads/moby-dick.jpg"\ndate = "2015-02-15T00:00:00.000Z"\ndescription = "TOML front matter post"\ncategory = "toml"\ntags = ["toml"]\n+++\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n', + }, + '2015-02-14-this-is-a-post-with-a-different-extension.other': { + content: + '---\ntitle: This post should not appear because the extension is different\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-16T00:00:00.000Z\ndescription: YAML front matter post\ncategory: yaml\ntags:\n - yaml---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n', + }, + }, + _faqs: { + 'what-is-static-cms.md': { + content: + '---\ntitle: What is Static CMS?\ndate: 2015-11-02T00:00.000Z\n---\n\n# Static CMS is Content Manager for Static Site Generators\n\nStatic sites are many times faster, cheaper and safer and traditional dynamic websites.\n\nModern static site generators like Jekyll, Middleman, Roots or Hugo are powerful publishing and development systems, but when we build sites for non-technical users, we need a layer on top of them.\n\nStatic CMS is there to let your marketing team push new content to your public site, or to let technical writers work on your documentation.\n\nStatic CMS integrates with Git and turns normal content editors into git committers.\n\n', + }, + 'what-is-jam-stack.md': { + content: + "---\ntitle: What is the “JAM Stack”?\ndate: 2015-11-02T00:00.000Z\n---\n\n# The JAM stack is a new way of building websites and apps that are fast, secure and simple to work with.\n\nJAM stands for JavaScript, APIs and Markup. It's the fastest growing new stack for building websites and apps: no more servers, host all your front-end on a CDN and use APIs for any moving parts.\n\n", + }, + 'cache-invalidation.md': { + content: + '---\ntitle: What about Cache Invalidation?\ndate: 2015-11-02T00:00.000Z\n---\n\n# Netlify handles cache invalidation automatically\n\nWhen your changes go live, they go live.\n\nNo waiting for cache purges, no cumbersome varnish setup, no API calls to clean your distribution. Netlify handles cache purges within an average of 250ms from your deploy!\n\n', + }, + 'continuous-deployment.md': { + content: + '---\ntitle: Does Netlify support Continuous Deployment?\ndate: 2015-11-02T00:00.000Z\n---\n\n# Yes, Netlify let you Integrate your site or web-app to GitHub, GitLab or BitBucket and run your build tool on our servers.\n\nAutomatically rebuild your site every time your content changes: trigger builds by pushing to git or via webhooks.\n\n', + }, + }, + _data: { + 'settings.json': { + content: '{"site_title": "CMS Demo"}', + }, + 'authors.yml': { + content: + 'authors:\n - name: Mathias\n description: Co-founder @ Netlify\n - name: Chris\n description: Co-founder @ Netlify\n', + }, + }, + _widgets: { + 'boolean.json': { + content: '{}', + }, + 'code.json': { + content: '{}', + }, + 'color.json': { + content: '{}', + }, + 'datetime.json': { + content: '{}', + }, + 'file.json': { + content: '{}', + }, + 'image.json': { + content: `{}`, + }, + 'map.json': { + content: '{}', + }, + 'markdown.json': { + content: '{}', + }, + 'number.json': { + content: '{}', + }, + 'object.json': { + content: '{}', + }, + 'relation.json': { + content: '{}', + }, + 'select.json': { + content: '{}', + }, + 'string.json': { + content: '{}', + }, + 'text.json': { + content: '{}', + }, + }, + _sink: { + 'a-big-entry-with-all-the-things.md': { + content: + "---\ntitle: CHAPTER 1. Loomings.\nboolean: true\ntext: >-\n Call me Ishmael. Some years ago—never mind how long precisely—having little or\n no money in my purse, and nothing particular to interest me on shore, I\n thought I would sail about a little and see the watery part of the world. It\n is a way I have of driving off the spleen and regulating the circulation.\n Whenever I find myself growing grim about the mouth; whenever it is a damp,\n drizzly November in my soul; whenever I find myself involuntarily pausing\n before coffin warehouses, and bringing up the rear of every funeral I meet;\n and especially whenever my hypos get such an upper hand of me, that it\n requires a strong moral principle to prevent me from deliberately stepping\n into the street, and methodically knocking people’s hats off—then, I account\n it high time to get to sea as soon as I can. This is my substitute for pistol\n and ball. With a philosophical flourish Cato throws himself upon his sword; I\n quietly take to the ship. There is nothing surprising in this. If they but\n knew it, almost all men in their degree, some time or other, cherish very\n nearly the same feelings towards the ocean with me.\nnumber: '111111'\nmarkdown: \"# Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking people’s hats off—then, I account it high time to get to sea as soon as I can. This is my substitute for pistol and ball. With a philosophical flourish Cato throws himself upon his sword; I quietly take to the ship. There is nothing surprising in this. If they but knew it, almost all men in their degree, some time or other, cherish very nearly the same feelings towards the ocean with me.\\n\\n## There now is your insular city of the Manhattoes, belted round by wharves as Indian isles by coral reefs—commerce surrounds it with her surf. Right and left, the streets take you waterward. Its extreme downtown is the battery, where that noble mole is washed by waves, and cooled by breezes, which a few hours previous were out of sight of land. Look at the crowds of water-gazers there.\\n\\n**Circumambulate the city of a dreamy Sabbath afternoon. Go from Corlears Hook to Coenties Slip, and from thence, by Whitehall, northward. What do you see?—Posted like silent sentinels all around the town, stand thousands upon thousands of mortal men fixed in ocean reveries. Some leaning against the spiles; some seated upon the pier-heads; some looking over the bulwarks of ships from China; some high aloft in the rigging, as if striving to get a still better seaward peep. But these are all landsmen; of week days pent up in lath and plaster—tied to counters, nailed to benches, clinched to desks. How then is this? Are the green fields gone? What do they here?**\\n\\n*But look! here come more crowds, pacing straight for the water, and seemingly bound for a dive. Strange! Nothing will content them but the extremest limit of the land; loitering under the shady lee of yonder warehouses will not suffice. No. They must get just as nigh the water as they possibly can without falling in. And there they stand—miles of them—leagues. Inlanders all, they come from lanes and alleys, streets and avenues—north, east, south, and west. Yet here they all unite. Tell me, does the magnetic virtue of the needles of the compasses of all those ships attract them thither?*\\n\\n[Once more. Say you are in the country; in some high land of lakes. Take almost any path you please, and ten to one it carries you down in a dale, and leaves you there by a pool in the stream. There is magic in it. Let the most absent-minded of men be plunged in his deepest reveries—stand that man on his legs, set his feet a-going, and he will infallibly lead you to water, if water there be in all that region. Should you ever be athirst in the great American desert, try this experiment, if your caravan happen to be supplied with a metaphysical professor. Yes, as every one knows, meditation and water are wedded for ever.](https://staticcms.org)\\n\\n![moby dick](/assets/uploads/moby-dick.jpg)\\n\\n{{< youtube lZ6NEHDgk58 >}}\\n\\nBut here is an artist. He desires to paint you the dreamiest, shadiest, quietest, most enchanting bit of romantic landscape in all the valley of the Saco. What is the chief element he employs? There stand his trees, each with a hollow trunk, as if a hermit and a crucifix were within; and here sleeps his meadow, and there sleep his cattle; and up from yonder cottage goes a sleepy smoke. Deep into distant woodlands winds a mazy way, reaching to overlapping spurs of mountains bathed in their hill-side blue. But though the picture lies thus tranced, and though this pine-tree shakes down its sighs like leaves upon this shepherd’s head, yet all were vain, unless the shepherd’s eye were fixed upon the magic stream before him. Go visit the Prairies in June, when for scores on scores of miles you wade knee-deep among Tiger-lilies—what is the one charm wanting?—Water—there is not a drop of water there! Were Niagara but a cataract of sand, would you travel your thousand miles to see it? Why did the poor poet of Tennessee, upon suddenly receiving two handfuls of silver, deliberate whether to buy him a coat, which he sadly needed, or invest his money in a pedestrian trip to Rockaway Beach? Why is almost every robust healthy boy with a robust healthy soul in him, at some time or other crazy to go to sea? Why upon your first voyage as a passenger, did you yourself feel such a mystical vibration, when first told that you and your ship were now out of sight of land? Why did the old Persians hold the sea holy? Why did the Greeks give it a separate deity, and own brother of Jove? Surely all this is not without meaning. And still deeper the meaning of that story of Narcissus, who because he could not grasp the tormenting, mild image he saw in the fountain, plunged into it and was drowned. But that same image, we ourselves see in all rivers and oceans. It is the image of the ungraspable phantom of life; and this is the key to it all.\\n\\nNow, when I say that I am in the habit of going to sea whenever I begin to grow hazy about the eyes, and begin to be over conscious of my lungs, I do not mean to have it inferred that I ever go to sea as a passenger. For to go as a passenger you must needs have a purse, and a purse is but a rag unless you have something in it. Besides, passengers get sea-sick—grow quarrelsome—don’t sleep of nights—do not enjoy themselves much, as a general thing;—no, I never go as a passenger; nor, though I am something of a salt, do I ever go to sea as a Commodore, or a Captain, or a Cook. I abandon the glory and distinction of such offices to those who like them. For my part, I abominate all honourable respectable toils, trials, and tribulations of every kind whatsoever. It is quite as much as I can do to take care of myself, without taking care of ships, barques, brigs, schooners, and what not. And as for going as cook,—though I confess there is considerable glory in that, a cook being a sort of officer on ship-board—yet, somehow, I never fancied broiling fowls;—though once broiled, judiciously buttered, and judgmatically salted and peppered, there is no one who will speak more respectfully, not to say reverentially, of a broiled fowl than I will. It is out of the idolatrous dotings of the old Egyptians upon broiled ibis and roasted river horse, that you see the mummies of those creatures in their huge bake-houses the pyramids.\\n\\nNo, when I go to sea, I go as a simple sailor, right before the mast, plumb down into the forecastle, aloft there to the royal mast-head. True, they rather order me about some, and make me jump from spar to spar, like a grasshopper in a May meadow. And at first, this sort of thing is unpleasant enough. It touches one’s sense of honour, particularly if you come of an old established family in the land, the Van Rensselaers, or Randolphs, or Hardicanutes. And more than all, if just previous to putting your hand into the tar-pot, you have been lording it as a country schoolmaster, making the tallest boys stand in awe of you. The transition is a keen one, I assure you, from a schoolmaster to a sailor, and requires a strong decoction of Seneca and the Stoics to enable you to grin and bear it. But even this wears off in time.\\n\\nWhat of it, if some old hunks of a sea-captain orders me to get a broom and sweep down the decks? What does that indignity amount to, weighed, I mean, in the scales of the New Testament? Do you think the archangel Gabriel thinks anything the less of me, because I promptly and respectfully obey that old hunks in that particular instance? Who ain’t a slave? Tell me that. Well, then, however the old sea-captains may order me about—however they may thump and punch me about, I have the satisfaction of knowing that it is all right; that everybody else is one way or other served in much the same way—either in a physical or metaphysical point of view, that is; and so the universal thump is passed round, and all hands should rub each other’s shoulder-blades, and be content.\\n\\nAgain, I always go to sea as a sailor, because they make a point of paying me for my trouble, whereas they never pay passengers a single penny that I ever heard of. On the contrary, passengers themselves must pay. And there is all the difference in the world between paying and being paid. The act of paying is perhaps the most uncomfortable infliction that the two orchard thieves entailed upon us. But_*being paid*,—what will compare with it? The urbane activity with which a man receives money is really marvellous, considering that we so earnestly believe money to be the root of all earthly ills, and that on no account can a monied man enter heaven. Ah! how cheerfully we consign ourselves to perdition!\\n\\nFinally, I always go to sea as a sailor, because of the wholesome exercise and pure air of the fore-castle deck. For as in this world, head winds are far more prevalent than winds from astern (that is, if you never violate the Pythagorean maxim), so for the most part the Commodore on the quarter-deck gets his atmosphere at second hand from the sailors on the forecastle. He thinks he breathes it first; but not so. In much the same way do the commonalty lead their leaders in many other things, at the same time that the leaders little suspect it. But wherefore it was that after having repeatedly smelt the sea as a merchant sailor, I should now take it into my head to go on a whaling voyage; this the invisible police officer of the Fates, who has the constant surveillance of me, and secretly dogs me, and influences me in some unaccountable way—he can better answer than any one else. And, doubtless, my going on this whaling voyage, formed part of the grand programme of Providence that was drawn up a long time ago. It came in as a sort of brief interlude and solo between more extensive performances. I take it that this part of the bill must have run something like this:\\n\\n“*Grand Contested Election for the Presidency of the United States.*_“WHALING VOYAGE BY ONE ISHMAEL. “BLOODY BATTLE IN AFFGHANISTAN.”\\n\\nThough I cannot tell why it was exactly that those stage managers, the Fates, put me down for this shabby part of a whaling voyage, when others were set down for magnificent parts in high tragedies, and short and easy parts in genteel comedies, and jolly parts in farces—though I cannot tell why this was exactly; yet, now that I recall all the circumstances, I think I can see a little into the springs and motives which being cunningly presented to me under various disguises, induced me to set about performing the part I did, besides cajoling me into the delusion that it was a choice resulting from my own unbiased freewill and discriminating judgment.\\n\\nChief among these motives was the overwhelming idea of the great whale himself. Such a portentous and mysterious monster roused all my curiosity. Then the wild and distant seas where he rolled his island bulk; the undeliverable, nameless perils of the whale; these, with all the attending marvels of a thousand Patagonian sights and sounds, helped to sway me to my wish. With other men, perhaps, such things would not have been inducements; but as for me, I am tormented with an everlasting itch for things remote. I love to sail forbidden seas, and land on barbarous coasts. Not ignoring what is good, I am quick to perceive a horror, and could still be social with it—would they let me—since it is but well to be on friendly terms with all the inmates of the place one lodges in.\\n\\nBy reason of these things, then, the whaling voyage was welcome; the great flood-gates of the wonder-world swung open, and in the wild conceits that swayed me to my purpose, two and two there floated into my inmost soul, endless processions of the whale, and, mid most of them all, one grand hooded phantom, like a snow hill in the air.\"\ndatetime: 2017-06-07T18:55:28.110Z\ndate: 2017-06-07T18:55:28.111Z\nimage: /assets/uploads/moby-dick.jpg\nfile: /assets/uploads/moby-dick.jpg\nselect: b\npost: 'This is a front matter post'\nobject:\n boolean: true\n text: >-\n I stuffed a shirt or two into my old carpet-bag, tucked it under my arm, and\n started for Cape Horn and the Pacific. Quitting the good city of old\n Manhatto, I duly arrived in New Bedford. It was a Saturday night in\n December. Much was I disappointed upon learning that the little packet for\n Nantucket had already sailed, and that no way of reaching that place would\n offer, till the following Monday.\n number: '222222'\n markdown: >-\n # I stuffed a shirt or two into my old carpet-bag, tucked it under my arm,\n and started for Cape Horn and the Pacific. Quitting the good city of old\n Manhatto, I duly arrived in New Bedford. It was a Saturday night in\n December. Much was I disappointed upon learning that the little packet for\n Nantucket had already sailed, and that no way of reaching that place would\n offer, till the following Monday.\n\n\n ## As most young candidates for the pains and penalties of whaling stop at\n this same New Bedford, thence to embark on their voyage, it may as well be\n related that I, for one, had no idea of so doing. For my mind was made up to\n sail in no other than a Nantucket craft, because there was a fine,\n boisterous something about everything connected with that famous old island,\n which amazingly pleased me. Besides though New Bedford has of late been\n gradually monopolising the business of whaling, and though in this matter\n poor old Nantucket is now much behind her, yet Nantucket was her great\n original—the Tyre of this Carthage;—the place where the first dead American\n whale was stranded. Where else but from Nantucket did those aboriginal\n whalemen, the Red-Men, first sally out in canoes to give chase to the\n Leviathan? And where but from Nantucket, too, did that first adventurous\n little sloop put forth, partly laden with imported cobblestones—so goes the\n story—to throw at the whales, in order to discover when they were nigh\n enough to risk a harpoon from the bowsprit?\n\n\n **Now having a night, a day, and still another night following before me in\n New Bedford, ere I could embark for my destined port, it became a matter of\n concernment where I was to eat and sleep meanwhile. It was a very\n dubious-looking, nay, a very dark and dismal night, bitingly cold and\n cheerless. I knew no one in the place. With anxious grapnels I had sounded\n my pocket, and only brought up a few pieces of silver,—So, wherever you go,\n Ishmael, said I to myself, as I stood in the middle of a dreary street\n shouldering my bag, and comparing the gloom towards the north with the\n darkness towards the south—wherever in your wisdom you may conclude to lodge\n for the night, my dear Ishmael, be sure to inquire the price, and don’t be\n too particular.**\n\n\n *With halting steps I paced the streets, and passed the sign of “The Crossed\n Harpoons”—but it looked too expensive and jolly there. Further on, from the\n bright red windows of the “Sword-Fish Inn,” there came such fervent rays,\n that it seemed to have melted the packed snow and ice from before the house,\n for everywhere else the congealed frost lay ten inches thick in a hard,\n asphaltic pavement,—rather weary for me, when I struck my foot against the\n flinty projections, because from hard, remorseless service the soles of my\n boots were in a most miserable plight. Too expensive and jolly, again\n thought I, pausing one moment to watch the broad glare in the street, and\n hear the sounds of the tinkling glasses within. But go on, Ishmael, said I\n at last; don’t you hear? get away from before the door; your patched boots\n are stopping the way. So on I went. I now by instinct followed the streets\n that took me waterward, for there, doubtless, were the cheapest, if not the\n cheeriest inns.*\n\n\n [Such dreary streets! blocks of blackness, not houses, on either hand, and\n here and there a candle, like a candle moving about in a tomb. At this hour\n of the night, of the last day of the week, that quarter of the town proved\n all but deserted. But presently I came to a smoky light proceeding from a\n low, wide building, the door of which stood invitingly open. It had a\n careless look, as if it were meant for the uses of the public; so, entering,\n the first thing I did was to stumble over an ash-box in the porch. Ha!\n thought I, ha, as the flying particles almost choked me, are these ashes\n from that destroyed city, Gomorrah? But “The Crossed Harpoons,” and “The\n Sword-Fish?”—this, then must needs be the sign of “The Trap.” However, I\n picked myself up and hearing a loud voice within, pushed on and opened a\n second, interior door.](https://staticcms.org)\n\n\n ![moby dick](/assets/uploads/moby-dick.jpg)\n\n\n {{< youtube lZ6NEHDgk58 >}}\n\n\n It seemed the great Black Parliament sitting in Tophet. A hundred black\n faces turned round in their rows to peer; and beyond, a black Angel of Doom\n was beating a book in a pulpit. It was a negro church; and the preacher’s\n text was about the blackness of darkness, and the weeping and wailing and\n teeth-gnashing there. Ha, Ishmael, muttered I, backing out, Wretched\n entertainment at the sign of ‘The Trap!’\n\n\n Moving on, I at last came to a dim sort of light not far from the docks, and\n heard a forlorn creaking in the air; and looking up, saw a swinging sign\n over the door with a white painting upon it, faintly representing a tall\n straight jet of misty spray, and these words underneath—“The Spouter\n Inn:—Peter Coffin.”\n\n\n Coffin?—Spouter?—Rather ominous in that particular connexion, thought I. But\n it is a common name in Nantucket, they say, and I suppose this Peter here is\n an emigrant from there. As the light looked so dim, and the place, for the\n time, looked quiet enough, and the dilapidated little wooden house itself\n looked as if it might have been carted here from the ruins of some burnt\n district, and as the swinging sign had a poverty-stricken sort of creak to\n it, I thought that here was the very spot for cheap lodgings, and the best\n of pea coffee.\n\n\n It was a queer sort of place—a gable-ended old house, one side palsied as it\n were, and leaning over sadly. It stood on a sharp bleak corner, where that\n tempestuous wind Euroclydon kept up a worse howling than ever it did about\n poor Paul’s tossed craft. Euroclydon, nevertheless, is a mighty pleasant\n zephyr to any one in-doors, with his feet on the hob quietly toasting for\n bed. “In judging of that tempestuous wind called Euroclydon,” says an old\n writer—of whose works I possess the only copy extant—“it maketh a marvellous\n difference, whether thou lookest out at it from a glass window where the\n frost is all on the outside, or whether thou observest it from that sashless\n window, where the frost is on both sides, and of which the wight Death is\n the only glazier.” True enough, thought I, as this passage occurred to my\n mind—old black-letter, thou reasonest well. Yes, these eyes are windows, and\n this body of mine is the house. What a pity they didn’t stop up the chinks\n and the crannies though, and thrust in a little lint here and there. But\n it’s too late to make any improvements now. The universe is finished; the\n copestone is on, and the chips were carted off a million years ago. Poor\n Lazarus there, chattering his teeth against the curbstone for his pillow,\n and shaking off his tatters with his shiverings, he might plug up both ears\n with rags, and put a corn-cob into his mouth, and yet that would not keep\n out the tempestuous Euroclydon. Euroclydon! says old Dives, in his red\n silken wrapper—(he had a redder one afterwards) pooh, pooh! What a fine\n frosty night; how Orion glitters; what northern lights! Let them talk of\n their oriental summer climes of everlasting conservatories; give me the\n privilege of making my own summer with my own coals.\n\n\n But what thinks Lazarus? Can he warm his blue hands by holding them up to\n the grand northern lights? Would not Lazarus rather be in Sumatra than here?\n Would he not far rather lay him down lengthwise along the line of the\n equator; yea, ye gods! go down to the fiery pit itself, in order to keep out\n this frost?\n\n\n Now, that Lazarus should lie stranded there on the curbstone before the door\n of Dives, this is more wonderful than that an iceberg should be moored to\n one of the Moluccas. Yet Dives himself, he too lives like a Czar in an ice\n palace made of frozen sighs, and being a president of a temperance society,\n he only drinks the tepid tears of orphans.\n\n\n But no more of this blubbering now, we are going a-whaling, and there is\n plenty of that yet to come. Let us scrape the ice from our frosted feet, and\n see what sort of a place this “Spouter” may be.\n datetime: 2017-06-07T19:05:43.816Z\n date: 2017-06-07T18:55:28.115Z\n image: /assets/uploads/moby-dick.jpg\n file: /assets/uploads/moby-dick.jpg\n select: b\n string: CHAPTER 2. The Carpet-Bag.\nlist:\n - text: >-\n Entering that gable-ended Spouter-Inn, you found yourself in a wide, low,\n straggling entry with old-fashioned wainscots, reminding one of the\n bulwarks of some condemned old craft. On one side hung a very large\n oilpainting so thoroughly besmoked, and every way defaced, that in the\n unequal crosslights by which you viewed it, it was only by diligent study\n and a series of systematic visits to it, and careful inquiry of the\n neighbors, that you could any way arrive at an understanding of its\n purpose. Such unaccountable masses of shades and shadows, that at first\n you almost thought some ambitious young artist, in the time of the New\n England hags, had endeavored to delineate chaos bewitched. But by dint of\n much and earnest contemplation, and oft repeated ponderings, and\n especially by throwing open the little window towards the back of the\n entry, you at last come to the conclusion that such an idea, however wild,\n might not be altogether unwarranted.\n number: '333333'\n markdown: \"# Entering that gable-ended Spouter-Inn, you found yourself in a wide, low, straggling entry with old-fashioned wainscots, reminding one of the bulwarks of some condemned old craft. On one side hung a very large oilpainting so thoroughly besmoked, and every way defaced, that in the unequal crosslights by which you viewed it, it was only by diligent study and a series of systematic visits to it, and careful inquiry of the neighbors, that you could any way arrive at an understanding of its purpose. Such unaccountable masses of shades and shadows, that at first you almost thought some ambitious young artist, in the time of the New England hags, had endeavored to delineate chaos bewitched. But by dint of much and earnest contemplation, and oft repeated ponderings, and especially by throwing open the little window towards the back of the entry, you at last come to the conclusion that such an idea, however wild, might not be altogether unwarranted.\n\n # But what most puzzled and confounded you was a long, limber, portentous, black mass of something hovering in the centre of the picture over three blue, dim, perpendicular lines floating in a nameless yeast. A boggy, soggy, squitchy picture truly, enough to drive a nervous man distracted. Yet was there a sort of indefinite, half-attained, unimaginable sublimity about it that fairly froze you to it, till you involuntarily took an oath with yourself to find out what that marvellous painting meant. Ever and anon a bright, but, alas, deceptive idea would dart you through.—It’s the Black Sea in a midnight gale.—It’s the unnatural combat of the four primal elements.—It’s a blasted heath.—It’s a Hyperborean winter scene.—It’s the breaking-up of the icebound stream of Time. But at last all these fancies yielded to that one portentous something in the picture’s midst._*That*_once found out, and all the rest were plain. But stop; does it not bear a faint resemblance to a gigantic fish? even the great leviathan himself?\n\n ## In fact, the artist’s design seemed this: a final theory of my own, partly based upon the aggregated opinions of many aged persons with whom I conversed upon the subject. The picture represents a Cape-Horner in a great hurricane; the half-foundered ship weltering there with its three dismantled masts alone visible; and an exasperated whale, purposing to spring clean over the craft, is in the enormous act of impaling himself upon the three mast-heads.\n\n ## The opposite wall of this entry was hung all over with a heathenish array of monstrous clubs and spears. Some were thickly set with glittering teeth resembling ivory saws; others were tufted with knots of human hair; and one was sickle-shaped, with a vast handle sweeping round like the segment made in the new-mown grass by a long-armed mower. You shuddered as you gazed, and wondered what monstrous cannibal and savage could ever have gone a death-harvesting with such a hacking, horrifying implement. Mixed with these were rusty old whaling lances and harpoons all broken and deformed. Some were storied weapons. With this once long lance, now wildly elbowed, fifty years ago did Nathan Swain kill fifteen whales between a sunrise and a sunset. And that harpoon—so like a corkscrew now—was flung in Javan seas, and run away with by a whale, years afterwards slain off the Cape of Blanco. The original iron entered nigh the tail, and, like a restless needle sojourning in the body of a man, travelled full forty feet, and at last was found imbedded in the hump.\n\n Crossing this dusky entry, and on through yon low-arched way—cut through what in old times must have been a great central chimney with fireplaces all round—you enter the public room. A still duskier place is this, with such low ponderous beams above, and such old wrinkled planks beneath, that you would almost fancy you trod some old craft’s cockpits, especially of such a howling night, when this corner-anchored old ark rocked so furiously. On one side stood a long, low, shelf-like table covered with cracked glass cases, filled with dusty rarities gathered from this wide world’s remotest nooks. Projecting from the further angle of the room stands a dark-looking den—the bar—a rude attempt at a right whale’s head. Be that how it may, there stands the vast arched bone of the whale’s jaw, so wide, a coach might almost drive beneath it. Within are shabby shelves, ranged round with old decanters, bottles, flasks; and in those jaws of swift destruction, like another cursed Jonah (by which name indeed they called him), bustles a little withered old man, who, for their money, dearly sells the sailors deliriums and death.\n\n Abominable are the tumblers into which he pours his poison. Though true cylinders without—within, the villanous green goggling glasses deceitfully tapered downwards to a cheating bottom. Parallel meridians rudely pecked into the glass, surround these footpads’ goblets. Fill to_*this*_mark, and your charge is but a penny; to_*this*_a penny more; and so on to the full glass—the Cape Horn measure, which you may gulp down for a shilling.\n\n Upon entering the place I found a number of young seamen gathered about a table, examining by a dim light divers specimens of_*skrimshander*. I sought the landlord, and telling him I desired to be accommodated with a room, received for answer that his house was full—not a bed unoccupied. “But avast,” he added, tapping his forehead, “you haint no objections to sharing a harpooneer’s blanket, have ye? I s’pose you are goin’ a-whalin’, so you’d better get used to that sort of thing.”\n\n I told him that I never liked to sleep two in a bed; that if I should ever do so, it would depend upon who the harpooneer might be, and that if he (the landlord) really had no other place for me, and the harpooneer was not decidedly objectionable, why rather than wander further about a strange town on so bitter a night, I would put up with the half of any decent man’s blanket.\n\n “I thought so. All right; take a seat. Supper?—you want supper? Supper’ll be ready directly.”\n\n I sat down on an old wooden settle, carved all over like a bench on the Battery. At one end a ruminating tar was still further adorning it with his jack-knife, stooping over and diligently working away at the space between his legs. He was trying his hand at a ship under full sail, but he didn’t make much headway, I thought.\n\n At last some four or five of us were summoned to our meal in an adjoining room. It was cold as Iceland—no fire at all—the landlord said he couldn’t afford it. Nothing but two dismal tallow candles, each in a winding sheet. We were fain to button up our monkey jackets, and hold to our lips cups of scalding tea with our half frozen fingers. But the fare was of the most substantial kind—not only meat and potatoes, but dumplings; good heavens! dumplings for supper! One young fellow in a green box coat, addressed himself to these dumplings in a most direful manner.\n\n “My boy,” said the landlord, “you’ll have the nightmare to a dead sartainty.”\n\n “Landlord,” I whispered, “that aint the harpooneer is it?”\n\n “Oh, no,” said he, looking a sort of diabolically funny, “the harpooneer is a dark complexioned chap. He never eats dumplings, he don’t—he eats nothing but steaks, and he likes ‘em rare.”\n\n “The devil he does,” says I. “Where is that harpooneer? Is he here?”\n\n “He’ll be here afore long,” was the answer.\n\n I could not help it, but I began to feel suspicious of this “dark complexioned” harpooneer. At any rate, I made up my mind that if it so turned out that we should sleep together, he must undress and get into bed before I did.\n\n Supper over, the company went back to the bar-room, when, knowing not what else to do with myself, I resolved to spend the rest of the evening as a looker on.\n\n Presently a rioting noise was heard without. Starting up, the landlord cried, “That’s the Grampus’s crew. I seed her reported in the offing this morning; a three years’ voyage, and a full ship. Hurrah, boys; now we’ll have the latest news from the Feegees.”\n\n A tramping of sea boots was heard in the entry; the door was flung open, and in rolled a wild set of mariners enough. Enveloped in their shaggy watch coats, and with their heads muffled in woollen comforters, all bedarned and ragged, and their beards stiff with icicles, they seemed an eruption of bears from Labrador. They had just landed from their boat, and this was the first house they entered. No wonder, then, that they made a straight wake for the whale’s mouth—the bar—when the wrinkled little old Jonah, there officiating, soon poured them out brimmers all round. One complained of a bad cold in his head, upon which Jonah mixed him a pitch-like potion of gin and molasses, which he swore was a sovereign cure for all colds and catarrhs whatsoever, never mind of how long standing, or whether caught off the coast of Labrador, or on the weather side of an ice-island.\n\n The liquor soon mounted into their heads, as it generally does even with the arrantest topers newly landed from sea, and they began capering about most obstreperously.\n\n I observed, however, that one of them held somewhat aloof, and though he seemed desirous not to spoil the hilarity of his shipmates by his own sober face, yet upon the whole he refrained from making as much noise as the rest. This man interested me at once; and since the sea-gods had ordained that he should soon become my shipmate (though but a sleeping-partner one, so far as this narrative is concerned), I will here venture upon a little description of him. He stood full six feet in height, with noble shoulders, and a chest like a coffer-dam. I have seldom seen such brawn in a man. His face was deeply brown and burnt, making his white teeth dazzling by the contrast; while in the deep shadows of his eyes floated some reminiscences that did not seem to give him much joy. His voice at once announced that he was a Southerner, and from his fine stature, I thought he must be one of those tall mountaineers from the Alleghanian Ridge in Virginia. When the revelry of his companions had mounted to its height, this man slipped away unobserved, and I saw no more of him till he became my comrade on the sea. In a few minutes, however, he was missed by his shipmates, and being, it seems, for some reason a huge favourite with them, they raised a cry of “Bulkington! Bulkington! where’s Bulkington?” and darted out of the house in pursuit of him.\n\n It was now about nine o’clock, and the room seeming almost supernaturally quiet after these orgies, I began to congratulate myself upon a little plan that had occurred to me just previous to the entrance of the seamen.\n\n No man prefers to sleep two in a bed. In fact, you would a good deal rather not sleep with your own brother. I don’t know how it is, but people like to be private when they are sleeping. And when it comes to sleeping with an unknown stranger, in a strange inn, in a strange town, and that stranger a harpooneer, then your objections indefinitely multiply. Nor was there any earthly reason why I as a sailor should sleep two in a bed, more than anybody else; for sailors no more sleep two in a bed at sea, than bachelor Kings do ashore. To be sure they all sleep together in one apartment, but you have your own hammock, and cover yourself with your own blanket, and sleep in your own skin.\n\n The more I pondered over this harpooneer, the more I abominated the thought of sleeping with him. It was fair to presume that being a harpooneer, his linen or woollen, as the case might be, would not be of the tidiest, certainly none of the finest. I began to twitch all over. Besides, it was getting late, and my decent harpooneer ought to be home and going bedwards. Suppose now, he should tumble in upon me at midnight—how could I tell from what vile hole he had been coming?\n\n “Landlord! I’ve changed my mind about that harpooneer.—I shan’t sleep with him. I’ll try the bench here.”\n\n “Just as you please; I’m sorry I can’t spare ye a tablecloth for a mattress, and it’s a plaguy rough board here”—feeling of the knots and notches. “But wait a bit, Skrimshander; I’ve got a carpenter’s plane there in the bar—wait, I say, and I’ll make ye snug enough.” So saying he procured the plane; and with his old silk handkerchief first dusting the bench, vigorously set to planing away at my bed, the while grinning like an ape. The shavings flew right and left; till at last the plane-iron came bump against an indestructible knot. The landlord was near spraining his wrist, and I told him for heaven’s sake to quit—the bed was soft enough to suit me, and I did not know how all the planing in the world could make eider down of a pine plank. So gathering up the shavings with another grin, and throwing them into the great stove in the middle of the room, he went about his business, and left me in a brown study.\n\n I now took the measure of the bench, and found that it was a foot too short; but that could be mended with a chair. But it was a foot too narrow, and the other bench in the room was about four inches higher than the planned one—so there was no yoking them. I then placed the first bench lengthwise along the only clear space against the wall, leaving a little interval between, for my back to settle down in. But I soon found that there came such a draught of cold air over me from under the sill of the window, that this plan would never do at all, especially as another current from the rickety door met the one from the window, and both together formed a series of small whirlwinds in the immediate vicinity of the spot where I had thought to spend the night.\n\n The devil fetch that harpooneer, thought I, but stop, couldn’t I steal a march on him—bolt his door inside, and jump into his bed, not to be wakened by the most violent knockings? It seemed no bad idea; but upon second thoughts I dismissed it. For who could tell but what the next morning, so soon as I popped out of the room, the harpooneer might be standing in the entry, all ready to knock me down!\n\n Still, looking round me again, and seeing no possible chance of spending a sufferable night unless in some other person’s bed, I began to think that after all I might be cherishing unwarrantable prejudices against this unknown harpooneer. Thinks I, I’ll wait awhile; he must be dropping in before long. I’ll have a good look at him then, and perhaps we may become jolly good bedfellows after all—there’s no telling.\n\n But though the other boarders kept coming in by ones, twos, and threes, and going to bed, yet no sign of my harpooneer.\n\n “Landlord!” said I, “what sort of a chap is he—does he always keep such late hours?” It was now hard upon twelve o’clock.\n\n The landlord chuckled again with his lean chuckle, and seemed to be mightily tickled at something beyond my comprehension. “No,” he answered, “generally he’s an early bird—airley to bed and airley to rise—yes, he’s the bird what catches the worm. But to-night he went out a peddling, you see, and I don’t see what on airth keeps him so late, unless, may be, he can’t sell his head.”\n\n “Can’t sell his head?—What sort of a bamboozingly story is this you are telling me?” getting into a towering rage. “Do you pretend to say, landlord, that this harpooneer is actually engaged this blessed Saturday night, or rather Sunday morning, in peddling his head around this town?”\n\n “That’s precisely it,” said the landlord, “and I told him he couldn’t sell it here, the market’s overstocked.”\n\n “With what?” shouted I.\n\n “With heads to be sure; ain’t there too many heads in the world?”\n\n “I tell you what it is, landlord,” said I quite calmly, “you’d better stop spinning that yarn to me—I’m not green.”\n\n “May be not,” taking out a stick and whittling a toothpick, “but I rayther guess you’ll be done_*brown*_if that ere harpooneer hears you a slanderin’ his head.”\n\n “I’ll break it for him,” said I, now flying into a passion again at this unaccountable farrago of the landlord’s.\n\n “It’s broke a’ready,” said he.\n\n “Broke,” said I—“*broke*, do you mean?”\n\n “Sartain, and that’s the very reason he can’t sell it, I guess.”\n\n “Landlord,” said I, going up to him as cool as Mt. Hecla in a snow-storm—“landlord, stop whittling. You and I must understand one another, and that too without delay. I come to your house and want a bed; you tell me you can only give me half a one; that the other half belongs to a certain harpooneer. And about this harpooneer, whom I have not yet seen, you persist in telling me the most mystifying and exasperating stories tending to beget in me an uncomfortable feeling towards the man whom you design for my bedfellow—a sort of connexion, landlord, which is an intimate and confidential one in the highest degree. I now demand of you to speak out and tell me who and what this harpooneer is, and whether I shall be in all respects safe to spend the night with him. And in the first place, you will be so good as to unsay that story about selling his head, which if true I take to be good evidence that this harpooneer is stark mad, and I’ve no idea of sleeping with a madman; and you, sir,_*you*_I mean, landlord,_*you*, sir, by trying to induce me to do so knowingly, would thereby render yourself liable to a criminal prosecution.”\n\n “Wall,” said the landlord, fetching a long breath, “that’s a purty long sarmon for a chap that rips a little now and then. But be easy, be easy, this here harpooneer I have been tellin’ you of has just arrived from the south seas, where he bought up a lot of ‘balmed New Zealand heads (great curios, you know), and he’s sold all on ‘em but one, and that one he’s trying to sell to-night, cause to-morrow’s Sunday, and it would not do to be sellin’ human heads about the streets when folks is goin’ to churches. He wanted to, last Sunday, but I stopped him just as he was goin’ out of the door with four heads strung on a string, for all the airth like a string of inions.”\n\n This account cleared up the otherwise unaccountable mystery, and showed that the landlord, after all, had had no idea of fooling me—but at the same time what could I think of a harpooneer who stayed out of a Saturday night clean into the holy Sabbath, engaged in such a cannibal business as selling the heads of dead idolators?\n\n “Depend upon it, landlord, that harpooneer is a dangerous man.”\n\n “He pays reg’lar,” was the rejoinder. “But come, it’s getting dreadful late, you had better be turning flukes—it’s a nice bed; Sal and me slept in that ere bed the night we were spliced. There’s plenty of room for two to kick about in that bed; it’s an almighty big bed that. Why, afore we give it up, Sal used to put our Sam and little Johnny in the foot of it. But I got a dreaming and sprawling about one night, and somehow, Sam got pitched on the floor, and came near breaking his arm. Arter that, Sal said it wouldn’t do. Come along here, I’ll give ye a glim in a jiffy;” and so saying he lighted a candle and held it towards me, offering to lead the way. But I stood irresolute; when looking at a clock in the corner, he exclaimed “I vum it’s Sunday—you won’t see that harpooneer to-night; he’s come to anchor somewhere—come along then;_*do*_come;_*won’t*_ye come?”\n\n I considered the matter a moment, and then up stairs we went, and I was ushered into a small room, cold as a clam, and furnished, sure enough, with a prodigious bed, almost big enough indeed for any four harpooneers to sleep abreast.\n\n “There,” said the landlord, placing the candle on a crazy old sea chest that did double duty as a wash-stand and centre table; “there, make yourself comfortable now, and good night to ye.” I turned round from eyeing the bed, but he had disappeared.\n\n Folding back the counterpane, I stooped over the bed. Though none of the most elegant, it yet stood the scrutiny tolerably well. I then glanced round the room; and besides the bedstead and centre table, could see no other furniture belonging to the place, but a rude shelf, the four walls, and a papered fireboard representing a man striking a whale. Of things not properly belonging to the room, there was a hammock lashed up, and thrown upon the floor in one corner; also a large seaman’s bag, containing the harpooneer’s wardrobe, no doubt in lieu of a land trunk. Likewise, there was a parcel of outlandish bone fish hooks on the shelf over the fire-place, and a tall harpoon standing at the head of the bed.\n\n But what is this on the chest? I took it up, and held it close to the light, and felt it, and smelt it, and tried every way possible to arrive at some satisfactory conclusion concerning it. I can compare it to nothing but a large door mat, ornamented at the edges with little tinkling tags something like the stained porcupine quills round an Indian moccasin. There was a hole or slit in the middle of this mat, as you see the same in South American ponchos. But could it be possible that any sober harpooneer would get into a door mat, and parade the streets of any Christian town in that sort of guise? I put it on, to try it, and it weighed me down like a hamper, being uncommonly shaggy and thick, and I thought a little damp, as though this mysterious harpooneer had been wearing it of a rainy day. I went up in it to a bit of glass stuck against the wall, and I never saw such a sight in my life. I tore myself out of it in such a hurry that I gave myself a kink in the neck.\n\n I sat down on the side of the bed, and commenced thinking about this head-peddling harpooneer, and his door mat. After thinking some time on the bed-side, I got up and took off my monkey jacket, and then stood in the middle of the room thinking. I then took off my coat, and thought a little more in my shirt sleeves. But beginning to feel very cold now, half undressed as I was, and remembering what the landlord said about the harpooneer’s not coming home at all that night, it being so very late, I made no more ado, but jumped out of my pantaloons and boots, and then blowing out the light tumbled into bed, and commended myself to the care of heaven.\n\n Whether that mattress was stuffed with corn-cobs or broken crockery, there is no telling, but I rolled about a good deal, and could not sleep for a long time. At last I slid off into a light doze, and had pretty nearly made a good offing towards the land of Nod, when I heard a heavy footfall in the passage, and saw a glimmer of light come into the room from under the door.\n\n Lord save me, thinks I, that must be the harpooneer, the infernal head-peddler. But I lay perfectly still, and resolved not to say a word till spoken to. Holding a light in one hand, and that identical New Zealand head in the other, the stranger entered the room, and without looking towards the bed, placed his candle a good way off from me on the floor in one corner, and then began working away at the knotted cords of the large bag I before spoke of as being in the room. I was all eagerness to see his face, but he kept it averted for some time while employed in unlacing the bag’s mouth. This accomplished, however, he turned round—when, good heavens! what a sight! Such a face! It was of a dark, purplish, yellow colour, here and there stuck over with large blackish looking squares. Yes, it’s just as I thought, he’s a terrible bedfellow; he’s been in a fight, got dreadfully cut, and here he is, just from the surgeon. But at that moment he chanced to turn his face so towards the light, that I plainly saw they could not be sticking-plasters at all, those black squares on his cheeks. They were stains of some sort or other. At first I knew not what to make of this; but soon an inkling of the truth occurred to me. I remembered a story of a white man—a whaleman too—who, falling among the cannibals, had been tattooed by them. I concluded that this harpooneer, in the course of his distant voyages, must have met with a similar adventure. And what is it, thought I, after all! It’s only his outside; a man can be honest in any sort of skin. But then, what to make of his unearthly complexion, that part of it, I mean, lying round about, and completely independent of the squares of tattooing. To be sure, it might be nothing but a good coat of tropical tanning; but I never heard of a hot sun’s tanning a white man into a purplish yellow one. However, I had never been in the South Seas; and perhaps the sun there produced these extraordinary effects upon the skin. Now, while all these ideas were passing through me like lightning, this harpooneer never noticed me at all. But, after some difficulty having opened his bag, he commenced fumbling in it, and presently pulled out a sort of tomahawk, and a seal-skin wallet with the hair on. Placing these on the old chest in the middle of the room, he then took the New Zealand head—a ghastly thing enough—and crammed it down into the bag. He now took off his hat—a new beaver hat—when I came nigh singing out with fresh surprise. There was no hair on his head—none to speak of at least—nothing but a small scalp-knot twisted up on his forehead. His bald purplish head now looked for all the world like a mildewed skull. Had not the stranger stood between me and the door, I would have bolted out of it quicker than ever I bolted a dinner.\n\n Even as it was, I thought something of slipping out of the window, but it was the second floor back. I am no coward, but what to make of this head-peddling purple rascal altogether passed my comprehension. Ignorance is the parent of fear, and being completely nonplussed and confounded about the stranger, I confess I was now as much afraid of him as if it was the devil himself who had thus broken into my room at the dead of night. In fact, I was so afraid of him that I was not game enough just then to address him, and demand a satisfactory answer concerning what seemed inexplicable in him.\n\n Meanwhile, he continued the business of undressing, and at last showed his chest and arms. As I live, these covered parts of him were checkered with the same squares as his face; his back, too, was all over the same dark squares; he seemed to have been in a Thirty Years’ War, and just escaped from it with a sticking-plaster shirt. Still more, his very legs were marked, as if a parcel of dark green frogs were running up the trunks of young palms. It was now quite plain that he must be some abominable savage or other shipped aboard of a whaleman in the South Seas, and so landed in this Christian country. I quaked to think of it. A peddler of heads too—perhaps the heads of his own brothers. He might take a fancy to mine—heavens! look at that tomahawk!\n\n But there was no time for shuddering, for now the savage went about something that completely fascinated my attention, and convinced me that he must indeed be a heathen. Going to his heavy grego, or wrapall, or dreadnaught, which he had previously hung on a chair, he fumbled in the pockets, and produced at length a curious little deformed image with a hunch on its back, and exactly the colour of a three days’ old Congo baby. Remembering the embalmed head, at first I almost thought that this black manikin was a real baby preserved in some similar manner. But seeing that it was not at all limber, and that it glistened a good deal like polished ebony, I concluded that it must be nothing but a wooden idol, which indeed it proved to be. For now the savage goes up to the empty fire-place, and removing the papered fire-board, sets up this little hunch-backed image, like a tenpin, between the andirons. The chimney jambs and all the bricks inside were very sooty, so that I thought this fire-place made a very appropriate little shrine or chapel for his Congo idol.\n\n I now screwed my eyes hard towards the half hidden image, feeling but ill at ease meantime—to see what was next to follow. First he takes about a double handful of shavings out of his grego pocket, and places them carefully before the idol; then laying a bit of ship biscuit on top and applying the flame from the lamp, he kindled the shavings into a sacrificial blaze. Presently, after many hasty snatches into the fire, and still hastier withdrawals of his fingers (whereby he seemed to be scorching them badly), he at last succeeded in drawing out the biscuit; then blowing off the heat and ashes a little, he made a polite offer of it to the little negro. But the little devil did not seem to fancy such dry sort of fare at all; he never moved his lips. All these strange antics were accompanied by still stranger guttural noises from the devotee, who seemed to be praying in a sing-song or else singing some pagan psalmody or other, during which his face twitched about in the most unnatural manner. At last extinguishing the fire, he took the idol up very unceremoniously, and bagged it again in his grego pocket as carelessly as if he were a sportsman bagging a dead woodcock.\n\n All these queer proceedings increased my uncomfortableness, and seeing him now exhibiting strong symptoms of concluding his business operations, and jumping into bed with me, I thought it was high time, now or never, before the light was put out, to break the spell in which I had so long been bound.\n\n But the interval I spent in deliberating what to say, was a fatal one. Taking up his tomahawk from the table, he examined the head of it for an instant, and then holding it to the light, with his mouth at the handle, he puffed out great clouds of tobacco smoke. The next moment the light was extinguished, and this wild cannibal, tomahawk between his teeth, sprang into bed with me. I sang out, I could not help it now; and giving a sudden grunt of astonishment he began feeling me.\n\n Stammering out something, I knew not what, I rolled away from him against the wall, and then conjured him, whoever or whatever he might be, to keep quiet, and let me get up and light the lamp again. But his guttural responses satisfied me at once that he but ill comprehended my meaning.\n\n “Who-e debel you?”—he at last said—“you no speak-e, dam-me, I kill-e.” And so saying the lighted tomahawk began flourishing about me in the dark.\n\n “Landlord, for God’s sake, Peter Coffin!” shouted I. “Landlord! Watch! Coffin! Angels! save me!”\n\n “Speak-e! tell-ee me who-ee be, or dam-me, I kill-e!” again growled the cannibal, while his horrid flourishings of the tomahawk scattered the hot tobacco ashes about me till I thought my linen would get on fire. But thank heaven, at that moment the landlord came into the room light in hand, and leaping from the bed I ran up to him.\n\n “Don’t be afraid now,” said he, grinning again, “Queequeg here wouldn’t harm a hair of your head.”\n\n “Stop your grinning,” shouted I, “and why didn’t you tell me that that infernal harpooneer was a cannibal?”\n\n “I thought ye know’d it;—didn’t I tell ye, he was a peddlin’ heads around town?—but turn flukes again and go to sleep. Queequeg, look here—you sabbee me, I sabbee—you this man sleepe you—you sabbee?”\n\n “Me sabbee plenty”—grunted Queequeg, puffing away at his pipe and sitting up in bed.\n\n “You gettee in,” he added, motioning to me with his tomahawk, and throwing the clothes to one side. He really did this in not only a civil but a really kind and charitable way. I stood looking at him a moment. For all his tattooings he was on the whole a clean, comely looking cannibal. What’s all this fuss I have been making about, thought I to myself—the man’s a human being just as I am: he has just as much reason to fear me, as I have to be afraid of him. Better sleep with a sober cannibal than a drunken Christian.\n\n “Landlord,” said I, “tell him to stash his tomahawk there, or pipe, or whatever you call it; tell him to stop smoking, in short, and I will turn in with him. But I don’t fancy having a man smoking in bed with me. It’s dangerous. Besides, I ain’t insured.”\n\n This being told to Queequeg, he at once complied, and again politely motioned me to get into bed—rolling over to one side as much as to say—“I won’t touch a leg of ye.”\n\n “Good night, landlord,” said I, “you may go.”\n\n I turned in, and never slept better in my life.\"\n datetime: 2017-06-02T00:00:00-04:00\n date: 2017-06-07T00:00:00-04:00\n image: /assets/uploads/moby-dick.jpg\n file: /assets/uploads/moby-dick.jpg\n select: c\n object:\n text: >-\n Upon waking next morning about daylight, I found Queequeg’s arm thrown\n over me in the most loving and affectionate manner. You had almost\n thought I had been his wife. The counterpane was of patchwork, full of\n odd little parti-coloured squares and triangles; and this arm of his\n tattooed all over with an interminable Cretan labyrinth of a figure, no\n two parts of which were of one precise shade—owing I suppose to his\n keeping his arm at sea unmethodically in sun and shade, his shirt\n sleeves irregularly rolled up at various times—this same arm of his, I\n say, looked for all the world like a strip of that same patchwork quilt.\n Indeed, partly lying on it as the arm did when I first awoke, I could\n hardly tell it from the quilt, they so blended their hues together; and\n it was only by the sense of weight and pressure that I could tell that\n Queequeg was hugging me.\n number: '444444'\n markdown: >-\n # Upon waking next morning about daylight, I found Queequeg’s arm thrown\n over me in the most loving and affectionate manner. You had almost\n thought I had been his wife. The counterpane was of patchwork, full of\n odd little parti-coloured squares and triangles; and this arm of his\n tattooed all over with an interminable Cretan labyrinth of a figure, no\n two parts of which were of one precise shade—owing I suppose to his\n keeping his arm at sea unmethodically in sun and shade, his shirt\n sleeves irregularly rolled up at various times—this same arm of his, I\n say, looked for all the world like a strip of that same patchwork quilt.\n Indeed, partly lying on it as the arm did when I first awoke, I could\n hardly tell it from the quilt, they so blended their hues together; and\n it was only by the sense of weight and pressure that I could tell that\n Queequeg was hugging me.\n\n\n ## My sensations were strange. Let me try to explain them. When I was a\n child, I well remember a somewhat similar circumstance that befell me;\n whether it was a reality or a dream, I never could entirely settle. The\n circumstance was this. I had been cutting up some caper or other—I think\n it was trying to crawl up the chimney, as I had seen a little sweep do a\n few days previous; and my stepmother who, somehow or other, was all the\n time whipping me, or sending me to bed supperless,—my mother dragged me\n by the legs out of the chimney and packed me off to bed, though it was\n only two o’clock in the afternoon of the 21st June, the longest day in\n the year in our hemisphere. I felt dreadfully. But there was no help for\n it, so up stairs I went to my little room in the third floor, undressed\n myself as slowly as possible so as to kill time, and with a bitter sigh\n got between the sheets.\n\n\n I lay there dismally calculating that sixteen entire hours must elapse\n before I could hope for a resurrection. Sixteen hours in bed! the small\n of my back ached to think of it. And it was so light too; the sun\n shining in at the window, and a great rattling of coaches in the\n streets, and the sound of gay voices all over the house. I felt worse\n and worse—at last I got up, dressed, and softly going down in my\n stockinged feet, sought out my stepmother, and suddenly threw myself at\n her feet, beseeching her as a particular favour to give me a good\n slippering for my misbehaviour; anything indeed but condemning me to lie\n abed such an unendurable length of time. But she was the best and most\n conscientious of stepmothers, and back I had to go to my room. For\n several hours I lay there broad awake, feeling a great deal worse than I\n have ever done since, even from the greatest subsequent misfortunes. At\n last I must have fallen into a troubled nightmare of a doze; and slowly\n waking from it—half steeped in dreams—I opened my eyes, and the before\n sun-lit room was now wrapped in outer darkness. Instantly I felt a shock\n running through all my frame; nothing was to be seen, and nothing was to\n be heard; but a supernatural hand seemed placed in mine. My arm hung\n over the counterpane, and the nameless, unimaginable, silent form or\n phantom, to which the hand belonged, seemed closely seated by my\n bed-side. For what seemed ages piled on ages, I lay there, frozen with\n the most awful fears, not daring to drag away my hand; yet ever thinking\n that if I could but stir it one single inch, the horrid spell would be\n broken. I knew not how this consciousness at last glided away from me;\n but waking in the morning, I shudderingly remembered it all, and for\n days and weeks and months afterwards I lost myself in confounding\n attempts to explain the mystery. Nay, to this very hour, I often puzzle\n myself with it.\n\n\n Now, take away the awful fear, and my sensations at feeling the\n supernatural hand in mine were very similar, in their strangeness, to\n those which I experienced on waking up and seeing Queequeg’s pagan arm\n thrown round me. But at length all the past night’s events soberly\n recurred, one by one, in fixed reality, and then I lay only alive to the\n comical predicament. For though I tried to move his arm—unlock his\n bridegroom clasp—yet, sleeping as he was, he still hugged me tightly, as\n though naught but death should part us twain. I now strove to rouse\n him—“Queequeg!”—but his only answer was a snore. I then rolled over, my\n neck feeling as if it were in a horse-collar; and suddenly felt a slight\n scratch. Throwing aside the counterpane, there lay the tomahawk sleeping\n by the savage’s side, as if it were a hatchet-faced baby. A pretty\n pickle, truly, thought I; abed here in a strange house in the broad day,\n with a cannibal and a tomahawk! “Queequeg!—in the name of goodness,\n Queequeg, wake!” At length, by dint of much wriggling, and loud and\n incessant expostulations upon the unbecomingness of his hugging a fellow\n male in that matrimonial sort of style, I succeeded in extracting a\n grunt; and presently, he drew back his arm, shook himself all over like\n a Newfoundland dog just from the water, and sat up in bed, stiff as a\n pike-staff, looking at me, and rubbing his eyes as if he did not\n altogether remember how I came to be there, though a dim consciousness\n of knowing something about me seemed slowly dawning over him. Meanwhile,\n I lay quietly eyeing him, having no serious misgivings now, and bent\n upon narrowly observing so curious a creature. When, at last, his mind\n seemed made up touching the character of his bedfellow, and he became,\n as it were, reconciled to the fact; he jumped out upon the floor, and by\n certain signs and sounds gave me to understand that, if it pleased me,\n he would dress first and then leave me to dress afterwards, leaving the\n whole apartment to myself. Thinks I, Queequeg, under the circumstances,\n this is a very civilized overture; but, the truth is, these savages have\n an innate sense of delicacy, say what you will; it is marvellous how\n essentially polite they are. I pay this particular compliment to\n Queequeg, because he treated me with so much civility and consideration,\n while I was guilty of great rudeness; staring at him from the bed, and\n watching all his toilette motions; for the time my curiosity getting the\n better of my breeding. Nevertheless, a man like Queequeg you don’t see\n every day, he and his ways were well worth unusual regarding.\n\n\n He commenced dressing at top by donning his beaver hat, a very tall one,\n by the by, and then—still minus his trowsers—he hunted up his boots.\n What under the heavens he did it for, I cannot tell, but his next\n movement was to crush himself—boots in hand, and hat on—under the bed;\n when, from sundry violent gaspings and strainings, I inferred he was\n hard at work booting himself; though by no law of propriety that I ever\n heard of, is any man required to be private when putting on his boots.\n But Queequeg, do you see, was a creature in the transition stage—neither\n caterpillar nor butterfly. He was just enough civilized to show off his\n outlandishness in the strangest possible manners. His education was not\n yet completed. He was an undergraduate. If he had not been a small\n degree civilized, he very probably would not have troubled himself with\n boots at all; but then, if he had not been still a savage, he never\n would have dreamt of getting under the bed to put them on. At last, he\n emerged with his hat very much dented and crushed down over his eyes,\n and began creaking and limping about the room, as if, not being much\n accustomed to boots, his pair of damp, wrinkled cowhide ones—probably\n not made to order either—rather pinched and tormented him at the first\n go off of a bitter cold morning.\n\n\n Seeing, now, that there were no curtains to the window, and that the\n street being very narrow, the house opposite commanded a plain view into\n the room, and observing more and more the indecorous figure that\n Queequeg made, staving about with little else but his hat and boots on;\n I begged him as well as I could, to accelerate his toilet somewhat, and\n particularly to get into his pantaloons as soon as possible. He\n complied, and then proceeded to wash himself. At that time in the\n morning any Christian would have washed his face; but Queequeg, to my\n amazement, contented himself with restricting his ablutions to his\n chest, arms, and hands. He then donned his waistcoat, and taking up a\n piece of hard soap on the wash-stand centre table, dipped it into water\n and commenced lathering his face. I was watching to see where he kept\n his razor, when lo and behold, he takes the harpoon from the bed corner,\n slips out the long wooden stock, unsheathes the head, whets it a little\n on his boot, and striding up to the bit of mirror against the wall,\n begins a vigorous scraping, or rather harpooning of his cheeks. Thinks\n I, Queequeg, this is using Rogers’s best cutlery with a vengeance.\n Afterwards I wondered the less at this operation when I came to know of\n what fine steel the head of a harpoon is made, and how exceedingly sharp\n the long straight edges are always kept.\n\n\n The rest of his toilet was soon achieved, and he proudly marched out of\n the room, wrapped up in his great pilot monkey jacket, and sporting his\n harpoon like a marshal’s baton.\n datetime: 2017-06-07T19:05:43.820Z\n date: 2017-06-07T19:05:36.240Z\n image: /assets/uploads/moby-dick.jpg\n file: /assets/uploads/moby-dick.jpg\n select: b\n list:\n - object:\n date: 2017-06-07T19:10:25.684Z\n string: CHAPTER 4. The Counterpane.\n string: CHAPTER 3. The Spouter-Inn.\ntyped_list:\n - type: type_1_object\n string: CHAPTER 2. The Carpet-Bag.\n boolean: true\n text: >-\n I stuffed a shirt or two into my old carpet-bag, tucked it under my arm, and\n started for Cape Horn and the Pacific. Quitting the good city of old\n Manhatto, I duly arrived in New Bedford. It was a Saturday night in\n December. Much was I disappointed upon learning that the little packet for\n Nantucket had already sailed, and that no way of reaching that place would\n offer, till the following Monday.\n - type: type_2_object\n number: 3\n select: b\n datetime: 2017-06-07T19:05:43.816Z\n markdown: >-\n Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam eget\n facilisis magna. Integer eget eleifend quam, sit amet sodales nisi. Fusce\n non lacus tempus, posuere tellus semper, facilisis lectus. Cras consequat\n aliquet ex.\n - type: type_1_object\n string: CHAPTER 2. The Carpet-Bag.\n boolean: false\n text: >-\n Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec elementum\n augue ornare lectus finibus mollis. Maecenas fringilla lorem at nisl pharetra\n scelerisque.\n - type: type_3_object\n date: 2017-06-07T18:55:28.111Z\n image: /assets/uploads/moby-dick.jpg\n file: /assets/uploads/moby-dick.jpg\n---\n\n", + }, + }, + _i18n_playground: { + 'file1.en.md': { + content: + '---\nslug: file1\ndescription: Coffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\ndate: 2015-02-14T00:00:00.000Z\n---\n', + }, + 'file1.de.md': { + content: + '---\ndescription: Kaffee ist ein kleiner Baum oder Strauch, der in seiner wilden Form im Unterholz des Waldes wächst und traditionell kommerziell unter anderen Bäumen angebaut wurde, die Schatten spendeten. Die waldähnliche Struktur schattiger Kaffeefarmen bietet Lebensraum für eine Vielzahl von wandernden und ansässigen Arten.\ndate: 2015-02-14T00:00:00.000Z\n---\n', + }, + 'file1.fr.md': { + content: + "---\ndescription: Le café est un petit arbre ou un arbuste qui pousse dans le sous-étage de la forêt sous sa forme sauvage et qui était traditionnellement cultivé commercialement sous d'autres arbres qui fournissaient de l'ombre. La structure forestière des plantations de café d'ombre fournit un habitat à un grand nombre d'espèces migratrices et résidentes.\ndate: 2015-02-14T00:00:00.000Z\n---\n", + }, + }, + _nested_pages: { + authors: { + 'author-1': { + 'index.md': { + content: '---\ntitle: An Author\n---\nAuthor details go here!.\n', + }, + }, + 'index.md': { + content: '---\ntitle: Authors\n---\n', + }, + }, + posts: { + 'hello-world': { + 'index.md': { + content: + '---\ntitle: Hello World\n---\nCoffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\n', + }, + }, + 'index.md': { + content: '---\ntitle: Posts\n---\n', + }, + news: { + 'hello-world-news': { + 'index.md': { + content: + '---\ntitle: Hello World News\n---\nCoffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\n', + }, + }, + 'index.md': { + content: '---\ntitle: News Articles\n---\n', + }, + }, + }, + 'index.md': { + content: '---\ntitle: Pages\n---\n', + }, + }, +}; + +var ONE_DAY = 60 * 60 * 24 * 1000; + +for (var i = 1; i <= 20; i++) { + var date = new Date(); + + date.setTime(date.getTime() - ONE_DAY); + var month = ('0' + (date.getMonth() + 1)).slice(-2); + var day = ('0' + date.getDate()).slice(-2); + var dateString = '' + date.getFullYear() + '-' + month + '-' + day; + var slug = dateString + '-post-number-' + i + '.md'; + + window.repoFiles._posts[slug] = { + content: `--- +title: "This is post # ${i}" +image: /assets/uploads/lobby.jpg +draft: ${i % 2 === 0} +date: ${dateString}T00:00:00.000Z +description: "Post # ${i}" +category: news +tags: + - tag1 + - tag2 +--- +# The post is number ${i} + +![Static CMS](https://raw.githubusercontent.com/StaticJsCMS/static-cms/main/static-cms-logo.png) + +# Awesome Editor! + +It was _released as open source in 2022_ and is **_continually_** evolving to be the **best editor experience** available for static site generators. + +## MDX + +The output out this widget is \`mdx\`, a mixture of \`markdown\` and \`javascript components\`. See [MDX documentation](https://mdxjs.com/docs/). + +\`\`\`yaml +name: body +label: Blog post content +widget: markdown +\`\`\` + +\`\`\`js +name: 'body', +label: 'Blog post content', +widget: 'markdown', +\`\`\` + +> See the table below for default options +> More API information can be found in the document + +| Name | Type | Default | Description | +| ------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| default | string | \`''\` | _Optional_. The default value for the field. Accepts markdown content | +| media_library | Media Library Options | \`{}\` | _Optional_. Media library settings to apply when a media library is opened by the current widget. See [Media Library Options](#media-library-options) | +| media_folder | string | | _Optional_. Specifies the folder path where uploaded files should be saved, relative to the base of the repo | +| public_folder | string | | _Optional_. Specifies the folder path where the files uploaded by the media library will be accessed, relative to the base of the built site | + +### Media Library Options + +| Name | Type | Default | Description | +| -------------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| allow_multiple | boolean | \`true\` | _Optional_. When set to \`false\`, prevents multiple selection for any media library extension, but must be supported by the extension in use | +| config | string | \`{}\` | _Optional_. A configuration object that will be passed directly to the media library being used - available options are determined by the library | +| choose_url | string
    \\| boolean | \`true\` | _Optional_. When set to \`false\`, the "Insert from URL" button will be hidden + +## Features + +* CommonMark + GFM Specifications +* Live \`Preview\` +* Auto Indent +* Syntax Highlight + 1. Rich Editor + 2. Preview + +## Formatting + +**Bold**, *Italic*, ***both*** + +~~Strikethrough~~ + +## Shortcodes + +[youtube|p6h-rYSVX90] + +## Tasks + +> * List Item A +> * List Item B +> 1. [x] Task 1 +> 2. [ ] Task 2`, + }; +} + +for (var i = 1; i <= 5; i++) { + var date = new Date(); + + date.setTime(date.getTime() + ONE_DAY); + var month = ('0' + (date.getMonth() + 1)).slice(-2); + var day = ('0' + date.getDate()).slice(-2); + var dateString = '' + date.getFullYear() + '-' + month + '-' + day; + var slug = dateString + '-faq-number-' + i + '.md'; + + window.repoFiles._faqs[slug] = { + content: + '---\ntitle: "This FAQ item # ' + + i + + '"\ndate: ' + + dateString + + 'T00:00:00.000Z\n---\n# Loren ipsum dolor sit amet', + }; +} +window.repoFiles._pages = { + directory: { + 'sub-directory': { + 'nested-directory': { + 'index.md': { + path: '_pages/directory/sub-directory/nested-directory/index.md', + content: '---\ntitle: "Nested Directory"\n---\n', + }, + }, + 'index.md': { + path: '_pages/directory/sub-directory/index.md', + content: '---\ntitle: "Sub Directory"\n---\n', + }, + }, + 'another-sub-directory': { + 'index.md': { + path: '_pages/directory/another-sub-directory/index.md', + content: '---\ntitle: "Another Sub Directory"\n---\n', + }, + }, + 'index.md': { + path: '_pages/directory/index.md', + content: '---\ntitle: "Directory"\n---\n', + }, + }, + 'index.md': { + path: '_pages/index.md', + content: '---\ntitle: "Root Page"\n---\n', + }, +}; diff --git a/packages/core/dev-test/index.html b/packages/core/dev-test/index.html index 467b75ca..bc75f077 100644 --- a/packages/core/dev-test/index.html +++ b/packages/core/dev-test/index.html @@ -4,314 +4,10 @@ Static CMS Development Test - - - + + + diff --git a/packages/core/dev-test/index.js b/packages/core/dev-test/index.js index fa798116..a4f321b9 100644 --- a/packages/core/dev-test/index.js +++ b/packages/core/dev-test/index.js @@ -11,134 +11,6 @@ const PostPreview = ({ entry, widgetFor }) => { ); }; -const PostPreviewCard = ({ entry, theme, hasLocalBackup, collection }) => { - const date = new Date(entry.data.date); - - const month = date.getMonth() + 1; - const day = date.getDate(); - - const imageField = useMemo(() => collection.fields.find((f) => f.name === 'image'), []); - const image = useMediaAsset(entry.data.image, collection, imageField, entry); - - return h( - 'div', - { style: { width: '100%' } }, - h('div', { - style: { - width: '100%', - borderTopLeftRadius: '8px', - borderTopRightRadius: '8px', - overflow: 'hidden', - height: '140px', - backgroundSize: 'cover', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center', - objectFit: 'cover', - backgroundImage: `url('${image}')`, - }, - }), - h( - 'div', - { style: { padding: '16px', width: '100%' } }, - h( - 'div', - { - style: { - display: 'flex', - width: '100%', - justifyContent: 'space-between', - alignItems: 'start', - gap: '4px', - color: theme === 'dark' ? 'white' : 'inherit', - }, - }, - h( - 'div', - { - style: { - display: 'flex', - flexDirection: 'column', - alignItems: 'baseline', - gap: '4px', - }, - }, - h( - 'div', - { - style: { - fontSize: '14px', - fontWeight: 700, - color: 'rgb(107, 114, 128)', - lineHeight: '18px', - }, - }, - entry.data.title, - ), - h( - 'span', - { style: { fontSize: '14px' } }, - `${date.getFullYear()}-${month < 10 ? `0${month}` : month}-${ - day < 10 ? `0${day}` : day - }`, - ), - ), - h( - 'div', - { - style: { - display: 'flex', - alignItems: 'center', - whiteSpace: 'no-wrap', - gap: '8px', - }, - }, - hasLocalBackup - ? h( - 'div', - { - style: { - border: '2px solid rgb(147, 197, 253)', - borderRadius: '50%', - color: 'rgb(147, 197, 253)', - height: '18px', - width: '18px', - fontWeight: 'bold', - fontSize: '11px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - textAlign: 'center', - }, - title: 'Has local backup', - }, - 'i', - ) - : null, - h( - 'div', - { - style: { - backgroundColor: - entry.data.draft === true ? 'rgb(37, 99, 235)' : 'rgb(22, 163, 74)', - color: 'white', - border: 'none', - padding: '2px 6px', - textAlign: 'center', - textDecoration: 'none', - display: 'inline-block', - cursor: 'pointer', - borderRadius: '4px', - fontSize: '14px', - }, - }, - entry.data.draft === true ? 'Draft' : 'Published', - ), - ), - ), - ), - ); -}; - const PostDateFieldPreview = ({ value }) => { const date = new Date(value); @@ -152,29 +24,6 @@ const PostDateFieldPreview = ({ value }) => { ); }; -const PostDraftFieldPreview = ({ value }) => { - return h( - 'div', - { - style: { - backgroundColor: value === true ? 'rgb(37 99 235)' : 'rgb(22 163 74)', - color: 'white', - border: 'none', - padding: '2px 6px', - textAlign: 'center', - textDecoration: 'none', - display: 'inline-block', - cursor: 'pointer', - borderRadius: '4px', - fontSize: '14px', - lineHeight: '16px', - height: '20px', - }, - }, - value === true ? 'Draft' : 'Published', - ); -}; - const GeneralPreview = ({ widgetsFor, entry, collection }) => { const title = entry.data.site_title; const posts = entry.data.posts; @@ -218,39 +67,16 @@ const AuthorsPreview = ({ widgetsFor }) => { ); }; -const RelationKitchenSinkPostPreview = ({ fieldsMetaData }) => { - // When a post is selected from the relation field, all of it's data - // will be available in the field's metadata nested under the collection - // name, and then further nested under the value specified in `value_field`. - // In this case, the post would be nested under "posts" and then under - // the title of the selected post, since our `value_field` in the config - // is "title". - const post = fieldsMetaData && fieldsMetaData.posts.value; - const style = { border: '2px solid #ccc', borderRadius: '8px', padding: '20px' }; - return post - ? h( - 'div', - { style: style }, - h('h2', {}, 'Related Post'), - h('h3', {}, post.title), - h('img', { src: post.image }), - h('p', {}, (post.body ?? '').slice(0, 100) + '...'), - ) - : null; -}; - const CustomPage = () => { return h('div', {}, 'I am a custom page!'); }; CMS.registerPreviewTemplate('posts', PostPreview); -CMS.registerPreviewCard('posts', PostPreviewCard, () => 240); CMS.registerFieldPreview('posts', 'date', PostDateFieldPreview); -CMS.registerFieldPreview('posts', 'draft', PostDraftFieldPreview); CMS.registerPreviewTemplate('general', GeneralPreview); CMS.registerPreviewTemplate('authors', AuthorsPreview); // Pass the name of a registered control to reuse with a new widget preview. -CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview); +CMS.registerWidget('relationKitchenSinkPost', 'relation'); CMS.registerAdditionalLink({ id: 'example', title: 'Example.com', @@ -268,6 +94,14 @@ CMS.registerAdditionalLink({ }, }); +CMS.registerTheme({ + name: 'Custom Red Orange', + extends: 'dark', + primary: { + main: '#ff4500', + } +}); + CMS.registerShortcode('youtube', { label: 'YouTube', openTag: '[', @@ -283,7 +117,9 @@ CMS.registerShortcode('youtube', { toArgs: ({ src }) => { return [src]; }, - control: ({ src, onChange, theme }) => { + control: ({ src, onChange }) => { + const theme = useTheme(); + return h('span', {}, [ h('input', { key: 'control-input', @@ -293,8 +129,8 @@ CMS.registerShortcode('youtube', { }, style: { width: '100%', - backgroundColor: theme === 'dark' ? 'rgb(30, 41, 59)' : 'white', - color: theme === 'dark' ? 'white' : 'black', + backgroundColor: theme.common.gray, + color: theme.text.primary, padding: '4px 8px', }, }), diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index 07f3209d..7392f3df 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -15,5 +15,4 @@ module.exports = { setupFiles: ['./test/setupEnv.js'], globalSetup: './test/globalSetup.js', testRegex: '\\.spec\\.tsx?$', - snapshotSerializers: ['@emotion/jest/serializer'], }; diff --git a/packages/core/package.json b/packages/core/package.json index 9923e653..3818e81b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@staticcms/core", - "version": "3.4.8", + "version": "4.0.0", "license": "MIT", "description": "Static CMS core application.", "repository": "https://github.com/StaticJsCMS/static-cms", @@ -16,21 +16,22 @@ "build": "cross-env NODE_ENV=production run-s clean build:webpack build:types", "clean": "rimraf dist dev-test/dist", "dev": "run-s clean serve", - "format:prettier": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --write", + "format:prettier": "prettier \"{src,test}/**/*.{js,jsx,ts,tsx,css}\" --write", "format": "run-s \"lint:js --fix --quiet\" \"format:prettier\"", "lint-quiet": "run-p -c --aggregate-output \"lint:* --quiet\"", - "lint:format": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --list-different", - "lint:js": "eslint --color \"src/**/*.{ts,tsx}\"", + "lint:format": "prettier \"{src,test}/**/*.{js,jsx,ts,tsx,css}\" --list-different", + "lint:js": "eslint --color \"{src,test}/**/*.{ts,tsx}\" --max-warnings=0", "lint": "run-p -c --aggregate-output \"lint:*\"", "prepublishOnly": "yarn build ", "prepack": "cp ../../README.md ./", "postpack": "rm ./README.md", "serve": "webpack serve --config-name configMain", - "test": "cross-env NODE_ENV=test jest", + "test": "cross-env NODE_ENV=test yarn run-s clean test:unit", + "test:unit": "jest", "test:integration": "cross-env NODE_ENV=test jest -c jest.config.integration.js", "test:ci": "cross-env NODE_ENV=test jest --maxWorkers=2 --coverage", "test:integration:ci": "cross-env NODE_ENV=test jest -c jest.config.integration.js --maxWorkers=2", - "type-check": "tsc --watch" + "type-check": "tsc --watch --project tsconfig.dev.json" }, "main": "dist/static-cms-core.js", "types": "dist/index.d.ts", @@ -47,33 +48,31 @@ "last 2 Safari versions" ], "dependencies": { - "@babel/eslint-parser": "7.21.3", - "@babel/runtime": "7.21.0", - "@codemirror/autocomplete": "6.5.1", - "@codemirror/commands": "6.2.3", - "@codemirror/language": "6.6.0", - "@codemirror/language-data": "6.3.0", - "@codemirror/legacy-modes": "6.3.2", - "@codemirror/lint": "6.2.1", - "@codemirror/search": "6.3.0", - "@codemirror/state": "6.2.0", + "@babel/eslint-parser": "7.22.15", + "@babel/runtime": "7.23.1", + "@codemirror/autocomplete": "6.9.1", + "@codemirror/commands": "6.3.0", + "@codemirror/language": "6.9.1", + "@codemirror/language-data": "6.3.1", + "@codemirror/legacy-modes": "6.3.3", + "@codemirror/lint": "6.4.2", + "@codemirror/search": "6.5.4", + "@codemirror/state": "6.2.1", "@codemirror/theme-one-dark": "6.1.2", - "@codemirror/view": "6.9.5", + "@codemirror/view": "6.21.2", "@dnd-kit/core": "6.0.8", "@dnd-kit/sortable": "7.0.2", "@dnd-kit/utilities": "3.2.1", - "@emotion/babel-preset-css-prop": "11.10.0", - "@emotion/css": "11.10.6", - "@emotion/react": "11.10.6", - "@emotion/styled": "11.10.6", - "@lezer/common": "1.0.2", + "@emotion/react": "11.11.1", + "@emotion/styled": "11.11.0", + "@lezer/common": "1.1.0", "@mdx-js/mdx": "3.0.0", "@mdx-js/react": "3.0.0", - "@mui/base": "5.0.0-beta.14", - "@mui/material": "5.11.16", - "@mui/system": "5.11.16", - "@mui/x-date-pickers": "5.0.20", - "@reduxjs/toolkit": "1.9.5", + "@mui/base": "5.0.0-beta.18", + "@mui/material": "5.14.12", + "@mui/system": "5.14.12", + "@mui/x-date-pickers": "6.16.0", + "@reduxjs/toolkit": "1.9.7", "@styled-icons/bootstrap": "10.47.0", "@styled-icons/fa-brands": "10.47.0", "@styled-icons/fluentui-system-regular": "10.47.0", @@ -81,8 +80,8 @@ "@styled-icons/material": "10.47.0", "@styled-icons/material-outlined": "10.47.0", "@styled-icons/material-rounded": "10.47.0", - "@styled-icons/remix-editor": "10.46.0", "@styled-icons/simple-icons": "10.46.0", + "@tanstack/react-virtual": "3.0.0-beta.61", "@udecode/plate": "23.7.4", "@udecode/plate-cursor": "23.7.4", "@udecode/plate-juice": "23.7.4", @@ -98,16 +97,16 @@ "common-tags": "1.8.2", "copy-text-to-clipboard": "3.1.0", "create-react-class": "15.7.0", - "date-fns": "2.29.3", + "date-fns": "2.30.0", "deepmerge": "4.3.1", "diacritics": "1.3.0", "escape-html": "1.0.3", - "eslint-config-prettier": "8.8.0", + "eslint-config-prettier": "9.0.0", "eslint-plugin-babel": "5.3.1", "fuzzy": "0.1.3", "globby": "13.1.4", "gotrue-js": "0.9.29", - "graphql": "16.6.0", + "graphql": "16.8.1", "graphql-tag": "2.12.6", "gray-matter": "4.0.3", "history": "5.3.0", @@ -130,7 +129,6 @@ "micromark-extension-gfm-task-list-item": "2.0.1", "micromark-util-combine-extensions": "2.0.0", "minimatch": "9.0.0", - "moment": "2.29.4", "node-polyglot": "2.5.0", "ol": "7.3.0", "path-browserify": "1.0.1", @@ -144,12 +142,12 @@ "react-is": "18.2.0", "react-markdown": "9.0.1", "react-polyglot": "0.7.2", - "react-redux": "8.0.5", - "react-router-dom": "6.10.0", + "react-redux": "8.1.3", + "react-resizable-panels": "0.0.55", + "react-router-dom": "6.16.0", "react-scroll-sync": "0.11.0", "react-topbar-progress-indicator": "4.1.1", - "react-virtual": "2.10.4", - "react-virtualized-auto-sizer": "1.0.15", + "react-virtualized-auto-sizer": "1.0.20", "react-waypoint": "10.3.0", "react-window": "1.8.9", "remark-gfm": "4.0.0", @@ -164,123 +162,111 @@ "slate-hyperscript": "0.77.0", "slate-react": "0.98.3", "stream-browserify": "3.0.0", - "styled-components": "5.3.10", + "styled-components": "5.3.11", "symbol-observable": "4.0.0", "unified": "11.0.4", "unist-util-visit": "5.0.0", "url": "0.11.0", "url-join": "5.0.0", - "uuid": "9.0.0", + "uuid": "9.0.1", "validate-color": "2.2.4", "vfile": "6.0.1", "vfile-message": "4.0.2", "vfile-statistics": "3.0.0", - "what-input": "5.2.12", "what-the-diff": "0.6.0", - "yaml": "2.2.2" + "yaml": "2.3.2" }, "devDependencies": { - "@babel/cli": "7.21.0", - "@babel/core": "7.21.4", + "@babel/cli": "7.23.0", + "@babel/core": "7.23.0", "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-export-default-from": "7.18.10", + "@babel/plugin-proposal-export-default-from": "7.22.17", "@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6", "@babel/plugin-proposal-numeric-separator": "7.18.6", "@babel/plugin-proposal-object-rest-spread": "7.20.7", "@babel/plugin-proposal-optional-chaining": "7.21.0", "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/preset-env": "7.21.4", - "@babel/preset-react": "7.18.6", - "@babel/preset-typescript": "7.21.4", - "@emotion/eslint-plugin": "11.10.0", - "@emotion/jest": "11.10.5", + "@babel/preset-env": "7.22.20", + "@babel/preset-react": "7.22.15", + "@babel/preset-typescript": "7.23.0", "@iarna/toml": "2.2.5", - "@pmmmwh/react-refresh-webpack-plugin": "0.5.10", + "@pmmmwh/react-refresh-webpack-plugin": "0.5.11", "@simbathesailor/use-what-changed": "2.0.0", - "@testing-library/dom": "9.2.0", - "@testing-library/jest-dom": "5.16.5", + "@testing-library/dom": "9.3.3", + "@testing-library/jest-dom": "6.1.3", "@testing-library/react": "14.0.0", - "@testing-library/user-event": "14.4.3", + "@testing-library/user-event": "14.5.1", "@types/common-tags": "1.8.1", "@types/create-react-class": "15.6.3", "@types/fs-extra": "11.0.1", "@types/is-hotkey": "0.1.7", - "@types/jest": "29.5.1", - "@types/js-yaml": "4.0.5", + "@types/jest": "29.5.5", + "@types/js-yaml": "4.0.6", "@types/jwt-decode": "2.2.1", "@types/lodash": "4.14.194", "@types/minimatch": "5.1.2", - "@types/node": "18.16.14", - "@types/node-fetch": "2.6.4", - "@types/react": "18.2.0", - "@types/react-color": "3.0.6", - "@types/react-dom": "18.2.1", + "@types/node": "18.17.19", + "@types/react": "18.2.25", + "@types/react-color": "3.0.7", + "@types/react-dom": "18.2.10", "@types/react-virtualized-auto-sizer": "1.0.1", - "@types/react-window": "1.8.5", - "@types/styled-components": "5.1.26", + "@types/react-window": "1.8.6", "@types/unist": "3.0.2", "@types/url-join": "4.0.1", - "@types/uuid": "9.0.1", - "@typescript-eslint/eslint-plugin": "5.59.1", - "@typescript-eslint/parser": "5.59.1", - "autoprefixer": "10.4.14", - "axios": "1.3.6", + "@types/uuid": "9.0.4", + "@typescript-eslint/eslint-plugin": "6.7.4", + "@typescript-eslint/parser": "6.7.4", + "autoprefixer": "10.4.16", "babel-core": "7.0.0-bridge.0", - "babel-loader": "9.1.2", - "babel-plugin-emotion": "11.0.0", + "babel-loader": "9.1.3", "babel-plugin-inline-json-import": "0.3.2", "babel-plugin-inline-react-svg": "2.0.2", "babel-plugin-lodash": "3.3.4", "babel-plugin-transform-builtin-extend": "1.1.2", - "babel-plugin-transform-define": "2.1.0", + "babel-plugin-transform-define": "2.1.4", "babel-plugin-transform-export-extensions": "6.22.0", "babel-plugin-transform-inline-environment-variables": "0.4.4", - "cache-me-outside": "1.0.0", "commonmark": "0.30.0", "commonmark-spec": "0.30.0", "cross-env": "7.0.3", - "css-loader": "6.7.3", - "dotenv": "16.0.3", - "eslint": "8.39.0", - "eslint-import-resolver-typescript": "3.5.5", - "eslint-plugin-cypress": "2.13.3", - "eslint-plugin-import": "2.27.5", - "eslint-plugin-prettier": "4.2.1", - "eslint-plugin-react": "7.32.2", + "css-loader": "6.8.1", + "dotenv": "16.3.1", + "eslint": "8.50.0", + "eslint-import-resolver-typescript": "3.6.1", + "eslint-plugin-cypress": "2.15.1", + "eslint-plugin-import": "2.28.1", + "eslint-plugin-prettier": "5.0.0", + "eslint-plugin-react": "7.33.2", "eslint-plugin-react-hooks": "4.6.0", - "eslint-plugin-unicorn": "46.0.1", + "eslint-plugin-unicorn": "48.0.1", "execa": "7.1.1", "fs-extra": "11.1.1", "gitlab": "14.2.2", - "http-server": "14.1.1", - "jest": "29.5.0", - "jest-environment-jsdom": "29.5.0", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", "js-yaml": "4.1.0", - "mini-css-extract-plugin": "2.7.5", - "mockserver-client": "5.15.0", - "mockserver-node": "5.15.0", + "mini-css-extract-plugin": "2.7.6", "ncp": "2.0.0", - "node-fetch": "3.3.1", "npm-run-all": "4.1.5", - "postcss": "8.4.23", - "postcss-loader": "7.2.4", - "prettier": "2.8.8", + "postcss": "8.4.31", + "postcss-loader": "7.3.3", + "prettier": "3.0.3", "process": "0.11.10", "react-refresh": "0.14.0", "react-svg-loader": "3.0.3", - "rimraf": "5.0.0", - "simple-git": "3.17.0", + "rimraf": "5.0.5", + "simple-git": "3.20.0", "source-map-loader": "4.0.1", - "style-loader": "3.3.2", - "tailwindcss": "3.3.1", + "style-loader": "3.3.3", + "tailwindcss": "3.3.3", "to-string-loader": "1.2.0", - "ts-jest": "29.1.0", + "ts-jest": "29.1.1", "ts-node": "10.9.1", - "tsconfig-paths-webpack-plugin": "4.0.1", - "typescript": "5.0.4", - "webpack": "5.80.0", - "webpack-cli": "5.0.2", - "webpack-dev-server": "4.13.3" + "tsconfig-paths-webpack-plugin": "4.1.0", + "typescript": "5.2.2", + "webpack": "5.88.2", + "webpack-cli": "5.1.4", + "webpack-dev-server": "4.15.1" }, "peerDependencies": { "react": "^18.2.0", diff --git a/packages/core/src/__mocks__/backend.ts b/packages/core/src/__mocks__/backend.ts index eceb94d8..65194313 100644 --- a/packages/core/src/__mocks__/backend.ts +++ b/packages/core/src/__mocks__/backend.ts @@ -6,11 +6,7 @@ const { mergeExpandedEntries: actualMergeExpandedEntries, } = jest.requireActual('@staticcms/core/backend'); -const isGitBackend = jest.fn().mockReturnValue(true); - -export const resolveBackend = jest.fn().mockReturnValue({ - isGitBackend, -}); +export const resolveBackend = jest.fn().mockReturnValue({}); export const currentBackend = jest.fn(); diff --git a/packages/core/src/__mocks__/yaml.ts b/packages/core/src/__mocks__/yaml.ts index 01c21d17..5cab8ef4 100644 --- a/packages/core/src/__mocks__/yaml.ts +++ b/packages/core/src/__mocks__/yaml.ts @@ -1,4 +1,5 @@ export const isNode = jest.fn(); export const isMap = jest.fn(); +export const parse = jest.fn(); export default {}; diff --git a/packages/core/src/__tests__/backend.spec.ts b/packages/core/src/__tests__/backend.spec.ts new file mode 100644 index 00000000..31751a1f --- /dev/null +++ b/packages/core/src/__tests__/backend.spec.ts @@ -0,0 +1,1110 @@ +import { createMockFolderCollectionWithDefaults } from '@staticcms/test/data/collections.mock'; +import { createMockConfig } from '@staticcms/test/data/config.mock'; +import { + createMockEntry, + createMockExpandedEntry, + createMockUnpublishedEntry, +} from '@staticcms/test/data/entry.mock'; +import { + Backend, + expandSearchEntries, + extractSearchFields, + mergeExpandedEntries, + resolveBackend, +} from '../backend'; +import { WorkflowStatus } from '../constants/publishModes'; +import { getBackend } from '../lib/registry'; +import { sanitizeChar, sanitizeSlug } from '../lib/urlHelper'; +import { asyncLock } from '../lib/util/asyncLock'; +import localForage from '../lib/util/localForage'; + +import type { + BackendClass, + BackendInitializer, + CollectionWithDefaults, + ConfigWithDefaults, +} from '../interface'; +import type { RootState } from '../store'; +import type { AssetProxy } from '../valueObjects'; + +jest.mock('../lib/registry'); +jest.mock('../lib/util/localForage'); +jest.mock('../lib/util/asyncLock'); +jest.mock('../lib/urlHelper'); + +describe('Backend', () => { + describe('filterEntries', () => { + let backend: Backend; + let config: ConfigWithDefaults; + let collection: CollectionWithDefaults; + + beforeEach(() => { + (getBackend as jest.Mock).mockReturnValue({ + init: jest.fn(), + }); + + collection = createMockFolderCollectionWithDefaults(); + + config = createMockConfig({ + backend: { + name: 'git-gateway', + }, + collections: [collection], + }); + + backend = resolveBackend(config); + }); + + it('filters string values', () => { + const result = backend.filterEntries( + { + entries: [ + createMockEntry({ + data: { + testField: 'testValue', + }, + }), + createMockEntry({ + data: { + testField: 'testValue2', + }, + }), + ], + }, + { field: 'testField', value: 'testValue' }, + ); + + expect(result.length).toBe(1); + }); + + it('filters number values', () => { + const result = backend.filterEntries( + { + entries: [ + createMockEntry({ + data: { + testField: 42, + }, + }), + createMockEntry({ + data: { + testField: 5, + }, + }), + ], + }, + { field: 'testField', value: 42 }, + ); + + expect(result.length).toBe(1); + }); + + it('filters boolean values', () => { + const result = backend.filterEntries( + { + entries: [ + createMockEntry({ + data: { + testField: false, + }, + }), + createMockEntry({ + data: { + testField: true, + }, + }), + ], + }, + { field: 'testField', value: false }, + ); + + expect(result.length).toBe(1); + }); + + it('filters list values', () => { + const result = backend.filterEntries( + { + entries: [ + createMockEntry({ + data: { + testField: ['valueOne', 'valueTwo', 'testValue'], + }, + }), + createMockEntry({ + data: { + testField: ['valueThree'], + }, + }), + ], + }, + { field: 'testField.*', value: 'testValue' }, + ); + + expect(result.length).toBe(1); + }); + }); + + describe('getLocalDraftBackup', () => { + (asyncLock as jest.Mock).mockImplementation(() => ({ acquire: jest.fn(), release: jest.fn() })); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty object on no item', async () => { + const implementation = {} as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + }) as CollectionWithDefaults; + + const config = createMockConfig({ + backend: { + name: 'github', + }, + collections: [collection], + }); + + const backend = new Backend(initializer, { + config, + backendName: 'github', + }); + + const slug = 'slug'; + + (localForage.getItem as jest.Mock).mockReturnValue(null); + + const result = await backend.getLocalDraftBackup(collection, config, slug); + + expect(result).toEqual({ entry: null }); + expect(localForage.getItem).toHaveBeenCalledTimes(1); + expect(localForage.getItem).toHaveBeenCalledWith('backup.posts.slug'); + }); + + it('should return empty object on item with empty content', async () => { + const implementation = {} as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + }) as CollectionWithDefaults; + + const config = createMockConfig({ + backend: { + name: 'github', + }, + collections: [collection], + }); + + const backend = new Backend(initializer, { + config, + backendName: 'github', + }); + + const slug = 'slug'; + + (localForage.getItem as jest.Mock).mockReturnValue({ raw: '' }); + + const result = await backend.getLocalDraftBackup(collection, config, slug); + + expect(result).toEqual({ entry: null }); + expect(localForage.getItem).toHaveBeenCalledTimes(1); + expect(localForage.getItem).toHaveBeenCalledWith('backup.posts.slug'); + }); + + it('should return backup entry, empty media files and assets when only raw property was saved', async () => { + const implementation = {} as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + format: 'json-frontmatter', + }) as CollectionWithDefaults; + + const config = createMockConfig({ + backend: { + name: 'github', + }, + collections: [collection], + }); + + const backend = new Backend(initializer, { + config, + backendName: 'github', + }); + + const slug = 'slug'; + + (localForage.getItem as jest.Mock).mockReturnValue({ + raw: '{\n"title": "Hello World"\n}\n', + }); + + const result = await backend.getLocalDraftBackup(collection, config, slug); + + expect(result).toEqual({ + entry: { + author: '', + mediaFiles: [], + collection: 'posts', + slug: 'slug', + path: '', + partial: false, + raw: '{\n"title": "Hello World"\n}\n', + data: { title: 'Hello World' }, + meta: undefined, + i18n: {}, + label: null, + isModification: null, + status: undefined, + updatedOn: '', + }, + }); + expect(localForage.getItem).toHaveBeenCalledTimes(1); + expect(localForage.getItem).toHaveBeenCalledWith('backup.posts.slug'); + }); + + it('should return backup entry, media files and assets when all were backed up', async () => { + const implementation = {} as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + format: 'json-frontmatter', + }) as CollectionWithDefaults; + + const config = createMockConfig({ + backend: { + name: 'github', + }, + collections: [collection], + }); + + const backend = new Backend(initializer, { + config, + backendName: 'github', + }); + + const slug = 'slug'; + + (localForage.getItem as jest.Mock).mockReturnValue({ + raw: '{\n"title": "Hello World"\n}\n', + mediaFiles: [{ id: '1' }], + }); + + const result = await backend.getLocalDraftBackup(collection, config, slug); + + expect(result).toEqual({ + entry: { + author: '', + mediaFiles: [{ id: '1' }], + collection: 'posts', + slug: 'slug', + path: '', + partial: false, + raw: '{\n"title": "Hello World"\n}\n', + data: { title: 'Hello World' }, + meta: undefined, + i18n: {}, + label: null, + isModification: null, + status: undefined, + updatedOn: '', + }, + }); + expect(localForage.getItem).toHaveBeenCalledTimes(1); + expect(localForage.getItem).toHaveBeenCalledWith('backup.posts.slug'); + }); + }); + + describe('persistLocalDraftBackup', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not persist empty entry', async () => { + const implementation = {} as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + format: 'json-frontmatter', + }) as CollectionWithDefaults; + + const config = createMockConfig({ collections: [collection] }); + + const backend = new Backend(initializer, { + config, + backendName: 'github', + }); + + backend.entryToRaw = jest.fn().mockReturnValue(''); + + const slug = 'slug'; + + const entry = createMockEntry({ + slug, + data: { + some: 'value', + }, + }); + + await backend.persistLocalDraftBackup(entry, collection, config); + + expect(backend.entryToRaw).toHaveBeenCalledTimes(1); + expect(backend.entryToRaw).toHaveBeenCalledWith(collection, entry, config); + expect(localForage.setItem).toHaveBeenCalledTimes(0); + }); + + it('should persist non empty entry', async () => { + const implementation = {} as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + format: 'json-frontmatter', + }) as CollectionWithDefaults; + + const config = createMockConfig({ collections: [collection] }); + + const backend = new Backend(initializer, { + config, + backendName: 'github', + }); + + backend.entryToRaw = jest.fn().mockReturnValue('content'); + + const slug = 'slug'; + + const entry = createMockEntry({ + slug, + path: 'content/posts/entry.md', + mediaFiles: [{ id: '1', name: 'file.png', path: '/path/to/file.png' }], + data: { + some: 'value', + }, + }); + + await backend.persistLocalDraftBackup(entry, collection, config); + + expect(backend.entryToRaw).toHaveBeenCalledTimes(1); + expect(backend.entryToRaw).toHaveBeenCalledWith(collection, entry, config); + expect(localForage.setItem).toHaveBeenCalledTimes(2); + expect(localForage.setItem).toHaveBeenCalledWith('backup.posts.slug', { + mediaFiles: [{ id: '1', name: 'file.png', path: '/path/to/file.png' }], + path: 'content/posts/entry.md', + raw: 'content', + }); + expect(localForage.setItem).toHaveBeenCalledWith('backup', 'content'); + }); + }); + + describe('persistMedia', () => { + it('should persist media', async () => { + const persistMediaResult = {}; + + const implementation = { + persistMedia: jest.fn().mockResolvedValue(persistMediaResult), + } as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + }) as CollectionWithDefaults; + + const config = createMockConfig({ backend: { name: 'github' }, collections: [collection] }); + + const backend = new Backend(initializer, { config, backendName: config.backend.name }); + const user = { login: 'login', name: 'name' }; + backend.currentUser = jest.fn().mockResolvedValue(user); + + const file = { path: 'static/media/image.png' } as AssetProxy; + + const result = await backend.persistMedia(config, file); + expect(result).toBe(persistMediaResult); + expect(implementation.persistMedia).toHaveBeenCalledTimes(1); + expect(implementation.persistMedia).toHaveBeenCalledWith( + { path: 'static/media/image.png' }, + { commitMessage: 'Upload “static/media/image.png”' }, + ); + }); + }); + + describe('unpublishedEntry', () => { + it('should return unpublished entry', async () => { + const unpublishedEntryResult = createMockUnpublishedEntry({ + diffs: [ + { id: 'index.md', path: 'src/posts/index.md', newFile: false }, + { id: 'netlify.png', path: 'netlify.png', newFile: true }, + ], + }); + + const implementation = { + unpublishedEntry: jest.fn().mockResolvedValue(unpublishedEntryResult), + unpublishedEntryDataFile: jest.fn().mockResolvedValueOnce('{\n"title": "Hello World"\n}\n'), + unpublishedEntryMediaFile: jest.fn().mockResolvedValueOnce({ id: '1' }), + } as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + folder: 'src/posts', + fields: [], + }) as CollectionWithDefaults; + + const config = createMockConfig({ + media_folder: 'static/images', + collections: [collection], + }); + const backend = new Backend(initializer, { config, backendName: 'github' }); + + const state = { + config: { config }, + integrations: {}, + mediaLibrary: {}, + } as unknown as RootState; + + const slug = 'slug'; + + const result = await backend.unpublishedEntry(state, collection, config, slug); + expect(result).toEqual({ + author: '', + collection: 'posts', + slug: 'unpublished-entry.md', + path: 'src/posts/index.md', + partial: false, + raw: '{\n"title": "Hello World"\n}\n', + data: { title: 'Hello World' }, + meta: { path: 'src/posts/index.md' }, + i18n: {}, + label: null, + isModification: true, + mediaFiles: [{ id: '1', draft: true }], + status: WorkflowStatus.DRAFT, + updatedOn: '20230-02-09T00:00:00.000Z', + openAuthoring: false, + }); + }); + }); + + describe('generateUniqueSlug', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should return unique slug when entry doesn't exist", async () => { + (sanitizeSlug as jest.Mock).mockReturnValue('some-post-title'); + + const implementation = { + getEntry: jest.fn(() => Promise.resolve()), + } as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + fields: [ + { + name: 'title', + widget: 'string', + }, + ], + folder: 'posts', + slug: '{{slug}}', + path: 'sub_dir/{{slug}}', + }) as unknown as CollectionWithDefaults; + + const entry = createMockEntry({ + data: { + title: 'some post title', + }, + }); + + const config = createMockConfig({ collections: [collection] }); + + const backend = new Backend(initializer, { + config, + backendName: 'github', + }); + + await expect( + backend.generateUniqueSlug(collection, entry, config, [], undefined), + ).resolves.toBe('sub_dir/some-post-title'); + }); + + it('should return unique slug when entry exists', async () => { + (sanitizeSlug as jest.Mock).mockReturnValue('some-post-title'); + (sanitizeChar as jest.Mock).mockReturnValue('-'); + + const implementation = { + getEntry: jest.fn(() => Promise.resolve()), + } as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + (getBackend as jest.Mock).mockReturnValue(initializer); + + (implementation.getEntry as jest.Mock).mockResolvedValueOnce({ data: 'data' }); + + const collection = createMockFolderCollectionWithDefaults({ + name: 'posts', + fields: [ + { + name: 'title', + widget: 'string', + }, + ], + folder: 'posts', + slug: '{{slug}}', + path: 'sub_dir/{{slug}}', + }) as unknown as CollectionWithDefaults; + + const entry = createMockEntry({ + data: { + title: 'some post title', + }, + }); + + const config = createMockConfig({ collections: [collection] }); + + const backend = new Backend(initializer, { config, backendName: 'github' }); + + await expect( + backend.generateUniqueSlug(collection, entry, config, [], undefined), + ).resolves.toBe('sub_dir/some-post-title-1'); + }); + }); + + describe('extractSearchFields', () => { + it('should extract slug', () => { + expect( + extractSearchFields(['slug'])(createMockEntry({ slug: 'entry-slug', data: {} })), + ).toEqual(' entry-slug'); + }); + + it('should extract path', () => { + expect( + extractSearchFields(['path'])(createMockEntry({ path: 'entry-path', data: {} })), + ).toEqual(' entry-path'); + }); + + it('should extract fields', () => { + expect( + extractSearchFields(['title', 'order'])( + createMockEntry({ data: { title: 'Entry Title', order: 5 } }), + ), + ).toEqual(' Entry Title 5'); + }); + + it('should extract nested fields', () => { + expect( + extractSearchFields(['nested.title'])( + createMockEntry({ data: { nested: { title: 'nested title' } } }), + ), + ).toEqual(' nested title'); + }); + }); + + describe('search/query', () => { + const collections = [ + createMockFolderCollectionWithDefaults({ + name: 'posts', + folder: 'posts', + fields: [ + { name: 'title', widget: 'string' }, + { name: 'short_title', widget: 'string' }, + { name: 'author', widget: 'string' }, + { name: 'description', widget: 'string' }, + { name: 'nested', widget: 'object', fields: [{ name: 'title', widget: 'string' }] }, + ], + }), + createMockFolderCollectionWithDefaults({ + name: 'pages', + folder: 'pages', + fields: [ + { name: 'title', widget: 'string' }, + { name: 'short_title', widget: 'string' }, + { name: 'author', widget: 'string' }, + { name: 'description', widget: 'string' }, + { name: 'nested', widget: 'object', fields: [{ name: 'title', widget: 'string' }] }, + ], + }), + ] as unknown as CollectionWithDefaults[]; + + const posts = [ + createMockEntry({ + path: 'posts/find-me.md', + slug: 'find-me', + data: { + title: 'find me by title', + short_title: 'find me by short title', + author: 'find me by author', + description: 'find me by description', + nested: { title: 'find me by nested title' }, + }, + }), + createMockEntry({ path: 'posts/not-me.md', slug: 'not-me', data: { title: 'not me' } }), + ]; + + const pages = [ + createMockEntry({ + path: 'pages/find-me.md', + slug: 'find-me', + data: { + title: 'find me by title', + short_title: 'find me by short title', + author: 'find me by author', + description: 'find me by description', + nested: { title: 'find me by nested title' }, + }, + }), + createMockEntry({ path: 'pages/not-me.md', slug: 'not-me', data: { title: 'not me' } }), + ]; + + const files = [ + createMockEntry({ + path: 'files/file1.md', + slug: 'file1', + data: { + author: 'find me by author', + }, + }), + createMockEntry({ + path: 'files/file2.md', + slug: 'file2', + data: { + other: 'find me by other', + }, + }), + ]; + + let config: ConfigWithDefaults; + + const implementation = { + listAllEntries: jest.fn(), + } as unknown as BackendClass; + + const initializer = { + init: jest.fn(() => implementation), + } as BackendInitializer; + + let backend: Backend; + beforeEach(() => { + jest.clearAllMocks(); + + (getBackend as jest.Mock).mockReturnValue(initializer); + + config = createMockConfig({ collections }); + + backend = new Backend(initializer, { config, backendName: 'github' }); + (backend.listAllEntries as jest.Mock) = jest.fn(collection => { + switch (collection.name) { + case 'posts': + return Promise.resolve(posts); + case 'pages': + return Promise.resolve(pages); + case 'files': + return Promise.resolve(files); + default: + return Promise.resolve([]); + } + }); + }); + + it('should search collections by title', async () => { + const results = await backend.search(collections, 'find me by title', config); + + expect(results).toEqual({ + entries: [posts[0], pages[0]], + pagination: 1, + }); + }); + + it('should search collections by short title', async () => { + const results = await backend.search(collections, 'find me by short title', config); + + expect(results).toEqual({ + entries: [posts[0], pages[0]], + pagination: 1, + }); + }); + + it('should search collections by author', async () => { + const results = await backend.search(collections, 'find me by author', config); + + expect(results).toEqual({ + entries: [posts[0], pages[0]], + pagination: 1, + }); + }); + + it('should search collections by summary description', async () => { + const results = await backend.search( + collections.map(c => ({ ...c, summary: '{{description}}' })), + 'find me by description', + config, + ); + + expect(results).toEqual({ + entries: [posts[0], pages[0]], + pagination: 1, + }); + }); + + it('should search in file collection using top level fields', async () => { + const collections = [ + createMockFolderCollectionWithDefaults({ + name: 'files', + files: [ + { + name: 'file1', + label: 'File 1', + file: 'file1.json', + fields: [{ name: 'author', widget: 'string' }], + }, + { + name: 'file2', + label: 'File 2', + file: 'file2.json', + fields: [{ name: 'other', widget: 'string' }], + }, + ], + }), + ] as unknown as CollectionWithDefaults[]; + + expect(await backend.search(collections, 'find me by author', config)).toEqual({ + entries: [files[0]], + pagination: 1, + }); + expect(await backend.search(collections, 'find me by other', config)).toEqual({ + entries: [files[1]], + pagination: 1, + }); + }); + + it('should query collections by title', async () => { + const results = await backend.query(collections[0], config, ['title'], 'find me by title'); + + expect(results).toEqual({ + hits: [posts[0]], + query: 'find me by title', + }); + }); + + it('should query collections by slug', async () => { + const results = await backend.query(collections[0], config, ['slug'], 'find-me'); + + expect(results).toEqual({ + hits: [posts[0]], + query: 'find-me', + }); + }); + + it('should query collections by path', async () => { + const results = await backend.query(collections[0], config, ['path'], 'posts/find-me.md'); + + expect(results).toEqual({ + hits: [posts[0]], + query: 'posts/find-me.md', + }); + }); + + it('should query collections by nested field', async () => { + const results = await backend.query( + collections[0], + config, + ['nested.title'], + 'find me by nested title', + ); + + expect(results).toEqual({ + hits: [posts[0]], + query: 'find me by nested title', + }); + }); + }); + + describe('expandSearchEntries', () => { + it('should expand entry with list to multiple entries', () => { + const entry = createMockEntry({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + ], + }, + }, + list: [1, 2], + }, + }); + + expect(expandSearchEntries([entry], ['list.*', 'field.nested.list.*.name'])).toEqual([ + expect.objectContaining({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + ], + }, + }, + list: [1, 2], + }, + field: 'list.0', + }), + expect.objectContaining({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + ], + }, + }, + list: [1, 2], + }, + field: 'list.1', + }), + expect.objectContaining({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + ], + }, + }, + list: [1, 2], + }, + field: 'field.nested.list.0.name', + }), + expect.objectContaining({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + ], + }, + }, + list: [1, 2], + }, + field: 'field.nested.list.1.name', + }), + ]); + }); + }); + + describe('mergeExpandedEntries', () => { + it('should merge entries and filter data', () => { + const expanded = [ + createMockExpandedEntry({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + { id: 3, name: '3' }, + { id: 4, name: '4' }, + ], + }, + }, + list: [1, 2], + }, + field: 'field.nested.list.0.name', + }), + createMockExpandedEntry({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + { id: 3, name: '3' }, + { id: 4, name: '4' }, + ], + }, + }, + list: [1, 2], + }, + field: 'field.nested.list.3.name', + }), + ]; + + expect(mergeExpandedEntries(expanded)).toEqual([ + expect.objectContaining({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 4, name: '4' }, + ], + }, + }, + list: [1, 2], + }, + }), + ]); + }); + + it('should merge entries and filter data based on different fields', () => { + const expanded = [ + createMockExpandedEntry({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + { id: 3, name: '3' }, + { id: 4, name: '4' }, + ], + }, + }, + list: [1, 2], + }, + field: 'field.nested.list.0.name', + }), + createMockExpandedEntry({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + { id: 3, name: '3' }, + { id: 4, name: '4' }, + ], + }, + }, + list: [1, 2], + }, + field: 'field.nested.list.3.name', + }), + createMockExpandedEntry({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 2, name: '2' }, + { id: 3, name: '3' }, + { id: 4, name: '4' }, + ], + }, + }, + list: [1, 2], + }, + field: 'list.1', + }), + ]; + + expect(mergeExpandedEntries(expanded)).toEqual([ + expect.objectContaining({ + data: { + field: { + nested: { + list: [ + { id: 1, name: '1' }, + { id: 4, name: '4' }, + ], + }, + }, + list: [2], + }, + }), + ]); + }); + + it('should merge entries and keep sort by entry index', () => { + const expanded = [ + createMockExpandedEntry({ + data: { + list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }, + field: 'list.5', + }), + createMockExpandedEntry({ + data: { + list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }, + field: 'list.0', + }), + createMockExpandedEntry({ + data: { + list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }, + field: 'list.11', + }), + createMockExpandedEntry({ + data: { + list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }, + field: 'list.1', + }), + ]; + + expect(mergeExpandedEntries(expanded)).toEqual([ + expect.objectContaining({ + data: { + list: [5, 0, 11, 1], + }, + }), + ]); + }); + }); +}); diff --git a/packages/core/src/__tests__/testConfig.ts b/packages/core/src/__tests__/testConfig.ts index 3494fcaf..80dafa29 100644 --- a/packages/core/src/__tests__/testConfig.ts +++ b/packages/core/src/__tests__/testConfig.ts @@ -14,7 +14,7 @@ const testConfig: Config = { i18n: { structure: 'multiple_files', locales: ['en', 'de', 'fr'], - defaultLocale: 'en', + default_locale: 'en', }, collections: [ { @@ -25,7 +25,7 @@ const testConfig: Config = { '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_fields: ['title', 'date', 'draft'], + summary_fields: ['title', 'date'], sortable_fields: { fields: ['title', 'date'], default: { @@ -33,46 +33,39 @@ const testConfig: Config = { }, }, 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', - }, - ], + view_filters: { + default: 'posts-without-index', + filters: [ + { + name: 'posts-with-index', + label: 'Posts With Index', + field: 'title', + pattern: 'This is post #', + }, + { + name: 'posts-without-index', + label: 'Posts Without Index', + field: 'title', + pattern: 'front matter post', + }, + ], + }, + view_groups: { + groups: [ + { + name: 'by-year', + label: 'Year', + field: 'date', + pattern: '\\d{4}', + }, + ], + }, fields: [ { label: 'Title', name: 'title', widget: 'string', }, - { - label: 'Draft', - name: 'draft', - widget: 'boolean', - default: false, - }, { label: 'Publish Date', name: 'date', @@ -91,7 +84,7 @@ const testConfig: Config = { label: 'Body', name: 'body', widget: 'markdown', - hint: 'Main content goes here.', + hint: '*Main* __content__ __*goes*__ [here](https://example.com/).', }, ], }, @@ -1172,13 +1165,6 @@ const testConfig: Config = { preview: true, }, fields: [ - { - label: 'Number of posts on frontpage', - name: 'front_limit', - widget: 'number', - min: 1, - max: 10, - }, { label: 'Global title', name: 'site_title', @@ -1800,7 +1786,7 @@ const testConfig: Config = { i18n: { structure: 'multiple_folders', locales: ['en', 'de', 'fr'], - defaultLocale: 'en', + default_locale: 'en', }, folder: 'packages/core/dev-test/backends/proxy/_i18n_playground_multiple_folders', identifier_field: 'slug', @@ -1831,7 +1817,7 @@ const testConfig: Config = { i18n: { structure: 'single_file', locales: ['en', 'de', 'fr'], - defaultLocale: 'en', + default_locale: 'en', }, folder: 'packages/core/dev-test/backends/proxy/_i18n_playground_single_file', identifier_field: 'slug', diff --git a/packages/core/src/actions/__tests__/config.spec.ts b/packages/core/src/actions/__tests__/config.spec.ts new file mode 100644 index 00000000..3b8958b0 --- /dev/null +++ b/packages/core/src/actions/__tests__/config.spec.ts @@ -0,0 +1,1310 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { stripIndent } from 'common-tags'; +import yaml from 'yaml'; + +import { createMockConfig } from '@staticcms/test/data/config.mock'; +import { detectProxyServer, handleLocalBackend, loadConfig, parseConfig } from '../config'; + +import type { + Config, + DefaultEditorConfig, + FileOrImageField, + FilesCollection, + FolderCollection, +} from '@staticcms/core'; + +jest.spyOn(console, 'error').mockImplementation(() => {}); +jest.spyOn(console, 'info').mockImplementation(() => {}); +jest.spyOn(console, 'warn').mockImplementation(() => {}); +jest.mock('../../backend', () => { + return { + resolveBackend: jest.fn(() => ({ isGitBackend: jest.fn(() => true) })), + }; +}); +jest.mock('../../constants/configSchema'); + +jest.mock('yaml', () => ({ + parse: jest.fn(), +})); + +describe('config', () => { + describe('parseConfig', () => { + let yamlParseMock: jest.Mock; + + beforeEach(() => { + yamlParseMock = yaml.parse as jest.Mock; + }); + + it('can parse simple yaml config', () => { + const file = stripIndent` + backend: + name: test-repo + media_folder: static/images + `; + + yamlParseMock.mockReturnValue({ + backend: { + name: 'test-repo', + }, + media_folder: 'static/images', + }); + + expect(parseConfig(file)).toEqual({ + backend: { name: 'test-repo' }, + media_folder: 'static/images', + }); + + expect(yamlParseMock).toHaveBeenCalledWith(file, { + maxAliasCount: -1, + prettyErrors: true, + merge: true, + }); + }); + + it('should merge yaml aliases', () => { + const file = stripIndent` + backend: + name: github + repo: staticjscms/static-cms + squash_merges: true + open_authoring: true + local_backend: true + site_url: https://www.staticcms.org + publish_mode: editorial_workflow + media_folder: website/static/img + public_folder: img + docs_collection: &docs_collection + folder: website/content/docs + create: true + preview_path: 'docs/{{slug}}' + fields: + - { label: Title, name: title } + - { label: Body, name: body, widget: markdown } + collections: + - <<: *docs_collection + name: docs_start + label: 'Docs: Quick Start' + `; + + yamlParseMock.mockReturnValue({ + backend: { + name: 'github', + repo: 'staticjscms/static-cms', + squash_merges: true, + open_authoring: true, + }, + local_backend: true, + site_url: 'https://www.staticcms.org', + publish_mode: 'editorial_workflow', + media_folder: 'website/static/img', + public_folder: 'img', + docs_collection: { + folder: 'website/content/docs', + create: true, + preview_path: 'docs/{{slug}}', + fields: [ + { + label: 'Title', + name: 'title', + }, + { + label: 'Body', + name: 'body', + widget: 'markdown', + }, + ], + }, + collections: [ + { + folder: 'website/content/docs', + create: true, + preview_path: 'docs/{{slug}}', + fields: [ + { + label: 'Title', + name: 'title', + }, + { + label: 'Body', + name: 'body', + widget: 'markdown', + }, + ], + name: 'docs_start', + label: 'Docs: Quick Start', + }, + ], + }); + + expect(parseConfig(file)).toEqual({ + backend: { + name: 'github', + repo: 'staticjscms/static-cms', + squash_merges: true, + open_authoring: true, + }, + local_backend: true, + site_url: 'https://www.staticcms.org', + publish_mode: 'editorial_workflow', + media_folder: 'website/static/img', + public_folder: 'img', + docs_collection: { + folder: 'website/content/docs', + create: true, + preview_path: 'docs/{{slug}}', + fields: [ + { label: 'Title', name: 'title' }, + { label: 'Body', name: 'body', widget: 'markdown' }, + ], + }, + collections: [ + { + folder: 'website/content/docs', + create: true, + preview_path: 'docs/{{slug}}', + fields: [ + { label: 'Title', name: 'title' }, + { label: 'Body', name: 'body', widget: 'markdown' }, + ], + name: 'docs_start', + label: 'Docs: Quick Start', + }, + ], + }); + + expect(yamlParseMock).toHaveBeenCalledWith(file, { + maxAliasCount: -1, + prettyErrors: true, + merge: true, + }); + }); + }); + + describe('applyDefaults', () => { + describe('publish_mode', () => { + it('should set publish_mode if not set', () => { + const config = createMockConfig({ + media_folder: 'path/to/media', + public_folder: '/path/to/media', + collections: [], + }); + expect(config.publish_mode).toEqual('simple'); + }); + + it('should set publish_mode from config', () => { + const config = createMockConfig({ + publish_mode: 'editorial_workflow', + media_folder: 'path/to/media', + public_folder: '/path/to/media', + collections: [], + }); + expect(config.publish_mode).toEqual('editorial_workflow'); + }); + }); + + describe('media_library', () => { + it('should set media_library based on global config if not set', () => { + const config = createMockConfig({ + media_library: { + max_file_size: 600000, + folder_support: true, + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + fields: [{ name: 'title', widget: 'file' }], + }, + ], + }); + + const collection = config.collections[0] as FolderCollection; + expect(collection.media_library).toEqual({ + max_file_size: 600000, + folder_support: true, + }); + + const field = collection.fields[0] as FileOrImageField; + expect(field.media_library).toEqual({ + max_file_size: 600000, + folder_support: true, + }); + }); + + it('should set media_library based on collection config if set', () => { + const config = createMockConfig({ + media_library: { + max_file_size: 600000, + folder_support: true, + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + media_library: { + max_file_size: 500, + folder_support: false, + }, + fields: [{ name: 'title', widget: 'file' }], + }, + ], + }); + + const collection = config.collections[0] as FolderCollection; + expect(collection.media_library).toEqual({ + max_file_size: 500, + folder_support: false, + }); + + const field = collection.fields[0] as FileOrImageField; + expect(field.media_library).toEqual({ + max_file_size: 500, + folder_support: false, + }); + }); + + it('should not override media_library if set', () => { + const config = createMockConfig({ + media_library: { + max_file_size: 600000, + folder_support: true, + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + media_library: { + max_file_size: 500, + folder_support: false, + }, + fields: [ + { + name: 'title', + widget: 'file', + media_library: { + max_file_size: 1000, + folder_support: true, + }, + }, + ], + }, + ], + }); + + const collection = config.collections[0] as FolderCollection; + expect(collection.media_library).toEqual({ + max_file_size: 500, + folder_support: false, + }); + + const field = collection.fields[0] as FileOrImageField; + expect(field.media_library).toEqual({ + max_file_size: 1000, + folder_support: true, + }); + }); + }); + + describe('public_folder', () => { + it('should set public_folder based on media_folder if not set', () => { + expect( + createMockConfig({ + media_folder: 'path/to/media', + collections: [], + }).public_folder, + ).toEqual('/path/to/media'); + }); + + it('should not overwrite public_folder if set', () => { + expect( + createMockConfig({ + media_folder: 'path/to/media', + public_folder: '/publib/path', + collections: [], + }).public_folder, + ).toEqual('/publib/path'); + expect( + createMockConfig({ + media_folder: 'path/to/media', + public_folder: '', + collections: [], + }).public_folder, + ).toEqual(''); + }); + }); + + describe('slug', () => { + it('should set default slug config if not set', () => { + expect(createMockConfig({ collections: [] }).slug).toEqual({ + encoding: 'unicode', + clean_accents: false, + sanitize_replacement: '-', + }); + }); + + it('should not overwrite slug encoding if set', () => { + expect( + createMockConfig({ collections: [], slug: { encoding: 'ascii' } }).slug?.encoding, + ).toEqual('ascii'); + }); + + it('should not overwrite slug clean_accents if set', () => { + expect( + createMockConfig({ collections: [], slug: { clean_accents: true } }).slug?.clean_accents, + ).toEqual(true); + }); + + it('should not overwrite slug sanitize_replacement if set', () => { + expect( + createMockConfig({ collections: [], slug: { sanitize_replacement: '_' } }).slug + ?.sanitize_replacement, + ).toEqual('_'); + }); + }); + + describe('collections', () => { + it('should strip leading slashes from collection folder', () => { + expect( + ( + createMockConfig({ + collections: [ + { + folder: '/foo', + fields: [{ name: 'title', widget: 'string' }], + } as FolderCollection, + ], + }).collections[0] as FolderCollection + ).folder, + ).toEqual('foo'); + }); + + it('should strip leading slashes from collection files', () => { + expect( + ( + createMockConfig({ + collections: [ + { + files: [{ file: '/foo', fields: [{ name: 'title', widget: 'string' }] }], + } as FilesCollection, + ], + }).collections[0] as FilesCollection + ).files[0].file, + ).toEqual('foo'); + }); + + describe('public_folder and media_folder', () => { + it('should set collection public_folder based on media_folder if not set', () => { + expect( + createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + media_folder: 'static/images/docs', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }).collections[0].public_folder, + ).toEqual('static/images/docs'); + }); + + it('should not overwrite collection public_folder if set to non empty string', () => { + expect( + createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + media_folder: 'static/images/docs', + public_folder: 'images/docs', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }).collections[0].public_folder, + ).toEqual('images/docs'); + }); + + it('should not overwrite collection public_folder if set to empty string', () => { + expect( + createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + media_folder: 'static/images/docs', + public_folder: '', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }).collections[0].public_folder, + ).toEqual(''); + }); + + it('should set collection media_folder and public_folder to an empty string when collection path exists, but collection media_folder does not', () => { + const result = createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + path: '{{slug}}/index', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }); + expect(result.collections[0].media_folder).toEqual(''); + expect(result.collections[0].public_folder).toEqual(''); + }); + + it('should set file public_folder based on media_folder if not set', () => { + expect( + ( + createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + files: [ + { + name: 'foo-file', + label: 'Foo', + file: 'foo', + media_folder: 'static/images/docs', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + } as FilesCollection, + ], + }).collections[0] as FilesCollection + ).files[0].public_folder, + ).toEqual('static/images/docs'); + }); + + it('should not overwrite file public_folder if set', () => { + expect( + ( + createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + files: [ + { + name: 'foo-file', + label: 'Foo', + file: 'foo', + media_folder: 'static/images/docs', + public_folder: 'images/docs', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + } as FilesCollection, + ], + }).collections[0] as FilesCollection + ).files[0].public_folder, + ).toEqual('images/docs'); + }); + + it('should set nested field public_folder based on media_folder if not set', () => { + const config = createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + path: '{{slug}}/index', + fields: [ + { + name: 'image', + widget: 'image', + media_folder: 'collection/static/images/docs', + }, + ], + } as FolderCollection, + { + name: 'foo-collection', + label: 'Foo', + files: [ + { + name: 'foo-file', + label: 'Foo', + file: 'foo', + fields: [ + { + name: 'image', + widget: 'image', + media_folder: 'file/static/images/docs', + }, + ], + }, + ], + } as FilesCollection, + ], + }); + expect( + ((config.collections[0] as FolderCollection).fields[0] as FileOrImageField) + .public_folder, + ).toEqual('collection/static/images/docs'); + expect( + ((config.collections[1] as FilesCollection).files[0].fields[0] as FileOrImageField) + .public_folder, + ).toEqual('file/static/images/docs'); + }); + + it('should not overwrite nested field public_folder if set', () => { + const config = createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + path: '{{slug}}/index', + fields: [ + { + name: 'image', + widget: 'image', + media_folder: 'collection/static/images/docs', + public_folder: 'collection/public_folder', + }, + ], + } as FolderCollection, + { + name: 'foo-collection', + label: 'Foo', + files: [ + { + name: 'foo-file', + label: 'Foo', + file: 'foo', + fields: [ + { + name: 'image', + widget: 'image', + public_folder: 'file/public_folder', + }, + ], + }, + ], + } as FilesCollection, + ], + }); + expect( + ((config.collections[0] as FolderCollection).fields[0] as FileOrImageField) + .public_folder, + ).toEqual('collection/public_folder'); + expect( + ((config.collections[1] as FilesCollection).files[0].fields[0] as FileOrImageField) + .public_folder, + ).toEqual('file/public_folder'); + }); + }); + + describe('publish', () => { + it('should set publish to true if not set', () => { + expect( + ( + createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + media_folder: 'static/images/docs', + fields: [{ name: 'title', widget: 'string' }], + } as FolderCollection, + ], + }).collections[0] as FolderCollection + ).publish, + ).toEqual(true); + }); + + it('should not override existing publish config', () => { + expect( + ( + createMockConfig({ + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + media_folder: 'static/images/docs', + publish: false, + fields: [{ name: 'title', widget: 'string' }], + } as FolderCollection, + ], + }).collections[0] as FolderCollection + ).publish, + ).toEqual(false); + }); + }); + + describe('editor preview', () => { + it('should set editor preview honoring global config before and specific config after', () => { + const config = createMockConfig({ + editor: { + preview: false, + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + fields: [{ name: 'title', widget: 'string' }], + folder: 'foo', + }, + { + name: 'bar-collection', + label: 'Bar', + editor: { + preview: true, + }, + fields: [{ name: 'title', widget: 'string' }], + folder: 'bar', + }, + ], + }); + + expect((config.collections[0].editor as DefaultEditorConfig).preview).toEqual(false); + expect((config.collections[1].editor as DefaultEditorConfig).preview).toEqual(true); + }); + }); + }); + + describe('i18n', () => { + it('should set root i18n on collection when collection i18n is set to true', () => { + expect( + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + i18n: true, + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }).collections[0].i18n, + ).toEqual({ structure: 'multiple_folders', locales: ['en', 'de'], default_locale: 'en' }); + }); + + it('should not set root i18n on collection when collection i18n is not set', () => { + expect( + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }).collections[0].i18n, + ).toBeUndefined(); + }); + + it('should not set root i18n on collection when collection i18n is set to false', () => { + expect( + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + i18n: false, + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }).collections[0].i18n, + ).toBeUndefined(); + }); + + it('should merge root i18n on collection when collection i18n is set to an object', () => { + expect( + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + default_locale: 'en', + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + i18n: { locales: ['en', 'fr'], default_locale: 'fr' }, + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }).collections[0].i18n, + ).toEqual({ structure: 'multiple_folders', locales: ['en', 'fr'], default_locale: 'fr' }); + }); + + it('should throw when i18n structure is not single_file on files collection', () => { + expect(() => + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + files: [ + { + name: 'file', + label: 'File', + file: 'file', + i18n: true, + fields: [{ name: 'title', widget: 'string', i18n: true }], + }, + ], + i18n: true, + }, + ], + }), + ).toThrow('i18n configuration for files collections is limited to single_file structure'); + }); + + it('should throw when i18n structure is set to multiple_folders and contains a single file collection', () => { + expect(() => + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + files: [ + { + name: 'file', + label: 'File', + file: 'file', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + i18n: true, + }, + ], + }), + ).toThrow('i18n configuration for files collections is limited to single_file structure'); + }); + + it('should throw when i18n structure is set to multiple_files and contains a single file collection', () => { + expect(() => + createMockConfig({ + i18n: { + structure: 'multiple_files', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + files: [ + { + name: 'file', + label: 'File', + file: 'file', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + i18n: true, + }, + ], + }), + ).toThrow('i18n configuration for files collections is limited to single_file structure'); + }); + + it('should set i18n value to translate on field when i18n=true for field in files collection', () => { + expect( + ( + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + files: [ + { + name: 'file', + label: 'File', + file: 'file', + i18n: true, + fields: [{ name: 'title', widget: 'string', i18n: true }], + }, + ], + i18n: { + structure: 'single_file', + }, + } as FilesCollection, + ], + }).collections[0] as FilesCollection + ).files[0].fields[0].i18n, + ).toEqual('translate'); + }); + + it('should set i18n value to translate on field when i18n=true for field', () => { + expect( + ( + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + i18n: true, + fields: [{ name: 'title', widget: 'string', i18n: true }], + } as FolderCollection, + ], + }).collections[0] as FolderCollection + ).fields[0].i18n, + ).toEqual('translate'); + }); + + it('should set i18n value to none on field when i18n=false for field', () => { + expect( + ( + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + i18n: true, + fields: [{ name: 'title', widget: 'string', i18n: false }], + } as FolderCollection, + ], + }).collections[0] as FolderCollection + ).fields[0].i18n, + ).toEqual('none'); + }); + + it('should throw is default locale is missing from root i18n config', () => { + expect(() => + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + default_locale: 'fr', + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }), + ).toThrow("i18n locales 'en, de' are missing the default locale fr"); + }); + + it('should throw if default locale is missing from collection i18n config', () => { + expect(() => + createMockConfig({ + i18n: { + structure: 'multiple_folders', + locales: ['en', 'de'], + }, + collections: [ + { + name: 'foo-collection', + label: 'Foo', + folder: 'foo', + i18n: { + default_locale: 'fr', + }, + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }), + ).toThrow("i18n locales 'en, de' are missing the default locale fr"); + }); + }); + }); + + describe('detectProxyServer', () => { + function assetFetchCalled(url = 'http://localhost:8081/api/v1') { + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'info' }), + }); + } + + beforeEach(() => { + delete (window as any).location; + }); + + it('should return empty object when not on localhost', async () => { + (window as any).location = { hostname: 'www.netlify.com', pathname: '/' }; + global.fetch = jest.fn(); + await expect(detectProxyServer()).resolves.toEqual({}); + + expect(global.fetch).toHaveBeenCalledTimes(0); + }); + + it('should return empty object when fetch returns an error', async () => { + (window as any).location = { + hostname: 'localhost', + origin: 'http://localhost', + pathname: '/', + }; + global.fetch = jest.fn().mockRejectedValue(new Error()); + await expect(detectProxyServer(true)).resolves.toEqual({}); + + assetFetchCalled(); + }); + + it('should return empty object when fetch returns an invalid response', async () => { + (window as any).location = { + hostname: 'localhost', + origin: 'http://localhost', + pathname: '/', + }; + global.fetch = jest + .fn() + .mockResolvedValue({ json: jest.fn().mockResolvedValue({ repo: [] }) }); + await expect(detectProxyServer(true)).resolves.toEqual({}); + + assetFetchCalled(); + }); + + it('should return result object when fetch returns a valid response', async () => { + (window as any).location = { + hostname: 'localhost', + origin: 'http://localhost', + pathname: '/', + }; + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + repo: 'test-repo', + publish_modes: ['simple', 'editorial_workflow'], + type: 'local_git', + }), + }); + await expect(detectProxyServer(true)).resolves.toEqual({ + proxyUrl: 'http://localhost:8081/api/v1', + publish_modes: ['simple', 'editorial_workflow'], + type: 'local_git', + }); + + assetFetchCalled(); + }); + + it('should use local_backend url', async () => { + const url = 'http://localhost:8082/api/v1'; + (window as any).location = { + hostname: 'localhost', + origin: 'http://localhost', + pathname: '/', + }; + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + repo: 'test-repo', + publish_modes: ['simple', 'editorial_workflow'], + type: 'local_git', + }), + }); + await expect(detectProxyServer({ url })).resolves.toEqual({ + proxyUrl: url, + publish_modes: ['simple', 'editorial_workflow'], + type: 'local_git', + }); + + assetFetchCalled(url); + }); + + it('should use local_backend allowed_hosts', async () => { + const allowed_hosts = ['192.168.0.1']; + (window as any).location = { hostname: '192.168.0.1', pathname: '/' }; + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + repo: 'test-repo', + publish_modes: ['simple', 'editorial_workflow'], + type: 'local_git', + }), + }); + await expect(detectProxyServer({ allowed_hosts })).resolves.toEqual({ + proxyUrl: 'http://192.168.0.1:8081/api/v1', + publish_modes: ['simple', 'editorial_workflow'], + type: 'local_git', + }); + + assetFetchCalled('http://192.168.0.1:8081/api/v1'); + }); + }); + + describe('handleLocalBackend', () => { + beforeEach(() => { + delete (window as any).location; + }); + + it('should not replace backend config when proxy is not detected', async () => { + (window as any).location = { + hostname: 'localhost', + origin: 'http://localhost', + pathname: '/', + }; + global.fetch = jest.fn().mockRejectedValue(new Error()); + + const config = createMockConfig({ + local_backend: true, + backend: { name: 'github' }, + collections: [], + }) as Config; + const actual = await handleLocalBackend(config); + + expect(actual).toEqual(config); + }); + + it('should replace backend config when proxy is detected', async () => { + (window as any).location = { + hostname: 'localhost', + origin: 'http://localhost', + pathname: '/', + }; + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + repo: 'test-repo', + publish_modes: ['simple', 'editorial_workflow'], + type: 'local_git', + }), + }); + + const config = createMockConfig({ + local_backend: true, + backend: { name: 'github' }, + collections: [], + }) as Config; + const actual = await handleLocalBackend(config); + + expect(actual.backend).toEqual({ + name: 'proxy', + proxy_url: 'http://localhost:8081/api/v1', + }); + }); + + it('should replace publish mode when not supported by proxy', async () => { + (window as any).location = { + hostname: 'localhost', + origin: 'http://localhost', + pathname: '/', + }; + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + repo: 'test-repo', + publish_modes: ['simple'], + type: 'local_fs', + }), + }); + + const config = createMockConfig({ + local_backend: true, + publish_mode: 'editorial_workflow', + backend: { name: 'github' }, + collections: [], + }) as Config; + const actual = await handleLocalBackend(config); + + expect(actual.publish_mode).toBe('simple'); + }); + }); + + describe('loadConfig', () => { + let yamlParseMock: jest.Mock; + + beforeEach(() => { + document.querySelector = jest.fn(); + global.fetch = jest.fn(); + yamlParseMock = yaml.parse as jest.Mock; + }); + + it(`should fetch default 'config.yml'`, async () => { + (window as any).location = { + hostname: 'localhost', + origin: 'http://localhost', + pathname: '/', + }; + const dispatch = jest.fn(); + + const content = stripIndent` + backend: + repo: test-repo + `; + + yamlParseMock.mockReturnValue({ + backend: { + repo: 'test-repo', + }, + }); + + (global.fetch as jest.Mock).mockResolvedValue({ + status: 200, + text: () => Promise.resolve(content), + headers: new Headers(), + }); + const response = await loadConfig(undefined, () => {}); + if (typeof response === 'function') { + await response(dispatch); + } + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith('http://localhost/config.yml', { + credentials: 'same-origin', + }); + + expect(yamlParseMock).toHaveBeenCalledWith(content, { + maxAliasCount: -1, + prettyErrors: true, + merge: true, + }); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith({ type: 'CONFIG_REQUEST' }); + expect(dispatch).toHaveBeenCalledWith({ + type: 'CONFIG_SUCCESS', + payload: { + config: { + backend: { repo: 'test-repo' }, + collections: [], + publish_mode: 'simple', + slug: { encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' }, + public_folder: '/', + }, + originalConfig: { + backend: { + repo: 'test-repo', + }, + }, + }, + }); + }); + + it(`should fetch from custom 'config.yml'`, async () => { + (window as any).location = { + hostname: 'localhost', + origin: 'http://localhost', + pathname: '/', + }; + const dispatch = jest.fn(); + + const content = stripIndent` + backend: + repo: github + `; + + yamlParseMock.mockReturnValue({ + backend: { + repo: 'github', + }, + }); + + (document.querySelector as jest.Mock).mockReturnValue({ + type: 'text/yaml', + href: 'custom-config.yml', + }); + (global.fetch as jest.Mock).mockResolvedValue({ + status: 200, + text: () => Promise.resolve(content), + headers: new Headers({ + 'Content-Type': 'text/plain', + }), + }); + const response = await loadConfig(undefined, () => {}); + if (typeof response === 'function') { + await response(dispatch); + } + + expect(document.querySelector).toHaveBeenCalledTimes(1); + expect(document.querySelector).toHaveBeenCalledWith('link[rel="cms-config-url"]'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith('custom-config.yml', { + credentials: 'same-origin', + }); + + expect(yamlParseMock).toHaveBeenCalledWith(content, { + maxAliasCount: -1, + prettyErrors: true, + merge: true, + }); + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'CONFIG_REQUEST' }); + expect(dispatch).toHaveBeenNthCalledWith(2, { + type: 'CONFIG_SUCCESS', + payload: { + config: { + backend: { repo: 'github' }, + collections: [], + publish_mode: 'simple', + slug: { encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' }, + public_folder: '/', + }, + originalConfig: { + backend: { + repo: 'github', + }, + }, + }, + }); + }); + + it(`should throw on failure to fetch 'config.yml'`, async () => { + const dispatch = jest.fn(); + + (global.fetch as jest.Mock).mockRejectedValue(new Error('Failed to fetch')); + const response = await loadConfig(undefined, () => {}); + if (typeof response === 'function') { + await expect(() => response(dispatch)).rejects.toEqual( + new Error('Failed to load config.yml (Failed to fetch)'), + ); + } + + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith({ type: 'CONFIG_REQUEST' }); + expect(dispatch).toHaveBeenCalledWith({ + type: 'CONFIG_FAILURE', + error: 'Error loading config', + payload: new Error('Failed to load config.yml (Failed to fetch)'), + }); + }); + }); +}); diff --git a/packages/core/src/actions/auth.ts b/packages/core/src/actions/auth.ts index 42a2ece1..707cd327 100644 --- a/packages/core/src/actions/auth.ts +++ b/packages/core/src/actions/auth.ts @@ -2,6 +2,7 @@ import { currentBackend } from '../backend'; import { AUTH_FAILURE, AUTH_REQUEST, AUTH_REQUEST_DONE, AUTH_SUCCESS, LOGOUT } from '../constants'; import { invokeEvent } from '../lib/registry'; import { addSnackbar } from '../store/slices/snackbars'; +import { useOpenAuthoring } from './globalUI'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; @@ -57,6 +58,9 @@ export function authenticateUser() { return Promise.resolve(backend.currentUser()) .then(user => { if (user) { + if (user.useOpenAuthoring) { + dispatch(useOpenAuthoring()); + } dispatch(authenticate(user)); } else { dispatch(doneAuthenticating()); @@ -85,6 +89,9 @@ export function loginUser(credentials: Credentials) { return backend .authenticate(credentials) .then(user => { + if (user.useOpenAuthoring) { + dispatch(useOpenAuthoring()); + } dispatch(authenticate(user)); }) .catch((error: unknown) => { diff --git a/packages/core/src/actions/config.ts b/packages/core/src/actions/config.ts index 1596ecaa..66ebc393 100644 --- a/packages/core/src/actions/config.ts +++ b/packages/core/src/actions/config.ts @@ -1,28 +1,32 @@ import deepmerge from 'deepmerge'; import { produce } from 'immer'; +import cloneDeep from 'lodash/cloneDeep'; import trim from 'lodash/trim'; import trimStart from 'lodash/trimStart'; import yaml from 'yaml'; -import { resolveBackend } from '@staticcms/core/backend'; import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../constants'; import validateConfig from '../constants/configSchema'; -import { - I18N, - I18N_FIELD_NONE, - I18N_FIELD_TRANSLATE, - I18N_STRUCTURE_SINGLE_FILE, -} from '../lib/i18n'; +import { SIMPLE as SIMPLE_PUBLISH_MODE } from '../constants/publishModes'; +import { I18N_FIELD_NONE, I18N_FIELD_TRANSLATE, I18N_STRUCTURE_SINGLE_FILE } from '../lib/i18n'; import { selectDefaultSortableFields } from '../lib/util/collection.util'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; +import type { Workflow } from '../constants/publishModes'; import type { BaseField, Collection, CollectionFile, + CollectionFileWithDefaults, + CollectionWithDefaults, Config, + ConfigWithDefaults, Field, + FilesCollection, + FilesCollectionWithDefaults, + FolderCollection, + FolderCollectionWithDefaults, I18nInfo, LocalBackend, UnknownField, @@ -60,14 +64,16 @@ function getConfigUrl() { } const setFieldDefaults = - (collection: Collection, collectionFile?: CollectionFile) => (field: Field) => { + (collection: Collection, config: Config, collectionFile?: CollectionFile) => (field: Field) => { if ('media_folder' in field && !('public_folder' in field)) { return { ...field, public_folder: field.media_folder }; } if (field.widget === 'image' || field.widget === 'file' || field.widget === 'markdown') { field.media_library = { - ...((collectionFile ?? collection).media_library ?? {}), + ...(config.media_library ?? {}), + ...(collectionFile?.media_library ?? {}), + ...(collection.media_library ?? {}), ...(field.media_library ?? {}), }; } @@ -76,23 +82,27 @@ const setFieldDefaults = }; function setI18nField(field: T) { - if (field[I18N] === true) { - return { ...field, [I18N]: I18N_FIELD_TRANSLATE }; - } else if (field[I18N] === false || !field[I18N]) { - return { ...field, [I18N]: I18N_FIELD_NONE }; + if (field.i18n === true) { + return { ...field, ['i18n']: I18N_FIELD_TRANSLATE }; + } else if (field.i18n === false || !field['i18n']) { + return { ...field, ['i18n']: I18N_FIELD_NONE }; } return field; } -function getI18nDefaults(collectionOrFileI18n: boolean | I18nInfo, defaultI18n: I18nInfo) { +function getI18nDefaults( + collectionOrFileI18n: boolean | Partial, + { default_locale, locales = ['en'], structure = I18N_STRUCTURE_SINGLE_FILE }: Partial, +): I18nInfo { if (typeof collectionOrFileI18n === 'boolean') { - return defaultI18n; + return { default_locale, locales, structure }; } else { - const locales = collectionOrFileI18n.locales || defaultI18n.locales; - const defaultLocale = collectionOrFileI18n.defaultLocale || locales[0]; - const mergedI18n: I18nInfo = deepmerge(defaultI18n, collectionOrFileI18n); - mergedI18n.locales = locales; - mergedI18n.defaultLocale = defaultLocale; + const mergedI18n: I18nInfo = deepmerge( + { default_locale, locales, structure }, + collectionOrFileI18n, + ); + mergedI18n.locales = collectionOrFileI18n.locales ?? locales; + mergedI18n.default_locale = collectionOrFileI18n.default_locale || locales?.[0]; throwOnMissingDefaultLocale(mergedI18n); return mergedI18n; } @@ -104,13 +114,13 @@ function setI18nDefaultsForFields(collectionOrFileFields: Field[], hasI18n: bool } else { return traverseFields(collectionOrFileFields, field => { const newField = { ...field }; - delete newField[I18N]; + delete newField.i18n; return newField; }); } } -function throwOnInvalidFileCollectionStructure(i18n?: I18nInfo) { +function throwOnInvalidFileCollectionStructure(i18n?: Partial) { if (i18n && i18n.structure !== I18N_STRUCTURE_SINGLE_FILE) { throw new Error( `i18n configuration for files collections is limited to ${I18N_STRUCTURE_SINGLE_FILE} structure`, @@ -118,162 +128,229 @@ function throwOnInvalidFileCollectionStructure(i18n?: I18nInfo) { } } -function throwOnMissingDefaultLocale(i18n?: I18nInfo) { - if (i18n && i18n.defaultLocale && !i18n.locales.includes(i18n.defaultLocale)) { +function throwOnMissingDefaultLocale(i18n?: Partial) { + if (i18n && i18n.default_locale && !i18n.locales?.includes(i18n.default_locale)) { throw new Error( - `i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${ - i18n.defaultLocale + `i18n locales '${i18n.locales?.join(', ')}' are missing the default locale ${ + i18n.default_locale }`, ); } } -export function applyDefaults( - originalConfig: Config, -): Config { - return produce(originalConfig, (config: Config) => { - config.slug = config.slug || {}; - config.collections = config.collections || []; +function applyFolderCollectionDefaults( + originalCollection: FolderCollection, + collectionI18n: I18nInfo | undefined, + config: Config, +): FolderCollectionWithDefaults { + const collection: FolderCollectionWithDefaults = { + ...originalCollection, + i18n: collectionI18n, + }; - // Use `site_url` as default `display_url`. - if (!config.display_url && config.site_url) { - config.display_url = config.site_url; - } + if (collection.path && !collection.media_folder) { + // default value for media folder when using the path config + collection.media_folder = ''; + } - // Use media_folder as default public_folder. - const defaultPublicFolder = `/${trimStart(config.media_folder, '/')}`; - if (!('public_folder' in config)) { - config.public_folder = defaultPublicFolder; - } + if ('media_folder' in collection && !('public_folder' in collection)) { + collection.public_folder = collection.media_folder; + } - // default values for the slug config - if (!('encoding' in config.slug)) { - config.slug.encoding = 'unicode'; - } + if ('fields' in collection && collection.fields) { + collection.fields = traverseFields(collection.fields, setFieldDefaults(collection, config)); + } - if (!('clean_accents' in config.slug)) { - config.slug.clean_accents = false; - } + collection.folder = trim(collection.folder, '/'); + collection.publish = collection.publish ?? true; - if (!('sanitize_replacement' in config.slug)) { - config.slug.sanitize_replacement = '-'; - } + return collection; +} - const i18n = config[I18N]; +function applyCollectionFileDefaults( + originalFile: CollectionFile, + collection: Collection, + collectionI18n: I18nInfo | undefined, + config: Config, +): CollectionFileWithDefaults { + const file: CollectionFileWithDefaults = { + ...originalFile, + i18n: undefined, + }; - if (i18n) { - i18n.defaultLocale = i18n.defaultLocale || i18n.locales[0]; - } + file.file = trimStart(file.file, '/'); - throwOnMissingDefaultLocale(i18n); + if ('media_folder' in file && !('public_folder' in file)) { + file.public_folder = file.media_folder; + } - const backend = resolveBackend(config); + file.media_library = { + ...(collection.media_library ?? {}), + ...(file.media_library ?? {}), + }; - for (const collection of config.collections) { - let collectionI18n = collection[I18N]; + if (file.fields) { + file.fields = traverseFields(file.fields, setFieldDefaults(collection, config, file)); + } - if (config.editor && !collection.editor) { - collection.editor = config.editor; - } + let fileI18n: I18nInfo | undefined; - collection.media_library = { - ...(config.media_library ?? {}), - ...(collection.media_library ?? {}), + if (originalFile.i18n && collectionI18n) { + fileI18n = getI18nDefaults(originalFile.i18n, { + locales: collectionI18n.locales, + default_locale: collectionI18n.default_locale, + structure: collectionI18n.structure, + }); + file.i18n = fileI18n; + } else { + fileI18n = undefined; + delete file.i18n; + } + + throwOnInvalidFileCollectionStructure(fileI18n); + + if (file.fields) { + file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n)); + } + + if (collection.editor && !file.editor) { + file.editor = collection.editor; + } + + return file; +} + +function applyFilesCollectionDefaults( + originalCollection: FilesCollection, + collectionI18n: I18nInfo | undefined, + config: Config, +): FilesCollectionWithDefaults { + const collection: FilesCollectionWithDefaults = { + ...originalCollection, + i18n: collectionI18n, + files: originalCollection.files.map(f => + applyCollectionFileDefaults(f, originalCollection, collectionI18n, config), + ), + }; + + throwOnInvalidFileCollectionStructure(collectionI18n); + + return collection; +} + +function applyCollectionDefaults( + originalCollection: Collection, + config: Config, +): CollectionWithDefaults { + let collection: CollectionWithDefaults; + + let collectionI18n: I18nInfo | undefined; + + if (config.i18n && originalCollection.i18n) { + collectionI18n = getI18nDefaults(originalCollection.i18n, config.i18n); + } else { + collectionI18n = undefined; + } + + if ('folder' in originalCollection) { + collection = applyFolderCollectionDefaults(originalCollection, collectionI18n, config); + } else { + collection = applyFilesCollectionDefaults(originalCollection, collectionI18n, config); + } + + if (config.editor && !collection.editor) { + collection.editor = config.editor; + } + + collection.media_library = { + ...(config.media_library ?? {}), + ...(collection.media_library ?? {}), + }; + + if ('fields' in collection && collection.fields) { + collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n)); + } + + const { view_filters, view_groups } = collection; + + if (!collection.sortable_fields) { + collection.sortable_fields = { + fields: selectDefaultSortableFields(collection, config), + }; + } + + collection.view_filters = { + default: collection.view_filters?.default, + filters: (view_filters?.filters ?? []).map(filter => { + return { + ...filter, + id: `${filter.field}__${filter.pattern}`, }; + }), + }; - if (i18n && collectionI18n) { - collectionI18n = getI18nDefaults(collectionI18n, i18n); - collection[I18N] = collectionI18n; - } else { - collectionI18n = undefined; - delete collection[I18N]; - } + collection.view_groups = { + default: collection.view_groups?.default, + groups: (view_groups?.groups ?? []).map(group => { + return { + ...group, + id: `${group.field}__${group.pattern}`, + }; + }), + }; - if ('fields' in collection && collection.fields) { - collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n)); - } + return collection; +} - const { view_filters, view_groups } = collection; +export function applyDefaults( + originConfig: Config, +): ConfigWithDefaults { + const clonedConfig = cloneDeep(originConfig) as Config; - if ('folder' in collection && collection.folder) { - if (collection.path && !collection.media_folder) { - // default value for media folder when using the path config - collection.media_folder = ''; - } + const i18n = clonedConfig.i18n; - if ('media_folder' in collection && !('public_folder' in collection)) { - collection.public_folder = collection.media_folder; - } + if (i18n) { + i18n.default_locale = i18n.default_locale ?? i18n.locales[0]; + } - if ('fields' in collection && collection.fields) { - collection.fields = traverseFields(collection.fields, setFieldDefaults(collection)); - } + throwOnMissingDefaultLocale(i18n); - collection.folder = trim(collection.folder, '/'); - } + const config: ConfigWithDefaults = { + ...clonedConfig, + collections: (clonedConfig.collections ?? []).map(c => + applyCollectionDefaults(c, clonedConfig), + ), + }; - if ('files' in collection && collection.files) { - throwOnInvalidFileCollectionStructure(collectionI18n); + config.publish_mode = config.publish_mode ?? SIMPLE_PUBLISH_MODE; + config.slug = config.slug ?? {}; + config.collections = config.collections ?? []; - for (const file of collection.files) { - file.file = trimStart(file.file, '/'); + // Use `site_url` as default `display_url`. + if (!config.display_url && config.site_url) { + config.display_url = config.site_url; + } - if ('media_folder' in file && !('public_folder' in file)) { - file.public_folder = file.media_folder; - } + // Use media_folder as default public_folder. + const defaultPublicFolder = `/${trimStart(config.media_folder, '/')}`; + if (!('public_folder' in config)) { + config.public_folder = defaultPublicFolder; + } - file.media_library = { - ...(collection.media_library ?? {}), - ...(file.media_library ?? {}), - }; + // default values for the slug config + if (!('encoding' in config.slug)) { + config.slug.encoding = 'unicode'; + } - if (file.fields) { - file.fields = traverseFields(file.fields, setFieldDefaults(collection, file)); - } + if (!('clean_accents' in config.slug)) { + config.slug.clean_accents = false; + } - let fileI18n = file[I18N]; + if (!('sanitize_replacement' in config.slug)) { + config.slug.sanitize_replacement = '-'; + } - if (fileI18n && collectionI18n) { - fileI18n = getI18nDefaults(fileI18n, collectionI18n); - file[I18N] = fileI18n; - } else { - fileI18n = undefined; - delete file[I18N]; - } - - throwOnInvalidFileCollectionStructure(fileI18n); - - if (file.fields) { - file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n)); - } - - if (collection.editor && !file.editor) { - file.editor = collection.editor; - } - } - } - - if (!collection.sortable_fields) { - collection.sortable_fields = { - fields: selectDefaultSortableFields(collection, backend), - }; - } - - collection.view_filters = (view_filters || []).map(filter => { - return { - ...filter, - id: `${filter.field}__${filter.pattern}`, - }; - }); - - collection.view_groups = (view_groups || []).map(group => { - return { - ...group, - id: `${group.field}__${group.pattern}`, - }; - }); - } - }); + return config as ConfigWithDefaults; } export function parseConfig(data: string) { @@ -305,10 +382,13 @@ async function getConfigYaml(file: string): Promise { return parseConfig(await response.text()); } -export function configLoaded(config: Config) { +export function configLoaded(config: ConfigWithDefaults, originalConfig: Config) { return { type: CONFIG_SUCCESS, - payload: config, + payload: { + config, + originalConfig, + }, } as const; } @@ -350,15 +430,16 @@ export async function detectProxyServer(localBackend?: boolean | LocalBackend) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'info' }), }); - const { repo, type } = (await res.json()) as { + const { repo, publish_modes, type } = (await res.json()) as { repo?: string; + publish_modes?: Workflow[]; type?: string; }; - if (typeof repo === 'string' && typeof type === 'string') { + if (typeof repo === 'string' && Array.isArray(publish_modes) && typeof type === 'string') { console.info( `[StaticCMS] Detected Static CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`, ); - return { proxyUrl, type }; + return { proxyUrl, publish_modes, type }; } else { console.info(`[StaticCMS] Static CMS Proxy Server not detected at '${proxyUrl}'`); return {}; @@ -369,12 +450,28 @@ export async function detectProxyServer(localBackend?: boolean | LocalBackend) { } } +function getPublishMode(config: Config, publishModes?: Workflow[], backendType?: string) { + if (config.publish_mode && publishModes && !publishModes.includes(config.publish_mode)) { + const newPublishMode = publishModes[0]; + console.info( + `'${config.publish_mode}' is not supported by '${backendType}' backend, switching to '${newPublishMode}'`, + ); + return newPublishMode; + } + + return config.publish_mode; +} + export async function handleLocalBackend(originalConfig: Config) { if (!originalConfig.local_backend) { return originalConfig; } - const { proxyUrl } = await detectProxyServer(originalConfig.local_backend); + const { + proxyUrl, + publish_modes: publishModes, + type: backendType, + } = await detectProxyServer(originalConfig.local_backend); if (!proxyUrl) { return originalConfig; @@ -383,26 +480,36 @@ export async function handleLocalBackend(originalConfig: Config) { return produce(originalConfig, config => { config.backend.name = 'proxy'; config.backend.proxy_url = proxyUrl; + + if (config.publish_mode) { + config.publish_mode = getPublishMode(config as Config, publishModes, backendType); + } }); } -export function loadConfig(manualConfig: Config | undefined, onLoad: (config: Config) => unknown) { - if (window.CMS_CONFIG) { - return configLoaded(window.CMS_CONFIG); - } +export function loadConfig( + manualConfig: Config | undefined, + onLoad: (config: ConfigWithDefaults) => unknown, +) { return async (dispatch: ThunkDispatch) => { dispatch(configLoading()); try { - const configUrl = getConfigUrl(); - const mergedConfig = manualConfig ? manualConfig : await getConfigYaml(configUrl); + let originalConfig: Config; - validateConfig(mergedConfig); + if (window.CMS_CONFIG) { + originalConfig = window.CMS_CONFIG; + } else { + const configUrl = getConfigUrl(); + originalConfig = manualConfig ? manualConfig : await getConfigYaml(configUrl); + } - const withLocalBackend = await handleLocalBackend(mergedConfig); + validateConfig(originalConfig); + + const withLocalBackend = await handleLocalBackend(originalConfig); const config = applyDefaults(withLocalBackend); - dispatch(configLoaded(config)); + dispatch(configLoaded(config, originalConfig)); if (typeof onLoad === 'function') { onLoad(config); diff --git a/packages/core/src/actions/editorialWorkflow.ts b/packages/core/src/actions/editorialWorkflow.ts new file mode 100644 index 00000000..f65be4c6 --- /dev/null +++ b/packages/core/src/actions/editorialWorkflow.ts @@ -0,0 +1,657 @@ +import { currentBackend } from '../backend'; +import { + UNPUBLISHED_ENTRIES_FAILURE, + UNPUBLISHED_ENTRIES_REQUEST, + UNPUBLISHED_ENTRIES_SUCCESS, + UNPUBLISHED_ENTRY_DELETE_FAILURE, + UNPUBLISHED_ENTRY_DELETE_REQUEST, + UNPUBLISHED_ENTRY_DELETE_SUCCESS, + UNPUBLISHED_ENTRY_PERSIST_FAILURE, + UNPUBLISHED_ENTRY_PERSIST_REQUEST, + UNPUBLISHED_ENTRY_PERSIST_SUCCESS, + UNPUBLISHED_ENTRY_PUBLISH_FAILURE, + UNPUBLISHED_ENTRY_PUBLISH_REQUEST, + UNPUBLISHED_ENTRY_PUBLISH_SUCCESS, + UNPUBLISHED_ENTRY_REDIRECT, + UNPUBLISHED_ENTRY_REQUEST, + UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE, + UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST, + UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS, + UNPUBLISHED_ENTRY_SUCCESS, +} from '../constants'; +import { EDITORIAL_WORKFLOW, WorkflowStatus } from '../constants/publishModes'; +import ValidationErrorTypes from '../constants/validationErrorTypes'; +import { EditorialWorkflowError } from '../lib'; +import { slugFromCustomPath } from '../lib/util/nested.util'; +import { + selectUnpublishedEntry, + selectUnpublishedSlugs, +} from '../reducers/selectors/editorialWorkflow'; +import { selectEntry, selectPublishedSlugs } from '../reducers/selectors/entries'; +import { selectEditingDraft } from '../reducers/selectors/entryDraft'; +import { addSnackbar } from '../store/slices/snackbars'; +import { createAssetProxy } from '../valueObjects/AssetProxy'; +import { + createDraftFromEntry, + entryDeleted, + getMediaAssets, + getSerializedEntry, + loadEntries, + loadEntry, +} from './entries'; +import { addAssets } from './media'; +import { loadMedia } from './mediaLibrary'; + +import type { NavigateFunction } from 'react-router-dom'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; +import type { + CollectionWithDefaults, + CollectionsWithDefaults, + ConfigWithDefaults, + Entry, + EntryDraft, +} from '../interface'; +import type { RootState } from '../store'; + +/* + * Simple Action Creators (Internal) + */ + +function unpublishedEntryLoading(collection: CollectionWithDefaults, slug: string) { + return { + type: UNPUBLISHED_ENTRY_REQUEST, + payload: { + collection: collection.name, + slug, + }, + } as const; +} + +function unpublishedEntryLoaded(collection: CollectionWithDefaults, entry: Entry) { + return { + type: UNPUBLISHED_ENTRY_SUCCESS, + payload: { + collection: collection.name, + entry, + }, + } as const; +} + +function unpublishedEntryRedirected(collection: CollectionWithDefaults, slug: string) { + return { + type: UNPUBLISHED_ENTRY_REDIRECT, + payload: { + collection: collection.name, + slug, + }, + } as const; +} + +function unpublishedEntriesLoading() { + return { + type: UNPUBLISHED_ENTRIES_REQUEST, + } as const; +} + +function unpublishedEntriesLoaded(entries: Entry[], pagination: number) { + return { + type: UNPUBLISHED_ENTRIES_SUCCESS, + payload: { + entries, + pages: pagination, + }, + } as const; +} + +function unpublishedEntriesFailed(error: Error) { + return { + type: UNPUBLISHED_ENTRIES_FAILURE, + error: 'Failed to load entries', + payload: error, + } as const; +} + +function unpublishedEntryPersisting(collection: CollectionWithDefaults, slug: string) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_REQUEST, + payload: { + collection: collection.name, + slug, + }, + } as const; +} + +function unpublishedEntryPersisted(collection: CollectionWithDefaults, entry: Entry) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, + payload: { + collection: collection.name, + entry, + slug: entry.slug, + }, + } as const; +} + +function unpublishedEntryPersistedFail( + error: unknown, + collection: CollectionWithDefaults, + slug: string, +) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_FAILURE, + payload: { + error, + collection: collection.name, + slug, + }, + error, + } as const; +} + +function unpublishedEntryStatusChangeRequest(collection: string, slug: string) { + return { + type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST, + payload: { + collection, + slug, + }, + } as const; +} + +function unpublishedEntryStatusChangePersisted( + collection: string, + slug: string, + newStatus: WorkflowStatus, +) { + return { + type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS, + payload: { + collection, + slug, + newStatus, + }, + } as const; +} + +function unpublishedEntryStatusChangeError(collection: string, slug: string) { + return { + type: UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE, + payload: { collection, slug }, + } as const; +} + +function unpublishedEntryPublishRequest(collection: string, slug: string) { + return { + type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST, + payload: { collection, slug }, + } as const; +} + +function unpublishedEntryPublished(collection: string, slug: string) { + return { + type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS, + payload: { collection, slug }, + } as const; +} + +function unpublishedEntryPublishError(collection: string, slug: string) { + return { + type: UNPUBLISHED_ENTRY_PUBLISH_FAILURE, + payload: { collection, slug }, + } as const; +} + +function unpublishedEntryDeleteRequest(collection: string, slug: string) { + return { + type: UNPUBLISHED_ENTRY_DELETE_REQUEST, + payload: { collection, slug }, + } as const; +} + +function unpublishedEntryDeleted(collection: string, slug: string) { + return { + type: UNPUBLISHED_ENTRY_DELETE_SUCCESS, + payload: { collection, slug }, + } as const; +} + +function unpublishedEntryDeleteError(collection: string, slug: string) { + return { + type: UNPUBLISHED_ENTRY_DELETE_FAILURE, + payload: { collection, slug }, + } as const; +} + +/* + * Exported Thunk Action Creators + */ + +export function loadUnpublishedEntry(collection: CollectionWithDefaults, slug: string) { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { + const state = getState(); + if (!state.config.config) { + return; + } + + const backend = currentBackend(state.config.config); + const entriesLoaded = state.editorialWorkflow.ids; + //run possible unpublishedEntries migration + if (!entriesLoaded) { + try { + const { entries, pagination } = await backend.unpublishedEntries( + state.collections, + state.config.config, + ); + dispatch(unpublishedEntriesLoaded(entries, pagination)); + // eslint-disable-next-line no-empty + } catch (e) {} + } + + dispatch(unpublishedEntryLoading(collection, slug)); + + try { + const entry = await backend.unpublishedEntry(state, collection, state.config.config, slug); + const assetProxies = await Promise.all( + entry.mediaFiles + .filter(file => file.draft) + .map(({ url, file, path }) => + createAssetProxy({ + path, + url, + file, + }), + ), + ); + dispatch(addAssets(assetProxies)); + dispatch(unpublishedEntryLoaded(collection, entry)); + dispatch(createDraftFromEntry(collection, entry)); + } catch (error) { + if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) { + dispatch(unpublishedEntryRedirected(collection, slug)); + dispatch(loadEntry(collection, slug)); + } else { + console.error(error); + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToLoadEntries', + options: { + details: error, + }, + }, + }), + ); + } + } + }; +} + +export function loadUnpublishedEntries(collections: CollectionsWithDefaults) { + return (dispatch: ThunkDispatch, getState: () => RootState) => { + const state = getState(); + if (!state.config.config) { + return; + } + + const backend = currentBackend(state.config.config); + + if (state.config.config.publish_mode !== EDITORIAL_WORKFLOW) { + return; + } + + dispatch(unpublishedEntriesLoading()); + backend + .unpublishedEntries(collections, state.config.config) + .then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination))) + .catch((error: Error) => { + console.error(error); + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToLoadEntries', + options: { + details: error, + }, + }, + }), + ); + dispatch(unpublishedEntriesFailed(error)); + Promise.reject(error); + }); + }; +} + +export function persistUnpublishedEntry( + collection: CollectionWithDefaults, + rootSlug: string | undefined, + existingUnpublishedEntry: boolean, + navigate: NavigateFunction, +) { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { + const state = getState(); + if (!state.config.config) { + return; + } + + const entryDraft = state.entryDraft; + const fieldsErrors = entryDraft.fieldsErrors; + const unpublishedSlugs = selectUnpublishedSlugs(state, collection.name); + const publishedSlugs = selectPublishedSlugs(state, collection.name); + const usedSlugs = publishedSlugs.concat(unpublishedSlugs); + const entriesLoaded = state.editorialWorkflow.ids; + + //load unpublishedEntries + !entriesLoaded && dispatch(loadUnpublishedEntries(state.collections)); + + // Early return if draft contains validation errors + if (Object.keys(fieldsErrors).length > 0) { + const hasPresenceErrors = Object.values(fieldsErrors).find(errors => + errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), + ); + + if (hasPresenceErrors) { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.missingRequiredField', + }, + }), + ); + } + return Promise.reject(); + } + + const backend = currentBackend(state.config.config); + const entry = entryDraft.entry; + if (!entry) { + return; + } + + entry.status = WorkflowStatus.DRAFT; + + const assetProxies = getMediaAssets({ + entry, + }); + + let serializedEntry = getSerializedEntry(collection, entry); + serializedEntry = { + ...serializedEntry, + raw: backend.entryToRaw(collection, serializedEntry, state.config.config), + }; + const serializedEntryDraft: EntryDraft = { + ...(entryDraft as EntryDraft), + entry: serializedEntry, + }; + + dispatch(unpublishedEntryPersisting(collection, entry.slug)); + const persistAction = existingUnpublishedEntry + ? backend.persistUnpublishedEntry + : backend.persistEntry; + + try { + const newSlug = await persistAction.call(backend, { + config: state.config.config, + collection, + entryDraft: serializedEntryDraft, + assetProxies, + rootSlug, + usedSlugs, + status: WorkflowStatus.DRAFT, + }); + dispatch( + addSnackbar({ + type: 'success', + message: { + key: 'ui.toast.entrySaved', + }, + }), + ); + dispatch(unpublishedEntryPersisted(collection, serializedEntry)); + + if (entry.slug !== newSlug) { + navigate(`/collections/${collection.name}/entries/${newSlug}`); + return; + } + } catch (error) { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToPersist', + options: { + details: error, + }, + }, + }), + ); + return Promise.reject(dispatch(unpublishedEntryPersistedFail(error, collection, entry.slug))); + } + }; +} + +export function updateUnpublishedEntryStatus( + collection: string, + slug: string, + oldStatus: WorkflowStatus, + newStatus: WorkflowStatus, +) { + return (dispatch: ThunkDispatch, getState: () => RootState) => { + if (oldStatus === newStatus) { + return; + } + + const state = getState(); + if (!state.config.config) { + return; + } + + const backend = currentBackend(state.config.config); + dispatch(unpublishedEntryStatusChangeRequest(collection, slug)); + backend + .updateUnpublishedEntryStatus(collection, slug, newStatus) + .then(() => { + dispatch( + addSnackbar({ + type: 'success', + message: { + key: 'ui.toast.entryUpdated', + }, + }), + ); + dispatch(unpublishedEntryStatusChangePersisted(collection, slug, newStatus)); + }) + .catch((error: Error) => { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToUpdateStatus', + options: { + details: error, + }, + }, + }), + ); + dispatch(unpublishedEntryStatusChangeError(collection, slug)); + }); + }; +} + +export function deleteUnpublishedEntry(collection: string, slug: string) { + return (dispatch: ThunkDispatch, getState: () => RootState) => { + const state = getState(); + if (!state.config.config) { + return; + } + + const backend = currentBackend(state.config.config); + dispatch(unpublishedEntryDeleteRequest(collection, slug)); + return backend + .deleteUnpublishedEntry(collection, slug) + .then(() => { + dispatch( + addSnackbar({ + type: 'success', + message: { + key: 'ui.toast.onDeleteUnpublishedChanges', + }, + }), + ); + dispatch(unpublishedEntryDeleted(collection, slug)); + }) + .catch((error: Error) => { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onDeleteUnpublishedChanges', + options: { + details: error, + }, + }, + }), + ); + dispatch(unpublishedEntryDeleteError(collection, slug)); + }); + }; +} + +export function publishUnpublishedEntry( + collectionName: string, + slug: string, + navigate?: NavigateFunction, +) { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { + const state = getState(); + if (!state.config.config) { + return; + } + + const collections = state.collections; + const backend = currentBackend(state.config.config); + const entry = selectUnpublishedEntry(state, collectionName, slug); + if (!entry) { + return; + } + + dispatch(unpublishedEntryPublishRequest(collectionName, slug)); + try { + const collection = collections[collectionName]; + if (!collection) { + return; + } + + await backend.publishUnpublishedEntry(collection, entry); + // re-load media after entry was published + dispatch(loadMedia()); + dispatch( + addSnackbar({ + type: 'success', + message: { + key: 'ui.toast.entryPublished', + }, + }), + ); + dispatch(unpublishedEntryPublished(collectionName, slug)); + if ('nested' in collection) { + dispatch(loadEntries(collection)); + const newSlug = slugFromCustomPath(collection, entry.path); + loadEntry(collection, newSlug); + if (slug !== newSlug && selectEditingDraft(state)) { + navigate?.(`/collections/${collection.name}/entries/${newSlug}`); + } + } else { + return dispatch(loadEntry(collection, slug)); + } + } catch (error) { + dispatch( + addSnackbar({ + type: 'error', + message: { key: 'ui.toast.onFailToPublishEntry', options: { details: error } }, + }), + ); + dispatch(unpublishedEntryPublishError(collectionName, slug)); + } + }; +} + +export function unpublishPublishedEntry(collection: CollectionWithDefaults, slug: string) { + return (dispatch: ThunkDispatch, getState: () => RootState) => { + const state = getState(); + if (!state.config.config) { + return; + } + + const backend = currentBackend(state.config.config); + const entry = selectEntry(state, collection.name, slug); + if (!entry) { + return; + } + + const entryDraft: EntryDraft = { entry, fieldsErrors: {} }; + dispatch(unpublishedEntryPersisting(collection, slug)); + return backend + .deleteEntry(state, collection, slug) + .then(() => + backend.persistEntry({ + config: state.config.config as ConfigWithDefaults, + collection, + entryDraft, + assetProxies: [], + usedSlugs: [], + rootSlug: slug, + status: WorkflowStatus.PENDING_PUBLISH, + }), + ) + .then(async () => { + dispatch(unpublishedEntryPersisted(collection, entry)); + dispatch(entryDeleted(collection, slug)); + await dispatch(loadUnpublishedEntry(collection, slug)); + dispatch( + addSnackbar({ + type: 'success', + message: { + key: 'ui.toast.entryUnpublished', + }, + }), + ); + }) + .catch((error: Error) => { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToUnpublishEntry', + options: { details: error }, + }, + }), + ); + dispatch(unpublishedEntryPersistedFail(error, collection, entry.slug)); + }); + }; +} + +export type EditorialWorkflowAction = ReturnType< + | typeof unpublishedEntryLoading + | typeof unpublishedEntryLoaded + | typeof unpublishedEntryRedirected + | typeof unpublishedEntriesLoading + | typeof unpublishedEntriesLoaded + | typeof unpublishedEntriesFailed + | typeof unpublishedEntryPersisting + | typeof unpublishedEntryPersisted + | typeof unpublishedEntryPersistedFail + | typeof unpublishedEntryStatusChangeRequest + | typeof unpublishedEntryStatusChangePersisted + | typeof unpublishedEntryStatusChangeError + | typeof unpublishedEntryPublishRequest + | typeof unpublishedEntryPublished + | typeof unpublishedEntryPublishError + | typeof unpublishedEntryDeleteRequest + | typeof unpublishedEntryDeleted + | typeof unpublishedEntryDeleteError +>; diff --git a/packages/core/src/actions/entries.ts b/packages/core/src/actions/entries.ts index 0f5c3960..698ff2a4 100644 --- a/packages/core/src/actions/entries.ts +++ b/packages/core/src/actions/entries.ts @@ -41,11 +41,11 @@ import ValidationErrorTypes from '../constants/validationErrorTypes'; import { hasI18n, serializeI18n } from '../lib/i18n'; import { serializeValues } from '../lib/serializeEntryValues'; import { Cursor } from '../lib/util'; -import { selectFields, updateFieldByKey } from '../lib/util/collection.util'; +import { getFields, updateFieldByKey } from '../lib/util/collection.util'; import { createEmptyDraftData, createEmptyDraftI18nData } from '../lib/util/entry.util'; import { selectCollectionEntriesCursor } from '../reducers/selectors/cursors'; import { - selectEntriesSortField, + selectEntriesSelectedSort, selectIsFetching, selectPublishedSlugs, } from '../reducers/selectors/entries'; @@ -54,7 +54,6 @@ import { createAssetProxy } from '../valueObjects/AssetProxy'; import createEntry from '../valueObjects/createEntry'; import { addAssets, getAsset } from './media'; import { loadMedia } from './mediaLibrary'; -import { waitUntil } from './waitUntil'; import type { NavigateFunction } from 'react-router-dom'; import type { AnyAction } from 'redux'; @@ -62,7 +61,8 @@ import type { ThunkDispatch } from 'redux-thunk'; import type { Backend } from '../backend'; import type { ViewStyle } from '../constants/views'; import type { - Collection, + CollectionWithDefaults, + ConfigWithDefaults, Entry, EntryData, EntryDraft, @@ -82,7 +82,7 @@ import type AssetProxy from '../valueObjects/AssetProxy'; * Simple Action Creators (Internal) * We still need to export them for tests */ -export function entryLoading(collection: Collection, slug: string) { +export function entryLoading(collection: CollectionWithDefaults, slug: string) { return { type: ENTRY_REQUEST, payload: { @@ -92,7 +92,7 @@ export function entryLoading(collection: Collection, slug: string) { } as const; } -export function entryLoaded(collection: Collection, entry: Entry) { +export function entryLoaded(collection: CollectionWithDefaults, entry: Entry) { return { type: ENTRY_SUCCESS, payload: { @@ -102,7 +102,7 @@ export function entryLoaded(collection: Collection, entry: Entry) { } as const; } -export function entryLoadError(error: Error, collection: Collection, slug: string) { +export function entryLoadError(error: Error, collection: CollectionWithDefaults, slug: string) { return { type: ENTRY_FAILURE, payload: { @@ -113,7 +113,7 @@ export function entryLoadError(error: Error, collection: Collection, slug: strin } as const; } -export function entriesLoading(collection: Collection) { +export function entriesLoading(collection: CollectionWithDefaults) { return { type: ENTRIES_REQUEST, payload: { @@ -122,7 +122,7 @@ export function entriesLoading(collection: Collection) { } as const; } -export function filterEntriesRequest(collection: Collection, filter: ViewFilter) { +export function filterEntriesRequest(collection: CollectionWithDefaults, filter: ViewFilter) { return { type: FILTER_ENTRIES_REQUEST, payload: { @@ -132,7 +132,11 @@ export function filterEntriesRequest(collection: Collection, filter: ViewFilter) } as const; } -export function filterEntriesSuccess(collection: Collection, filter: ViewFilter, entries: Entry[]) { +export function filterEntriesSuccess( + collection: CollectionWithDefaults, + filter: ViewFilter, + entries: Entry[], +) { return { type: FILTER_ENTRIES_SUCCESS, payload: { @@ -143,7 +147,11 @@ export function filterEntriesSuccess(collection: Collection, filter: ViewFilter, } as const; } -export function filterEntriesFailure(collection: Collection, filter: ViewFilter, error: unknown) { +export function filterEntriesFailure( + collection: CollectionWithDefaults, + filter: ViewFilter, + error: unknown, +) { return { type: FILTER_ENTRIES_FAILURE, payload: { @@ -154,7 +162,7 @@ export function filterEntriesFailure(collection: Collection, filter: ViewFilter, } as const; } -export function groupEntriesRequest(collection: Collection, group: ViewGroup) { +export function groupEntriesRequest(collection: CollectionWithDefaults, group: ViewGroup) { return { type: GROUP_ENTRIES_REQUEST, payload: { @@ -164,7 +172,11 @@ export function groupEntriesRequest(collection: Collection, group: ViewGroup) { } as const; } -export function groupEntriesSuccess(collection: Collection, group: ViewGroup, entries: Entry[]) { +export function groupEntriesSuccess( + collection: CollectionWithDefaults, + group: ViewGroup, + entries: Entry[], +) { return { type: GROUP_ENTRIES_SUCCESS, payload: { @@ -175,7 +187,11 @@ export function groupEntriesSuccess(collection: Collection, group: ViewGroup, en } as const; } -export function groupEntriesFailure(collection: Collection, group: ViewGroup, error: unknown) { +export function groupEntriesFailure( + collection: CollectionWithDefaults, + group: ViewGroup, + error: unknown, +) { return { type: GROUP_ENTRIES_FAILURE, payload: { @@ -186,7 +202,11 @@ export function groupEntriesFailure(collection: Collection, group: ViewGroup, er } as const; } -export function sortEntriesRequest(collection: Collection, key: string, direction: SortDirection) { +export function sortEntriesRequest( + collection: CollectionWithDefaults, + key: string, + direction: SortDirection, +) { return { type: SORT_ENTRIES_REQUEST, payload: { @@ -198,7 +218,7 @@ export function sortEntriesRequest(collection: Collection, key: string, directio } export function sortEntriesSuccess( - collection: Collection, + collection: CollectionWithDefaults, key: string, direction: SortDirection, entries: Entry[], @@ -215,7 +235,7 @@ export function sortEntriesSuccess( } export function sortEntriesFailure( - collection: Collection, + collection: CollectionWithDefaults, key: string, direction: SortDirection, error: unknown, @@ -232,7 +252,7 @@ export function sortEntriesFailure( } export function entriesLoaded( - collection: Collection, + collection: CollectionWithDefaults, entries: Entry[], pagination: number | null, cursor: Cursor, @@ -250,7 +270,7 @@ export function entriesLoaded( } as const; } -export function entriesFailed(collection: Collection, error: Error) { +export function entriesFailed(collection: CollectionWithDefaults, error: Error) { return { type: ENTRIES_FAILURE, error: 'Failed to load entries', @@ -261,18 +281,18 @@ export function entriesFailed(collection: Collection, error: Error) { } as const; } -async function getAllEntries(state: RootState, collection: Collection) { +async function getAllEntries(state: RootState, collection: CollectionWithDefaults) { const configState = state.config; if (!configState.config) { throw new Error('Config not loaded'); } const backend = currentBackend(configState.config); - return backend.listAllEntries(collection); + return backend.listAllEntries(collection, configState.config); } export function sortByField( - collection: Collection, + collection: CollectionWithDefaults, key: string, direction: SortDirection = SORT_DIRECTION_ASCENDING, ) { @@ -295,7 +315,7 @@ export function sortByField( }; } -export function filterByField(collection: Collection, filter: ViewFilter) { +export function filterByField(collection: CollectionWithDefaults, filter: ViewFilter) { return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); // if we're already fetching we update the filter key, but skip loading entries @@ -314,7 +334,7 @@ export function filterByField(collection: Collection, filter: ViewFilter) { }; } -export function groupByField(collection: Collection, group: ViewGroup) { +export function groupByField(collection: CollectionWithDefaults, group: ViewGroup) { return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); const isFetching = selectIsFetching(state, collection.name); @@ -354,7 +374,7 @@ export function changeViewStyle(viewStyle: ViewStyle) { } as const; } -export function entryPersisting(collection: Collection, entry: Entry) { +export function entryPersisting(collection: CollectionWithDefaults, entry: Entry) { return { type: ENTRY_PERSIST_REQUEST, payload: { @@ -364,7 +384,7 @@ export function entryPersisting(collection: Collection, entry: Entry) { } as const; } -export function entryPersisted(collection: Collection, entry: Entry, slug: string) { +export function entryPersisted(collection: CollectionWithDefaults, entry: Entry, slug: string) { return { type: ENTRY_PERSIST_SUCCESS, payload: { @@ -379,7 +399,7 @@ export function entryPersisted(collection: Collection, entry: Entry, slug: strin } as const; } -export function entryPersistFail(collection: Collection, entry: Entry, error: Error) { +export function entryPersistFail(collection: CollectionWithDefaults, entry: Entry, error: Error) { return { type: ENTRY_PERSIST_FAILURE, error: 'Failed to persist entry', @@ -391,7 +411,7 @@ export function entryPersistFail(collection: Collection, entry: Entry, error: Er } as const; } -export function entryDeleting(collection: Collection, slug: string) { +export function entryDeleting(collection: CollectionWithDefaults, slug: string) { return { type: ENTRY_DELETE_REQUEST, payload: { @@ -401,7 +421,7 @@ export function entryDeleting(collection: Collection, slug: string) { } as const; } -export function entryDeleted(collection: Collection, slug: string) { +export function entryDeleted(collection: CollectionWithDefaults, slug: string) { return { type: ENTRY_DELETE_SUCCESS, payload: { @@ -411,7 +431,7 @@ export function entryDeleted(collection: Collection, slug: string) { } as const; } -export function entryDeleteFail(collection: Collection, slug: string, error: Error) { +export function entryDeleteFail(collection: CollectionWithDefaults, slug: string, error: Error) { return { type: ENTRY_DELETE_FAILURE, payload: { @@ -431,23 +451,13 @@ export function emptyDraftCreated(entry: Entry) { /* * Exported simple Action Creators */ -export function createDraftFromEntry(collection: Collection, entry: Entry) { +export function createDraftFromEntry(collection: CollectionWithDefaults, entry: Entry) { return { type: DRAFT_CREATE_FROM_ENTRY, payload: { collection, entry }, } as const; } -export function draftDuplicateEntry(entry: Entry) { - return { - type: DRAFT_CREATE_DUPLICATE_FROM_ENTRY, - payload: createEntry(entry.collection, '', '', { - data: entry.data, - mediaFiles: entry.mediaFiles, - }), - } as const; -} - export function discardDraft() { return { type: DRAFT_DISCARD } as const; } @@ -528,7 +538,18 @@ export function removeDraftEntryMediaFile({ id }: { id: string }) { return { type: REMOVE_DRAFT_ENTRY_MEDIA_FILE, payload: { id } } as const; } -export function persistLocalBackup(entry: Entry, collection: Collection) { +export function loadBackup() { + return (dispatch: ThunkDispatch, getState: () => RootState) => { + const state = getState(); + if (!state.entryDraft.localBackup) { + return; + } + + dispatch(loadLocalBackup()); + }; +} + +export function persistLocalBackup(entry: Entry, collection: CollectionWithDefaults) { return (_dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); const configState = state.config; @@ -538,22 +559,21 @@ export function persistLocalBackup(entry: Entry, collection: Collection) { const backend = currentBackend(configState.config); - return backend.persistLocalDraftBackup(entry, collection); + return backend.persistLocalDraftBackup(entry, collection, configState.config); }; } export function createDraftDuplicateFromEntry(entry: Entry) { - return (dispatch: ThunkDispatch) => { - dispatch( - waitUntil({ - predicate: ({ type }) => type === DRAFT_CREATE_EMPTY, - run: () => dispatch(draftDuplicateEntry(entry)), - }), - ); - }; + return { + type: DRAFT_CREATE_DUPLICATE_FROM_ENTRY, + payload: createEntry(entry.collection, '', '', { + data: entry.data, + mediaFiles: entry.mediaFiles, + }), + } as const; } -export function retrieveLocalBackup(collection: Collection, slug: string) { +export function retrieveLocalBackup(collection: CollectionWithDefaults, slug: string) { return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); const configState = state.config; @@ -562,7 +582,7 @@ export function retrieveLocalBackup(collection: Collection, slug: string) { } const backend = currentBackend(configState.config); - const { entry } = await backend.getLocalDraftBackup(collection, slug); + const { entry } = await backend.getLocalDraftBackup(collection, configState.config, slug); if (entry) { // load assets from backup @@ -590,7 +610,7 @@ export function retrieveLocalBackup(collection: Collection, slug: string) { }; } -export function deleteLocalBackup(collection: Collection, slug: string) { +export function deleteLocalBackup(collection: CollectionWithDefaults, slug: string) { return (_dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); const configState = state.config; @@ -607,7 +627,7 @@ export function deleteLocalBackup(collection: Collection, slug: string) { * Exported Thunk Action Creators */ -export function loadEntry(collection: Collection, slug: string, silent = false) { +export function loadEntry(collection: CollectionWithDefaults, slug: string, silent = false) { return async (dispatch: ThunkDispatch, getState: () => RootState) => { if (!silent) { dispatch(entryLoading(collection, slug)); @@ -638,14 +658,18 @@ export function loadEntry(collection: Collection, slug: string, silent = false) }; } -export async function tryLoadEntry(state: RootState, collection: Collection, slug: string) { +export async function tryLoadEntry( + state: RootState, + collection: CollectionWithDefaults, + slug: string, +) { const configState = state.config; if (!configState.config) { throw new Error('Config not loaded'); } const backend = currentBackend(configState.config); - return backend.getEntry(state, collection, slug); + return backend.getEntry(state, collection, configState.config, slug); } interface AppendAction { @@ -669,13 +693,13 @@ function addAppendActionsToCursor(cursor: Cursor) { })); } -export function loadEntries(collection: Collection, page = 0) { +export function loadEntries(collection: CollectionWithDefaults, page = 0) { return async (dispatch: ThunkDispatch, getState: () => RootState) => { if (collection.isFetching) { return; } const state = getState(); - const sortField = selectEntriesSortField(collection.name)(state); + const sortField = selectEntriesSelectedSort(state, collection.name); if (sortField) { return dispatch(sortByField(collection, sortField.key, sortField.direction)); } @@ -698,8 +722,10 @@ export function loadEntries(collection: Collection, page = 0) { entries: Entry[]; } = await (loadAllEntries ? // nested collections require all entries to construct the tree - backend.listAllEntries(collection).then((entries: Entry[]) => ({ entries })) - : backend.listEntries(collection)); + backend + .listAllEntries(collection, configState.config) + .then((entries: Entry[]) => ({ entries })) + : backend.listEntries(collection, configState.config)); const cleanResponse = { ...response, @@ -745,14 +771,19 @@ export function loadEntries(collection: Collection, page = 0) { }; } -function traverseCursor(backend: Backend, cursor: Cursor, action: string) { +function traverseCursor( + backend: Backend, + cursor: Cursor, + action: string, + config: ConfigWithDefaults, +) { if (!cursor.actions!.has(action)) { throw new Error(`The current cursor does not support the pagination action "${action}".`); } - return backend.traverseCursor(cursor, action); + return backend.traverseCursor(cursor, action, config); } -export function traverseCollectionCursor(collection: Collection, action: string) { +export function traverseCollectionCursor(collection: CollectionWithDefaults, action: string) { return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); const collectionName = collection.name; @@ -783,7 +814,12 @@ export function traverseCollectionCursor(collection: Collection, action: string) try { dispatch(entriesLoading(collection)); - const { entries, cursor: newCursor } = await traverseCursor(backend, cursor, realAction); + const { entries, cursor: newCursor } = await traverseCursor( + backend, + cursor, + realAction, + configState.config, + ); const pagination = newCursor.meta?.page as number | null; return dispatch( @@ -831,7 +867,7 @@ function processValue(unsafe: string) { return escapeHtml(unsafe); } -export function createEmptyDraft(collection: Collection, search: string) { +export function createEmptyDraft(collection: CollectionWithDefaults, search: string) { return async (dispatch: ThunkDispatch, getState: () => RootState) => { if ('files' in collection) { return; @@ -886,12 +922,12 @@ export function getMediaAssets({ entry }: { entry: Entry }) { return assets; } -export function getSerializedEntry(collection: Collection, entry: Entry): Entry { +export function getSerializedEntry(collection: CollectionWithDefaults, entry: Entry): Entry { /** * Serialize the values of any fields with registered serializers, and * update the entry and entryDraft with the serialized values. */ - const fields = selectFields(collection, entry.slug); + const fields = getFields(collection, entry.slug); // eslint-disable-next-line @typescript-eslint/no-explicit-any function serializeData(data: any) { @@ -911,7 +947,7 @@ export function getSerializedEntry(collection: Collection, entry: Entry): Entry } export function persistEntry( - collection: Collection, + collection: CollectionWithDefaults, rootSlug: string | undefined, navigate: NavigateFunction, ) { @@ -919,7 +955,7 @@ export function persistEntry( const state = getState(); const entryDraft = state.entryDraft; const fieldsErrors = entryDraft.fieldsErrors; - const usedSlugs = selectPublishedSlugs(collection.name)(state); + const usedSlugs = selectPublishedSlugs(state, collection.name); // Early return if draft contains validation errors if (Object.keys(fieldsErrors).length > 0) { @@ -983,6 +1019,7 @@ export function persistEntry( entryDraft: newEntryDraft, assetProxies, usedSlugs, + status: entry.status, }) .then(async (newSlug: string) => { dispatch( @@ -1027,7 +1064,7 @@ export function persistEntry( }; } -export function deleteEntry(collection: Collection, slug: string) { +export function deleteEntry(collection: CollectionWithDefaults, slug: string) { return (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); const configState = state.config; @@ -1077,7 +1114,7 @@ export type EntriesAction = ReturnType< | typeof entryDeleteFail | typeof emptyDraftCreated | typeof createDraftFromEntry - | typeof draftDuplicateEntry + | typeof createDraftDuplicateFromEntry | typeof discardDraft | typeof updateDraft | typeof changeDraftField diff --git a/packages/core/src/actions/globalUI.ts b/packages/core/src/actions/globalUI.ts index 01bf465f..1466b8dc 100644 --- a/packages/core/src/actions/globalUI.ts +++ b/packages/core/src/actions/globalUI.ts @@ -1,8 +1,14 @@ /* eslint-disable import/prefer-default-export */ -import { THEME_CHANGE } from '../constants'; +import { THEME_CHANGE, USE_OPEN_AUTHORING } from '../constants'; -export function changeTheme(theme: 'dark' | 'light') { +export function useOpenAuthoring() { + return { + type: USE_OPEN_AUTHORING, + } as const; +} + +export function changeTheme(theme: string) { return { type: THEME_CHANGE, payload: theme } as const; } -export type GlobalUIAction = ReturnType; +export type GlobalUIAction = ReturnType; diff --git a/packages/core/src/actions/media.ts b/packages/core/src/actions/media.ts index 0788d2c5..bf5336d2 100644 --- a/packages/core/src/actions/media.ts +++ b/packages/core/src/actions/media.ts @@ -12,7 +12,14 @@ import { getMediaFile } from './mediaLibrary'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; -import type { BaseField, Collection, Entry, Field, MediaField, UnknownField } from '../interface'; +import type { + BaseField, + CollectionWithDefaults, + Entry, + Field, + MediaField, + UnknownField, +} from '../interface'; import type { RootState } from '../store'; import type AssetProxy from '../valueObjects/AssetProxy'; @@ -71,7 +78,7 @@ async function loadAsset( const promiseCache: Record> = {}; export function getAsset( - collection: Collection | null | undefined, + collection: CollectionWithDefaults | null | undefined, entry: Entry | null | undefined, path: string, field?: T, @@ -88,7 +95,7 @@ export function getAsset( allowMultiple?: boolean; replaceIndex?: number; config?: MediaLibraryConfig; - collection?: Collection; - collectionFile?: CollectionFile; + collection?: CollectionWithDefaults; + collectionFile?: CollectionFileWithDefaults; field?: EF; insertOptions?: MediaLibrarInsertOptions; } = {}, @@ -89,8 +89,8 @@ export function openMediaLibrary( allowMultiple, replaceIndex, config, - collection: collection as Collection, - collectionFile: collectionFile as CollectionFile, + collection: collection as CollectionWithDefaults, + collectionFile: collectionFile as CollectionFileWithDefaults, field: field as Field, insertOptions, }, @@ -226,7 +226,7 @@ export function persistMedia( } const backend = currentBackend(config); - const files: MediaFile[] = selectMediaFiles(field)(state); + const files: MediaFile[] = selectMediaFiles(state, field); const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug); const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName); diff --git a/packages/core/src/actions/search.ts b/packages/core/src/actions/search.ts index e5b607b1..83665562 100644 --- a/packages/core/src/actions/search.ts +++ b/packages/core/src/actions/search.ts @@ -121,6 +121,7 @@ export function searchEntries(searchTerm: string, searchCollections: string[], p .filter(([key, _value]) => allCollections.indexOf(key) !== -1) .map(([_key, value]) => value), searchTerm, + configState.config, ); return dispatch(searchSuccess(response.entries, page)); @@ -163,6 +164,7 @@ export function query( try { const response: SearchQueryResponse = await backend.query( collection, + configState.config, searchFields, searchTerm, file, diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index 663a5961..3ba85272 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -4,10 +4,11 @@ import flatten from 'lodash/flatten'; import get from 'lodash/get'; import isError from 'lodash/isError'; import uniq from 'lodash/uniq'; -import { dirname } from 'path'; +import { dirname, extname } from 'path'; import { DRAFT_MEDIA_FILES } from './constants/mediaLibrary'; -import { resolveFormat } from './formats/formats'; +import { WorkflowStatus, workflowStatusFromString } from './constants/publishModes'; +import { formatExtensions, resolveFormat } from './formats/formats'; import { commitMessageFormatter, slugFormatter } from './lib/formatters'; import { I18N_STRUCTURE_MULTIPLE_FILES, @@ -15,6 +16,7 @@ import { formatI18nBackup, getFilePaths, getI18nBackup, + getI18nDataFiles, getI18nEntry, getI18nFiles, getI18nFilesDepth, @@ -32,8 +34,10 @@ import { getPathDepth, localForage, } from './lib/util'; +import { EDITORIAL_WORKFLOW_ERROR } from './lib/util/EditorialWorkflowError'; import { getEntryBackupKey } from './lib/util/backup.util'; import { + getFields, selectAllowDeletion, selectAllowNewEntries, selectEntryPath, @@ -47,10 +51,11 @@ import { import filterEntries from './lib/util/filter.util'; import { selectMediaFilePublicPath } from './lib/util/media.util'; import { selectCustomPath, slugFromCustomPath } from './lib/util/nested.util'; -import { isNullish } from './lib/util/null.util'; +import { isNotNullish, isNullish } from './lib/util/null.util'; import { fileSearch, sortByScore } from './lib/util/search.util'; import set from './lib/util/set.util'; import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate'; +import { getUseWorkflow } from './reducers/selectors/config'; import createEntry from './valueObjects/createEntry'; import type { @@ -58,9 +63,10 @@ import type { BackendInitializer, BackupEntry, BaseField, - Collection, CollectionFile, - Config, + CollectionWithDefaults, + CollectionsWithDefaults, + ConfigWithDefaults, Credentials, DataFile, DisplayURL, @@ -68,6 +74,7 @@ import type { EntryData, EventData, FilterRule, + FolderCollectionWithDefaults, I18nInfo, ImplementationEntry, MediaField, @@ -76,6 +83,8 @@ import type { SearchQueryResponse, SearchResponse, UnknownField, + UnpublishedEntry, + UnpublishedEntryDiff, User, ValueOrNestedValue, } from './interface'; @@ -83,6 +92,8 @@ import type { AsyncLock } from './lib/util'; import type { RootState } from './store'; import type AssetProxy from './valueObjects/AssetProxy'; +const LIST_ALL_ENTRIES_CACHE_TIME = 5000; + function updatePath(entryPath: string, assetPath: string): string | null { const pathDir = dirname(entryPath); @@ -185,19 +196,22 @@ export function expandSearchEntries( field: string; })[] { // expand the entries for the purpose of the search - const expandedEntries = entries.reduce((acc, e) => { - const expandedFields = searchFields.reduce((acc, f) => { - const fields = expandPath({ data: e.data, path: f }); - acc.push(...fields); + const expandedEntries = entries.reduce( + (acc, e) => { + const expandedFields = searchFields.reduce((acc, f) => { + const fields = expandPath({ data: e.data, path: f }); + acc.push(...fields); + return acc; + }, [] as string[]); + + for (let i = 0; i < expandedFields.length; i++) { + acc.push({ ...e, field: expandedFields[i] }); + } + return acc; - }, [] as string[]); - - for (let i = 0; i < expandedFields.length; i++) { - acc.push({ ...e, field: expandedFields[i] }); - } - - return acc; - }, [] as (Entry & { field: string })[]); + }, + [] as (Entry & { field: string })[], + ); return expandedEntries; } @@ -207,31 +221,34 @@ export function mergeExpandedEntries(entries: (Entry & { field: string })[]): En const fields = entries.map(f => f.field); const arrayPaths: Record> = {}; - const merged = entries.reduce((acc, e) => { - if (!acc[e.slug]) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { field, ...rest } = e; - acc[e.slug] = rest; - arrayPaths[e.slug] = new Set(); - } - - const nestedFields = e.field.split('.'); - // 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)) { - const path = nestedFields.slice(0, i + 1).join('.'); - arrayPaths[e.slug] = arrayPaths[e.slug].add(path); + const merged = entries.reduce( + (acc, e) => { + if (!acc[e.slug]) { + const { field: _field, ...rest } = e; + acc[e.slug] = rest; + arrayPaths[e.slug] = new Set(); } - } - return acc; - }, {} as Record); + const nestedFields = e.field.split('.'); + let value: ValueOrNestedValue = acc[e.slug].data; + for (let i = 0; i < nestedFields.length; i++) { + if (isNotNullish(value)) { + value = value[nestedFields[i]]; + if (Array.isArray(value)) { + const path = nestedFields.slice(0, i + 1).join('.'); + arrayPaths[e.slug] = arrayPaths[e.slug].add(path); + } + } + } + + return acc; + }, + {} as Record, + ); // this keeps the search score sorting order designated by the order in entries // and filters non matching items - Object.keys(merged).forEach(slug => { + return Object.keys(merged).map(slug => { let data = merged[slug].data ?? {}; for (const path of arrayPaths[slug]) { const array = get(data, path) as unknown[]; @@ -252,9 +269,12 @@ export function mergeExpandedEntries(entries: (Entry & { field: string })[]): En data = set(data, path, filtered); } - }); - return Object.values(merged); + return { + ...merged[slug], + data, + }; + }); } interface AuthStore { @@ -265,7 +285,7 @@ interface AuthStore { interface BackendOptions { backendName: string; - config: Config; + config: ConfigWithDefaults; authStore?: AuthStore; } @@ -285,7 +305,21 @@ export interface MediaFile { isDirectory?: boolean; } -function collectionDepth(collection: Collection) { +function selectHasMetaPath( + collection: CollectionWithDefaults, +): collection is FolderCollectionWithDefaults { + return Boolean('folder' in collection && collection.meta?.path); +} + +function prepareMetaPath(path: string, collection: CollectionWithDefaults) { + if (!selectHasMetaPath(collection)) { + return path; + } + const dir = dirname(path); + return dir.slice(collection.folder!.length + 1) || '/'; +} + +function collectionDepth(collection: CollectionWithDefaults) { let depth; depth = ('nested' in collection && collection.nested?.depth) || getPathDepth(collection.path ?? ''); @@ -297,19 +331,21 @@ function collectionDepth(collection: Collection) { return depth; } -function i18nRuleString(ruleString: string, { defaultLocale, structure }: I18nInfo): string { +function i18nRuleString(ruleString: string, { default_locale, structure }: I18nInfo): string { if (structure === I18N_STRUCTURE_MULTIPLE_FOLDERS) { - return `${defaultLocale}\\/${ruleString}`; + return `${default_locale}\\/${ruleString}`; } if (structure === I18N_STRUCTURE_MULTIPLE_FILES) { - return `${ruleString}\\.${defaultLocale}\\..*`; + return `${ruleString}\\.${default_locale}\\..*`; } return ruleString; } -function collectionRegex(collection: Collection): RegExp | undefined { +function collectionRegex( + collection: CollectionWithDefaults, +): RegExp | undefined { let ruleString = ''; if ('folder' in collection && collection.path) { @@ -326,7 +362,7 @@ function collectionRegex(collection: Collection): RegE export class Backend { implementation: BC; backendName: string; - config: Config; + config: ConfigWithDefaults; authStore?: AuthStore; user?: User | null; backupSync: AsyncLock; @@ -339,7 +375,9 @@ export class Backend { const storedUser = this.authStore!.retrieve(); if (storedUser && storedUser.backendName === this.backendName) { @@ -429,7 +463,27 @@ export class Backend this.implementation.getToken(); - async entryExist(path: string) { + async entryExist( + collection: CollectionWithDefaults, + path: string, + slug: string, + useWorkflow: boolean, + ) { + const unpublishedEntry = + useWorkflow && + (await this.implementation + .unpublishedEntry({ collection: collection.name, slug }) + .catch(error => { + if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) { + return Promise.resolve(false); + } + return Promise.reject(error); + })); + + if (unpublishedEntry) { + return unpublishedEntry; + } + const publishedEntry = await this.implementation .getEntry(path) .then(({ data }) => data) @@ -441,9 +495,9 @@ export class Backend( loadedEntries: ImplementationEntry[], - collection: Collection, + collection: CollectionWithDefaults, + config: ConfigWithDefaults, ): Entry[] { const entries = loadedEntries.map(loadedEntry => createEntry( @@ -484,7 +545,7 @@ export class Backend Promise; if ('folder' in collection) { @@ -528,18 +589,19 @@ export class Backend(collection: Collection) { + backendPromise: Record }> = + {}; + + async listAllEntriesExecutor( + collection: CollectionWithDefaults, + config: ConfigWithDefaults, + ): Promise { if ('folder' in collection && collection.folder && this.implementation.allEntriesByFolder) { const depth = collectionDepth(collection); const extension = selectFolderEntryExtension(collection); @@ -550,21 +612,80 @@ export class Backend this.processEntries(entries, collection)); + .then(entries => this.processEntries(entries, collection, config)); } - const response = await this.listEntries(collection as Collection); + const response = await this.listEntries( + collection as CollectionWithDefaults, + config as ConfigWithDefaults, + ); const { entries } = response; let { cursor } = response; while (cursor && cursor.actions?.has('next')) { - const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, 'next'); + const { entries: newEntries, cursor: newCursor } = await this.traverseCursor( + cursor, + 'next', + config as ConfigWithDefaults, + ); entries.push(...newEntries); cursor = newCursor; } return entries; } - async search(collections: Collection[], searchTerm: string): Promise { + // The same as listEntries, except that if a cursor with the "next" + // action available is returned, it calls "next" on the cursor and + // repeats the process. Once there is no available "next" action, it + // returns all the collected entries. Used to retrieve all entries + // for local searches and queries. + async listAllEntries( + collection: CollectionWithDefaults, + config: ConfigWithDefaults, + ): Promise { + const now = new Date().getTime(); + if (collection.name in this.backendPromise) { + const cachedRequest = this.backendPromise[collection.name]; + if (cachedRequest && cachedRequest.expires >= now) { + if (cachedRequest.data) { + return Promise.resolve(cachedRequest.data); + } + + if (cachedRequest.promise) { + return cachedRequest.promise; + } + } + + delete this.backendPromise[collection.name]; + } + + const p = new Promise(resolve => { + this.listAllEntriesExecutor(collection, config).then(entries => { + const responseNow = new Date().getTime(); + this.backendPromise[collection.name] = { + expires: responseNow + LIST_ALL_ENTRIES_CACHE_TIME, + data: entries, + }; + resolve(entries); + }); + }); + + this.backendPromise[collection.name] = { + expires: now + LIST_ALL_ENTRIES_CACHE_TIME, + promise: p, + }; + + return p; + } + + printError(error: Error) { + return `\n\n${error.stack}`; + } + + async search( + collections: CollectionWithDefaults[], + searchTerm: string, + config: ConfigWithDefaults, + ): Promise { // Perform a local search by requesting all entries. For each // collection, load it, search, and call onCollectionResults with // its results. @@ -596,7 +717,7 @@ export class Backend flatten(arrays)); if (errors.length > 0) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - throw new Error({ message: 'Errors occurred while searching entries locally!', errors }); + throw new Error( + `Errors occurred while searching entries locally!${errors.map(this.printError)}`, + ); } const hits = entries @@ -624,13 +745,17 @@ export class Backend( - collection: Collection, + collection: CollectionWithDefaults, + config: ConfigWithDefaults, searchFields: string[], searchTerm: string, file?: string, limit?: number, ): Promise { - const entries = await this.listAllEntries(collection as Collection); + const entries = await this.listAllEntries( + collection as CollectionWithDefaults, + config as ConfigWithDefaults, + ); if (file) { let hits = fileSearch( entries.find(e => e.slug === file), @@ -663,13 +788,17 @@ export class Backend { + traverseCursor( + cursor: Cursor, + action: string, + config: ConfigWithDefaults, + ): Promise<{ entries: Entry[]; cursor: Cursor }> { const [data, unwrappedCursor] = cursor.unwrapData(); // TODO: stop assuming all cursors are for collections - const collection = data.collection as Collection; + const collection = data.collection as CollectionWithDefaults; return this.implementation.traverseCursor!(unwrappedCursor, action).then( async ({ entries, cursor: newCursor }) => ({ - entries: this.processEntries(entries, collection), + entries: this.processEntries(entries, collection, config), cursor: Cursor.create(newCursor).wrapData({ cursorType: 'collectionEntries', collection, @@ -679,7 +808,8 @@ export class Backend { const key = getEntryBackupKey(collection.name, slug); @@ -701,7 +831,10 @@ export class Backend { - return this.entryWithFormat(collection)( + return this.entryWithFormat( + collection, + config, + )( createEntry(collection.name, slug, path, { raw, label, @@ -719,11 +852,15 @@ export class Backend this.entryToRaw(collection, entry)); + i18n = getI18nBackup(collection, entry, entry => + this.entryToRaw(collection, entry, config), + ); } await localForage.setItem(key, { @@ -760,7 +899,7 @@ export class Backend( state: RootState, - collection: Collection, + collection: CollectionWithDefaults, + config: ConfigWithDefaults, slug: string, ) { const path = selectEntryPath(collection, slug) as string; @@ -798,7 +938,7 @@ export class Backend(collection: Collection) { + entryWithFormat( + collection: CollectionWithDefaults, + config: ConfigWithDefaults, + ) { return (entry: Entry): Entry => { const format = resolveFormat(collection, entry); if (entry && entry.raw !== undefined) { - const data = (format && attempt(format.fromFile.bind(format, entry.raw))) || {}; + const data = + (format && + attempt(format.fromFile.bind(format, entry.raw, config as ConfigWithDefaults))) || + {}; if (isError(data)) { console.error(data); } + return Object.assign(entry, { data: isError(data) ? {} : data }); } @@ -850,7 +997,7 @@ export class Backend( state: RootState, - collection: Collection, + collection: CollectionWithDefaults, entry: Entry, ) { const configState = state.config; @@ -891,6 +1038,7 @@ export class Backend this.entryToRaw(collection, draftData), + (draftData: Entry) => this.entryToRaw(collection, draftData, config), path, slug, newPath, @@ -958,24 +1109,34 @@ export class Backend { + async invokePrePublishEvent(entry: Entry, collection: CollectionWithDefaults) { + const eventData = await this.getEventData(entry); + return await invokeEvent({ name: 'prePublish', collection: collection.name, data: eventData }); + } + + async invokePostPublishEvent(entry: Entry, collection: CollectionWithDefaults) { + const eventData = await this.getEventData(entry); + return await invokeEvent({ name: 'postPublish', collection: collection.name, data: eventData }); + } + + async invokePreSaveEvent(entry: Entry, collection: CollectionWithDefaults): Promise { const eventData = await this.getEventData(entry); return await invokeEvent({ name: 'preSave', collection: collection.name, data: eventData }); } - async invokePostSaveEvent(entry: Entry, collection: Collection): Promise { + async invokePostSaveEvent(entry: Entry, collection: CollectionWithDefaults): Promise { const eventData = await this.getEventData(entry); await invokeEvent({ name: 'postSave', collection: collection.name, data: eventData }); } - async persistMedia(config: Config, file: AssetProxy) { + async persistMedia(config: ConfigWithDefaults, file: AssetProxy) { const user = (await this.currentUser()) as User; const options = { - commitMessage: commitMessageFormatter('uploadMedia', config, { - path: file.path, - authorLogin: user.login, - authorName: user.name, - }), + commitMessage: commitMessageFormatter( + 'uploadMedia', + config, + { + path: file.path, + authorLogin: user.login, + authorName: user.name, + }, + user.useOpenAuthoring, + ), }; return this.implementation.persistMedia(file, options); } async deleteEntry( state: RootState, - collection: Collection, + collection: CollectionWithDefaults, slug: string, ) { const configState = state.config; @@ -1034,13 +1214,18 @@ export class Backend f!.name) ?? []; } @@ -1083,9 +1273,144 @@ export class Backend f?.name === slug); + extension = file ? extname(file.file) : formatExtensions['json']; + } else { + extension = selectFolderEntryExtension(collection); + } + + const mediaFiles: MediaFile[] = []; + if (withMediaFiles) { + const nonDataFiles = entryData.diffs.filter(d => !d.path.endsWith(extension)); + const files = await Promise.all( + nonDataFiles.map(f => + this.implementation!.unpublishedEntryMediaFile(collection.name, slug, f.path, f.id), + ), + ); + mediaFiles.push(...files.map(f => ({ ...f, draft: true }))); + } + + const dataFiles = entryData.diffs.filter(d => d.path.endsWith(extension)); + dataFiles.sort((a, b) => a.path.length - b.path.length); + + const formatData = (data: string, path: string, newFile: boolean) => { + const entry = createEntry(collection.name, slug, path, { + raw: data, + isModification: !newFile, + label: collection && selectFileEntryLabel(collection, slug), + mediaFiles, + updatedOn: entryData.updatedAt, + author: entryData.pullRequestAuthor, + status: workflowStatusFromString(entryData.status), + meta: { path: prepareMetaPath(path, collection) }, + openAuthoring, + }); + + return this.entryWithFormat(collection, config)(entry); + }; + + const readAndFormatDataFile = async (dataFile: UnpublishedEntryDiff) => { + const data = await this.implementation.unpublishedEntryDataFile( + collection.name, + entryData.slug, + dataFile.path, + dataFile.id, + ); + + return formatData(data, dataFile.path, dataFile.newFile); + }; + + // if the unpublished entry has no diffs, return the original + if (dataFiles.length <= 0) { + const loadedEntry = await this.implementation.getEntry( + selectEntryPath(collection, slug) as string, + ); + return formatData(loadedEntry.data, loadedEntry.file.path, false); + } else if (hasI18n(collection)) { + // we need to read all locales files and not just the changes + const path = selectEntryPath(collection, slug) as string; + const i18nFiles = getI18nDataFiles(collection, extension, path, slug, dataFiles); + let entries = await Promise.all( + i18nFiles.map(dataFile => readAndFormatDataFile(dataFile).catch(() => null)), + ); + entries = entries.filter(Boolean); + const grouped = await groupEntries(collection, extension, entries as Entry[]); + return grouped[0]; + } else { + return readAndFormatDataFile(dataFiles[0]); + } + } + + async unpublishedEntries(collections: CollectionsWithDefaults, config: ConfigWithDefaults) { + const ids = await this.implementation.unpublishedEntries(); + const entries = ( + await Promise.all( + ids.map(async id => { + const entryData = await this.implementation.unpublishedEntry({ id }); + const collectionName = entryData.collection; + const collection = Object.values(collections).find(c => c.name === collectionName); + if (!collection) { + console.warn(`Missing collection '${collectionName}' for unpublished entry '${id}'`); + return null; + } + + return this.processUnpublishedEntry(collection, config, entryData, false); + }), + ) + ).filter(Boolean) as Entry[]; + + return { pagination: 0, entries }; + } + + async unpublishedEntry( + state: RootState, + collection: CollectionWithDefaults, + config: ConfigWithDefaults, + slug: string, + ) { + const entryData = await this.implementation.unpublishedEntry({ + collection: collection.name, + slug, + }); + + let entry = await this.processUnpublishedEntry(collection, config, entryData, true); + entry = await this.processEntry(state, collection, entry); + return entry; + } + + persistUnpublishedEntry(args: PersistArgs) { + return this.persistEntry({ ...args, unpublished: true }); + } + + updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) { + return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus); + } + + deleteUnpublishedEntry(collection: string, slug: string) { + return this.implementation.deleteUnpublishedEntry(collection, slug); + } + + async publishUnpublishedEntry(collection: CollectionWithDefaults, entry: Entry) { + await this.invokePrePublishEvent(entry, collection); + await this.implementation.publishUnpublishedEntry(collection.name, entry.slug); + await this.invokePostPublishEvent(entry, collection); + } } -export function resolveBackend(config?: Config) { +export function resolveBackend(config?: ConfigWithDefaults) { if (!config?.backend.name) { throw new Error('No backend defined in configuration'); } @@ -1104,7 +1429,7 @@ export function resolveBackend(config?: Config) { export const currentBackend = (function () { let backend: Backend; - return (config: Config) => { + return (config: ConfigWithDefaults) => { if (backend) { return backend; } diff --git a/packages/core/src/backends/bitbucket/API.ts b/packages/core/src/backends/bitbucket/API.ts index cd66d37b..40fda801 100644 --- a/packages/core/src/backends/bitbucket/API.ts +++ b/packages/core/src/backends/bitbucket/API.ts @@ -1,12 +1,15 @@ +import { oneLine } from 'common-tags'; import flow from 'lodash/flow'; import get from 'lodash/get'; import { dirname } from 'path'; import { parse } from 'what-the-diff'; +import { PreviewState } from '@staticcms/core/constants/enums'; import { APIError, basename, Cursor, + EditorialWorkflowError, localForage, readFile, readFileMetadata, @@ -16,8 +19,20 @@ import { throwOnConflictingBranches, unsentRequest, } from '@staticcms/core/lib/util'; +import { + branchFromContentKey, + CMS_BRANCH_PREFIX, + DEFAULT_PR_BODY, + generateContentKey, + isCMSLabel, + labelToStatus, + MERGE_COMMIT_MESSAGE, + parseContentKey, + statusToLabel, +} from '@staticcms/core/lib/util/APIUtils'; -import type { DataFile, PersistOptions } from '@staticcms/core/interface'; +import type { DataFile, PersistOptions, UnpublishedEntry } from '@staticcms/core'; +import type { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import type { ApiRequest, FetchError } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; @@ -28,6 +43,9 @@ interface Config { repo?: string; requestFunction?: (req: ApiRequest) => Promise; hasWriteAccess?: () => Promise; + squashMerges: boolean; + initialWorkflowStatus: WorkflowStatus; + cmsLabelPrefix: string; } interface CommitAuthor { @@ -35,6 +53,96 @@ interface CommitAuthor { email: string; } +enum BitBucketPullRequestState { + MERGED = 'MERGED', + SUPERSEDED = 'SUPERSEDED', + OPEN = 'OPEN', + DECLINED = 'DECLINED', +} + +type BitBucketPullRequest = { + description: string; + id: number; + title: string; + state: BitBucketPullRequestState; + updated_on: string; + summary: { + raw: string; + }; + source: { + commit: { + hash: string; + }; + branch: { + name: string; + }; + }; + destination: { + commit: { + hash: string; + }; + branch: { + name: string; + }; + }; + author: BitBucketUser; +}; + +type BitBucketPullRequests = { + size: number; + page: number; + pagelen: number; + next: string; + preview: string; + values: BitBucketPullRequest[]; +}; + +type BitBucketPullComment = { + content: { + raw: string; + }; +}; + +type BitBucketPullComments = { + size: number; + page: number; + pagelen: number; + next: string; + preview: string; + values: BitBucketPullComment[]; +}; + +enum BitBucketPullRequestStatusState { + Successful = 'SUCCESSFUL', + Failed = 'FAILED', + InProgress = 'INPROGRESS', + Stopped = 'STOPPED', +} + +type BitBucketPullRequestStatus = { + uuid: string; + name: string; + key: string; + refname: string; + url: string; + description: string; + state: BitBucketPullRequestStatusState; +}; + +type BitBucketPullRequestStatues = { + size: number; + page: number; + pagelen: number; + next: string; + preview: string; + values: BitBucketPullRequestStatus[]; +}; + +type DeleteEntry = { + path: string; + delete: true; +}; + type BitBucketFile = { id: string; type: string; @@ -81,6 +189,8 @@ type BitBucketCommit = { export const API_NAME = 'Bitbucket'; +const APPLICATION_JSON = 'application/json; charset=utf-8'; + function replace404WithEmptyResponse(err: FetchError) { if (err && err.status === 404) { console.info('[StaticCMS] This 404 was expected and handled appropriately.'); @@ -97,6 +207,9 @@ export default class API { requestFunction: (req: ApiRequest) => Promise; repoURL: string; commitAuthor?: CommitAuthor; + mergeStrategy: string; + initialWorkflowStatus: WorkflowStatus; + cmsLabelPrefix: string; constructor(config: Config) { this.apiRoot = config.apiRoot || 'https://api.bitbucket.org/2.0'; @@ -106,6 +219,9 @@ export default class API { // Allow overriding this.hasWriteAccess this.hasWriteAccess = config.hasWriteAccess || this.hasWriteAccess; this.repoURL = this.repo ? `/repositories/${this.repo}` : ''; + this.mergeStrategy = config.squashMerges ? 'squash' : 'merge_commit'; + this.initialWorkflowStatus = config.initialWorkflowStatus; + this.cmsLabelPrefix = config.cmsLabelPrefix; } buildRequest = (req: ApiRequest) => { @@ -231,7 +347,7 @@ export default class API { } async isShaExistsInBranch(branch: string, sha: string) { - const { values }: { values: BitBucketCommit[] } = await this.requestJSON({ + const response: { values: BitBucketCommit[] } = await this.requestJSON({ url: `${this.repoURL}/commits`, params: { include: branch, pagelen: '100' }, }).catch(e => { @@ -239,7 +355,7 @@ export default class API { return []; }); - return values.some(v => v.hash === sha); + return response?.values?.some(v => v.hash === sha); } getEntriesAndCursor = (jsonResponse: BitBucketSrcResult) => { @@ -359,7 +475,11 @@ export default class API { branch: filesBranch, parseText: false, }); - formData.append(file.path.replace(sourceDir, destDir), content, basename(file.path)); + formData.append( + file.path.replace(sourceDir, destDir), + content as Blob, + basename(file.path), + ); } } @@ -412,6 +532,11 @@ export default class API { options: PersistOptions, ) { const files = [...dataFiles, ...mediaFiles]; + if (options.useWorkflow) { + const slug = dataFiles[0].slug; + return this.editorialWorkflowGit(files as (DataFile | AssetProxy)[], slug, options); + } + return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch }); } @@ -460,4 +585,248 @@ export default class API { unsentRequest.withBody(body, unsentRequest.withMethod('POST', `${this.repoURL}/src`)), ); }; + + /** + * Editorial Workflow + */ + async listUnpublishedBranches() { + console.info( + '%c Checking for Unpublished entries', + 'line-height: 30px;text-align: center;font-weight: bold', + ); + + const pullRequests = await this.getPullRequests(); + const branches = pullRequests.map(mr => mr.source.branch.name); + + return branches; + } + + async getPullRequestLabel(id: number) { + const comments: BitBucketPullComments = await this.requestJSON({ + url: `${this.repoURL}/pullrequests/${id}/comments`, + params: { + pagelen: '100', + }, + }); + return comments.values.map(c => c.content.raw)[comments.values.length - 1]; + } + + async getPullRequests(sourceBranch?: string) { + const sourceQuery = sourceBranch + ? `source.branch.name = "${sourceBranch}"` + : `source.branch.name ~ "${CMS_BRANCH_PREFIX}/"`; + + const pullRequests: BitBucketPullRequests = await this.requestJSON({ + url: `${this.repoURL}/pullrequests`, + params: { + pagelen: '50', + q: oneLine` + source.repository.full_name = "${this.repo}" + AND state = "${BitBucketPullRequestState.OPEN}" + AND destination.branch.name = "${this.branch}" + AND comment_count > 0 + AND ${sourceQuery} + `, + }, + }); + + const labels = await Promise.all( + pullRequests.values.map(pr => this.getPullRequestLabel(pr.id)), + ); + + return pullRequests.values.filter((_, index) => isCMSLabel(labels[index], this.cmsLabelPrefix)); + } + + async getBranchPullRequest(branch: string) { + const pullRequests = await this.getPullRequests(branch); + if (pullRequests.length <= 0) { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + } + + return pullRequests[0]; + } + + async retrieveUnpublishedEntryData(contentKey: string): Promise { + const { collection, slug } = parseContentKey(contentKey); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + const diffs = await this.getDifferences(branch); + const label = await this.getPullRequestLabel(pullRequest.id); + const status = labelToStatus(label, this.cmsLabelPrefix); + const updatedAt = pullRequest.updated_on; + const pullRequestAuthor = pullRequest.author.display_name; + return { + collection, + slug, + status, + // TODO: get real id + diffs: diffs + .filter(d => d.status !== 'deleted') + .map(d => ({ path: d.path, newFile: d.newFile, id: '' })), + updatedAt, + pullRequestAuthor, + openAuthoring: false, + }; + } + + async addPullRequestComment(pullRequest: BitBucketPullRequest, comment: string) { + await this.requestJSON({ + method: 'POST', + url: `${this.repoURL}/pullrequests/${pullRequest.id}/comments`, + headers: { 'Content-Type': APPLICATION_JSON }, + body: JSON.stringify({ + content: { + raw: comment, + }, + }), + }); + } + + async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) { + const contentKey = generateContentKey(collection, slug); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + + await this.addPullRequestComment(pullRequest, statusToLabel(newStatus, this.cmsLabelPrefix)); + } + + async declinePullRequest(pullRequest: BitBucketPullRequest) { + await this.requestJSON({ + method: 'POST', + url: `${this.repoURL}/pullrequests/${pullRequest.id}/decline`, + }); + } + + async deleteBranch(branch: string) { + await this.request({ + method: 'DELETE', + url: `${this.repoURL}/refs/branches/${branch}`, + }); + } + + async deleteUnpublishedEntry(collectionName: string, slug: string) { + const contentKey = generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + + await this.declinePullRequest(pullRequest); + await this.deleteBranch(branch); + } + + async mergePullRequest(pullRequest: BitBucketPullRequest) { + await this.requestJSON({ + method: 'POST', + url: `${this.repoURL}/pullrequests/${pullRequest.id}/merge`, + headers: { 'Content-Type': APPLICATION_JSON }, + body: JSON.stringify({ + message: MERGE_COMMIT_MESSAGE, + close_source_branch: true, + merge_strategy: this.mergeStrategy, + }), + }); + } + + async publishUnpublishedEntry(collectionName: string, slug: string) { + const contentKey = generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + + await this.mergePullRequest(pullRequest); + } + + async getPullRequestStatuses(pullRequest: BitBucketPullRequest) { + const statuses: BitBucketPullRequestStatues = await this.requestJSON({ + url: `${this.repoURL}/pullrequests/${pullRequest.id}/statuses`, + params: { + pagelen: '100', + }, + }); + + return statuses.values; + } + + async getStatuses(collectionName: string, slug: string) { + const contentKey = generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + const statuses = await this.getPullRequestStatuses(pullRequest); + + return statuses.map(({ key, state, url }) => ({ + context: key, + state: + state === BitBucketPullRequestStatusState.Successful + ? PreviewState.Success + : PreviewState.Other, + target_url: url, + })); + } + + async createPullRequest(branch: string, commitMessage: string, status: WorkflowStatus) { + const pullRequest: BitBucketPullRequest = await this.requestJSON({ + method: 'POST', + url: `${this.repoURL}/pullrequests`, + headers: { 'Content-Type': APPLICATION_JSON }, + body: JSON.stringify({ + title: commitMessage, + source: { + branch: { + name: branch, + }, + }, + destination: { + branch: { + name: this.branch, + }, + }, + description: DEFAULT_PR_BODY, + close_source_branch: true, + }), + }); + // use comments for status labels + await this.addPullRequestComment(pullRequest, statusToLabel(status, this.cmsLabelPrefix)); + } + + async editorialWorkflowGit( + files: (DataFile | AssetProxy)[], + slug: string, + options: PersistOptions, + ) { + const contentKey = generateContentKey(options.collectionName as string, slug); + const branch = branchFromContentKey(contentKey); + const unpublished = options.unpublished || false; + if (!unpublished) { + const defaultBranchSha = await this.branchCommitSha(this.branch); + await this.uploadFiles(files, { + commitMessage: options.commitMessage, + branch, + parentSha: defaultBranchSha, + }); + await this.createPullRequest( + branch, + options.commitMessage, + options.status || this.initialWorkflowStatus, + ); + } else { + // mark files for deletion + const diffs = await this.getDifferences(branch); + const toDelete: DeleteEntry[] = []; + for (const diff of diffs.filter(d => d.binary && d.status !== 'deleted')) { + if (!files.some(file => file.path === diff.path)) { + toDelete.push({ path: diff.path, delete: true }); + } + } + + await this.uploadFiles([...files, ...toDelete], { + commitMessage: options.commitMessage, + branch, + }); + } + } + + async getUnpublishedEntrySha(collection: string, slug: string) { + const contentKey = generateContentKey(collection, slug); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + return pullRequest.destination.commit.hash; + } } diff --git a/packages/core/src/backends/bitbucket/AuthenticationPage.tsx b/packages/core/src/backends/bitbucket/AuthenticationPage.tsx index 390d3e6d..8934a137 100644 --- a/packages/core/src/backends/bitbucket/AuthenticationPage.tsx +++ b/packages/core/src/backends/bitbucket/AuthenticationPage.tsx @@ -3,11 +3,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import Login from '@staticcms/core/components/login/Login'; import { ImplicitAuthenticator, NetlifyAuthenticator } from '@staticcms/core/lib/auth'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; -import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; -import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps } from '@staticcms/core'; +import type { FC, MouseEvent } from 'react'; -const BitbucketAuthenticationPage = ({ +const BitbucketAuthenticationPage: FC = ({ inProgress = false, config, base_url, @@ -15,8 +16,9 @@ const BitbucketAuthenticationPage = ({ authEndpoint, clearHash, onLogin, - t, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const [loginError, setLoginError] = useState(null); const [auth, authSettings] = useMemo(() => { diff --git a/packages/core/src/backends/bitbucket/__tests__/api.spec.ts b/packages/core/src/backends/bitbucket/__tests__/api.spec.ts new file mode 100644 index 00000000..27fe6695 --- /dev/null +++ b/packages/core/src/backends/bitbucket/__tests__/api.spec.ts @@ -0,0 +1,43 @@ +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; +import { CMS_BRANCH_PREFIX } from '@staticcms/core/lib/util/APIUtils'; +import API from '../API'; + +global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests')); + +describe('bitbucket API', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should get preview statuses', async () => { + const api = new API({ + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: CMS_BRANCH_PREFIX, + }); + + const pr = { id: 1 }; + const statuses = [ + { key: 'deploy', state: 'SUCCESSFUL', url: 'deploy-url' }, + { key: 'build', state: 'FAILED' }, + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (api as any).getBranchPullRequest = jest.fn(() => Promise.resolve(pr)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (api as any).getPullRequestStatuses = jest.fn(() => Promise.resolve(statuses)); + + const collectionName = 'posts'; + const slug = 'title'; + await expect(api.getStatuses(collectionName, slug)).resolves.toEqual([ + { context: 'deploy', state: 'success', target_url: 'deploy-url' }, + { context: 'build', state: 'other' }, + ]); + + expect(api.getBranchPullRequest).toHaveBeenCalledTimes(1); + expect(api.getBranchPullRequest).toHaveBeenCalledWith(`cms/posts/title`); + + expect(api.getPullRequestStatuses).toHaveBeenCalledTimes(1); + expect(api.getPullRequestStatuses).toHaveBeenCalledWith(pr); + }); +}); diff --git a/packages/core/src/backends/bitbucket/implementation.ts b/packages/core/src/backends/bitbucket/implementation.ts index c4edb02d..493f4c81 100644 --- a/packages/core/src/backends/bitbucket/implementation.ts +++ b/packages/core/src/backends/bitbucket/implementation.ts @@ -2,6 +2,7 @@ import { stripIndent } from 'common-tags'; import trimStart from 'lodash/trimStart'; import semaphore from 'semaphore'; +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import { NetlifyAuthenticator } from '@staticcms/core/lib/auth'; import { AccessTokenError, @@ -23,23 +24,31 @@ import { runWithLock, unsentRequest, } from '@staticcms/core/lib/util'; +import { getPreviewStatus } from '@staticcms/core/lib/util/API'; +import { + branchFromContentKey, + contentKeyFromBranch, + generateContentKey, +} from '@staticcms/core/lib/util/APIUtils'; +import { unpublishedEntries } from '@staticcms/core/lib/util/implementation'; import API, { API_NAME } from './API'; import AuthenticationPage from './AuthenticationPage'; import GitLfsClient from './git-lfs-client'; -import type { Semaphore } from 'semaphore'; import type { - BackendEntry, BackendClass, - Config, + BackendEntry, + ConfigWithDefaults, Credentials, DisplayURL, ImplementationFile, PersistOptions, + UnpublishedEntry, User, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; import type { ApiRequest, AsyncLock, Cursor, FetchError } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; +import type { Semaphore } from 'semaphore'; const MAX_CONCURRENT_DOWNLOADS = 10; @@ -61,6 +70,7 @@ export default class BitbucketBackend implements BackendClass { proxied: boolean; API: API | null; updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise; + initialWorkflowStatus: WorkflowStatus; }; repo: string; branch: string; @@ -73,15 +83,19 @@ export default class BitbucketBackend implements BackendClass { refreshedTokenPromise?: Promise; authenticator?: NetlifyAuthenticator; _mediaDisplayURLSem?: Semaphore; + squashMerges: boolean; + cmsLabelPrefix: string; + previewContext: string; largeMediaURL: string; _largeMediaClientPromise?: Promise; authType: string; - constructor(config: Config, options = {}) { + constructor(config: ConfigWithDefaults, options = {}) { this.options = { proxied: false, API: null, updateUserCredentials: async () => null, + initialWorkflowStatus: WorkflowStatus.DRAFT, ...options, }; @@ -105,14 +119,13 @@ export default class BitbucketBackend implements BackendClass { config.backend.large_media_url || `https://bitbucket.org/${config.backend.repo}/info/lfs`; this.token = ''; this.mediaFolder = config.media_folder; + this.squashMerges = config.backend.squash_merges || false; + this.cmsLabelPrefix = config.backend.cms_label_prefix || ''; + this.previewContext = config.backend.preview_context || ''; this.lock = asyncLock(); this.authType = config.backend.auth_type || ''; } - isGitBackend() { - return true; - } - async status() { const api = await fetch(BITBUCKET_STATUS_ENDPOINT) .then(res => res.json()) @@ -168,6 +181,9 @@ export default class BitbucketBackend implements BackendClass { branch: this.branch, repo: this.repo, apiRoot: this.apiRoot, + squashMerges: this.squashMerges, + cmsLabelPrefix: this.cmsLabelPrefix, + initialWorkflowStatus: this.options.initialWorkflowStatus, }); const isCollab = await this.api.hasWriteAccess().catch(error => { @@ -535,4 +551,99 @@ export default class BitbucketBackend implements BackendClass { file: fileObj, }; } + + /** + * Editorial Workflow + */ + async unpublishedEntries() { + const listEntriesKeys = () => + this.api!.listUnpublishedBranches().then(branches => + branches.map(branch => contentKeyFromBranch(branch)), + ); + + const ids = await unpublishedEntries(listEntriesKeys); + return ids; + } + + async unpublishedEntry({ + id, + collection, + slug, + }: { + id?: string; + collection?: string; + slug?: string; + }): Promise { + if (id) { + const data = await this.api!.retrieveUnpublishedEntryData(id); + return data; + } else if (collection && slug) { + const entryId = generateContentKey(collection, slug); + const data = await this.api!.retrieveUnpublishedEntryData(entryId); + return data; + } else { + throw new Error('Missing unpublished entry id or collection and slug'); + } + } + + getBranch(collection: string, slug: string) { + const contentKey = generateContentKey(collection, slug); + const branch = branchFromContentKey(contentKey); + return branch; + } + + async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) { + const branch = this.getBranch(collection, slug); + const data = (await this.api!.readFile(path, id, { branch })) as string; + return data; + } + + async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) { + const branch = this.getBranch(collection, slug); + const mediaFile = await this.loadMediaFile(path, id, { branch }); + return mediaFile; + } + + async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) { + // updateUnpublishedEntryStatus is a transactional operation + return runWithLock( + this.lock, + () => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus), + 'Failed to acquire update entry status lock', + ); + } + + async deleteUnpublishedEntry(collection: string, slug: string) { + // deleteUnpublishedEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.deleteUnpublishedEntry(collection, slug), + 'Failed to acquire delete entry lock', + ); + } + + async publishUnpublishedEntry(collection: string, slug: string) { + // publishUnpublishedEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.publishUnpublishedEntry(collection, slug), + 'Failed to acquire publish entry lock', + ); + } + + async getDeployPreview(collection: string, slug: string) { + try { + const statuses = await this.api!.getStatuses(collection, slug); + const deployStatus = getPreviewStatus(statuses, this.previewContext); + + if (deployStatus) { + const { target_url: url, state } = deployStatus; + return { url, status: state }; + } else { + return null; + } + } catch (e) { + return null; + } + } } diff --git a/packages/core/src/backends/git-gateway/AuthenticationPage.tsx b/packages/core/src/backends/git-gateway/AuthenticationPage.tsx index 267f66d8..bf2d8ef5 100644 --- a/packages/core/src/backends/git-gateway/AuthenticationPage.tsx +++ b/packages/core/src/backends/git-gateway/AuthenticationPage.tsx @@ -1,8 +1,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import Login from '@staticcms/core/components/login/Login'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; -import type { AuthenticationPageProps, TranslatedProps, User } from '@staticcms/core/interface'; +import type { AuthenticationPageProps, User } from '@staticcms/core'; +import type { FC } from 'react'; function useNetlifyIdentifyEvent(eventName: 'login', callback: (login: User) => void): void; function useNetlifyIdentifyEvent(eventName: 'logout', callback: () => void): void; @@ -17,12 +19,13 @@ function useNetlifyIdentifyEvent( }, [callback, eventName]); } -export interface GitGatewayAuthenticationPageProps - extends TranslatedProps { +export interface GitGatewayAuthenticationPageProps extends AuthenticationPageProps { handleAuth: (email: string, password: string) => Promise; } -const GitGatewayAuthenticationPage = ({ onLogin, t }: GitGatewayAuthenticationPageProps) => { +const GitGatewayAuthenticationPage: FC = ({ onLogin }) => { + const t = useTranslate(); + const [loggingIn, setLoggingIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false); const [errors, setErrors] = useState<{ diff --git a/packages/core/src/backends/git-gateway/GitHubAPI.ts b/packages/core/src/backends/git-gateway/GitHubAPI.ts index feaa0e05..fe992804 100644 --- a/packages/core/src/backends/git-gateway/GitHubAPI.ts +++ b/packages/core/src/backends/git-gateway/GitHubAPI.ts @@ -4,7 +4,7 @@ import { API as GithubAPI } from '../github'; import type { FetchError } from '@staticcms/core/lib/util'; import type { Config as GitHubConfig } from '../github/API'; -type Config = GitHubConfig & { +export type GitHubApiOptions = GitHubConfig & { apiRoot: string; tokenPromise: () => Promise; commitAuthor: { name: string }; @@ -16,12 +16,12 @@ export default class API extends GithubAPI { commitAuthor: { name: string }; isLargeMedia: (filename: string) => Promise; - constructor(config: Config) { - super(config); - this.apiRoot = config.apiRoot; - this.tokenPromise = config.tokenPromise; - this.commitAuthor = config.commitAuthor; - this.isLargeMedia = config.isLargeMedia; + constructor(options: GitHubApiOptions) { + super(options); + this.apiRoot = options.apiRoot; + this.tokenPromise = options.tokenPromise; + this.commitAuthor = options.commitAuthor; + this.isLargeMedia = options.isLargeMedia; this.repoURL = ''; this.originRepoURL = ''; } diff --git a/packages/core/src/backends/git-gateway/__tests__/AuthenticationPage.spec.tsx b/packages/core/src/backends/git-gateway/__tests__/AuthenticationPage.spec.tsx new file mode 100644 index 00000000..dff0c8b1 --- /dev/null +++ b/packages/core/src/backends/git-gateway/__tests__/AuthenticationPage.spec.tsx @@ -0,0 +1,63 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { act } from '@testing-library/react'; +import React from 'react'; + +import { resolveBackend } from '@staticcms/core/backend'; +import { createMockConfig } from '@staticcms/test/data/config.mock'; +import { renderWithProviders } from '@staticcms/test/test-utils'; +import GitGatewayAuthenticationPage from '../AuthenticationPage'; + +import type { GitGatewayAuthenticationPageProps } from '../AuthenticationPage'; + +jest.mock('@staticcms/core/backend'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any).netlifyIdentity = { + currentUser: jest.fn(), + on: jest.fn(), + close: jest.fn(), +}; + +describe('GitGatewayAuthenticationPage', () => { + const props: GitGatewayAuthenticationPageProps = { + onLogin: jest.fn(), + inProgress: false, + config: createMockConfig({ logo_url: 'logo_url', collections: [] }), + handleAuth: jest.fn(), + }; + + beforeEach(() => { + (resolveBackend as jest.Mock).mockResolvedValue(null); + + jest.clearAllMocks(); + jest.resetModules(); + }); + + it('should render with identity error', () => { + const { queryByTestId } = renderWithProviders(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const errorCallback = (window as any).netlifyIdentity.on.mock.calls.find( + (call: string[]) => call[0] === 'error', + )[1]; + + act(() => { + errorCallback( + new Error('Failed to load settings from https://site.netlify.com/.netlify/identity'), + ); + }); + + expect(queryByTestId('login-button')).toBeInTheDocument(); + expect(queryByTestId('login-error')).toBeInTheDocument(); + }); + + it('should render with no identity error', () => { + const { queryByTestId } = renderWithProviders(); + + expect(queryByTestId('login-button')).toBeInTheDocument(); + expect(queryByTestId('login-error')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/backends/git-gateway/__tests__/GitHubAPI.spec.ts b/packages/core/src/backends/git-gateway/__tests__/GitHubAPI.spec.ts new file mode 100644 index 00000000..120bfe18 --- /dev/null +++ b/packages/core/src/backends/git-gateway/__tests__/GitHubAPI.spec.ts @@ -0,0 +1,110 @@ +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; +import API from '../GitHubAPI'; + +import type { GitHubApiOptions } from '../GitHubAPI'; + +const createApi = (options: Partial = {}) => { + return new API({ + apiRoot: 'https://site.netlify.com/.netlify/git/github', + tokenPromise: () => Promise.resolve('token'), + squashMerges: true, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: 'CMS', + isLargeMedia: () => Promise.resolve(false), + commitAuthor: { name: 'Bob' }, + ...options, + }); +}; + +describe('github API', () => { + describe('request', () => { + const fetch = jest.fn(); + + beforeEach(() => { + global.fetch = fetch; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should fetch url with authorization header', async () => { + const api = createApi(); + + fetch.mockResolvedValue({ + text: jest.fn().mockResolvedValue('some response'), + ok: true, + status: 200, + headers: { get: () => '' }, + }); + const result = await api.request('/some-path'); + expect(result).toEqual('some response'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('https://site.netlify.com/.netlify/git/github/some-path', { + cache: 'no-cache', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/json; charset=utf-8', + }, + }); + }); + + it('should throw error on not ok response with message property', async () => { + const api = createApi({ + apiRoot: 'https://site.netlify.com/.netlify/git/github', + tokenPromise: () => Promise.resolve('token'), + }); + + fetch.mockResolvedValue({ + text: jest.fn().mockResolvedValue({ message: 'some error' }), + ok: false, + status: 404, + headers: { get: () => '' }, + }); + + await expect(api.request('some-path')).rejects.toThrow( + expect.objectContaining({ + message: 'some error', + name: 'API_ERROR', + status: 404, + api: 'Git Gateway', + }), + ); + }); + + it('should throw error on not ok response with msg property', async () => { + const api = createApi({ + apiRoot: 'https://site.netlify.com/.netlify/git/github', + tokenPromise: () => Promise.resolve('token'), + }); + + fetch.mockResolvedValue({ + text: jest.fn().mockResolvedValue({ msg: 'some error' }), + ok: false, + status: 404, + headers: { get: () => '' }, + }); + + await expect(api.request('some-path')).rejects.toThrow( + expect.objectContaining({ + message: 'some error', + name: 'API_ERROR', + status: 404, + api: 'Git Gateway', + }), + ); + }); + }); + + describe('nextUrlProcessor', () => { + it('should re-write github url', () => { + const api = createApi({ + apiRoot: 'https://site.netlify.com/.netlify/git/github', + }); + + expect(api.nextUrlProcessor()('https://api.github.com/repositories/10000/pulls')).toEqual( + 'https://site.netlify.com/.netlify/git/github/pulls', + ); + }); + }); +}); diff --git a/packages/core/src/backends/git-gateway/implementation.tsx b/packages/core/src/backends/git-gateway/implementation.tsx index ed873662..796e37ee 100644 --- a/packages/core/src/backends/git-gateway/implementation.tsx +++ b/packages/core/src/backends/git-gateway/implementation.tsx @@ -5,6 +5,8 @@ import intersection from 'lodash/intersection'; import pick from 'lodash/pick'; import React, { useCallback } from 'react'; +import { PreviewState } from '@staticcms/core/constants/enums'; +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import { AccessTokenError, APIError, @@ -28,17 +30,18 @@ import type { AuthenticationPageProps, BackendClass, BackendEntry, - Config, + ConfigWithDefaults, Credentials, DisplayURL, DisplayURLObject, ImplementationFile, PersistOptions, - TranslatedProps, + UnpublishedEntry, User, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; import type { ApiRequest, Cursor } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; +import type { FC } from 'react'; import type { Client } from './netlify-lfs-client'; const STATUS_PAGE = 'https://www.netlifystatus.com'; @@ -112,10 +115,18 @@ interface NetlifyUser extends Credentials { user_metadata: { full_name: string; avatar_url: string }; } +async function apiGet(path: string) { + const apiRoot = 'https://api.netlify.com/api/v1/sites'; + const response = await fetch(`${apiRoot}/${path}`).then(res => res.json()); + return response; +} + export default class GitGateway implements BackendClass { - config: Config; + config: ConfigWithDefaults; api?: GitHubAPI | GitLabAPI | BitBucketAPI; branch: string; + squashMerges: boolean; + cmsLabelPrefix: string; mediaFolder?: string; transformImages: boolean; gatewayUrl: string; @@ -131,15 +142,19 @@ export default class GitGateway implements BackendClass { options: { proxied: boolean; API: GitHubAPI | GitLabAPI | BitBucketAPI | null; + initialWorkflowStatus: WorkflowStatus; }; - constructor(config: Config, options = {}) { + constructor(config: ConfigWithDefaults, options = {}) { this.options = { proxied: true, API: null, + initialWorkflowStatus: WorkflowStatus.DRAFT, ...options, }; this.config = config; this.branch = config.backend.branch?.trim() || 'main'; + this.squashMerges = config.backend.squash_merges || false; + this.cmsLabelPrefix = config.backend.cms_label_prefix || ''; this.mediaFolder = config.media_folder; const { use_large_media_transforms_in_media_library: transformImages = true } = config.backend; this.transformImages = transformImages; @@ -163,10 +178,6 @@ export default class GitGateway implements BackendClass { this.backend = null; } - isGitBackend() { - return true; - } - async status() { const api = await fetch(GIT_GATEWAY_STATUS_ENDPOINT) .then(res => res.json()) @@ -299,6 +310,9 @@ export default class GitGateway implements BackendClass { tokenPromise: this.tokenPromise!, commitAuthor: pick(userData, ['name', 'email']), isLargeMedia: (filename: string) => this.isLargeMediaFile(filename), + squashMerges: this.squashMerges, + cmsLabelPrefix: this.cmsLabelPrefix, + initialWorkflowStatus: this.options.initialWorkflowStatus, }; if (this.backendType === 'github') { @@ -333,7 +347,7 @@ export default class GitGateway implements BackendClass { } authComponent() { - const WrappedAuthenticationPage = (props: TranslatedProps) => { + const WrappedAuthenticationPage: FC = props => { const handleAuth = useCallback( async (email: string, password: string): Promise => { try { @@ -542,4 +556,87 @@ export default class GitGateway implements BackendClass { traverseCursor(cursor: Cursor, action: string) { return this.backend!.traverseCursor!(cursor, action); } + + /** + * Editorial Workflow + */ + unpublishedEntries() { + return this.backend!.unpublishedEntries(); + } + + unpublishedEntry({ + id, + collection, + slug, + }: { + id?: string; + collection?: string; + slug?: string; + }): Promise { + return this.backend!.unpublishedEntry({ id, collection, slug }); + } + + updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) { + return this.backend!.updateUnpublishedEntryStatus(collection, slug, newStatus); + } + + deleteUnpublishedEntry(collection: string, slug: string) { + return this.backend!.deleteUnpublishedEntry(collection, slug); + } + + publishUnpublishedEntry(collection: string, slug: string) { + return this.backend!.publishUnpublishedEntry(collection, slug); + } + + async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) { + return this.backend!.unpublishedEntryDataFile(collection, slug, path, id); + } + + async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) { + const isLargeMedia = await this.isLargeMediaFile(path); + if (isLargeMedia) { + const branch = this.backend!.getBranch(collection, slug); + const { url, blob } = await this.getLargeMediaDisplayURL({ path, id }, branch); + return { + id, + name: basename(path), + path, + url, + displayURL: url, + file: new File([blob], basename(path)), + size: blob.size, + }; + } else { + return this.backend!.unpublishedEntryMediaFile(collection, slug, path, id); + } + } + + async getDeployPreview(collection: string, slug: string) { + let preview = await this.backend!.getDeployPreview(collection, slug); + if (!preview) { + try { + // if the commit doesn't have a status, try to use Netlify API directly + // this is useful when builds are queue up in Netlify and don't have a commit status yet + // and only works with public logs at the moment + // TODO: get Netlify API Token and use it to access private logs + const siteId = new URL(localStorage.getItem('netlifySiteURL') || '').hostname; + const site = await apiGet(siteId); + const deploys: { state: string; commit_ref: string; deploy_url: string }[] = await apiGet( + `${site.id}/deploys?per_page=100`, + ); + if (deploys.length > 0) { + const ref = await this.api!.getUnpublishedEntrySha(collection, slug); + const deploy = deploys.find(d => d.commit_ref === ref); + if (deploy) { + preview = { + status: deploy.state === 'ready' ? PreviewState.Success : PreviewState.Other, + url: deploy.deploy_url, + }; + } + } + // eslint-disable-next-line no-empty + } catch (e) {} + } + return preview; + } } diff --git a/packages/core/src/backends/gitea/API.ts b/packages/core/src/backends/gitea/API.ts index a7fe4783..5acd118e 100644 --- a/packages/core/src/backends/gitea/API.ts +++ b/packages/core/src/backends/gitea/API.ts @@ -13,7 +13,7 @@ import { unsentRequest, } from '@staticcms/core/lib/util'; -import type { DataFile, PersistOptions } from '@staticcms/core/interface'; +import type { DataFile, PersistOptions } from '@staticcms/core'; import type { ApiRequest, FetchError } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; import type { Semaphore } from 'semaphore'; diff --git a/packages/core/src/backends/gitea/AuthenticationPage.tsx b/packages/core/src/backends/gitea/AuthenticationPage.tsx index 04822c42..bfeaa2b3 100644 --- a/packages/core/src/backends/gitea/AuthenticationPage.tsx +++ b/packages/core/src/backends/gitea/AuthenticationPage.tsx @@ -3,17 +3,19 @@ import React, { useCallback, useMemo, useState } from 'react'; import Login from '@staticcms/core/components/login/Login'; import { PkceAuthenticator } from '@staticcms/core/lib/auth'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; -import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; -import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps } from '@staticcms/core'; +import type { FC, MouseEvent } from 'react'; -const GiteaAuthenticationPage = ({ +const GiteaAuthenticationPage: FC = ({ inProgress = false, config, clearHash, onLogin, - t, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const [loginError, setLoginError] = useState(null); const auth = useMemo(() => { diff --git a/packages/core/src/backends/gitea/__tests__/implementation.spec.ts b/packages/core/src/backends/gitea/__tests__/implementation.spec.ts index 4fcfebfd..ce410373 100644 --- a/packages/core/src/backends/gitea/__tests__/implementation.spec.ts +++ b/packages/core/src/backends/gitea/__tests__/implementation.spec.ts @@ -1,7 +1,7 @@ import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util/Cursor'; import GiteaImplementation from '../implementation'; -import type { Config, UnknownField } from '@staticcms/core'; +import type { ConfigWithDefaults, UnknownField } from '@staticcms/core'; import type API from '../API'; import type { AssetProxy } from '@staticcms/core/valueObjects'; @@ -14,7 +14,7 @@ describe('gitea backend implementation', () => { repo: 'owner/repo', api_root: 'https://try.gitea.io/api/v1', }, - } as Config; + } as ConfigWithDefaults; const createObjectURL = jest.fn(); global.URL = { diff --git a/packages/core/src/backends/gitea/implementation.tsx b/packages/core/src/backends/gitea/implementation.tsx index b74f5232..af816df7 100644 --- a/packages/core/src/backends/gitea/implementation.tsx +++ b/packages/core/src/backends/gitea/implementation.tsx @@ -23,13 +23,15 @@ import AuthenticationPage from './AuthenticationPage'; import type { BackendClass, BackendEntry, - Config, + ConfigWithDefaults, Credentials, DisplayURL, ImplementationFile, + ImplementationMediaFile, PersistOptions, + UnpublishedEntry, User, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; import type { AsyncLock } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; import type { Semaphore } from 'semaphore'; @@ -60,7 +62,7 @@ export default class Gitea implements BackendClass { }; _mediaDisplayURLSem?: Semaphore; - constructor(config: Config, options = {}) { + constructor(config: ConfigWithDefaults, options = {}) { this.options = { proxied: false, API: null, @@ -82,11 +84,6 @@ export default class Gitea implements BackendClass { this.mediaFolder = config.media_folder; this.lock = asyncLock(); } - - isGitBackend() { - return true; - } - async status() { const auth = (await this.api @@ -405,4 +402,36 @@ export default class Gitea implements BackendClass { cursor: result.cursor, }; } + + async unpublishedEntries(): Promise { + throw new Error('Editorial workflow is not yet available for Gitea'); + } + + async unpublishedEntry(): Promise { + throw new Error('Editorial workflow is not yet available for Gitea'); + } + + async unpublishedEntryDataFile(): Promise { + throw new Error('Editorial workflow is not yet available for Gitea'); + } + + async unpublishedEntryMediaFile(): Promise { + throw new Error('Editorial workflow is not yet available for Gitea'); + } + + async updateUnpublishedEntryStatus(): Promise { + throw new Error('Editorial workflow is not yet available for Gitea'); + } + + async publishUnpublishedEntry(): Promise { + throw new Error('Editorial workflow is not yet available for Gitea'); + } + + async deleteUnpublishedEntry(): Promise { + throw new Error('Editorial workflow is not yet available for Gitea'); + } + + async getDeployPreview(): Promise<{ url: string; status: string } | null> { + throw new Error('Editorial workflow is not yet available for Gitea'); + } } diff --git a/packages/core/src/backends/github/API.ts b/packages/core/src/backends/github/API.ts index 1e5d926a..a5b70bfd 100644 --- a/packages/core/src/backends/github/API.ts +++ b/packages/core/src/backends/github/API.ts @@ -7,8 +7,11 @@ import trim from 'lodash/trim'; import trimStart from 'lodash/trimStart'; import { dirname } from 'path'; +import { PreviewState } from '@staticcms/core/constants/enums'; +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import { APIError, + EditorialWorkflowError, basename, generateContentKey, getAllResponses, @@ -16,10 +19,21 @@ import { parseContentKey, readFileMetadata, requestWithBackoff, + throwOnConflictingBranches, unsentRequest, } from '@staticcms/core/lib/util'; +import { + CMS_BRANCH_PREFIX, + DEFAULT_PR_BODY, + MERGE_COMMIT_MESSAGE, + branchFromContentKey, + isCMSLabel, + labelToStatus, + statusToLabel, +} from '@staticcms/core/lib/util/APIUtils'; +import { GitHubCommitStatusState, PullRequestState } from './types'; -import type { DataFile, PersistOptions } from '@staticcms/core/interface'; +import type { DataFile, PersistOptions, UnpublishedEntry } from '@staticcms/core'; import type { ApiRequest, FetchError } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; import type { Semaphore } from 'semaphore'; @@ -31,22 +45,44 @@ import type { GitGetBlobResponse, GitGetTreeResponse, GitHubAuthor, + GitHubCommitStatus, GitHubCommitter, + GitHubCompareCommit, + GitHubCompareCommits, + GitHubCompareFiles, + GitHubPull, GitHubUser, + GitListMatchingRefsResponse, + GitListMatchingRefsResponseItem, GitUpdateRefResponse, + PullsCreateResponse, + PullsGetResponseLabelsItem, + PullsListResponse, + PullsMergeResponse, + PullsUpdateBranchResponse, + ReposCompareCommitsResponse, + ReposCompareCommitsResponseFilesItem, ReposGetBranchResponse, ReposGetResponse, ReposListCommitsResponse, + TreeFile, } from './types'; export const API_NAME = 'GitHub'; +export const MOCK_PULL_REQUEST = -1; + export interface Config { apiRoot?: string; token?: string; branch?: string; + useOpenAuthoring?: boolean; + openAuthoringEnabled?: boolean; repo?: string; originRepo?: string; + squashMerges: boolean; + initialWorkflowStatus: WorkflowStatus; + cmsLabelPrefix: string; } type Override = Pick> & U; @@ -89,6 +125,33 @@ type MediaFile = { path: string; }; +function withCmsLabel(pr: GitHubPull, cmsLabelPrefix: string) { + return pr.labels.some(l => isCMSLabel(l.name, cmsLabelPrefix)); +} + +function getTreeFiles(files: GitHubCompareFiles) { + const treeFiles = files.reduce( + (arr, file) => { + if (file.status === 'removed') { + // delete the file + arr.push({ sha: null, path: file.filename }); + } else if (file.status === 'renamed') { + // delete the previous file + arr.push({ sha: null, path: file.previous_filename as string }); + // add the renamed file + arr.push({ sha: file.sha, path: file.filename }); + } else { + // add the file + arr.push({ sha: file.sha, path: file.filename }); + } + return arr; + }, + [] as { sha: string | null; path: string }[], + ); + + return treeFiles; +} + export type Diff = { path: string; newFile: boolean; @@ -100,6 +163,8 @@ export default class API { apiRoot: string; token: string; branch: string; + useOpenAuthoring?: boolean; + openAuthoringEnabled?: boolean; repo: string; originRepo: string; repoOwner: string; @@ -108,6 +173,9 @@ export default class API { originRepoName: string; repoURL: string; originRepoURL: string; + mergeMethod: string; + initialWorkflowStatus: WorkflowStatus; + cmsLabelPrefix: string; _userPromise?: Promise; _metadataSemaphore?: Semaphore; @@ -118,9 +186,11 @@ export default class API { this.apiRoot = config.apiRoot || 'https://api.github.com'; this.token = config.token || ''; this.branch = config.branch || 'main'; + this.useOpenAuthoring = config.useOpenAuthoring; this.repo = config.repo || ''; this.originRepo = config.originRepo || this.repo; this.repoURL = `/repos/${this.repo}`; + // when not in 'useOpenAuthoring' mode originRepoURL === repoURL this.originRepoURL = `/repos/${this.originRepo}`; const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')]; @@ -129,6 +199,11 @@ export default class API { this.originRepoOwner = originRepoParts[0]; this.originRepoName = originRepoParts[1]; + + this.mergeMethod = config.squashMerges ? 'squash' : 'merge'; + this.cmsLabelPrefix = config.cmsLabelPrefix; + this.initialWorkflowStatus = config.initialWorkflowStatus; + this.openAuthoringEnabled = config.openAuthoringEnabled; } static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS'; @@ -265,11 +340,27 @@ export default class API { } generateContentKey(collectionName: string, slug: string) { - return generateContentKey(collectionName, slug); + const contentKey = generateContentKey(collectionName, slug); + if (this.useOpenAuthoring) { + return `${this.repo}/${contentKey}`; + } + + return contentKey; + } + + getContentKeySlug(contentKey: string) { + let key = contentKey; + + const parts = contentKey.split(this.repoName); + if (parts.length > 1) { + key = parts[1]; + } + + return key.replace(/^\//g, '').replace(/^cms\//g, ''); } parseContentKey(contentKey: string) { - return parseContentKey(contentKey); + return parseContentKey(this.getContentKeySlug(contentKey)); } async readFile( @@ -379,17 +470,27 @@ export default class API { async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any); + const files = [...mediaFiles, ...dataFiles] as unknown[] as TreeFile[]; const uploadPromises = files.map(file => this.uploadBlob(file)); await Promise.all(uploadPromises); - return ( - this.getDefaultBranch() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .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)) - ); + if (options.useWorkflow) { + /** + * Editorial Workflow + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mediaFilesList = (mediaFiles as any[]).map(({ sha, path }) => ({ + path: trimStart(path, '/'), + sha, + })); + const slug = dataFiles[0].slug; + return this.editorialWorkflowGit(files, slug, mediaFilesList, options); + } + + return this.getDefaultBranch() + .then(branchData => this.updateTree(branchData.commit.sha, files)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then(response => this.patchBranch(this.branch, response.sha)); } async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) { @@ -415,6 +516,10 @@ export default class API { } async deleteFiles(paths: string[], message: string) { + if (this.useOpenAuthoring) { + return Promise.reject('Cannot delete published entries as an Open Authoring user!'); + } + const branchData = await this.getDefaultBranch(); const files = paths.map(path => ({ path, sha: null })); const changeTree = await this.updateTree(branchData.commit.sha, files); @@ -430,12 +535,13 @@ export default class API { return result; } - async patchRef(type: string, name: string, sha: string) { + async patchRef(type: string, name: string, sha: string, opts: { force?: boolean } = {}) { + const force = opts.force || false; const result: GitUpdateRefResponse = await this.request( `${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`, { method: 'PATCH', - body: JSON.stringify({ sha }), + body: JSON.stringify({ sha, force }), }, ); return result; @@ -454,8 +560,16 @@ export default class API { return result; } - patchBranch(branchName: string, sha: string) { - return this.patchRef('heads', branchName, sha); + assertCmsBranch(branchName: string) { + return branchName.startsWith(`${CMS_BRANCH_PREFIX}/`); + } + + patchBranch(branchName: string, sha: string, opts: { force?: boolean } = {}) { + const force = opts.force || false; + if (force && !this.assertCmsBranch(branchName)) { + throw Error(`Only CMS branches can be force updated, cannot force update ${branchName}`); + } + return this.patchRef('heads', branchName, sha, { force }); } async getHeadReference(head: string) { @@ -558,4 +672,588 @@ export default class API { }); return result; } + + /** + * Editorial Workflow + */ + async listUnpublishedBranches() { + console.info( + '%c Checking for Unpublished entries', + 'line-height: 30px;text-align: center;font-weight: bold', + ); + + let branches: string[]; + if (this.useOpenAuthoring) { + // open authoring branches can exist without a pr + const cmsBranches: GitListMatchingRefsResponse = await this.getOpenAuthoringBranches(); + branches = cmsBranches.map(b => b.ref.slice('refs/heads/'.length)); + // filter irrelevant branches + const branchesWithFilter = await Promise.all( + branches.map(b => this.filterOpenAuthoringBranches(b)), + ); + branches = branchesWithFilter.filter(b => b.filter).map(b => b.branch); + } else if (this.openAuthoringEnabled) { + const cmsPullRequests = await this.getPullRequests( + undefined, + PullRequestState.Open, + () => true, + ); + branches = cmsPullRequests.map(pr => pr.head.ref); + } else { + const cmsPullRequests = await this.getPullRequests(undefined, PullRequestState.Open, pr => + withCmsLabel(pr, this.cmsLabelPrefix), + ); + branches = cmsPullRequests.map(pr => pr.head.ref); + } + + return branches; + } + + async getOpenAuthoringBranches() { + return this.requestAllPages( + `${this.repoURL}/git/refs/heads/cms/${this.repo}`, + ).catch(() => [] as GitListMatchingRefsResponseItem[]); + } + + filterOpenAuthoringBranches = async (branch: string) => { + try { + const pullRequest = await this.getBranchPullRequest(branch); + const { state: currentState, merged_at: mergedAt } = pullRequest; + if ( + pullRequest.number !== MOCK_PULL_REQUEST && + currentState === PullRequestState.Closed && + mergedAt + ) { + // pr was merged, delete branch + await this.deleteBranch(branch); + return { branch, filter: false }; + } else { + return { branch, filter: true }; + } + } catch (e) { + return { branch, filter: false }; + } + }; + + async getPullRequests( + head: string | undefined, + state: PullRequestState, + predicate: (pr: GitHubPull) => boolean, + ) { + const pullRequests: PullsListResponse = await this.requestAllPages( + `${this.originRepoURL}/pulls`, + { + params: { + ...(head ? { head: await this.getHeadReference(head) } : {}), + base: this.branch, + state, + per_page: 100, + }, + }, + ); + + return pullRequests.filter(pr => { + return pr.head.ref.startsWith(`${CMS_BRANCH_PREFIX}/`) && predicate(pr); + }); + } + + deleteBranch(branchName: string) { + return this.deleteRef('heads', branchName).catch((err: Error) => { + // If the branch doesn't exist, then it has already been deleted - + // deletion should be idempotent, so we can consider this a + // success. + if (err.message === 'Reference does not exist') { + return Promise.resolve(); + } + console.error(err); + return Promise.reject(err); + }); + } + + async getBranchPullRequest(branch: string) { + if (this.useOpenAuthoring) { + const pullRequests = await this.getPullRequests(branch, PullRequestState.All, () => true); + return this.getOpenAuthoringPullRequest(branch, pullRequests); + } else if (this.openAuthoringEnabled) { + const pullRequests = await this.getPullRequests(undefined, PullRequestState.Open, pr => { + return this.getContentKeySlug(pr.head.ref) === this.getContentKeySlug(branch); + }); + if (pullRequests.length <= 0) { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + } + return pullRequests[0]; + } else { + const pullRequests = await this.getPullRequests(branch, PullRequestState.Open, pr => + withCmsLabel(pr, this.cmsLabelPrefix), + ); + if (pullRequests.length <= 0) { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + } + return pullRequests[0]; + } + } + + async getOpenAuthoringPullRequest(branch: string, pullRequests: GitHubPull[]) { + // we can't use labels when using open authoring + // since the contributor doesn't have access to set labels + // a branch without a pr (or a closed pr) means a 'draft' entry + // a branch with an opened pr means a 'pending_review' entry + const data = await this.getBranch(branch).catch(() => { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + }); + // since we get all (open and closed) pull requests by branch name, make sure to filter by head sha + const pullRequest = pullRequests.filter(pr => pr.head.sha === data.commit.sha)[0]; + // if no pull request is found for the branch we return a mocked one + if (!pullRequest) { + try { + return { + head: { sha: data.commit.sha }, + number: MOCK_PULL_REQUEST, + labels: [{ name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix) }], + state: PullRequestState.Open, + } as GitHubPull; + } catch (e) { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + } + } else { + pullRequest.labels = pullRequest.labels.filter(l => !isCMSLabel(l.name, this.cmsLabelPrefix)); + const cmsLabel = + pullRequest.state === PullRequestState.Closed + ? { name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix) } + : { name: statusToLabel(WorkflowStatus.PENDING_REVIEW, this.cmsLabelPrefix) }; + + pullRequest.labels.push(cmsLabel as PullsGetResponseLabelsItem); + return pullRequest; + } + } + + async getBranch(branch: string) { + const result: ReposGetBranchResponse = await this.request( + `${this.repoURL}/branches/${encodeURIComponent(branch)}`, + ); + return result; + } + + async retrieveUnpublishedEntryData(contentKey: string): Promise { + const { collection, slug } = this.parseContentKey(contentKey); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + const [{ files }, pullRequestAuthor] = await Promise.all([ + this.getDifferences(this.branch, pullRequest.head.sha), + this.getPullRequestAuthor(pullRequest), + ]); + const diffs = await Promise.all(files.map(file => this.diffFromFile(file))); + const label = pullRequest.labels.find(l => isCMSLabel(l.name, this.cmsLabelPrefix)) as { + name: string; + }; + const status = label + ? labelToStatus(label.name, this.cmsLabelPrefix) + : WorkflowStatus.PENDING_REVIEW; + const updatedAt = pullRequest.updated_at; + + return { + collection, + slug, + status, + diffs: diffs.map(d => ({ path: d.path, newFile: d.newFile, id: d.sha })), + updatedAt, + pullRequestAuthor, + openAuthoring: + !pullRequest.head.ref.includes(this.repo) && pullRequest.head.ref.includes(this.repoName), + }; + } + + async getDifferences(from: string, to: string) { + // retry this as sometimes GitHub returns an initial 404 on cross repo compare + const attempts = this.useOpenAuthoring ? 10 : 1; + for (let i = 1; i <= attempts; i++) { + try { + const result: ReposCompareCommitsResponse = await this.request( + `${this.originRepoURL}/compare/${from}...${to}`, + ); + return result; + } catch (e) { + if (i === attempts) { + console.warn(`Reached maximum number of attempts '${attempts}' for getDifferences`); + throw e; + } + await new Promise(resolve => setTimeout(resolve, i * 500)); + } + } + throw new APIError('Not Found', 404, API_NAME); + } + + async getPullRequestAuthor(pullRequest: GitHubPull) { + if (!pullRequest.user?.login) { + return; + } + + try { + const user: GitHubUser = await this.request(`/users/${pullRequest.user.login}`); + return user.name || user.login; + } catch { + return; + } + } + + // async since it is overridden in a child class + async diffFromFile(diff: ReposCompareCommitsResponseFilesItem): Promise { + return { + path: diff.filename, + newFile: diff.status === 'added', + sha: diff.sha, + // media files diffs don't have a patch attribute, except svg files + // renamed files don't have a patch attribute too + binary: (diff.status !== 'renamed' && !diff.patch) || diff.filename.endsWith('.svg'), + }; + } + + async publishUnpublishedEntry(collectionName: string, slug: string) { + const contentKey = this.generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + + const pullRequest = await this.getBranchPullRequest(branch); + await this.mergePR(pullRequest); + await this.deleteBranch(branch); + } + + async mergePR(pullrequest: GitHubPull) { + console.info('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold'); + try { + const result: PullsMergeResponse = await this.request( + `${this.originRepoURL}/pulls/${pullrequest.number}/merge`, + { + method: 'PUT', + body: JSON.stringify({ + commit_message: MERGE_COMMIT_MESSAGE, + sha: pullrequest.head.sha, + merge_method: this.mergeMethod, + }), + }, + ); + return result; + } catch (error) { + if (error instanceof APIError && error.status === 405) { + return this.forceMergePR(pullrequest); + } else { + throw error; + } + } + } + + async forceMergePR(pullRequest: GitHubPull) { + const result = await this.getDifferences(pullRequest.base.sha, pullRequest.head.sha); + const files = getTreeFiles(result.files as GitHubCompareFiles); + + let commitMessage = 'Automatically generated. Merged on Static CMS\n\nForce merge of:'; + files.forEach(file => { + commitMessage += `\n* "${file.path}"`; + }); + console.info( + '%c Automatic merge not possible - Forcing merge.', + 'line-height: 30px;text-align: center;font-weight: bold', + ); + return this.getDefaultBranch() + .then(branchData => this.updateTree(branchData.commit.sha, files)) + .then(changeTree => this.commit(commitMessage, changeTree)) + .then(response => this.patchBranch(this.branch, response.sha)); + } + + async deleteUnpublishedEntry(collectionName: string, slug: string) { + const contentKey = this.generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + + const pullRequest = await this.getBranchPullRequest(branch); + if (pullRequest.number !== MOCK_PULL_REQUEST) { + await this.closePR(pullRequest.number); + } + await this.deleteBranch(branch); + } + + async closePR(number: number) { + console.info('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold'); + const result: PullsUpdateBranchResponse = await this.request( + `${this.originRepoURL}/pulls/${number}`, + { + method: 'PATCH', + body: JSON.stringify({ + state: PullRequestState.Closed, + }), + }, + ); + return result; + } + + async updatePullRequestLabels(number: number, labels: string[]) { + await this.request(`${this.repoURL}/issues/${number}/labels`, { + method: 'PUT', + body: JSON.stringify({ labels }), + }); + } + + async setPullRequestStatus(pullRequest: GitHubPull, newStatus: WorkflowStatus) { + const labels = [ + ...pullRequest.labels + .filter(label => !isCMSLabel(label.name, this.cmsLabelPrefix)) + .map(l => l.name), + statusToLabel(newStatus, this.cmsLabelPrefix), + ]; + await this.updatePullRequestLabels(pullRequest.number, labels); + } + + async createPR(title: string, head: string) { + const result: PullsCreateResponse = await this.request(`${this.originRepoURL}/pulls`, { + method: 'POST', + body: JSON.stringify({ + title, + body: DEFAULT_PR_BODY, + head: await this.getHeadReference(head), + base: this.branch, + }), + }); + + return result; + } + + async openPR(number: number) { + console.info('%c Re-opening PR', 'line-height: 30px;text-align: center;font-weight: bold'); + const result: PullsUpdateBranchResponse = await this.request( + `${this.originRepoURL}/pulls/${number}`, + { + method: 'PATCH', + body: JSON.stringify({ + state: PullRequestState.Open, + }), + }, + ); + return result; + } + + async updateUnpublishedEntryStatus( + collectionName: string, + slug: string, + newStatus: WorkflowStatus, + ) { + const contentKey = this.generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + + if (!this.useOpenAuthoring) { + await this.setPullRequestStatus(pullRequest, newStatus); + } else { + if (status === 'pending_publish') { + throw new Error('Open Authoring entries may not be set to the status "pending_publish".'); + } + + if (pullRequest.number !== MOCK_PULL_REQUEST) { + const { state } = pullRequest; + if (state === PullRequestState.Open && newStatus === 'draft') { + await this.closePR(pullRequest.number); + } + if (state === PullRequestState.Closed && newStatus === 'pending_review') { + await this.openPR(pullRequest.number); + } + } else if (newStatus === 'pending_review') { + const branch = branchFromContentKey(contentKey); + // get the first commit message as the pr title + const diff = await this.getDifferences(this.branch, await this.getHeadReference(branch)); + const title = diff.commits[0]?.commit?.message || API.DEFAULT_COMMIT_MESSAGE; + await this.createPR(title, branch); + } + } + } + + /** + * Retrieve statuses for a given SHA. Unrelated to the editorial workflow + * concept of entry "status". Useful for things like deploy preview links. + */ + async getStatuses(collectionName: string, slug: string) { + const contentKey = this.generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + const sha = pullRequest.head.sha; + const resp: { statuses: GitHubCommitStatus[] } = await this.request( + `${this.originRepoURL}/commits/${sha}/status`, + ); + return resp.statuses.map(s => ({ + context: s.context, + target_url: s.target_url, + state: + s.state === GitHubCommitStatusState.Success ? PreviewState.Success : PreviewState.Other, + })); + } + + async editorialWorkflowGit( + files: TreeFile[], + slug: string, + mediaFilesList: MediaFile[], + options: PersistOptions, + ) { + const contentKey = this.generateContentKey(options.collectionName as string, slug); + const branch = branchFromContentKey(contentKey); + const unpublished = options.unpublished || false; + if (!unpublished) { + const branchData = await this.getDefaultBranch(); + const changeTree = await this.updateTree(branchData.commit.sha, files); + const commitResponse = await this.commit(options.commitMessage, changeTree); + + if (this.useOpenAuthoring) { + await this.createBranch(branch, commitResponse.sha); + } else { + const pr = await this.createBranchAndPullRequest( + branch, + commitResponse.sha, + options.commitMessage, + ); + await this.setPullRequestStatus(pr, options.status || this.initialWorkflowStatus); + } + } else { + // Entry is already on editorial review workflow - commit to existing branch + const { files: diffFiles } = await this.getDifferences( + this.branch, + await this.getHeadReference(branch), + ); + + const diffs = await Promise.all(diffFiles.map(file => this.diffFromFile(file))); + // mark media files to remove + const mediaFilesToRemove: { path: string; sha: string | null }[] = []; + for (const diff of diffs.filter(d => d.binary)) { + if (!mediaFilesList.some(file => file.path === diff.path)) { + mediaFilesToRemove.push({ path: diff.path, sha: null }); + } + } + + // rebase the branch before applying new changes + const rebasedHead = await this.rebaseBranch(branch); + const treeFiles = mediaFilesToRemove.concat(files); + const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch); + const commit = await this.commit(options.commitMessage, changeTree); + + return this.patchBranch(branch, commit.sha, { force: true }); + } + } + + async backupBranch(branchName: string) { + try { + const existingBranch = await this.getBranch(branchName); + await this.createBranch( + existingBranch.name.replace( + new RegExp(`${CMS_BRANCH_PREFIX}/`), + `${CMS_BRANCH_PREFIX}_${Date.now()}/`, + ), + existingBranch.commit.sha, + ); + } catch (e) { + console.warn(e); + } + } + + async createBranch(branchName: string, sha: string) { + try { + const result = await this.createRef('heads', branchName, sha); + return result; + } catch (e) { + if (e instanceof Error) { + const message = String(e.message || ''); + if (message === 'Reference update failed') { + await throwOnConflictingBranches(branchName, name => this.getBranch(name), API_NAME); + } else if ( + message === 'Reference already exists' && + branchName.startsWith(`${CMS_BRANCH_PREFIX}/`) + ) { + try { + // this can happen if the branch wasn't deleted when the PR was merged + // we backup the existing branch just in case and patch it with the new sha + await this.backupBranch(branchName); + const result = await this.patchBranch(branchName, sha, { force: true }); + return result; + } catch (e) { + console.error(e); + } + } + } + throw e; + } + } + + async createBranchAndPullRequest(branchName: string, sha: string, commitMessage: string) { + await this.createBranch(branchName, sha); + return this.createPR(commitMessage, branchName); + } + + /** + * Rebase an array of commits one-by-one, starting from a given base SHA + */ + async rebaseCommits(baseCommit: GitHubCompareCommit, commits: GitHubCompareCommits) { + /** + * If the parent of the first commit already matches the target base, + * return commits as is. + */ + if (commits.length === 0 || commits[0].parents[0].sha === baseCommit.sha) { + const head = last(commits) as GitHubCompareCommit; + return head; + } else { + /** + * Re-create each commit over the new base, applying each to the previous, + * changing only the parent SHA and tree for each, but retaining all other + * info, such as the author/committer data. + */ + const newHeadPromise = commits.reduce((lastCommitPromise, commit) => { + return lastCommitPromise.then(newParent => { + const parent = newParent; + const commitToRebase = commit; + return this.rebaseSingleCommit(parent, commitToRebase); + }); + }, Promise.resolve(baseCommit)); + return newHeadPromise; + } + } + + async rebaseSingleCommit(baseCommit: GitHubCompareCommit, commit: GitHubCompareCommit) { + // first get the diff between the commits + const result = await this.getDifferences(commit.parents[0].sha, commit.sha); + const files = getTreeFiles(result.files as GitHubCompareFiles); + + // only update the tree if changes were detected + if (files.length > 0) { + // create a tree with baseCommit as the base with the diff applied + const tree = await this.updateTree(baseCommit.sha, files); + const { message, author, committer } = commit.commit; + + // create a new commit from the updated tree + const newCommit = await this.createCommit( + message, + tree.sha, + [baseCommit.sha], + author, + committer, + ); + return newCommit as unknown as GitHubCompareCommit; + } else { + return commit; + } + } + + async rebaseBranch(branch: string) { + try { + // Get the diff between the default branch the published branch + const { base_commit: baseCommit, commits } = await this.getDifferences( + this.branch, + await this.getHeadReference(branch), + ); + // Rebase the branch based on the diff + const rebasedHead = await this.rebaseCommits(baseCommit, commits); + return rebasedHead; + } catch (error) { + console.error(error); + throw error; + } + } + + async getUnpublishedEntrySha(collection: string, slug: string) { + const contentKey = this.generateContentKey(collection, slug); + const branch = branchFromContentKey(contentKey); + const pullRequest = await this.getBranchPullRequest(branch); + return pullRequest.head.sha; + } } diff --git a/packages/core/src/backends/github/AuthenticationPage.css b/packages/core/src/backends/github/AuthenticationPage.css new file mode 100644 index 00000000..c382aa07 --- /dev/null +++ b/packages/core/src/backends/github/AuthenticationPage.css @@ -0,0 +1,26 @@ +.CMS_Github_AuthenticationPage_fork-approve-container { + @apply flex + flex-col + flex-nowrap + justify-around + flex-grow-[0.2]; +} + +.CMS_Github_AuthenticationPage_fork-text { + @apply max-w-[600px] + w-full + px-2 + my-2 + justify-center + items-center + text-center; +} + +.CMS_Github_AuthenticationPage_fork-buttons { + @apply flex + flex-col + flex-nowrap + justify-around + items-center + gap-2; +} diff --git a/packages/core/src/backends/github/AuthenticationPage.tsx b/packages/core/src/backends/github/AuthenticationPage.tsx index 3484ad58..0697a4d9 100644 --- a/packages/core/src/backends/github/AuthenticationPage.tsx +++ b/packages/core/src/backends/github/AuthenticationPage.tsx @@ -1,22 +1,83 @@ import { Github as GithubIcon } from '@styled-icons/simple-icons/Github'; import React, { useCallback, useState } from 'react'; +import Button from '@staticcms/core/components/common/button/Button'; import Login from '@staticcms/core/components/login/Login'; import { NetlifyAuthenticator } from '@staticcms/core/lib/auth'; +import useCurrentBackend from '@staticcms/core/lib/hooks/useCurrentBackend'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; +import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; -import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; -import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps, User } from '@staticcms/core'; +import type { FC, MouseEvent } from 'react'; +import type GitHub from './implementation'; -const GitHubAuthenticationPage = ({ +import './AuthenticationPage.css'; + +const classes = generateClassNames('Github_AuthenticationPage', [ + 'fork-approve-container', + 'fork-text', + 'fork-buttons', +]); + +const GitHubAuthenticationPage: FC = ({ inProgress = false, config, base_url, siteId, authEndpoint, onLogin, - t, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const [loginError, setLoginError] = useState(null); + const [forkState, setForkState] = useState<{ + requestingFork?: boolean; + findingFork?: boolean; + approveFork?: () => void; + }>(); + + const { requestingFork = false, findingFork = false, approveFork } = forkState ?? {}; + + const backend = useCurrentBackend(); + + const getPermissionToFork = useCallback(() => { + return new Promise(resolve => { + setForkState({ + findingFork: true, + requestingFork: true, + approveFork: () => { + setForkState({ + findingFork: true, + requestingFork: false, + }); + resolve(true); + }, + }); + }); + }, []); + + const loginWithOpenAuthoring = useCallback( + (userData: User): Promise => { + if (backend?.backendName !== 'github') { + return Promise.resolve(); + } + + const githubBackend = backend.implementation as GitHub; + + setForkState({ findingFork: true }); + return githubBackend + .authenticateWithFork({ userData, getPermissionToFork }) + .then(() => { + setForkState({ findingFork: false }); + }) + .catch(() => { + setForkState({ findingFork: false }); + console.error('Cannot create fork'); + }); + }, + [backend?.backendName, backend?.implementation, getPermissionToFork], + ); const handleLogin = useCallback( (e: MouseEvent) => { @@ -28,18 +89,25 @@ const GitHubAuthenticationPage = ({ }; const auth = new NetlifyAuthenticator(cfg); - const { auth_scope: authScope = '' } = config.backend; + const { auth_scope: authScope = '', open_authoring: openAuthoringEnabled } = config.backend; - const scope = authScope || 'repo'; + const scope = authScope || (openAuthoringEnabled ? 'public_repo' : 'repo'); auth.authenticate({ provider: 'github', scope }, (err, data) => { if (err) { setLoginError(err.toString()); - } else if (data) { + return; + } + + if (data) { + if (openAuthoringEnabled) { + return loginWithOpenAuthoring(data).then(() => onLogin(data)); + } + onLogin(data); } }); }, - [authEndpoint, base_url, config.backend, onLogin, siteId], + [authEndpoint, base_url, config.backend, loginWithOpenAuthoring, onLogin, siteId], ); return ( @@ -47,8 +115,18 @@ const GitHubAuthenticationPage = ({ login={handleLogin} label={t('auth.loginWithGitHub')} icon={GithubIcon} - inProgress={inProgress} + inProgress={inProgress || findingFork || requestingFork} error={loginError} + buttonContent={ + requestingFork ? ( +
    +

    {t('workflow.openAuthoring.forkRequired')}

    +
    + +
    +
    + ) : null + } /> ); }; diff --git a/packages/core/src/backends/github/__tests__/API.spec.ts b/packages/core/src/backends/github/__tests__/API.spec.ts index 6e751051..5b8d8740 100644 --- a/packages/core/src/backends/github/__tests__/API.spec.ts +++ b/packages/core/src/backends/github/__tests__/API.spec.ts @@ -1,6 +1,7 @@ import { Base64 } from 'js-base64'; import API from '../API'; +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import type { Options } from '../API'; @@ -24,7 +25,13 @@ describe('github API', () => { describe('updateTree', () => { it('should create tree with nested paths', async () => { - const api = new API({ branch: 'master', repo: 'owner/repo' }); + const api = new API({ + branch: 'master', + repo: 'owner/repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); api.createTree = jest.fn().mockImplementation(() => Promise.resolve({ sha: 'newTreeSha' })); @@ -69,7 +76,14 @@ describe('github API', () => { }); it('should fetch url with authorization header', async () => { - const api = new API({ branch: 'gh-pages', repo: 'my-repo', token: 'token' }); + const api = new API({ + branch: 'gh-pages', + repo: 'my-repo', + token: 'token', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); fetch.mockResolvedValue({ text: jest.fn().mockResolvedValue('some response'), @@ -90,7 +104,14 @@ describe('github API', () => { }); it('should throw error on not ok response', async () => { - const api = new API({ branch: 'gh-pages', repo: 'my-repo', token: 'token' }); + const api = new API({ + branch: 'gh-pages', + repo: 'my-repo', + token: 'token', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); fetch.mockResolvedValue({ text: jest.fn().mockResolvedValue({ message: 'some error' }), @@ -110,7 +131,14 @@ describe('github API', () => { }); it('should allow overriding requestHeaders to return a promise ', async () => { - const api = new API({ branch: 'gh-pages', repo: 'my-repo', token: 'token' }); + const api = new API({ + branch: 'gh-pages', + repo: 'my-repo', + token: 'token', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); api.requestHeaders = jest.fn().mockResolvedValue({ Authorization: 'promise-token', @@ -138,7 +166,13 @@ describe('github API', () => { describe('persistFiles', () => { it('should update tree, commit and patch branch when useWorkflow is false', async () => { - const api = new API({ branch: 'master', repo: 'owner/repo' }); + const api = new API({ + branch: 'master', + repo: 'owner/repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); const responses = { // upload the file @@ -226,6 +260,7 @@ describe('github API', () => { { body: JSON.stringify({ sha: 'commit-sha', + force: false, }), method: 'PATCH', }, @@ -235,7 +270,13 @@ describe('github API', () => { describe('listFiles', () => { it('should get files by depth', async () => { - const api = new API({ branch: 'master', repo: 'owner/repo' }); + const api = new API({ + branch: 'master', + repo: 'owner/repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); const tree = [ { @@ -315,7 +356,13 @@ describe('github API', () => { }); }); it('should get files and folders', async () => { - const api = new API({ branch: 'master', repo: 'owner/repo' }); + const api = new API({ + branch: 'master', + repo: 'owner/repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); const tree = [ { diff --git a/packages/core/src/backends/github/__tests__/implementation.spec.ts b/packages/core/src/backends/github/__tests__/implementation.spec.ts index 81cb6a5a..70e3cd66 100644 --- a/packages/core/src/backends/github/__tests__/implementation.spec.ts +++ b/packages/core/src/backends/github/__tests__/implementation.spec.ts @@ -1,9 +1,9 @@ import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util/Cursor'; import GitHubImplementation from '../implementation'; -import type { Config, UnknownField } from '@staticcms/core'; -import type API from '../API'; +import type { ConfigWithDefaults, UnknownField } from '@staticcms/core'; import type { AssetProxy } from '@staticcms/core/valueObjects'; +import type API from '../API'; // eslint-disable-next-line @typescript-eslint/no-explicit-any declare const global: any; @@ -14,7 +14,7 @@ describe('github backend implementation', () => { repo: 'owner/repo', api_root: 'https://api.github.com', }, - } as Config; + } as ConfigWithDefaults; const createObjectURL = jest.fn(); global.URL = { @@ -100,6 +100,44 @@ describe('github backend implementation', () => { }); }); + describe('unpublishedEntry', () => { + const generateContentKey = jest.fn(); + const retrieveUnpublishedEntryData = jest.fn(); + + const mockAPI = { + generateContentKey, + retrieveUnpublishedEntryData, + } as unknown as API; + + it('should return unpublished entry data', async () => { + const gitHubImplementation = new GitHubImplementation(config); + gitHubImplementation.api = mockAPI; + + generateContentKey.mockReturnValue('contentKey'); + + const data = { + collection: 'collection', + slug: 'slug', + status: 'draft', + diffs: [], + updatedAt: 'updatedAt', + }; + retrieveUnpublishedEntryData.mockResolvedValue(data); + + const collection = 'posts'; + const slug = 'slug'; + await expect(gitHubImplementation.unpublishedEntry({ collection, slug })).resolves.toEqual( + data, + ); + + expect(generateContentKey).toHaveBeenCalledTimes(1); + expect(generateContentKey).toHaveBeenCalledWith('posts', 'slug'); + + expect(retrieveUnpublishedEntryData).toHaveBeenCalledTimes(1); + expect(retrieveUnpublishedEntryData).toHaveBeenCalledWith('contentKey'); + }); + }); + describe('entriesByFolder', () => { const listFiles = jest.fn(); const readFile = jest.fn(); diff --git a/packages/core/src/backends/github/implementation.tsx b/packages/core/src/backends/github/implementation.tsx index 4cb5a825..65dce4ec 100644 --- a/packages/core/src/backends/github/implementation.tsx +++ b/packages/core/src/backends/github/implementation.tsx @@ -2,6 +2,7 @@ import { stripIndent } from 'common-tags'; import trimStart from 'lodash/trimStart'; import semaphore from 'semaphore'; +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import { asyncLock, basename, @@ -17,19 +18,24 @@ import { runWithLock, unsentRequest, } from '@staticcms/core/lib/util'; +import { getPreviewStatus } from '@staticcms/core/lib/util/API'; +import { branchFromContentKey, contentKeyFromBranch } from '@staticcms/core/lib/util/APIUtils'; +import { unpublishedEntries } from '@staticcms/core/lib/util/implementation'; import API, { API_NAME } from './API'; import AuthenticationPage from './AuthenticationPage'; import type { BackendClass, BackendEntry, - Config, + ConfigWithDefaults, Credentials, DisplayURL, ImplementationFile, PersistOptions, + UnpublishedEntry, + UnpublishedEntryMediaFile, User, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; import type { AsyncLock } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; import type { Semaphore } from 'semaphore'; @@ -56,23 +62,32 @@ export default class GitHub implements BackendClass { options: { proxied: boolean; API: API | null; + useWorkflow?: boolean; + initialWorkflowStatus: WorkflowStatus; }; originRepo: string; repo?: string; + openAuthoringEnabled: boolean; + useOpenAuthoring?: boolean; + alwaysForkEnabled: boolean; branch: string; apiRoot: string; mediaFolder?: string; + previewContext: string; token: string | null; + squashMerges: boolean; + cmsLabelPrefix: string; _currentUserPromise?: Promise; _userIsOriginMaintainerPromises?: { [key: string]: Promise; }; _mediaDisplayURLSem?: Semaphore; - constructor(config: Config, options = {}) { + constructor(config: ConfigWithDefaults, options = {}) { this.options = { proxied: false, API: null, + initialWorkflowStatus: WorkflowStatus.DRAFT, ...options, }; @@ -84,18 +99,28 @@ export default class GitHub implements BackendClass { } this.api = this.options.API || null; - this.repo = this.originRepo = config.backend.repo || ''; + this.openAuthoringEnabled = config.backend.open_authoring || false; + if (this.openAuthoringEnabled) { + if (!this.options.useWorkflow) { + throw new Error( + 'backend.open_authoring is true but publish_mode is not set to editorial_workflow.', + ); + } + this.originRepo = config.backend.repo || ''; + } else { + this.repo = this.originRepo = config.backend.repo || ''; + } + this.alwaysForkEnabled = config.backend.always_fork || false; this.branch = config.backend.branch?.trim() || 'main'; this.apiRoot = config.backend.api_root || 'https://api.github.com'; this.token = ''; + this.squashMerges = config.backend.squash_merges || false; + this.cmsLabelPrefix = config.backend.cms_label_prefix || ''; this.mediaFolder = config.media_folder; + this.previewContext = config.backend.preview_context || ''; this.lock = asyncLock(); } - isGitBackend() { - return true; - } - async status() { const api = await fetch(GITHUB_STATUS_ENDPOINT) .then(res => res.json()) @@ -134,7 +159,35 @@ export default class GitHub implements BackendClass { } restoreUser(user: User) { - return this.authenticate(user); + return this.openAuthoringEnabled + ? this.authenticateWithFork({ userData: user, getPermissionToFork: () => true }).then(() => + this.authenticate(user), + ) + : this.authenticate(user); + } + + async pollUntilForkExists({ repo, token }: { repo: string; token: string }) { + const pollDelay = 250; // milliseconds + let repoExists = false; + while (!repoExists) { + repoExists = await fetch(`${this.apiRoot}/repos/${repo}`, { + headers: { Authorization: `token ${token}` }, + }) + .then(() => true) + .catch(err => { + if (err && err.status === 404) { + console.info('This 404 was expected and handled appropriately.'); + return false; + } else { + return Promise.reject(err); + } + }); + // wait between polls + if (!repoExists) { + await new Promise(resolve => setTimeout(resolve, pollDelay)); + } + } + return Promise.resolve(); } async currentUser({ token }: { token: string }) { @@ -172,6 +225,65 @@ export default class GitHub implements BackendClass { return this._userIsOriginMaintainerPromises[username]; } + async forkExists({ token }: { token: string }) { + try { + const currentUser = await this.currentUser({ token }); + const repoName = this.originRepo.split('/')[1]; + const repo = await fetch(`${this.apiRoot}/repos/${currentUser.login}/${repoName}`, { + method: 'GET', + headers: { + Authorization: `token ${token}`, + }, + }).then(res => res.json()); + + // https://developer.github.com/v3/repos/#get + // The parent and source objects are present when the repository is a fork. + // parent is the repository this repository was forked from, source is the ultimate source for the network. + const forkExists = + repo.fork === true && + repo.parent && + repo.parent.full_name.toLowerCase() === this.originRepo.toLowerCase(); + return forkExists; + } catch { + return false; + } + } + + async authenticateWithFork({ + userData, + getPermissionToFork, + }: { + userData: User; + getPermissionToFork: () => Promise | boolean; + }) { + if (!this.openAuthoringEnabled) { + throw new Error('Cannot authenticate with fork; Open Authoring is turned off.'); + } + const token = userData.token as string; + + // Origin maintainers should be able to use the CMS normally. If alwaysFork + // is enabled we always fork (and avoid the origin maintainer check) + if (!this.alwaysForkEnabled && (await this.userIsOriginMaintainer({ token }))) { + this.repo = this.originRepo; + this.useOpenAuthoring = false; + return Promise.resolve(); + } + + if (!(await this.forkExists({ token }))) { + await getPermissionToFork(); + } + + const fork = await fetch(`${this.apiRoot}/repos/${this.originRepo}/forks`, { + method: 'POST', + headers: { + Authorization: `token ${token}`, + }, + }).then(res => res.json()); + this.useOpenAuthoring = true; + this.repo = fork.full_name; + return this.pollUntilForkExists({ repo: fork.full_name, token }); + } + async authenticate(state: Credentials) { this.token = state.token as string; const apiCtor = API; @@ -181,6 +293,11 @@ export default class GitHub implements BackendClass { repo: this.repo, originRepo: this.originRepo, apiRoot: this.apiRoot, + squashMerges: this.squashMerges, + cmsLabelPrefix: this.cmsLabelPrefix, + useOpenAuthoring: this.useOpenAuthoring, + openAuthoringEnabled: this.openAuthoringEnabled, + initialWorkflowStatus: this.options.initialWorkflowStatus, }); const user = await this.api!.user(); const isCollab = await this.api!.hasWriteAccess().catch(error => { @@ -203,7 +320,7 @@ export default class GitHub implements BackendClass { } // Authorized user - return { ...user, token: state.token as string }; + return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring }; } logout() { @@ -299,7 +416,7 @@ export default class GitHub implements BackendClass { } entriesByFiles(files: ImplementationFile[]) { - const repoURL = this.api!.repoURL; + const repoURL = this.useOpenAuthoring ? this.api!.originRepoURL : this.api!.repoURL; const readFile = (path: string, id: string | null | undefined) => this.api!.readFile(path, id, { repoURL }).catch(() => '') as Promise; @@ -435,4 +552,117 @@ export default class GitHub implements BackendClass { cursor: result.cursor, }; } + + /** + * Editorial Workflow + */ + async unpublishedEntries() { + const listEntriesKeys = () => + this.api!.listUnpublishedBranches().then(branches => + branches.map(branch => contentKeyFromBranch(branch)), + ); + + const ids = await unpublishedEntries(listEntriesKeys); + return ids; + } + + async unpublishedEntry({ + id, + collection, + slug, + }: { + id?: string; + collection?: string; + slug?: string; + }): Promise { + if (id) { + return this.api!.retrieveUnpublishedEntryData(id); + } else if (collection && slug) { + const entryId = this.api!.generateContentKey(collection, slug); + return this.api!.retrieveUnpublishedEntryData(entryId); + } else { + throw new Error('Missing unpublished entry id or collection and slug'); + } + } + + getBranch(collection: string, slug: string) { + const contentKey = this.api!.generateContentKey(collection, slug); + const branch = branchFromContentKey(contentKey); + return branch; + } + + async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) { + const branch = this.getBranch(collection, slug); + const data = (await this.api!.readFile(path, id, { branch })) as string; + return data; + } + + async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) { + const branch = this.getBranch(collection, slug); + const mediaFile = await this.loadMediaFile(branch, { path, id }); + return mediaFile; + } + + async getDeployPreview(collection: string, slug: string) { + try { + const statuses = await this.api!.getStatuses(collection, slug); + const deployStatus = getPreviewStatus(statuses, this.previewContext); + + if (deployStatus) { + const { target_url: url, state } = deployStatus; + return { url, status: state }; + } else { + return null; + } + } catch (e) { + return null; + } + } + + updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) { + // updateUnpublishedEntryStatus is a transactional operation + return runWithLock( + this.lock, + () => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus), + 'Failed to acquire update entry status lock', + ); + } + + deleteUnpublishedEntry(collection: string, slug: string) { + // deleteUnpublishedEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.deleteUnpublishedEntry(collection, slug), + 'Failed to acquire delete entry lock', + ); + } + + publishUnpublishedEntry(collection: string, slug: string) { + // publishUnpublishedEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.publishUnpublishedEntry(collection, slug), + 'Failed to acquire publish entry lock', + ); + } + + async loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) { + const readFile = ( + path: string, + id: string | null | undefined, + { parseText }: { parseText: boolean }, + ) => this.api!.readFile(path, id, { branch, parseText }); + + const blob = await getMediaAsBlob(file.path, file.id, readFile); + const name = basename(file.path); + const fileObj = blobToFileObj(name, blob); + return { + id: file.id, + displayURL: URL.createObjectURL(fileObj), + path: file.path, + name, + size: fileObj.size, + file: fileObj, + }; + } } diff --git a/packages/core/src/backends/github/types.ts b/packages/core/src/backends/github/types.ts index 0e255c1c..debc23a6 100644 --- a/packages/core/src/backends/github/types.ts +++ b/packages/core/src/backends/github/types.ts @@ -688,3 +688,1348 @@ export type GitCreateTreeResponse = { tree: Array; url: string; }; + +/** + * Editorial Workflow + */ +export type GitListMatchingRefsResponseItem = { + node_id: string; + object: GitListMatchingRefsResponseItemObject; + ref: string; + url: string; +}; + +export type GitListMatchingRefsResponseItemObject = { + sha: string; + type: string; + url: string; +}; + +export type GitListMatchingRefsResponse = Array; + +export type GitHubCompareFile = ReposCompareCommitsResponseFilesItem & { + previous_filename?: string; +}; + +export type GitHubCompareFiles = GitHubCompareFile[]; + +export type ReposCompareCommitsResponseFilesItem = { + additions: number; + blob_url: string; + changes: number; + contents_url: string; + deletions: number; + filename: string; + patch: string; + raw_url: string; + sha: string; + status: string; +}; +type PullsListResponseItemUser = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsListResponseItemRequestedTeamsItem = { + description: string; + html_url: string; + id: number; + members_url: string; + name: string; + node_id: string; + parent: null; + permission: string; + privacy: string; + repositories_url: string; + slug: string; + url: string; +}; +type PullsListResponseItemRequestedReviewersItem = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsListResponseItemMilestoneCreator = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsListResponseItemMilestone = { + closed_at: string; + closed_issues: number; + created_at: string; + creator: PullsListResponseItemMilestoneCreator; + description: string; + due_on: string; + html_url: string; + id: number; + labels_url: string; + node_id: string; + number: number; + open_issues: number; + state: string; + title: string; + updated_at: string; + url: string; +}; +type PullsListResponseItemLabelsItem = { + color: string; + default: boolean; + description: string; + id: number; + name: string; + node_id: string; + url: string; +}; +type PullsListResponseItemHeadUser = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsListResponseItemHeadRepoPermissions = { + admin: boolean; + pull: boolean; + push: boolean; +}; +type PullsListResponseItemHeadRepoOwner = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsListResponseItemHeadRepo = { + allow_merge_commit: boolean; + allow_rebase_merge: boolean; + allow_squash_merge: boolean; + archive_url: string; + archived: boolean; + assignees_url: string; + blobs_url: string; + branches_url: string; + clone_url: string; + collaborators_url: string; + comments_url: string; + commits_url: string; + compare_url: string; + contents_url: string; + contributors_url: string; + created_at: string; + default_branch: string; + deployments_url: string; + description: string; + disabled: boolean; + downloads_url: string; + events_url: string; + fork: boolean; + forks_count: number; + forks_url: string; + full_name: string; + git_commits_url: string; + git_refs_url: string; + git_tags_url: string; + git_url: string; + has_downloads: boolean; + has_issues: boolean; + has_pages: boolean; + has_projects: boolean; + has_wiki: boolean; + homepage: string; + hooks_url: string; + html_url: string; + id: number; + is_template: boolean; + issue_comment_url: string; + issue_events_url: string; + issues_url: string; + keys_url: string; + labels_url: string; + language: null; + languages_url: string; + merges_url: string; + milestones_url: string; + mirror_url: string; + name: string; + network_count: number; + node_id: string; + notifications_url: string; + open_issues_count: number; + owner: PullsListResponseItemHeadRepoOwner; + permissions: PullsListResponseItemHeadRepoPermissions; + private: boolean; + pulls_url: string; + pushed_at: string; + releases_url: string; + size: number; + ssh_url: string; + stargazers_count: number; + stargazers_url: string; + statuses_url: string; + subscribers_count: number; + subscribers_url: string; + subscription_url: string; + svn_url: string; + tags_url: string; + teams_url: string; + temp_clone_token: string; + template_repository: null; + topics: Array; + trees_url: string; + updated_at: string; + url: string; + visibility: string; + watchers_count: number; +}; +type PullsListResponseItemHead = { + label: string; + ref: string; + repo: PullsListResponseItemHeadRepo; + sha: string; + user: PullsListResponseItemHeadUser; +}; +type PullsListResponseItemBaseUser = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsListResponseItemBaseRepoPermissions = { + admin: boolean; + pull: boolean; + push: boolean; +}; +type PullsListResponseItemBaseRepoOwner = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsListResponseItemBaseRepo = { + allow_merge_commit: boolean; + allow_rebase_merge: boolean; + allow_squash_merge: boolean; + archive_url: string; + archived: boolean; + assignees_url: string; + blobs_url: string; + branches_url: string; + clone_url: string; + collaborators_url: string; + comments_url: string; + commits_url: string; + compare_url: string; + contents_url: string; + contributors_url: string; + created_at: string; + default_branch: string; + deployments_url: string; + description: string; + disabled: boolean; + downloads_url: string; + events_url: string; + fork: boolean; + forks_count: number; + forks_url: string; + full_name: string; + git_commits_url: string; + git_refs_url: string; + git_tags_url: string; + git_url: string; + has_downloads: boolean; + has_issues: boolean; + has_pages: boolean; + has_projects: boolean; + has_wiki: boolean; + homepage: string; + hooks_url: string; + html_url: string; + id: number; + is_template: boolean; + issue_comment_url: string; + issue_events_url: string; + issues_url: string; + keys_url: string; + labels_url: string; + language: null; + languages_url: string; + merges_url: string; + milestones_url: string; + mirror_url: string; + name: string; + network_count: number; + node_id: string; + notifications_url: string; + open_issues_count: number; + owner: PullsListResponseItemBaseRepoOwner; + permissions: PullsListResponseItemBaseRepoPermissions; + private: boolean; + pulls_url: string; + pushed_at: string; + releases_url: string; + size: number; + ssh_url: string; + stargazers_count: number; + stargazers_url: string; + statuses_url: string; + subscribers_count: number; + subscribers_url: string; + subscription_url: string; + svn_url: string; + tags_url: string; + teams_url: string; + temp_clone_token: string; + template_repository: null; + topics: Array; + trees_url: string; + updated_at: string; + url: string; + visibility: string; + watchers_count: number; +}; +type PullsListResponseItemBase = { + label: string; + ref: string; + repo: PullsListResponseItemBaseRepo; + sha: string; + user: PullsListResponseItemBaseUser; +}; +type PullsListResponseItemAssigneesItem = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsListResponseItemAssignee = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsListResponseItemLinksStatuses = { href: string }; +type PullsListResponseItemLinksSelf = { href: string }; +type PullsListResponseItemLinksReviewComments = { href: string }; +type PullsListResponseItemLinksReviewComment = { href: string }; +type PullsListResponseItemLinksIssue = { href: string }; +type PullsListResponseItemLinksHtml = { href: string }; +type PullsListResponseItemLinksCommits = { href: string }; +type PullsListResponseItemLinksComments = { href: string }; +type PullsListResponseItemLinks = { + comments: PullsListResponseItemLinksComments; + commits: PullsListResponseItemLinksCommits; + html: PullsListResponseItemLinksHtml; + issue: PullsListResponseItemLinksIssue; + review_comment: PullsListResponseItemLinksReviewComment; + review_comments: PullsListResponseItemLinksReviewComments; + self: PullsListResponseItemLinksSelf; + statuses: PullsListResponseItemLinksStatuses; +}; + +export type GitHubPull = { + _links: PullsListResponseItemLinks; + active_lock_reason: string; + assignee: PullsListResponseItemAssignee; + assignees: Array; + author_association: string; + base: PullsListResponseItemBase; + body: string; + closed_at: string; + comments_url: string; + commits_url: string; + created_at: string; + diff_url: string; + draft: boolean; + head: PullsListResponseItemHead; + html_url: string; + id: number; + issue_url: string; + labels: Array; + locked: boolean; + merge_commit_sha: string; + merged_at: string; + milestone: PullsListResponseItemMilestone; + node_id: string; + number: number; + patch_url: string; + requested_reviewers: Array; + requested_teams: Array; + review_comment_url: string; + review_comments_url: string; + state: string; + statuses_url: string; + title: string; + updated_at: string; + url: string; + user: PullsListResponseItemUser; +}; + +export enum PullRequestState { + Open = 'open', + Closed = 'closed', + All = 'all', +} + +export type PullsListResponse = Array; + +export type PullsGetResponseLabelsItem = { + color: string; + default: boolean; + description: string; + id: number; + name: string; + node_id: string; + url: string; +}; + +type ReposCompareCommitsResponseMergeBaseCommitParentsItem = { + sha: string; + url: string; +}; +type ReposCompareCommitsResponseMergeBaseCommitCommitter = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type ReposCompareCommitsResponseMergeBaseCommitCommitVerification = { + payload: null; + reason: string; + signature: null; + verified: boolean; +}; +type ReposCompareCommitsResponseMergeBaseCommitCommitTree = { + sha: string; + url: string; +}; +type ReposCompareCommitsResponseMergeBaseCommitCommitCommitter = { + date: string; + email: string; + name: string; +}; +type ReposCompareCommitsResponseMergeBaseCommitCommitAuthor = { + date: string; + email: string; + name: string; +}; +type ReposCompareCommitsResponseMergeBaseCommitCommit = { + author: ReposCompareCommitsResponseMergeBaseCommitCommitAuthor; + comment_count: number; + committer: ReposCompareCommitsResponseMergeBaseCommitCommitCommitter; + message: string; + tree: ReposCompareCommitsResponseMergeBaseCommitCommitTree; + url: string; + verification: ReposCompareCommitsResponseMergeBaseCommitCommitVerification; +}; +type ReposCompareCommitsResponseMergeBaseCommitAuthor = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type ReposCompareCommitsResponseMergeBaseCommit = { + author: ReposCompareCommitsResponseMergeBaseCommitAuthor; + comments_url: string; + commit: ReposCompareCommitsResponseMergeBaseCommitCommit; + committer: ReposCompareCommitsResponseMergeBaseCommitCommitter; + html_url: string; + node_id: string; + parents: Array; + sha: string; + url: string; +}; +type ReposCompareCommitsResponseCommitsItemParentsItem = { + sha: string; + url: string; +}; +type ReposCompareCommitsResponseCommitsItemCommitter = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type ReposCompareCommitsResponseCommitsItemCommitVerification = { + payload: null; + reason: string; + signature: null; + verified: boolean; +}; +type ReposCompareCommitsResponseCommitsItemCommitTree = { + sha: string; + url: string; +}; +type ReposCompareCommitsResponseCommitsItemCommitCommitter = { + date: string; + email: string; + name: string; +}; +type ReposCompareCommitsResponseCommitsItemCommitAuthor = { + date: string; + email: string; + name: string; +}; +type ReposCompareCommitsResponseCommitsItemCommit = { + author: ReposCompareCommitsResponseCommitsItemCommitAuthor; + comment_count: number; + committer: ReposCompareCommitsResponseCommitsItemCommitCommitter; + message: string; + tree: ReposCompareCommitsResponseCommitsItemCommitTree; + url: string; + verification: ReposCompareCommitsResponseCommitsItemCommitVerification; +}; +type ReposCompareCommitsResponseCommitsItemAuthor = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type ReposCompareCommitsResponseCommitsItem = { + author: ReposCompareCommitsResponseCommitsItemAuthor; + comments_url: string; + commit: ReposCompareCommitsResponseCommitsItemCommit; + committer: ReposCompareCommitsResponseCommitsItemCommitter; + html_url: string; + node_id: string; + parents: Array; + sha: string; + url: string; +}; +type ReposCompareCommitsResponseBaseCommitParentsItem = { + sha: string; + url: string; +}; +type ReposCompareCommitsResponseBaseCommitCommitter = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type ReposCompareCommitsResponseBaseCommitCommitVerification = { + payload: null; + reason: string; + signature: null; + verified: boolean; +}; +type ReposCompareCommitsResponseBaseCommitCommitTree = { + sha: string; + url: string; +}; +type ReposCompareCommitsResponseBaseCommitCommitCommitter = { + date: string; + email: string; + name: string; +}; +type ReposCompareCommitsResponseBaseCommitCommitAuthor = { + date: string; + email: string; + name: string; +}; +type ReposCompareCommitsResponseBaseCommitCommit = { + author: ReposCompareCommitsResponseBaseCommitCommitAuthor; + comment_count: number; + committer: ReposCompareCommitsResponseBaseCommitCommitCommitter; + message: string; + tree: ReposCompareCommitsResponseBaseCommitCommitTree; + url: string; + verification: ReposCompareCommitsResponseBaseCommitCommitVerification; +}; +type ReposCompareCommitsResponseBaseCommitAuthor = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type ReposCompareCommitsResponseBaseCommit = { + author: ReposCompareCommitsResponseBaseCommitAuthor; + comments_url: string; + commit: ReposCompareCommitsResponseBaseCommitCommit; + committer: ReposCompareCommitsResponseBaseCommitCommitter; + html_url: string; + node_id: string; + parents: Array; + sha: string; + url: string; +}; +export type ReposCompareCommitsResponse = { + ahead_by: number; + base_commit: ReposCompareCommitsResponseBaseCommit; + behind_by: number; + commits: Array; + diff_url: string; + files: Array; + html_url: string; + merge_base_commit: ReposCompareCommitsResponseMergeBaseCommit; + patch_url: string; + permalink_url: string; + status: string; + total_commits: number; + url: string; +}; + +export type PullsMergeResponse = { merged: boolean; message: string; sha: string }; + +export type PullsUpdateBranchResponse = { message: string; url: string }; + +type PullsCreateResponseUser = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseRequestedTeamsItem = { + description: string; + html_url: string; + id: number; + members_url: string; + name: string; + node_id: string; + parent: null; + permission: string; + privacy: string; + repositories_url: string; + slug: string; + url: string; +}; +type PullsCreateResponseRequestedReviewersItem = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseMilestoneCreator = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseMilestone = { + closed_at: string; + closed_issues: number; + created_at: string; + creator: PullsCreateResponseMilestoneCreator; + description: string; + due_on: string; + html_url: string; + id: number; + labels_url: string; + node_id: string; + number: number; + open_issues: number; + state: string; + title: string; + updated_at: string; + url: string; +}; +type PullsCreateResponseMergedBy = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseLabelsItem = { + color: string; + default: boolean; + description: string; + id: number; + name: string; + node_id: string; + url: string; +}; +type PullsCreateResponseHeadUser = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseHeadRepoPermissions = { + admin: boolean; + pull: boolean; + push: boolean; +}; +type PullsCreateResponseHeadRepoOwner = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseHeadRepo = { + allow_merge_commit: boolean; + allow_rebase_merge: boolean; + allow_squash_merge: boolean; + archive_url: string; + archived: boolean; + assignees_url: string; + blobs_url: string; + branches_url: string; + clone_url: string; + collaborators_url: string; + comments_url: string; + commits_url: string; + compare_url: string; + contents_url: string; + contributors_url: string; + created_at: string; + default_branch: string; + deployments_url: string; + description: string; + disabled: boolean; + downloads_url: string; + events_url: string; + fork: boolean; + forks_count: number; + forks_url: string; + full_name: string; + git_commits_url: string; + git_refs_url: string; + git_tags_url: string; + git_url: string; + has_downloads: boolean; + has_issues: boolean; + has_pages: boolean; + has_projects: boolean; + has_wiki: boolean; + homepage: string; + hooks_url: string; + html_url: string; + id: number; + is_template: boolean; + issue_comment_url: string; + issue_events_url: string; + issues_url: string; + keys_url: string; + labels_url: string; + language: null; + languages_url: string; + merges_url: string; + milestones_url: string; + mirror_url: string; + name: string; + network_count: number; + node_id: string; + notifications_url: string; + open_issues_count: number; + owner: PullsCreateResponseHeadRepoOwner; + permissions: PullsCreateResponseHeadRepoPermissions; + private: boolean; + pulls_url: string; + pushed_at: string; + releases_url: string; + size: number; + ssh_url: string; + stargazers_count: number; + stargazers_url: string; + statuses_url: string; + subscribers_count: number; + subscribers_url: string; + subscription_url: string; + svn_url: string; + tags_url: string; + teams_url: string; + temp_clone_token: string; + template_repository: null; + topics: Array; + trees_url: string; + updated_at: string; + url: string; + visibility: string; + watchers_count: number; +}; +type PullsCreateResponseHead = { + label: string; + ref: string; + repo: PullsCreateResponseHeadRepo; + sha: string; + user: PullsCreateResponseHeadUser; +}; +type PullsCreateResponseBaseUser = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseBaseRepoPermissions = { + admin: boolean; + pull: boolean; + push: boolean; +}; +type PullsCreateResponseBaseRepoOwner = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseBaseRepo = { + allow_merge_commit: boolean; + allow_rebase_merge: boolean; + allow_squash_merge: boolean; + archive_url: string; + archived: boolean; + assignees_url: string; + blobs_url: string; + branches_url: string; + clone_url: string; + collaborators_url: string; + comments_url: string; + commits_url: string; + compare_url: string; + contents_url: string; + contributors_url: string; + created_at: string; + default_branch: string; + deployments_url: string; + description: string; + disabled: boolean; + downloads_url: string; + events_url: string; + fork: boolean; + forks_count: number; + forks_url: string; + full_name: string; + git_commits_url: string; + git_refs_url: string; + git_tags_url: string; + git_url: string; + has_downloads: boolean; + has_issues: boolean; + has_pages: boolean; + has_projects: boolean; + has_wiki: boolean; + homepage: string; + hooks_url: string; + html_url: string; + id: number; + is_template: boolean; + issue_comment_url: string; + issue_events_url: string; + issues_url: string; + keys_url: string; + labels_url: string; + language: null; + languages_url: string; + merges_url: string; + milestones_url: string; + mirror_url: string; + name: string; + network_count: number; + node_id: string; + notifications_url: string; + open_issues_count: number; + owner: PullsCreateResponseBaseRepoOwner; + permissions: PullsCreateResponseBaseRepoPermissions; + private: boolean; + pulls_url: string; + pushed_at: string; + releases_url: string; + size: number; + ssh_url: string; + stargazers_count: number; + stargazers_url: string; + statuses_url: string; + subscribers_count: number; + subscribers_url: string; + subscription_url: string; + svn_url: string; + tags_url: string; + teams_url: string; + temp_clone_token: string; + template_repository: null; + topics: Array; + trees_url: string; + updated_at: string; + url: string; + visibility: string; + watchers_count: number; +}; +type PullsCreateResponseBase = { + label: string; + ref: string; + repo: PullsCreateResponseBaseRepo; + sha: string; + user: PullsCreateResponseBaseUser; +}; +type PullsCreateResponseAssigneesItem = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseAssignee = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; +type PullsCreateResponseLinksStatuses = { href: string }; +type PullsCreateResponseLinksSelf = { href: string }; +type PullsCreateResponseLinksReviewComments = { href: string }; +type PullsCreateResponseLinksReviewComment = { href: string }; +type PullsCreateResponseLinksIssue = { href: string }; +type PullsCreateResponseLinksHtml = { href: string }; +type PullsCreateResponseLinksCommits = { href: string }; +type PullsCreateResponseLinksComments = { href: string }; +type PullsCreateResponseLinks = { + comments: PullsCreateResponseLinksComments; + commits: PullsCreateResponseLinksCommits; + html: PullsCreateResponseLinksHtml; + issue: PullsCreateResponseLinksIssue; + review_comment: PullsCreateResponseLinksReviewComment; + review_comments: PullsCreateResponseLinksReviewComments; + self: PullsCreateResponseLinksSelf; + statuses: PullsCreateResponseLinksStatuses; +}; +export type PullsCreateResponse = { + _links: PullsCreateResponseLinks; + active_lock_reason: string; + additions: number; + assignee: PullsCreateResponseAssignee; + assignees: Array; + author_association: string; + base: PullsCreateResponseBase; + body: string; + changed_files: number; + closed_at: string; + comments: number; + comments_url: string; + commits: number; + commits_url: string; + created_at: string; + deletions: number; + diff_url: string; + draft: boolean; + head: PullsCreateResponseHead; + html_url: string; + id: number; + issue_url: string; + labels: Array; + locked: boolean; + maintainer_can_modify: boolean; + merge_commit_sha: string; + mergeable: boolean; + mergeable_state: string; + merged: boolean; + merged_at: string; + merged_by: PullsCreateResponseMergedBy; + milestone: PullsCreateResponseMilestone; + node_id: string; + number: number; + patch_url: string; + rebaseable: boolean; + requested_reviewers: Array; + requested_teams: Array; + review_comment_url: string; + review_comments: number; + review_comments_url: string; + state: string; + statuses_url: string; + title: string; + updated_at: string; + url: string; + user: PullsCreateResponseUser; +}; + +export enum GitHubCommitStatusState { + Error = 'error', + Failure = 'failure', + Pending = 'pending', + Success = 'success', +} + +type ReposListStatusesForRefResponseItemCreator = { + avatar_url: string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: string; + html_url: string; + id: number; + login: string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; +}; + +type ReposListStatusesForRefResponseItem = { + avatar_url: string; + context: string; + created_at: string; + creator: ReposListStatusesForRefResponseItemCreator; + description: string; + id: number; + node_id: string; + state: string; + target_url: string; + updated_at: string; + url: string; +}; + +export type GitHubCommitStatus = ReposListStatusesForRefResponseItem & { + state: GitHubCommitStatusState; +}; + +export interface TreeFile { + type: 'blob' | 'tree'; + sha: string; + path: string; + raw?: string; +} + +export type GitHubCompareCommit = ReposCompareCommitsResponseCommitsItem; +export type GitHubCompareCommits = GitHubCompareCommit[]; diff --git a/packages/core/src/backends/gitlab/API.ts b/packages/core/src/backends/gitlab/API.ts index cd1216cf..b4076a6b 100644 --- a/packages/core/src/backends/gitlab/API.ts +++ b/packages/core/src/backends/gitlab/API.ts @@ -4,9 +4,11 @@ import result from 'lodash/result'; import trimStart from 'lodash/trimStart'; import { dirname } from 'path'; +import { PreviewState } from '@staticcms/core/constants/enums'; import { APIError, Cursor, + EditorialWorkflowError, localForage, parseLinkHeader, readFile, @@ -16,8 +18,20 @@ import { throwOnConflictingBranches, unsentRequest, } from '@staticcms/core/lib/util'; +import { + CMS_BRANCH_PREFIX, + DEFAULT_PR_BODY, + MERGE_COMMIT_MESSAGE, + branchFromContentKey, + generateContentKey, + isCMSLabel, + labelToStatus, + parseContentKey, + statusToLabel, +} from '@staticcms/core/lib/util/APIUtils'; -import type { DataFile, PersistOptions } from '@staticcms/core/interface'; +import type { DataFile, PersistOptions, UnpublishedEntry } from '@staticcms/core'; +import type { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import type { ApiRequest, FetchError } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; @@ -28,6 +42,9 @@ export interface Config { token?: string; branch?: string; repo?: string; + squashMerges: boolean; + initialWorkflowStatus: WorkflowStatus; + cmsLabelPrefix: string; } export interface CommitAuthor { @@ -74,6 +91,55 @@ type GitLabCommitDiff = { deleted_file: boolean; }; +enum GitLabCommitStatuses { + Pending = 'pending', + Running = 'running', + Success = 'success', + Failed = 'failed', + Canceled = 'canceled', +} + +type GitLabCommitStatus = { + status: GitLabCommitStatuses; + name: string; + author: { + username: string; + name: string; + }; + description: null; + sha: string; + ref: string; + target_url: string; +}; + +type GitLabMergeRebase = { + rebase_in_progress: boolean; + merge_error: string; +}; + +type GitLabMergeRequest = { + id: number; + iid: number; + title: string; + description: string; + state: string; + merged_by: { + name: string; + username: string; + }; + merged_at: string; + created_at: string; + updated_at: string; + target_branch: string; + source_branch: string; + author: { + name: string; + username: string; + }; + labels: string[]; + sha: string; +}; + type GitLabRepo = { shared_with_groups: { group_access_level: number }[] | null; permissions: { @@ -126,6 +192,9 @@ export default class API { repo: string; repoURL: string; commitAuthor?: CommitAuthor; + squashMerges: boolean; + initialWorkflowStatus: WorkflowStatus; + cmsLabelPrefix: string; constructor(config: Config) { this.apiRoot = config.apiRoot || 'https://gitlab.com/api/v4'; @@ -133,6 +202,9 @@ export default class API { this.branch = config.branch || 'main'; this.repo = config.repo || ''; this.repoURL = `/projects/${encodeURIComponent(this.repo)}`; + this.squashMerges = config.squashMerges; + this.initialWorkflowStatus = config.initialWorkflowStatus; + this.cmsLabelPrefix = config.cmsLabelPrefix; } withAuthorizationHeaders = (req: ApiRequest) => { @@ -444,6 +516,12 @@ export default class API { async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) { const files = [...dataFiles, ...mediaFiles]; const items = await this.getCommitItems(files, this.branch); + + if (options.useWorkflow) { + const slug = dataFiles[0].slug; + return this.editorialWorkflowGit(files, slug, options); + } + return this.uploadAndCommit(items, { commitMessage: options.commitMessage, }); @@ -465,14 +543,17 @@ export default class API { }; async getFileId(path: string, branch: string) { - const request = await this.request({ + const response = await this.request({ method: 'HEAD', url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}`, params: { ref: branch }, }); - const blobId = request.headers.get('X - Gitlab - Blob - Id') as string; - return blobId; + try { + return response.headers.get('X - Gitlab - Blob - Id') as string; + } catch { + return ''; + } } async isFileExists(path: string, branch: string) { @@ -542,4 +623,255 @@ export default class API { }); return refs.some(r => r.name === branch); } + + /** + * Editorial Workflow + */ + async listUnpublishedBranches() { + console.info( + '%c Checking for Unpublished entries', + 'line-height: 30px;text-align: center;font-weight: bold', + ); + + const mergeRequests = await this.getMergeRequests(); + const branches = mergeRequests.map(mr => mr.source_branch); + + return branches; + } + + async getMergeRequests(sourceBranch?: string) { + const mergeRequests: GitLabMergeRequest[] = await this.requestJSON({ + url: `${this.repoURL}/merge_requests`, + params: { + state: 'opened', + labels: 'Any', + per_page: '100', + target_branch: this.branch, + ...(sourceBranch ? { source_branch: sourceBranch } : {}), + }, + }); + + return mergeRequests.filter( + mr => + mr.source_branch.startsWith(CMS_BRANCH_PREFIX) && + mr.labels.some(l => isCMSLabel(l, this.cmsLabelPrefix)), + ); + } + + async getBranchMergeRequest(branch: string) { + const mergeRequests = await this.getMergeRequests(branch); + if (mergeRequests.length <= 0) { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + } + + return mergeRequests[0]; + } + + async retrieveUnpublishedEntryData(contentKey: string): Promise { + const { collection, slug } = parseContentKey(contentKey); + const branch = branchFromContentKey(contentKey); + const mergeRequest = await this.getBranchMergeRequest(branch); + const diffs = await this.getDifferences(mergeRequest.sha); + const diffsWithIds = await Promise.all( + diffs.map(async d => { + const { path, newFile } = d; + const id = await this.getFileId(path, branch); + return { id, path, newFile }; + }), + ); + const label = mergeRequest.labels.find(l => isCMSLabel(l, this.cmsLabelPrefix)) as string; + const status = labelToStatus(label, this.cmsLabelPrefix); + const updatedAt = mergeRequest.updated_at; + const pullRequestAuthor = mergeRequest.author.name; + return { + collection, + slug, + status, + diffs: diffsWithIds, + updatedAt, + pullRequestAuthor, + openAuthoring: false, + }; + } + + async updateMergeRequestLabels(mergeRequest: GitLabMergeRequest, labels: string[]) { + await this.requestJSON({ + method: 'PUT', + url: `${this.repoURL}/merge_requests/${mergeRequest.iid}`, + params: { + labels: labels.join(','), + }, + }); + } + + async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) { + const contentKey = generateContentKey(collection, slug); + const branch = branchFromContentKey(contentKey); + const mergeRequest = await this.getBranchMergeRequest(branch); + + const labels = [ + ...mergeRequest.labels.filter(label => !isCMSLabel(label, this.cmsLabelPrefix)), + statusToLabel(newStatus, this.cmsLabelPrefix), + ]; + await this.updateMergeRequestLabels(mergeRequest, labels); + } + + async deleteBranch(branch: string) { + await this.request({ + method: 'DELETE', + url: `${this.repoURL}/repository/branches/${encodeURIComponent(branch)}`, + }); + } + + async closeMergeRequest(mergeRequest: GitLabMergeRequest) { + await this.requestJSON({ + method: 'PUT', + url: `${this.repoURL}/merge_requests/${mergeRequest.iid}`, + params: { + state_event: 'close', + }, + }); + } + + async deleteUnpublishedEntry(collectionName: string, slug: string) { + const contentKey = generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + const mergeRequest = await this.getBranchMergeRequest(branch); + await this.closeMergeRequest(mergeRequest); + await this.deleteBranch(branch); + } + + async getMergeRequestStatues(mergeRequest: GitLabMergeRequest, branch: string) { + const statuses: GitLabCommitStatus[] = await this.requestJSON({ + url: `${this.repoURL}/repository/commits/${mergeRequest.sha}/statuses`, + params: { + ref: branch, + }, + }); + return statuses; + } + + async mergeMergeRequest(mergeRequest: GitLabMergeRequest) { + await this.requestJSON({ + method: 'PUT', + url: `${this.repoURL}/merge_requests/${mergeRequest.iid}/merge`, + params: { + merge_commit_message: MERGE_COMMIT_MESSAGE, + squash_commit_message: MERGE_COMMIT_MESSAGE, + squash: String(this.squashMerges), + should_remove_source_branch: 'true', + }, + }); + } + + async publishUnpublishedEntry(collectionName: string, slug: string) { + const contentKey = generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + const mergeRequest = await this.getBranchMergeRequest(branch); + await this.mergeMergeRequest(mergeRequest); + } + + async getStatuses(collectionName: string, slug: string) { + const contentKey = generateContentKey(collectionName, slug); + const branch = branchFromContentKey(contentKey); + const mergeRequest = await this.getBranchMergeRequest(branch); + const statuses: GitLabCommitStatus[] = await this.getMergeRequestStatues(mergeRequest, branch); + return statuses.map(({ name, status, target_url }) => ({ + context: name, + state: status === GitLabCommitStatuses.Success ? PreviewState.Success : PreviewState.Other, + target_url, + })); + } + + async createMergeRequest(branch: string, commitMessage: string, status: WorkflowStatus) { + await this.requestJSON({ + method: 'POST', + url: `${this.repoURL}/merge_requests`, + params: { + source_branch: branch, + target_branch: this.branch, + title: commitMessage, + description: DEFAULT_PR_BODY, + labels: statusToLabel(status, this.cmsLabelPrefix), + remove_source_branch: 'true', + squash: String(this.squashMerges), + }, + }); + } + + async rebaseMergeRequest(mergeRequest: GitLabMergeRequest) { + let rebase: GitLabMergeRebase = await this.requestJSON({ + method: 'PUT', + url: `${this.repoURL}/merge_requests/${mergeRequest.iid}/rebase?skip_ci=true`, + }); + + let i = 1; + while (rebase.rebase_in_progress) { + await new Promise(resolve => setTimeout(resolve, 1000)); + rebase = await this.requestJSON({ + url: `${this.repoURL}/merge_requests/${mergeRequest.iid}`, + params: { + include_rebase_in_progress: 'true', + }, + }); + if (!rebase.rebase_in_progress || i > 30) { + break; + } + i++; + } + + if (rebase.rebase_in_progress) { + throw new APIError('Timed out rebasing merge request', null, API_NAME); + } else if (rebase.merge_error) { + throw new APIError(`Rebase error: ${rebase.merge_error}`, null, API_NAME); + } + } + + async editorialWorkflowGit( + files: (DataFile | AssetProxy)[], + slug: string, + options: PersistOptions, + ) { + const contentKey = generateContentKey(options.collectionName as string, slug); + const branch = branchFromContentKey(contentKey); + const unpublished = options.unpublished || false; + if (!unpublished) { + const items = await this.getCommitItems(files, this.branch); + await this.uploadAndCommit(items, { + commitMessage: options.commitMessage, + branch, + newBranch: true, + }); + await this.createMergeRequest( + branch, + options.commitMessage, + options.status || this.initialWorkflowStatus, + ); + } else { + const mergeRequest = await this.getBranchMergeRequest(branch); + await this.rebaseMergeRequest(mergeRequest); + const [items, diffs] = await Promise.all([ + this.getCommitItems(files, branch), + this.getDifferences(branch), + ]); + // mark files for deletion + for (const diff of diffs.filter(d => d.binary)) { + if (!items.some(item => item.path === diff.path)) { + items.push({ action: CommitAction.DELETE, path: diff.newPath }); + } + } + + await this.uploadAndCommit(items, { + commitMessage: options.commitMessage, + branch, + }); + } + } + + async getUnpublishedEntrySha(collection: string, slug: string) { + const contentKey = generateContentKey(collection, slug); + const branch = branchFromContentKey(contentKey); + const mergeRequest = await this.getBranchMergeRequest(branch); + return mergeRequest.sha; + } } diff --git a/packages/core/src/backends/gitlab/AuthenticationPage.tsx b/packages/core/src/backends/gitlab/AuthenticationPage.tsx index 717b9011..4792de63 100644 --- a/packages/core/src/backends/gitlab/AuthenticationPage.tsx +++ b/packages/core/src/backends/gitlab/AuthenticationPage.tsx @@ -3,28 +3,26 @@ import React, { useCallback, useMemo, useState } from 'react'; import Login from '@staticcms/core/components/login/Login'; import { NetlifyAuthenticator, PkceAuthenticator } from '@staticcms/core/lib/auth'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; -import type { - AuthenticationPageProps, - AuthenticatorConfig, - TranslatedProps, -} from '@staticcms/core/interface'; -import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps, AuthenticatorConfig } from '@staticcms/core'; +import type { FC, MouseEvent } from 'react'; const clientSideAuthenticators = { pkce: (config: AuthenticatorConfig) => new PkceAuthenticator(config), } as const; -const GitLabAuthenticationPage = ({ +const GitLabAuthenticationPage: FC = ({ inProgress = false, config, siteId, authEndpoint, clearHash, onLogin, - t, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const [loginError, setLoginError] = useState(null); const auth = useMemo(() => { diff --git a/packages/core/src/backends/gitlab/__tests__/API.spec.ts b/packages/core/src/backends/gitlab/__tests__/API.spec.ts index f179f260..7c0721bd 100644 --- a/packages/core/src/backends/gitlab/__tests__/API.spec.ts +++ b/packages/core/src/backends/gitlab/__tests__/API.spec.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import API, { getMaxAccess } from '../API'; +import { CMS_BRANCH_PREFIX } from '@staticcms/core/lib/util/APIUtils'; global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests')); @@ -14,7 +16,12 @@ describe('GitLab API', () => { describe('hasWriteAccess', () => { test('should return true on project access_level >= 30', async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); api.requestJSON = jest .fn() @@ -24,7 +31,12 @@ describe('GitLab API', () => { }); test('should return false on project access_level < 30', async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); api.requestJSON = jest .fn() @@ -34,7 +46,12 @@ describe('GitLab API', () => { }); test('should return true on group access_level >= 30', async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); api.requestJSON = jest .fn() @@ -44,7 +61,12 @@ describe('GitLab API', () => { }); test('should return false on group access_level < 30', async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); api.requestJSON = jest .fn() @@ -54,7 +76,12 @@ describe('GitLab API', () => { }); test('should return true on shared group access_level >= 40', async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); api.requestJSON = jest.fn().mockResolvedValueOnce({ permissions: { project_access: null, group_access: null }, shared_with_groups: [{ group_access_level: 10 }, { group_access_level: 40 }], @@ -66,7 +93,12 @@ describe('GitLab API', () => { }); test('should return true on shared group access_level >= 30, developers can merge and push', async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); const requestJSONMock = (api.requestJSON = jest.fn()); requestJSONMock.mockResolvedValueOnce({ @@ -82,7 +114,12 @@ describe('GitLab API', () => { }); test('should return false on shared group access_level < 30,', async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); const requestJSONMock = (api.requestJSON = jest.fn()); requestJSONMock.mockResolvedValueOnce({ @@ -98,7 +135,12 @@ describe('GitLab API', () => { }); test("should return false on shared group access_level >= 30, developers can't merge", async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); const requestJSONMock = (api.requestJSON = jest.fn()); requestJSONMock.mockResolvedValueOnce({ @@ -114,7 +156,12 @@ describe('GitLab API', () => { }); test("should return false on shared group access_level >= 30, developers can't push", async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); const requestJSONMock = (api.requestJSON = jest.fn()); requestJSONMock.mockResolvedValueOnce({ @@ -130,7 +177,12 @@ describe('GitLab API', () => { }); test('should return false on shared group access_level >= 30, error getting branch', async () => { - const api = new API({ repo: 'repo' }); + const api = new API({ + repo: 'repo', + squashMerges: false, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: '', + }); const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -152,6 +204,41 @@ describe('GitLab API', () => { }); }); + describe('getStatuses', () => { + test('should get preview statuses', async () => { + const api = new API({ + repo: 'repo', + squashMerges: true, + initialWorkflowStatus: WorkflowStatus.DRAFT, + cmsLabelPrefix: CMS_BRANCH_PREFIX, + }); + + const mr = { sha: 'sha' }; + const statuses = [ + { name: 'deploy', status: 'success', target_url: 'deploy-url' }, + { name: 'build', status: 'pending' }, + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (api as any).getBranchMergeRequest = jest.fn(() => Promise.resolve(mr)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (api as any).getMergeRequestStatues = jest.fn(() => Promise.resolve(statuses)); + + const collectionName = 'posts'; + const slug = 'title'; + await expect(api.getStatuses(collectionName, slug)).resolves.toEqual([ + { context: 'deploy', state: 'success', target_url: 'deploy-url' }, + { context: 'build', state: 'other' }, + ]); + + expect(api.getBranchMergeRequest).toHaveBeenCalledTimes(1); + expect(api.getBranchMergeRequest).toHaveBeenCalledWith('cms/posts/title'); + + expect(api.getMergeRequestStatues).toHaveBeenCalledTimes(1); + expect(api.getMergeRequestStatues).toHaveBeenCalledWith(mr, 'cms/posts/title'); + }); + }); + describe('getMaxAccess', () => { it('should return group with max access level', () => { const groups = [ diff --git a/packages/core/src/backends/gitlab/__tests__/gitlab.spec.ts b/packages/core/src/backends/gitlab/__tests__/gitlab.spec.ts new file mode 100644 index 00000000..1b2ba9e6 --- /dev/null +++ b/packages/core/src/backends/gitlab/__tests__/gitlab.spec.ts @@ -0,0 +1,633 @@ +/** + * @jest-environment jsdom + */ +import { oneLine, stripIndent } from 'common-tags'; + +import Cursor from '@staticcms/core/lib/util/Cursor'; +import { + createMockFilesCollectionWithDefaults, + createMockFolderCollectionWithDefaults, +} from '@staticcms/test/data/collections.mock'; +import { createMockConfig } from '@staticcms/test/data/config.mock'; +import mockFetch from '@staticcms/test/mockFetch'; +import AuthenticationPage from '../AuthenticationPage'; +import GitLab from '../implementation'; + +import type { + Backend as BackendType, + LocalStorageAuthStore as LocalStorageAuthStoreType, +} from '@staticcms/core/backend'; +import type { + ConfigWithDefaults, + FilesCollectionWithDefaults, + FolderCollectionWithDefaults, +} from '@staticcms/core'; +import type { RootState } from '@staticcms/core/store'; +import type { FetchMethod } from '@staticcms/test/mockFetch'; + +jest.mock('@staticcms/core/backend'); + +const { Backend, LocalStorageAuthStore } = jest.requireActual('@staticcms/core/backend') as { + Backend: typeof BackendType; + LocalStorageAuthStore: typeof LocalStorageAuthStoreType; +}; + +function generateEntries(path: string, length: number) { + const entries = Array.from({ length }, (_val, idx) => { + const count = idx + 1; + const id = `00${count}`.slice(-3); + const fileName = `test${id}.md`; + return { id, fileName, filePath: `${path}/${fileName}` }; + }); + + return { + tree: entries.map(({ id, fileName, filePath }) => ({ + id: `d8345753a1d935fa47a26317a503e73e1192d${id}`, + name: fileName, + type: 'blob', + path: filePath, + mode: '100644', + })), + files: entries.reduce( + (acc, { id, filePath }) => ({ + ...acc, + [filePath]: stripIndent` + --- + title: test ${id} + --- + # test ${id} + `, + }), + {}, + ), + }; +} + +const manyEntries = generateEntries('many-entries', 500); + +interface TreeEntry { + id: string; + name: string; + type: string; + path: string; + mode: string; +} + +interface MockRepo { + tree: Record; + files: Record; +} + +const mockRepo: MockRepo = { + tree: { + '/': [ + { + id: '5d0620ebdbc92068a3e866866e928cc373f18429', + name: 'content', + type: 'tree', + path: 'content', + mode: '040000', + }, + ], + content: [ + { + id: 'b1a200e48be54fde12b636f9563d659d44c206a5', + name: 'test1.md', + type: 'blob', + path: 'content/test1.md', + mode: '100644', + }, + { + id: 'd8345753a1d935fa47a26317a503e73e1192d623', + name: 'test2.md', + type: 'blob', + path: 'content/test2.md', + mode: '100644', + }, + ], + 'many-entries': manyEntries.tree, + }, + files: { + 'content/test1.md': stripIndent` + { + "title": "test" + } + # test + `, + 'content/test2.md': stripIndent` + { + "title": "test2" + } + # test 2 + `, + ...manyEntries.files, + }, +}; + +const resp: Record>> = { + user: { + success: { + id: 1, + }, + }, + branch: { + success: { + name: 'main', + commit: { + id: 1, + }, + }, + }, + project: { + success: { + permissions: { + project_access: { + access_level: 30, + }, + }, + }, + readOnly: { + permissions: { + project_access: { + access_level: 10, + }, + }, + }, + }, +}; + +function mockApi(apiRoot: string) { + return mockFetch(apiRoot); +} + +describe('gitlab backend', () => { + const apiRoot = 'https://gitlab.com/api/v4'; + const api = mockApi(apiRoot); + + let authStore: InstanceType; + const repo = 'foo/bar'; + + const collectionContentConfig = createMockFolderCollectionWithDefaults({ + name: 'foo', + folder: 'content', + format: 'json-frontmatter', + fields: [{ name: 'title', widget: 'string' }], + }) as unknown as FolderCollectionWithDefaults; + + const collectionManyEntriesConfig = createMockFolderCollectionWithDefaults({ + name: 'foo', + folder: 'many-entries', + format: 'json-frontmatter', + fields: [{ name: 'title', widget: 'string' }], + }) as unknown as FolderCollectionWithDefaults; + + const collectionFilesConfig = createMockFilesCollectionWithDefaults({ + name: 'foo', + files: [ + { + label: 'foo', + name: 'foo', + file: 'content/test1.json', + fields: [{ name: 'title', widget: 'string' }], + }, + { + label: 'bar', + name: 'bar', + file: 'content/test2.json', + fields: [{ name: 'title', widget: 'string' }], + }, + ], + }) as unknown as FilesCollectionWithDefaults; + + const defaultConfig = createMockConfig({ + backend: { + name: 'gitlab', + repo, + }, + collections: [], + }) as ConfigWithDefaults; + + const mockCredentials = { token: 'MOCK_TOKEN' }; + const expectedRepo = encodeURIComponent(repo); + const expectedRepoUrl = `/projects/${expectedRepo}`; + + function resolveBackend(config: ConfigWithDefaults) { + authStore = new LocalStorageAuthStore(); + return new Backend( + { + init: (...args) => new GitLab(...args), + }, + { + backendName: 'gitlab', + config, + authStore, + }, + ); + } + + interface InterceptAuthOptions { + userResponse?: (typeof resp)['user'][string]; + projectResponse?: (typeof resp)['project'][string]; + } + + function interceptAuth({ userResponse, projectResponse }: InterceptAuthOptions = {}) { + api + .when('GET', '/user') + .query(true) + .reply({ status: 200, json: userResponse ?? resp.user.success }); + + api + .when('GET', expectedRepoUrl) + .query(true) + .reply({ status: 200, json: projectResponse || resp.project.success }); + } + + function interceptBranch({ branch = 'main' } = {}) { + api + .when('GET', `${expectedRepoUrl}/repository/branches/${encodeURIComponent(branch)}`) + .query(true) + .reply({ status: 200, json: resp.branch.success }); + } + + function parseQuery(uri: string) { + const query = uri.split('?')[1]; + if (!query) { + return {}; + } + return query.split('&').reduce( + (acc, q) => { + const [key, value] = q.split('='); + acc[key] = value; + return acc; + }, + {} as Record, + ); + } + + interface CreateHeadersOptions { + basePath: string; + path: string; + page: string; + perPage: string; + pageCount: string; + totalCount: string; + } + + function createHeaders({ + basePath, + path, + page, + perPage, + pageCount, + totalCount, + }: CreateHeadersOptions) { + const pageNum = parseInt(page, 10); + const pageCountNum = parseInt(pageCount, 10); + const url = `${apiRoot}${basePath}`; + + function link(linkPage: string | number) { + return `<${url}?id=${expectedRepo}&page=${linkPage}&path=${path}&per_page=${perPage}&recursive=false>`; + } + + const linkHeader = oneLine` + ${link(1)}; rel="first", + ${link(pageCount)}; rel="last", + ${pageNum === 1 ? '' : `${link(pageNum - 1)}; rel="prev",`} + ${pageNum === pageCountNum ? '' : `${link(pageNum + 1)}; rel="next",`} + `.slice(0, -1); + + return { + 'X-Page': page, + 'X-Total-Pages': pageCount, + 'X-Per-Page': perPage, + 'X-Total': totalCount, + Link: linkHeader, + }; + } + + interface InterceptCollectionOptions { + verb?: FetchMethod; + repeat?: number; + page?: string; + } + + function interceptCollection( + collection: FolderCollectionWithDefaults, + { verb = 'GET', repeat = 1, page: expectedPage }: InterceptCollectionOptions = {}, + ) { + const url = `${expectedRepoUrl}/repository/tree`; + const { folder } = collection; + const tree = mockRepo.tree[folder]; + + api + .when(verb, url) + .query(searchParams => { + const path = searchParams.get('path'); + const page = searchParams.get('page'); + + if (path !== folder) { + return false; + } + + if ( + expectedPage && + page && + (Array.isArray(page) || parseInt(page, 10) !== parseInt(expectedPage, 10)) + ) { + return false; + } + + return true; + }) + .repeat(repeat) + .reply(uri => { + const { page = '1', per_page = '20' } = parseQuery(uri); + const perPage = parseInt(per_page, 10); + const parsedPage = parseInt(page, 10); + const pageCount = tree.length <= perPage ? 1 : Math.round(tree.length / perPage); + const pageLastIndex = parsedPage * perPage; + const pageFirstIndex = pageLastIndex - perPage; + const resp = tree.slice(pageFirstIndex, pageLastIndex); + return { + status: 200, + json: verb === 'HEAD' ? null : resp, + headers: createHeaders({ + basePath: url, + path: folder, + page, + perPage: `${perPage}`, + pageCount: `${pageCount}`, + totalCount: `${tree.length}`, + }), + }; + }); + } + + function interceptFiles(path: string) { + const url = `${expectedRepoUrl}/repository/files/${encodeURIComponent(path)}/raw`; + + api.when('GET', url).query(true).reply({ status: 200, json: mockRepo.files[path] }); + + api + .when('GET', `${expectedRepoUrl}/repository/commits`) + .query(searchParams => searchParams.get('path') === path) + .reply({ + status: 200, + json: { + author_name: 'author_name', + author_email: 'author_email', + authored_date: 'authored_date', + }, + }); + } + + async function sharedSetup() { + const backend = resolveBackend(defaultConfig); + interceptAuth(); + await backend.authenticate(mockCredentials); + interceptCollection(collectionManyEntriesConfig, { verb: 'HEAD' }); + interceptCollection(collectionContentConfig, { verb: 'HEAD' }); + + return backend; + } + + it('throws if configuration does not include repo', () => { + expect(() => + resolveBackend(createMockConfig({ backend: { name: 'gitlab' }, collections: [] })), + ).toThrowErrorMatchingInlineSnapshot( + `"The GitLab backend needs a "repo" in the backend configuration."`, + ); + }); + + describe('authComponent', () => { + it('returns authentication page component', () => { + const backend = resolveBackend(defaultConfig); + expect(backend.authComponent()).toEqual(AuthenticationPage); + }); + }); + + describe('authenticate', () => { + it('throws if user does not have access to project', async () => { + const backend = resolveBackend(defaultConfig); + interceptAuth({ projectResponse: resp.project.readOnly }); + await expect( + backend.authenticate(mockCredentials), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Your GitLab user account does not have access to this repo."`, + ); + }); + + it('stores and returns user object on success', async () => { + const backendName = defaultConfig.backend.name; + const backend = resolveBackend(defaultConfig); + interceptAuth(); + const user = await backend.authenticate(mockCredentials); + expect(authStore.retrieve()).toEqual(user); + expect(user).toEqual({ ...resp.user.success, ...mockCredentials, backendName }); + }); + }); + + describe('currentUser', () => { + it('returns null if no user', async () => { + const backend = resolveBackend(defaultConfig); + const user = await backend.currentUser(); + expect(user).toEqual(null); + }); + + it('returns the stored user if exists', async () => { + const backendName = defaultConfig.backend.name; + const backend = resolveBackend(defaultConfig); + interceptAuth(); + await backend.authenticate(mockCredentials); + const user = await backend.currentUser(); + expect(user).toEqual({ ...resp.user.success, ...mockCredentials, backendName }); + }); + }); + + describe('getToken', () => { + it('returns the token for the current user', async () => { + const backend = resolveBackend(defaultConfig); + interceptAuth(); + await backend.authenticate(mockCredentials); + const token = await backend.getToken(); + expect(token).toEqual(mockCredentials.token); + }); + }); + + describe('logout', () => { + it('sets token to null', async () => { + const backend = resolveBackend(defaultConfig); + interceptAuth(); + await backend.authenticate(mockCredentials); + await backend.logout(); + const token = await backend.getToken(); + expect(token).toEqual(null); + }); + }); + + describe('getEntry', () => { + it('returns an entry from folder collection', async () => { + const backend = await sharedSetup(); + + const entryTree = mockRepo.tree[collectionContentConfig.folder][0]; + const slug = entryTree.path.split('/').pop()?.replace('.md', '') ?? ''; + + interceptFiles(entryTree.path); + interceptCollection(collectionContentConfig); + + const config = createMockConfig({ collections: [createMockFolderCollectionWithDefaults()] }); + + const entry = await backend.getEntry( + { + config: { + config, + }, + integrations: [], + entryDraft: {}, + mediaLibrary: {}, + } as unknown as RootState, + collectionContentConfig, + config, + slug, + ); + + expect(entry).toEqual(expect.objectContaining({ path: entryTree.path })); + }); + }); + + describe('listEntries', () => { + it('returns entries from folder collection', async () => { + const backend = await sharedSetup(); + + const tree = mockRepo.tree[collectionContentConfig.folder]; + tree.forEach(file => interceptFiles(file.path)); + + interceptCollection(collectionContentConfig); + const entries = await backend.listEntries(collectionContentConfig, defaultConfig); + + expect(entries).toEqual({ + cursor: expect.any(Cursor), + pagination: 1, + entries: expect.arrayContaining( + tree.map(file => expect.objectContaining({ path: file.path })), + ), + }); + expect(entries.entries).toHaveLength(2); + }); + + it('returns all entries from folder collection', async () => { + const backend = await sharedSetup(); + + const tree = mockRepo.tree[collectionManyEntriesConfig.folder]; + interceptBranch(); + tree.forEach(file => interceptFiles(file.path)); + + interceptCollection(collectionManyEntriesConfig, { repeat: 5 }); + const entries = await backend.listAllEntries(collectionManyEntriesConfig, defaultConfig); + + expect(entries).toEqual( + expect.arrayContaining(tree.map(file => expect.objectContaining({ path: file.path }))), + ); + expect(entries).toHaveLength(500); + }, 7000); + + it('returns entries from file collection', async () => { + const backend = await sharedSetup(); + + const { files } = collectionFilesConfig; + files.forEach(file => interceptFiles(file.file)); + const entries = await backend.listEntries(collectionFilesConfig, defaultConfig); + + expect(entries).toEqual({ + cursor: expect.any(Cursor), + entries: expect.arrayContaining( + files.map(file => expect.objectContaining({ path: file.file })), + ), + }); + expect(entries.entries).toHaveLength(2); + }); + + it('returns first page from paginated folder collection tree', async () => { + const backend = await sharedSetup(); + + const tree = mockRepo.tree[collectionManyEntriesConfig.folder]; + const pageTree = tree.slice(0, 20); + pageTree.forEach(file => interceptFiles(file.path)); + interceptCollection(collectionManyEntriesConfig, { page: '1' }); + const entries = await backend.listEntries(collectionManyEntriesConfig, defaultConfig); + + expect(entries.entries).toEqual( + expect.arrayContaining(pageTree.map(file => expect.objectContaining({ path: file.path }))), + ); + expect(entries.entries).toHaveLength(20); + }); + }); + + describe('traverseCursor', () => { + it('returns complete last page of paginated tree', async () => { + const backend = await sharedSetup(); + + const tree = mockRepo.tree[collectionManyEntriesConfig.folder]; + tree.slice(0, 20).forEach(file => interceptFiles(file.path)); + interceptCollection(collectionManyEntriesConfig, { page: '1' }); + const entries = await backend.listEntries(collectionManyEntriesConfig, defaultConfig); + + const nextPageTree = tree.slice(20, 40); + nextPageTree.forEach(file => interceptFiles(file.path)); + interceptCollection(collectionManyEntriesConfig, { page: '2' }); + const nextPage = await backend.traverseCursor(entries.cursor, 'next', defaultConfig); + + expect(nextPage.entries).toEqual( + expect.arrayContaining( + nextPageTree.map(file => expect.objectContaining({ path: file.path })), + ), + ); + expect(nextPage.entries).toHaveLength(20); + + const lastPageTree = tree.slice(-20); + lastPageTree.forEach(file => interceptFiles(file.path)); + interceptCollection(collectionManyEntriesConfig, { page: '25' }); + const lastPage = await backend.traverseCursor(nextPage.cursor, 'last', defaultConfig); + expect(lastPage.entries).toEqual( + expect.arrayContaining( + lastPageTree.map(file => expect.objectContaining({ path: file.path })), + ), + ); + expect(lastPage.entries).toHaveLength(20); + }); + }); + + describe('filterFile', () => { + it('should return true for nested file with matching depth', () => { + const backend = resolveBackend(defaultConfig); + + expect( + (backend.implementation as GitLab).filterFile( + 'content/posts', + { name: 'index.md', path: 'content/posts/dir1/dir2/index.md' }, + 'md', + 3, + ), + ).toBe(true); + }); + + it('should return false for nested file with non matching depth', () => { + const backend = resolveBackend(defaultConfig); + + expect( + (backend.implementation as GitLab).filterFile( + 'content/posts', + { name: 'index.md', path: 'content/posts/dir1/dir2/index.md' }, + 'md', + 2, + ), + ).toBe(false); + }); + }); + + afterEach(() => { + authStore.logout(); + api.reset(); + expect(authStore.retrieve()).toEqual(null); + }); +}); diff --git a/packages/core/src/backends/gitlab/implementation.ts b/packages/core/src/backends/gitlab/implementation.ts index 7da9151b..96a02b23 100644 --- a/packages/core/src/backends/gitlab/implementation.ts +++ b/packages/core/src/backends/gitlab/implementation.ts @@ -3,6 +3,7 @@ import trim from 'lodash/trim'; import trimStart from 'lodash/trimStart'; import semaphore from 'semaphore'; +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import { allEntriesByFolder, asyncLock, @@ -18,22 +19,31 @@ import { localForage, runWithLock, } from '@staticcms/core/lib/util'; +import { getPreviewStatus } from '@staticcms/core/lib/util/API'; +import { + branchFromContentKey, + contentKeyFromBranch, + generateContentKey, +} from '@staticcms/core/lib/util/APIUtils'; +import { unpublishedEntries } from '@staticcms/core/lib/util/implementation'; import API, { API_NAME } from './API'; import AuthenticationPage from './AuthenticationPage'; -import type { Semaphore } from 'semaphore'; -import type { AsyncLock, Cursor } from '@staticcms/core/lib/util'; import type { - Config, + BackendClass, + BackendEntry, + ConfigWithDefaults, Credentials, DisplayURL, - BackendEntry, - BackendClass, ImplementationFile, PersistOptions, + UnpublishedEntry, + UnpublishedEntryMediaFile, User, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; +import type { AsyncLock, Cursor } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; +import type { Semaphore } from 'semaphore'; const MAX_CONCURRENT_DOWNLOADS = 10; @@ -43,19 +53,25 @@ export default class GitLab implements BackendClass { options: { proxied: boolean; API: API | null; + initialWorkflowStatus: WorkflowStatus; }; repo: string; branch: string; + useOpenAuthoring?: boolean; apiRoot: string; token: string | null; + squashMerges: boolean; + cmsLabelPrefix: string; mediaFolder?: string; + previewContext: string; _mediaDisplayURLSem?: Semaphore; - constructor(config: Config, options = {}) { + constructor(config: ConfigWithDefaults, options = {}) { this.options = { proxied: false, API: null, + initialWorkflowStatus: WorkflowStatus.DRAFT, ...options, }; @@ -72,14 +88,13 @@ export default class GitLab implements BackendClass { this.branch = config.backend.branch || 'main'; this.apiRoot = config.backend.api_root || 'https://gitlab.com/api/v4'; this.token = ''; + this.squashMerges = config.backend.squash_merges || false; + this.cmsLabelPrefix = config.backend.cms_label_prefix || ''; this.mediaFolder = config.media_folder; + this.previewContext = config.backend.preview_context || ''; this.lock = asyncLock(); } - isGitBackend() { - return true; - } - async status() { const auth = (await this.api @@ -108,8 +123,12 @@ export default class GitLab implements BackendClass { branch: this.branch, repo: this.repo, apiRoot: this.apiRoot, + squashMerges: this.squashMerges, + cmsLabelPrefix: this.cmsLabelPrefix, + initialWorkflowStatus: this.options.initialWorkflowStatus, }); const user = await this.api.user(); + const isCollab = await this.api.hasWriteAccess().catch((error: Error) => { error.message = stripIndent` Repo "${this.repo}" not found. @@ -313,4 +332,118 @@ export default class GitLab implements BackendClass { }; }); } + + /** + * Editorial Workflow + */ + async unpublishedEntries() { + const listEntriesKeys = () => + this.api!.listUnpublishedBranches().then(branches => + branches.map(branch => contentKeyFromBranch(branch)), + ); + + const ids = await unpublishedEntries(listEntriesKeys); + return ids; + } + + async unpublishedEntry({ + id, + collection, + slug, + }: { + id?: string; + collection?: string; + slug?: string; + }): Promise { + if (id) { + return this.api!.retrieveUnpublishedEntryData(id); + } else if (collection && slug) { + const entryId = generateContentKey(collection, slug); + return this.api!.retrieveUnpublishedEntryData(entryId); + } else { + throw new Error('Missing unpublished entry id or collection and slug'); + } + } + + getBranch(collection: string, slug: string) { + const contentKey = generateContentKey(collection, slug); + const branch = branchFromContentKey(contentKey); + return branch; + } + + async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) { + const branch = this.getBranch(collection, slug); + const data = (await this.api!.readFile(path, id, { branch })) as string; + return data; + } + + async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) { + const branch = this.getBranch(collection, slug); + const mediaFile = await this.loadMediaFile(branch, { path, id }); + return mediaFile; + } + + async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) { + // updateUnpublishedEntryStatus is a transactional operation + return runWithLock( + this.lock, + () => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus), + 'Failed to acquire update entry status lock', + ); + } + + async deleteUnpublishedEntry(collection: string, slug: string) { + // deleteUnpublishedEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.deleteUnpublishedEntry(collection, slug), + 'Failed to acquire delete entry lock', + ); + } + + async publishUnpublishedEntry(collection: string, slug: string) { + // publishUnpublishedEntry is a transactional operation + return runWithLock( + this.lock, + () => this.api!.publishUnpublishedEntry(collection, slug), + 'Failed to acquire publish entry lock', + ); + } + + async getDeployPreview(collection: string, slug: string) { + try { + const statuses = await this.api!.getStatuses(collection, slug); + const deployStatus = getPreviewStatus(statuses, this.previewContext); + + if (deployStatus) { + const { target_url: url, state } = deployStatus; + return { url, status: state }; + } else { + return null; + } + } catch (e) { + return null; + } + } + + loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) { + const readFile = ( + path: string, + id: string | null | undefined, + { parseText }: { parseText: boolean }, + ) => this.api!.readFile(path, id, { branch, parseText }); + + return getMediaAsBlob(file.path, null, readFile).then(blob => { + const name = basename(file.path); + const fileObj = blobToFileObj(name, blob); + return { + id: file.path, + displayURL: URL.createObjectURL(fileObj), + path: file.path, + name, + size: fileObj.size, + file: fileObj, + }; + }); + } } diff --git a/packages/core/src/backends/proxy/AuthenticationPage.tsx b/packages/core/src/backends/proxy/AuthenticationPage.tsx index 5b616c97..10f874af 100644 --- a/packages/core/src/backends/proxy/AuthenticationPage.tsx +++ b/packages/core/src/backends/proxy/AuthenticationPage.tsx @@ -2,13 +2,10 @@ import React, { useCallback } from 'react'; import Login from '@staticcms/core/components/login/Login'; -import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; -import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps } from '@staticcms/core'; +import type { FC, MouseEvent } from 'react'; -const AuthenticationPage = ({ - inProgress = false, - onLogin, -}: TranslatedProps) => { +const AuthenticationPage: FC = ({ inProgress = false, onLogin }) => { const handleLogin = useCallback( (e: MouseEvent) => { e.preventDefault(); diff --git a/packages/core/src/backends/proxy/implementation.ts b/packages/core/src/backends/proxy/implementation.ts index 6d6b7e51..01e4071a 100644 --- a/packages/core/src/backends/proxy/implementation.ts +++ b/packages/core/src/backends/proxy/implementation.ts @@ -1,17 +1,24 @@ -import { APIError, basename, blobToFileObj, unsentRequest } from '@staticcms/core/lib/util'; +import { + APIError, + EditorialWorkflowError, + basename, + blobToFileObj, + unsentRequest, +} from '@staticcms/core/lib/util'; import AuthenticationPage from './AuthenticationPage'; import type { - BackendEntry, BackendClass, - Config, + BackendEntry, + ConfigWithDefaults, DisplayURL, ImplementationEntry, ImplementationFile, - PersistOptions, - User, ImplementationMediaFile, -} from '@staticcms/core/interface'; + PersistOptions, + UnpublishedEntry, + User, +} from '@staticcms/core'; import type { Cursor } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; @@ -51,8 +58,9 @@ export default class ProxyBackend implements BackendClass { publicFolder?: string; options: {}; branch: string; + cmsLabelPrefix?: string; - constructor(config: Config, options = {}) { + constructor(config: ConfigWithDefaults, options = {}) { if (!config.backend.proxy_url) { throw new Error('The Proxy backend needs a "proxy_url" in the backend configuration.'); } @@ -62,10 +70,7 @@ export default class ProxyBackend implements BackendClass { this.mediaFolder = config.media_folder; this.publicFolder = config.public_folder; this.options = options; - } - - isGitBackend() { - return false; + this.cmsLabelPrefix = config.backend.cms_label_prefix; } status() { @@ -213,4 +218,86 @@ export default class ProxyBackend implements BackendClass { ): Promise { return this.entriesByFolder(folder, extension, depth); } + + unpublishedEntries(): Promise { + return this.request({ + action: 'unpublishedEntries', + params: { branch: this.branch }, + }); + } + + async unpublishedEntry({ + id, + collection, + slug, + }: { + id?: string; + collection?: string; + slug?: string; + }) { + try { + const entry: UnpublishedEntry = await this.request({ + action: 'unpublishedEntry', + params: { branch: this.branch, id, collection, slug, cmsLabelPrefix: this.cmsLabelPrefix }, + }); + + return entry; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + if (e.status === 404) { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + } + throw e; + } + } + + async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) { + const { data } = await this.request<{ data: string }>({ + action: 'unpublishedEntryDataFile', + params: { branch: this.branch, collection, slug, path, id }, + }); + return data; + } + + async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) { + const file = await this.request({ + action: 'unpublishedEntryMediaFile', + params: { branch: this.branch, collection, slug, path, id }, + }); + return deserializeMediaFile(file); + } + + updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) { + return this.request({ + action: 'updateUnpublishedEntryStatus', + params: { + branch: this.branch, + collection, + slug, + newStatus, + cmsLabelPrefix: this.cmsLabelPrefix, + }, + }); + } + + publishUnpublishedEntry(collection: string, slug: string) { + return this.request({ + action: 'publishUnpublishedEntry', + params: { branch: this.branch, collection, slug }, + }); + } + + deleteUnpublishedEntry(collection: string, slug: string) { + return this.request({ + action: 'deleteUnpublishedEntry', + params: { branch: this.branch, collection, slug }, + }); + } + + getDeployPreview(collection: string, slug: string) { + return this.request<{ url: string; status: string } | null>({ + action: 'getDeployPreview', + params: { branch: this.branch, collection, slug }, + }); + } } diff --git a/packages/core/src/backends/test/AuthenticationPage.tsx b/packages/core/src/backends/test/AuthenticationPage.tsx index 1ba880e2..91efacaf 100644 --- a/packages/core/src/backends/test/AuthenticationPage.tsx +++ b/packages/core/src/backends/test/AuthenticationPage.tsx @@ -2,14 +2,14 @@ import React, { useCallback, useEffect } from 'react'; import Login from '@staticcms/core/components/login/Login'; -import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface'; -import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps } from '@staticcms/core'; +import type { FC, MouseEvent } from 'react'; -const AuthenticationPage = ({ +const AuthenticationPage: FC = ({ inProgress = false, config, onLogin, -}: TranslatedProps) => { +}) => { useEffect(() => { /** * Allow login screen to be skipped for demo purposes. diff --git a/packages/core/src/backends/test/__tests__/implementation.spec.ts b/packages/core/src/backends/test/__tests__/implementation.spec.ts new file mode 100644 index 00000000..6b962210 --- /dev/null +++ b/packages/core/src/backends/test/__tests__/implementation.spec.ts @@ -0,0 +1,272 @@ +import { createMockConfig } from '@staticcms/test/data/config.mock'; +import TestBackend, { getFolderFiles } from '../implementation'; +import { resolveBackend } from '@staticcms/core/backend'; + +jest.mock('@staticcms/core/backend'); + +describe('test backend implementation', () => { + let backend: TestBackend; + + beforeEach(() => { + backend = new TestBackend(createMockConfig({ collections: [] })); + + (resolveBackend as jest.Mock).mockResolvedValue(null); + + jest.resetModules(); + }); + + describe('getEntry', () => { + it('should get entry by path', async () => { + window.repoFiles = { + posts: { + 'some-post.md': { + path: 'path/to/some-post.md', + content: 'post content', + }, + }, + }; + + await expect(backend.getEntry('posts/some-post.md')).resolves.toEqual({ + file: { path: 'posts/some-post.md', id: null }, + data: 'post content', + }); + }); + + it('should get entry by nested path', async () => { + window.repoFiles = { + posts: { + dir1: { + dir2: { + 'some-post.md': { + path: 'path/to/some-post.md', + content: 'post content', + }, + }, + }, + }, + }; + + await expect(backend.getEntry('posts/dir1/dir2/some-post.md')).resolves.toEqual({ + file: { path: 'posts/dir1/dir2/some-post.md', id: null }, + data: 'post content', + }); + }); + }); + + describe('persistEntry', () => { + it('should persist entry', async () => { + window.repoFiles = {}; + + const entry = { + dataFiles: [{ path: 'posts/some-post.md', raw: 'content', slug: 'some-post.md' }], + assets: [], + }; + await backend.persistEntry(entry, { newEntry: true, commitMessage: 'Persist file' }); + + expect(window.repoFiles).toEqual({ + posts: { + 'some-post.md': { + content: 'content', + path: 'posts/some-post.md', + }, + }, + }); + }); + + it('should persist entry and keep existing unrelated entries', async () => { + window.repoFiles = { + pages: { + 'other-page.md': { + path: 'path/to/other-page.md', + content: 'content', + }, + }, + posts: { + 'other-post.md': { + path: 'path/to/other-post.md', + content: 'content', + }, + }, + }; + + const entry = { + dataFiles: [{ path: 'posts/new-post.md', raw: 'content', slug: 'new-post.md' }], + assets: [], + }; + await backend.persistEntry(entry, { newEntry: true, commitMessage: 'Persist file' }); + + expect(window.repoFiles).toEqual({ + pages: { + 'other-page.md': { + path: 'path/to/other-page.md', + content: 'content', + }, + }, + posts: { + 'new-post.md': { + path: 'posts/new-post.md', + content: 'content', + }, + 'other-post.md': { + path: 'path/to/other-post.md', + content: 'content', + }, + }, + }); + }); + + it('should persist nested entry', async () => { + window.repoFiles = {}; + + const slug = 'dir1/dir2/some-post.md'; + const path = `posts/${slug}`; + const entry = { dataFiles: [{ path, raw: 'content', slug }], assets: [] }; + await backend.persistEntry(entry, { newEntry: true, commitMessage: 'Persist file' }); + + expect(window.repoFiles).toEqual({ + posts: { + dir1: { + dir2: { + 'some-post.md': { + content: 'content', + path: 'posts/dir1/dir2/some-post.md', + }, + }, + }, + }, + }); + }); + + it('should update existing nested entry', async () => { + window.repoFiles = { + posts: { + dir1: { + dir2: { + 'some-post.md': { + path: 'path/to/some-post.md', + mediaFiles: { file1: { path: 'path/to/media/file1.txt', content: 'file1' } }, + content: 'content', + }, + }, + }, + }, + }; + + const slug = 'dir1/dir2/some-post.md'; + const path = `posts/${slug}`; + const entry = { dataFiles: [{ path, raw: 'new content', slug }], assets: [] }; + await backend.persistEntry(entry, { newEntry: false, commitMessage: 'Persist file' }); + + expect(window.repoFiles).toEqual({ + posts: { + dir1: { + dir2: { + 'some-post.md': { + path: 'posts/dir1/dir2/some-post.md', + content: 'new content', + }, + }, + }, + }, + }); + }); + }); + + describe('deleteFiles', () => { + it('should delete entry by path', async () => { + window.repoFiles = { + posts: { + 'some-post.md': { + path: 'path/to/some-post.md', + content: 'post content', + }, + }, + }; + + await backend.deleteFiles(['posts/some-post.md']); + expect(window.repoFiles).toEqual({ + posts: {}, + }); + }); + + it('should delete entry by nested path', async () => { + window.repoFiles = { + posts: { + dir1: { + dir2: { + 'some-post.md': { + path: 'path/to/some-post.md', + content: 'post content', + }, + }, + }, + }, + }; + + await backend.deleteFiles(['posts/dir1/dir2/some-post.md']); + expect(window.repoFiles).toEqual({ + posts: { + dir1: { + dir2: {}, + }, + }, + }); + }); + }); + + describe('getFolderFiles', () => { + it('should get files by depth', () => { + const tree = { + pages: { + 'root-page.md': { + path: 'pages/root-page.md', + content: 'root page content', + }, + dir1: { + 'nested-page-1.md': { + path: 'pages/dir1/nested-page-1.md', + content: 'nested page 1 content', + }, + dir2: { + 'nested-page-2.md': { + path: 'pages/dir1/dir2/nested-page-2.md', + content: 'nested page 2 content', + }, + }, + }, + }, + }; + + expect(getFolderFiles(tree, 'pages', 'md', 1)).toEqual([ + { + path: 'pages/root-page.md', + content: 'root page content', + }, + ]); + expect(getFolderFiles(tree, 'pages', 'md', 2)).toEqual([ + { + path: 'pages/dir1/nested-page-1.md', + content: 'nested page 1 content', + }, + { + path: 'pages/root-page.md', + content: 'root page content', + }, + ]); + expect(getFolderFiles(tree, 'pages', 'md', 3)).toEqual([ + { + path: 'pages/dir1/dir2/nested-page-2.md', + content: 'nested page 2 content', + }, + { + path: 'pages/dir1/nested-page-1.md', + content: 'nested page 1 content', + }, + { + path: 'pages/root-page.md', + content: 'root page content', + }, + ]); + }); + }); +}); diff --git a/packages/core/src/backends/test/implementation.ts b/packages/core/src/backends/test/implementation.ts index 826549fa..be2a1282 100644 --- a/packages/core/src/backends/test/implementation.ts +++ b/packages/core/src/backends/test/implementation.ts @@ -5,40 +5,68 @@ import unset from 'lodash/unset'; import { basename, dirname } from 'path'; import { v4 as uuid } from 'uuid'; -import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util'; +import { + Cursor, + CURSOR_COMPATIBILITY_SYMBOL, + EditorialWorkflowError, +} from '@staticcms/core/lib/util'; +import { isNullish } from '@staticcms/core/lib/util/null.util'; import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; +import AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; import AuthenticationPage from './AuthenticationPage'; +import type { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import type { BackendClass, BackendEntry, - Config, + ConfigWithDefaults, + DataFile, DisplayURL, ImplementationEntry, ImplementationFile, ImplementationMediaFile, + PersistOptions, + UnpublishedEntry, User, -} from '@staticcms/core/interface'; -import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; +} from '@staticcms/core'; type RepoFile = { path: string; content: string | AssetProxy; isDirectory?: boolean }; type RepoTree = { [key: string]: RepoFile | RepoTree }; +type Diff = { + id: string; + originalPath?: string; + path: string; + newFile: boolean; + status: string; + content: string | AssetProxy; +}; + +type UnpublishedRepoEntry = { + slug: string; + collection: string; + status: WorkflowStatus; + diffs: Diff[]; + updatedAt: string; +}; + declare global { interface Window { repoFiles: RepoTree; + repoFilesUnpublished: { [key: string]: UnpublishedRepoEntry }; } } window.repoFiles = window.repoFiles || {}; +window.repoFilesUnpublished = window.repoFilesUnpublished || []; -function getFile(path: string, tree: RepoTree) { +function getFile(path: string, tree: RepoTree): RepoFile | undefined { const segments = path.split('/'); let obj: RepoTree = tree; while (obj && segments.length) { obj = obj[segments.shift() as string] as RepoTree; } - return (obj as unknown as RepoFile) || {}; + return (obj as unknown as RepoFile) || undefined; } function writeFile(path: string, content: string | AssetProxy, tree: RepoTree) { @@ -122,17 +150,13 @@ export function getFolderFiles( export default class TestBackend implements BackendClass { mediaFolder?: string; - options: {}; + options: { initialWorkflowStatus?: string }; - constructor(config: Config, options = {}) { + constructor(config: ConfigWithDefaults, options = {}) { this.options = options; this.mediaFolder = config.media_folder; } - isGitBackend() { - return false; - } - status() { return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } }); } @@ -210,7 +234,7 @@ export default class TestBackend implements BackendClass { return Promise.all( files.map(file => ({ file, - data: getFile(file.path, window.repoFiles).content as string, + data: (getFile(file.path, window.repoFiles)?.content ?? '') as string, })), ); } @@ -218,11 +242,28 @@ export default class TestBackend implements BackendClass { getEntry(path: string) { return Promise.resolve({ file: { path, id: null }, - data: getFile(path, window.repoFiles).content as string, + data: (getFile(path, window.repoFiles)?.content ?? '') as string, }); } - async persistEntry(entry: BackendEntry) { + async persistEntry(entry: BackendEntry, options: PersistOptions) { + if (options.useWorkflow) { + const slug = entry.dataFiles[0].slug; + const key = `${options.collectionName}/${slug}`; + const currentEntry = window.repoFilesUnpublished[key]; + const status = currentEntry?.status || options.status || this.options.initialWorkflowStatus; + + this.addOrUpdateUnpublishedEntry( + key, + entry.dataFiles, + entry.assets, + slug, + options.collectionName as string, + status, + ); + return Promise.resolve(); + } + entry.dataFiles.forEach(dataFile => { const { path, newPath, raw } = dataFile; @@ -291,7 +332,7 @@ export default class TestBackend implements BackendClass { path: assetProxy.path, url, displayURL: url, - fileObj, + file: fileObj, }; return normalizedAsset; @@ -331,4 +372,131 @@ export default class TestBackend implements BackendClass { getMediaDisplayURL(_displayURL: DisplayURL): Promise { throw new Error('Not supported'); } + + /** + * Editorial Workflow + */ + unpublishedEntries() { + return Promise.resolve(Object.keys(window.repoFilesUnpublished)); + } + + unpublishedEntry({ + id, + collection, + slug, + }: { + id?: string; + collection?: string; + slug?: string; + }): Promise { + if (id) { + const parts = id.split('/'); + collection = parts[0]; + slug = parts[1]; + } + const entry = window.repoFilesUnpublished[`${collection}/${slug}`]; + if (!entry) { + return Promise.reject( + new EditorialWorkflowError('content is not under editorial workflow', true), + ); + } + + return Promise.resolve({ + ...entry, + openAuthoring: false, + }); + } + + async unpublishedEntryDataFile(collection: string, slug: string, path: string) { + const entry = window.repoFilesUnpublished[`${collection}/${slug}`]; + const file = entry.diffs.find(d => d.path === path); + return file?.content as string; + } + + async unpublishedEntryMediaFile(collection: string, slug: string, path: string) { + const entry = window.repoFilesUnpublished[`${collection}/${slug}`]; + const file = entry.diffs.find(d => d.path === path); + return this.normalizeAsset(file?.content as AssetProxy); + } + + deleteUnpublishedEntry(collection: string, slug: string) { + delete window.repoFilesUnpublished[`${collection}/${slug}`]; + return Promise.resolve(); + } + + async addOrUpdateUnpublishedEntry( + key: string, + dataFiles: DataFile[], + assetProxies: AssetProxy[], + slug: string, + collection: string, + status: WorkflowStatus, + ) { + const diffs: Diff[] = []; + dataFiles.forEach(dataFile => { + const { path, newPath, raw } = dataFile; + const currentDataFile = window.repoFilesUnpublished[key]?.diffs.find(d => d.path === path); + const originalPath = currentDataFile ? currentDataFile.originalPath : path; + diffs.push({ + originalPath, + id: newPath || path, + path: newPath || path, + newFile: isNullish(getFile(originalPath as string, window.repoFiles)), + status: 'added', + content: raw, + }); + }); + assetProxies.forEach(a => { + const asset = this.normalizeAsset(a); + diffs.push({ + id: asset.id, + path: asset.path, + newFile: true, + status: 'added', + content: new AssetProxy(asset), + }); + }); + window.repoFilesUnpublished[key] = { + slug, + collection, + status, + diffs, + updatedAt: new Date().toISOString(), + }; + } + + updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) { + window.repoFilesUnpublished[`${collection}/${slug}`].status = newStatus; + return Promise.resolve(); + } + + publishUnpublishedEntry(collection: string, slug: string) { + const key = `${collection}/${slug}`; + const unpubEntry = window.repoFilesUnpublished[key]; + + delete window.repoFilesUnpublished[key]; + + const tree = window.repoFiles; + unpubEntry.diffs.forEach(d => { + if (d.originalPath && !d.newFile) { + const originalPath = d.originalPath; + const sourceDir = dirname(originalPath); + const destDir = dirname(d.path); + const toMove = getFolderFiles(tree, originalPath.split('/')[0], '', 100).filter(f => + f.path.startsWith(sourceDir), + ); + toMove.forEach(f => { + deleteFile(f.path, tree); + writeFile(f.path.replace(sourceDir, destDir), f.content, tree); + }); + } + writeFile(d.path, d.content, tree); + }); + + return Promise.resolve(); + } + + async getDeployPreview() { + return null; + } } diff --git a/packages/core/src/bootstrap.tsx b/packages/core/src/bootstrap.tsx index 54a7bab8..d4329677 100644 --- a/packages/core/src/bootstrap.tsx +++ b/packages/core/src/bootstrap.tsx @@ -6,26 +6,24 @@ 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 { loadConfig } from './actions/config'; import App from './components/App'; import './components/entry-editor/widgets'; import ErrorBoundary from './components/ErrorBoundary'; import addExtensions from './extensions'; +import useMeta from './lib/hooks/useMeta'; +import useTranslate from './lib/hooks/useTranslate'; import { getPhrases } from './lib/phrases'; import { selectLocale } from './reducers/selectors/config'; import { store } from './store'; -import useMeta from './lib/hooks/useMeta'; import type { AnyAction } from '@reduxjs/toolkit'; +import type { FC } from 'react'; import type { ConnectedProps } from 'react-redux'; import type { BaseField, Config, UnknownField } from './interface'; import type { RootState } from './store'; -import './styles/datetime/calendar.css'; -import './styles/datetime/clock.css'; -import './styles/datetime/datetime.css'; import './styles/main.css'; const ROOT_ID = 'nc-root'; @@ -45,7 +43,9 @@ import ReactDOM from 'react-dom'; // @ts-ignore ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.usingClientEntryPoint = true; -const TranslatedApp = ({ locale, config }: AppRootProps) => { +const TranslatedApp: FC = ({ locale, config }) => { + const t = useTranslate(); + useMeta({ name: 'viewport', content: 'width=device-width, initial-scale=1.0' }); if (!config) { @@ -54,7 +54,7 @@ const TranslatedApp = ({ locale, config }: AppRootProps) => { return ( - + @@ -117,7 +117,7 @@ function bootstrap(opts?: { if (config.backend.name !== 'git-gateway') { store.dispatch(authenticateUser() as unknown as AnyAction); } - }) as AnyAction, + }) as unknown as AnyAction, ); /** diff --git a/packages/core/src/components/App.tsx b/packages/core/src/components/App.tsx index 6ddf30cf..735b7125 100644 --- a/packages/core/src/components/App.tsx +++ b/packages/core/src/components/App.tsx @@ -1,6 +1,4 @@ -import { createTheme, ThemeProvider } from '@mui/material/styles'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { translate } from 'react-polyglot'; import { connect } from 'react-redux'; import { Navigate, @@ -17,25 +15,32 @@ import TopBarProgress from 'react-topbar-progress-indicator'; import { loginUser as loginUserAction } from '@staticcms/core/actions/auth'; import { discardDraft } from '@staticcms/core/actions/entries'; import { currentBackend } from '@staticcms/core/backend'; +import { loadUnpublishedEntries } from '../actions/editorialWorkflow'; import { changeTheme } from '../actions/globalUI'; +import useDefaultPath from '../lib/hooks/useDefaultPath'; +import useTranslate from '../lib/hooks/useTranslate'; import { invokeEvent } from '../lib/registry'; -import { getDefaultPath } from '../lib/util/collection.util'; +import { isNotNullish } from '../lib/util/null.util'; +import { isEmpty } from '../lib/util/string.util'; import { generateClassNames } from '../lib/util/theming.util'; -import { selectTheme } from '../reducers/selectors/globalUI'; +import { selectUseWorkflow } from '../reducers/selectors/config'; import { useAppDispatch, useAppSelector } from '../store/hooks'; +import NotFoundPage from './NotFoundPage'; import CollectionRoute from './collections/CollectionRoute'; import { Alert } from './common/alert/Alert'; import { Confirm } from './common/confirm/Confirm'; import Loader from './common/progress/Loader'; import EditorRoute from './entry-editor/EditorRoute'; import MediaPage from './media-library/MediaPage'; -import NotFoundPage from './NotFoundPage'; import Page from './page/Page'; import Snackbars from './snackbar/Snackbars'; +import ThemeManager from './theme/ThemeManager'; +import useTheme from './theme/hooks/useTheme'; +import Dashboard from './workflow/Dashboard'; -import type { Credentials, TranslatedProps } from '@staticcms/core/interface'; +import type { Credentials } from '@staticcms/core'; import type { RootState } from '@staticcms/core/store'; -import type { ComponentType } from 'react'; +import type { FC } from 'react'; import type { ConnectedProps } from 'react-redux'; import './App.css'; @@ -65,38 +70,21 @@ function EditEntityRedirect() { return ; } -const App = ({ +const App: FC = ({ auth, user, config, collections, loginUser, isFetching, - t, scrollSyncEnabled, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const navigate = useNavigate(); const dispatch = useAppDispatch(); - const mode = useAppSelector(selectTheme); - - const theme = React.useMemo( - () => - createTheme({ - palette: { - mode, - primary: { - main: 'rgb(37 99 235)', - }, - ...(mode === 'dark' && { - background: { - paper: 'rgb(15 23 42)', - }, - }), - }, - }), - [mode], - ); + const theme = useTheme(); const configError = useCallback( (error?: string) => { @@ -154,12 +142,12 @@ const App = ({ authEndpoint={config.config.backend.auth_endpoint} config={config.config} clearHash={() => navigate('/', { replace: true })} - t={t} /> ); }, [AuthComponent, auth.error, auth.isFetching, config.config, handleLogin, navigate, t]); - const defaultPath = useMemo(() => getDefaultPath(collections), [collections]); + const useWorkflow = useAppSelector(selectUseWorkflow); + const defaultPath = useDefaultPath(collections); const { pathname } = useLocation(); const [searchParams] = useSearchParams(); @@ -176,19 +164,12 @@ const App = ({ }, [dispatch, pathname, searchParams]); useEffect(() => { - // On page load or when changing themes, best to add inline in `head` to avoid FOUC - if ( - localStorage.getItem('color-theme') === 'dark' || - (!('color-theme' in localStorage) && - window.matchMedia('(prefers-color-scheme: dark)').matches) - ) { - document.documentElement.classList.add('dark'); - dispatch(changeTheme('dark')); - } else { - document.documentElement.classList.remove('dark'); - dispatch(changeTheme('light')); + if (!user || !useWorkflow) { + return; } - }, [dispatch]); + + dispatch(loadUnpublishedEntries(collections)); + }, [collections, dispatch, useWorkflow, user]); const [prevUser, setPrevUser] = useState(user); useEffect(() => { @@ -214,6 +195,10 @@ const App = ({ {isFetching && } } /> + : } + /> } /> } /> ); - }, [authenticationPage, collections, defaultPath, isFetching, user]); + }, [authenticationPage, collections, defaultPath, isFetching, useWorkflow, user]); useEffect(() => { setTimeout(() => { @@ -255,6 +240,20 @@ const App = ({ }); }, []); + useEffect(() => { + const defaultTheme = config.config?.theme?.default_theme; + if (isEmpty(defaultTheme)) { + return; + } + + const themeName = localStorage.getItem('color-theme'); + if (isNotNullish(themeName)) { + return; + } + + dispatch(changeTheme(defaultTheme)); + }, [config.config?.theme?.default_theme, dispatch]); + if (!config.config) { return configError(t('app.app.configNotFound')); } @@ -268,7 +267,7 @@ const App = ({ } return ( - + <>
    @@ -282,7 +281,7 @@ const App = ({
    -
    + ); }; @@ -308,4 +307,4 @@ const mapDispatchToProps = { const connector = connect(mapStateToProps, mapDispatchToProps); export type AppProps = ConnectedProps; -export default connector(translate()(App) as ComponentType); +export default connector(App); diff --git a/packages/core/src/components/ErrorBoundary.css b/packages/core/src/components/ErrorBoundary.css index 52d0b32f..873fc8fd 100644 --- a/packages/core/src/components/ErrorBoundary.css +++ b/packages/core/src/components/ErrorBoundary.css @@ -1,8 +1,8 @@ .CMS_ErrorBoundary_root { + background: var(--background-dark); + @apply flex flex-col - bg-slate-50 - dark:bg-slate-900 min-h-screen gap-2; } @@ -21,8 +21,9 @@ } .CMS_ErrorBoundary_report-link { - @apply text-blue-500 - hover:underline; + color: var(--primary-main); + + @apply hover:underline; } .CMS_ErrorBoundary_content { diff --git a/packages/core/src/components/ErrorBoundary.tsx b/packages/core/src/components/ErrorBoundary.tsx index 95e3d9c0..e734c24b 100644 --- a/packages/core/src/components/ErrorBoundary.tsx +++ b/packages/core/src/components/ErrorBoundary.tsx @@ -2,14 +2,14 @@ import cleanStack from 'clean-stack'; import copyToClipboard from 'copy-text-to-clipboard'; import truncate from 'lodash/truncate'; import React, { Component } from 'react'; -import { translate } from 'react-polyglot'; import yaml from 'yaml'; import { localForage } from '@staticcms/core/lib/util'; +import useTranslate from '../lib/hooks/useTranslate'; import { generateClassNames } from '../lib/util/theming.util'; -import type { Config, TranslatedProps } from '@staticcms/core/interface'; -import type { ComponentClass, ReactNode } from 'react'; +import type { Config, TranslatedProps } from '@staticcms/core'; +import type { FC, ReactNode } from 'react'; import './ErrorBoundary.css'; @@ -86,7 +86,9 @@ interface RecoveredEntryProps { entry: string; } -const RecoveredEntry = ({ entry, t }: TranslatedProps) => { +const RecoveredEntry: FC = ({ entry }) => { + const t = useTranslate(); + console.info('[StaticCMS] Recovered entry', entry); return ( <> @@ -197,11 +199,11 @@ class ErrorBoundary extends Component, Error
    , ])}

    - {backup && showBackup && } + {backup && showBackup && } ); } } -export default translate()(ErrorBoundary) as ComponentClass; +export default ErrorBoundary; diff --git a/packages/core/src/components/MainView.css b/packages/core/src/components/MainView.css index 48826349..600bd18c 100644 --- a/packages/core/src/components/MainView.css +++ b/packages/core/src/components/MainView.css @@ -1,7 +1,7 @@ .CMS_MainView_root { - @apply flex - bg-slate-50 - dark:bg-slate-900; + background: var(--background-dark); + + @apply flex; } .CMS_MainView_body { diff --git a/packages/core/src/components/MainView.tsx b/packages/core/src/components/MainView.tsx index 4a678e82..8974864c 100644 --- a/packages/core/src/components/MainView.tsx +++ b/packages/core/src/components/MainView.tsx @@ -7,8 +7,8 @@ import BottomNavigation from './navbar/BottomNavigation'; import Navbar from './navbar/Navbar'; import Sidebar from './navbar/Sidebar'; -import type { ReactNode } from 'react'; -import type { Breadcrumb, Collection } from '../interface'; +import type { FC, ReactNode } from 'react'; +import type { Breadcrumb, CollectionWithDefaults } from '../interface'; import './MainView.css'; @@ -37,10 +37,10 @@ interface MainViewProps { noMargin?: boolean; noScroll?: boolean; children: ReactNode; - collection?: Collection; + collection?: CollectionWithDefaults; } -const MainView = ({ +const MainView: FC = ({ children, breadcrumbs, showQuickCreate = false, @@ -49,7 +49,7 @@ const MainView = ({ noScroll = false, navbarActions, collection, -}: MainViewProps) => { +}) => { return ( <> {children} diff --git a/packages/core/src/components/NotFoundPage.tsx b/packages/core/src/components/NotFoundPage.tsx index eaeb67e0..0515a749 100644 --- a/packages/core/src/components/NotFoundPage.tsx +++ b/packages/core/src/components/NotFoundPage.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { translate } from 'react-polyglot'; -import type { ComponentType } from 'react'; -import type { TranslateProps } from 'react-polyglot'; +import useTranslate from '../lib/hooks/useTranslate'; + +import type { FC } from 'react'; + +const NotFoundPage: FC = () => { + const t = useTranslate(); -const NotFoundPage = ({ t }: TranslateProps) => { return (

    {t('app.notFoundPage.header')}

    @@ -12,4 +14,4 @@ const NotFoundPage = ({ t }: TranslateProps) => { ); }; -export default translate()(NotFoundPage) as ComponentType<{}>; +export default NotFoundPage; diff --git a/packages/core/src/components/collections/Collection.css b/packages/core/src/components/collections/Collection.css index 2ee0aa6e..f751d38e 100644 --- a/packages/core/src/components/collections/Collection.css +++ b/packages/core/src/components/collections/Collection.css @@ -60,12 +60,12 @@ } .CMS_Collection_header { + color: var(--text-primary); + @apply text-xl font-semibold flex items-center - text-gray-800 - dark:text-gray-300 gap-2 md:w-auto; } diff --git a/packages/core/src/components/collections/CollectionControls.tsx b/packages/core/src/components/collections/CollectionControls.tsx index 7af6b09a..bfc47568 100644 --- a/packages/core/src/components/collections/CollectionControls.tsx +++ b/packages/core/src/components/collections/CollectionControls.tsx @@ -16,7 +16,8 @@ import type { SortMap, ViewFilter, ViewGroup, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; +import type { FC } from 'react'; interface CollectionControlsProps { viewStyle: ViewStyle; @@ -32,7 +33,7 @@ interface CollectionControlsProps { onGroupClick?: (filter: ViewGroup) => void; } -const CollectionControls = ({ +const CollectionControls: FC = ({ viewStyle, onChangeViewStyle, sortableFields, @@ -44,7 +45,7 @@ const CollectionControls = ({ onGroupClick, filter, group, -}: CollectionControlsProps) => { +}) => { const showGroupControl = useMemo( () => Boolean(viewGroups && onGroupClick && group && viewGroups.length > 0), [group, onGroupClick, viewGroups], diff --git a/packages/core/src/components/collections/CollectionHeader.tsx b/packages/core/src/components/collections/CollectionHeader.tsx index 3466538a..220c9173 100644 --- a/packages/core/src/components/collections/CollectionHeader.tsx +++ b/packages/core/src/components/collections/CollectionHeader.tsx @@ -1,10 +1,10 @@ import React, { useMemo } from 'react'; -import { translate } from 'react-polyglot'; import { useParams } from 'react-router-dom'; import useEntries from '@staticcms/core/lib/hooks/useEntries'; import useIcon from '@staticcms/core/lib/hooks/useIcon'; import useNewEntryUrl from '@staticcms/core/lib/hooks/useNewEntryUrl'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { selectEntryCollectionTitle, selectFolderEntryExtension, @@ -14,14 +14,16 @@ import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplat import Button from '../common/button/Button'; import collectionClasses from './Collection.classes'; -import type { Collection, Entry, TranslatedProps } from '@staticcms/core/interface'; +import type { CollectionWithDefaults, Entry } from '@staticcms/core'; import type { FC } from 'react'; interface CollectionHeaderProps { - collection: Collection; + collection: CollectionWithDefaults; } -const CollectionHeader: FC> = ({ collection, t }) => { +const CollectionHeader: FC = ({ collection }) => { + const t = useTranslate(); + const collectionLabel = collection.label; const collectionLabelSingular = collection.label_singular; @@ -35,10 +37,13 @@ const CollectionHeader: FC> = ({ collecti const pluralLabel = useMemo(() => { if ('nested' in collection && collection.nested?.path && filterTerm) { - const entriesByPath = entries.reduce((acc, entry) => { - acc[entry.path] = entry; - return acc; - }, {} as Record); + const entriesByPath = entries.reduce( + (acc, entry) => { + acc[entry.path] = entry; + return acc; + }, + {} as Record, + ); if (isNotEmpty(filterTerm)) { const extension = selectFolderEntryExtension(collection); @@ -72,7 +77,7 @@ const CollectionHeader: FC> = ({ collecti {newEntryUrl ? ( ) : null} @@ -80,4 +85,4 @@ const CollectionHeader: FC> = ({ collecti ); }; -export default translate()(CollectionHeader) as FC; +export default CollectionHeader; diff --git a/packages/core/src/components/collections/CollectionPage.tsx b/packages/core/src/components/collections/CollectionPage.tsx index 1a108bb2..d23be143 100644 --- a/packages/core/src/components/collections/CollectionPage.tsx +++ b/packages/core/src/components/collections/CollectionPage.tsx @@ -5,7 +5,7 @@ import useBreadcrumbs from '@staticcms/core/lib/hooks/useBreadcrumbs'; import MainView from '../MainView'; import CollectionView from './CollectionView'; -import type { Collection } from '@staticcms/core/interface'; +import type { CollectionWithDefaults } from '@staticcms/core'; import type { FC } from 'react'; const MultiSearchCollectionPage: FC = () => { @@ -26,7 +26,7 @@ const MultiSearchCollectionPage: FC = () => { }; interface SingleCollectionPageProps { - collection: Collection; + collection: CollectionWithDefaults; isSearchResults?: boolean; isSingleSearchResult?: boolean; } @@ -62,7 +62,7 @@ const SingleCollectionPage: FC = ({ }; interface CollectionPageProps { - collection?: Collection; + collection?: CollectionWithDefaults; isSearchResults?: boolean; isSingleSearchResult?: boolean; } diff --git a/packages/core/src/components/collections/CollectionRoute.tsx b/packages/core/src/components/collections/CollectionRoute.tsx index c575a3f1..71d583ec 100644 --- a/packages/core/src/components/collections/CollectionRoute.tsx +++ b/packages/core/src/components/collections/CollectionRoute.tsx @@ -1,29 +1,30 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { Navigate, useParams, useSearchParams } from 'react-router-dom'; +import useDefaultPath from '@staticcms/core/lib/hooks/useDefaultPath'; import { selectCollection, selectCollections, } from '@staticcms/core/reducers/selectors/collections'; import { useAppSelector } from '@staticcms/core/store/hooks'; -import { getDefaultPath } from '../../lib/util/collection.util'; import CollectionPage from './CollectionPage'; +import type { FC } from 'react'; + interface CollectionRouteProps { isSearchResults?: boolean; isSingleSearchResult?: boolean; } -const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRouteProps) => { +const CollectionRoute: FC = ({ isSearchResults, isSingleSearchResult }) => { const { name, searchTerm } = useParams(); const [searchParams] = useSearchParams(); const noRedirect = searchParams.has('noredirect'); - const collectionSelector = useMemo(() => selectCollection(name), [name]); - const collection = useAppSelector(collectionSelector); + const collection = useAppSelector(state => selectCollection(state, name)); const collections = useAppSelector(selectCollections); - const defaultPath = useMemo(() => getDefaultPath(collections), [collections]); + const defaultPath = useDefaultPath(collections); if (!searchTerm && (!name || !collection)) { return ; diff --git a/packages/core/src/components/collections/CollectionSearch.css b/packages/core/src/components/collections/CollectionSearch.css index eba7a555..63bd9eb7 100644 --- a/packages/core/src/components/collections/CollectionSearch.css +++ b/packages/core/src/components/collections/CollectionSearch.css @@ -13,48 +13,47 @@ } .CMS_CollectionSearch_icon { + color: var(--text-secondary); + @apply w-5 - h-5 - text-gray-500 - dark:text-gray-400; + h-5; } .CMS_CollectionSearch_input { + color: var(--text-primary); + background: color-mix(in srgb, var(--background-light) 50%, transparent); + border-color: color-mix(in srgb, var(--background-divider) 50%, transparent); + @apply block w-full p-1.5 pl-10 text-sm - text-gray-800 border - border-gray-300 rounded-lg - bg-gray-50 focus-visible:outline-none - focus:ring-4 - focus:ring-gray-200 - dark:bg-gray-700 - dark:border-gray-600 - dark:placeholder-gray-400 - dark:text-white - dark:focus:ring-slate-700; + focus:ring-4; + + &:placeholder { + color: var(--text-disabled); + } + + &:focus { + --tw-ring-color: color-mix(in srgb, var(--primary-light) 50%, transparent); + } } .CMS_CollectionSearch_search-in { + background: var(--background-light); + @apply absolute overflow-auto rounded-md - bg-white text-base shadow-md - ring-1 - ring-black - ring-opacity-5 focus:outline-none sm:text-sm - z-[1300] - dark:bg-slate-700 - dark:shadow-lg; + z-[1300]; } .CMS_CollectionSearch_search-in-content { @@ -64,17 +63,22 @@ } .CMS_CollectionSearch_search-in-label { + color: var(--text-secondary); + @apply text-base - text-slate-500 - dark:text-slate-400 py-2 px-3; } .CMS_CollectionSearch_search-in-option { + color: var(--text-primary); + + &:hover { + color: var(--text-secondary); + background: var(--primary-main); + } + @apply cursor-pointer - hover:bg-blue-500 - hover:text-gray-100 py-2 px-3; } diff --git a/packages/core/src/components/collections/CollectionSearch.tsx b/packages/core/src/components/collections/CollectionSearch.tsx index b63843fb..82b4c003 100644 --- a/packages/core/src/components/collections/CollectionSearch.tsx +++ b/packages/core/src/components/collections/CollectionSearch.tsx @@ -1,12 +1,12 @@ import { Popper as BasePopper } from '@mui/base/Popper'; import { Search as SearchIcon } from '@styled-icons/material/Search'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { translate } from 'react-polyglot'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; -import type { Collection, Collections, TranslatedProps } from '@staticcms/core/interface'; -import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react'; +import type { CollectionWithDefaults, CollectionsWithDefaults } from '@staticcms/core'; +import type { ChangeEvent, FC, FocusEvent, KeyboardEvent, MouseEvent } from 'react'; import './CollectionSearch.css'; @@ -23,19 +23,20 @@ export const classes = generateClassNames('CollectionSearch', [ ]); interface CollectionSearchProps { - collections: Collections; - collection?: Collection; + collections: CollectionsWithDefaults; + collection?: CollectionWithDefaults; searchTerm: string | undefined; onSubmit: (query: string, collection?: string) => void; } -const CollectionSearch = ({ +const CollectionSearch: FC = ({ collections: collectionsMap, collection, searchTerm = '', onSubmit, - t, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const inputRef = useRef(); const [query, setQuery] = useState(searchTerm); const [anchorEl, setAnchorEl] = useState(null); @@ -211,4 +212,4 @@ const CollectionSearch = ({ ); }; -export default translate()(CollectionSearch); +export default CollectionSearch; diff --git a/packages/core/src/components/collections/CollectionView.tsx b/packages/core/src/components/collections/CollectionView.tsx index ae049db1..ab9e97aa 100644 --- a/packages/core/src/components/collections/CollectionView.tsx +++ b/packages/core/src/components/collections/CollectionView.tsx @@ -1,25 +1,26 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { translate } from 'react-polyglot'; -import { connect } from 'react-redux'; import { - changeViewStyle as changeViewStyleAction, - filterByField as filterByFieldAction, - groupByField as groupByFieldAction, - sortByField as sortByFieldAction, + changeViewStyle, + filterByField, + groupByField, + sortByField, } from '@staticcms/core/actions/entries'; import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { - selectSortableFields, - selectViewFilters, - selectViewGroups, + getSortableFields, + getViewFilters, + getViewGroups, } from '@staticcms/core/lib/util/collection.util'; +import { selectCollections } from '@staticcms/core/reducers/selectors/collections'; import { selectEntriesFilter, selectEntriesGroup, selectEntriesSort, selectViewStyle, } from '@staticcms/core/reducers/selectors/entries'; +import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import Card from '../common/card/Card'; import collectionClasses from './Collection.classes'; import CollectionControls from './CollectionControls'; @@ -27,41 +28,50 @@ import CollectionHeader from './CollectionHeader'; import EntriesCollection from './entries/EntriesCollection'; import EntriesSearch from './entries/EntriesSearch'; -import type { - Collection, - SortDirection, - TranslatedProps, - ViewFilter, - ViewGroup, -} from '@staticcms/core/interface'; -import type { RootState } from '@staticcms/core/store'; -import type { ComponentType } from 'react'; -import type { ConnectedProps } from 'react-redux'; +import type { ViewStyle } from '@staticcms/core/constants/views'; +import type { CollectionWithDefaults, SortDirection, ViewFilter, ViewGroup } from '@staticcms/core'; +import type { FC } from 'react'; import './Collection.css'; -const CollectionView = ({ - collection, - collections, +export interface CollectionViewProps { + isSearchResults?: boolean; + isSingleSearchResult?: boolean; + name?: string; + searchTerm?: string; + filterTerm?: string; +} + +const CollectionView: FC = ({ + name: collectionName, isSearchResults, isSingleSearchResult, - searchTerm, - sortableFields, - sortByField, - sort, - viewFilters, - viewGroups, - filterTerm, - t, - filterByField, - groupByField, - filter, - group, - changeViewStyle, - viewStyle, -}: TranslatedProps) => { + searchTerm = '', + filterTerm = '', +}) => { + const t = useTranslate(); + const dispatch = useAppDispatch(); + + const collections = useAppSelector(selectCollections); + const collection = useMemo( + () => + (collectionName ? collections[collectionName] : collections[0]) as + | CollectionWithDefaults + | undefined, + [collectionName, collections], + ); + + const viewStyle = useAppSelector(selectViewStyle); + const sort = useAppSelector(state => selectEntriesSort(state, collectionName)); + const viewFilters = useMemo(() => getViewFilters(collection), [collection]); + const viewGroups = useMemo(() => getViewGroups(collection), [collection]); + + const sortableFields = useMemo(() => getSortableFields(collection, t), [collection, t]); + const filter = useAppSelector(state => selectEntriesFilter(state, collection?.name)); + const group = useAppSelector(state => selectEntriesGroup(state, collection?.name)); + const [readyToLoad, setReadyToLoad] = useState(false); - const [prevCollection, setPrevCollection] = useState(); + const [prevCollection, setPrevCollection] = useState(); useEffect(() => { setPrevCollection(collection); @@ -89,6 +99,7 @@ const CollectionView = ({ key="search" collections={searchCollections} searchTerm={searchTerm} + filterTerm={filterTerm} viewStyle={viewStyle} /> ); @@ -120,23 +131,30 @@ const CollectionView = ({ const onSortClick = useCallback( async (key: string, direction?: SortDirection) => { - collection && (await sortByField(collection, key, direction)); + collection && (await dispatch(sortByField(collection, key, direction))); }, - [collection, sortByField], + [collection, dispatch], ); const onFilterClick = useCallback( async (filter: ViewFilter) => { - collection && (await filterByField(collection, filter)); + collection && (await dispatch(filterByField(collection, filter))); }, - [collection, filterByField], + [collection, dispatch], ); const onGroupClick = useCallback( async (group: ViewGroup) => { - collection && (await groupByField(collection, group)); + collection && (await dispatch(groupByField(collection, group))); }, - [collection, groupByField], + [collection, dispatch], + ); + + const onChangeViewStyle = useCallback( + (viewStyle: ViewStyle) => { + dispatch(changeViewStyle(viewStyle)); + }, + [dispatch], ); useEffect(() => { @@ -155,7 +173,9 @@ const CollectionView = ({ } const defaultSort = collection?.sortable_fields?.default; - if (!defaultSort || !defaultSort.field) { + const defaultViewGroupName = collection?.view_groups?.default; + const defaultViewFilterName = collection?.view_filters?.default; + if (!defaultViewGroupName && !defaultViewFilterName && (!defaultSort || !defaultSort.field)) { if (!readyToLoad) { setReadyToLoad(true); } @@ -166,9 +186,27 @@ const CollectionView = ({ let alive = true; - const sortEntries = () => { + const sortGroupFilterEntries = () => { setTimeout(async () => { - await onSortClick(defaultSort.field, defaultSort.direction ?? SORT_DIRECTION_ASCENDING); + if (defaultSort && defaultSort.field) { + await onSortClick(defaultSort.field, defaultSort.direction ?? SORT_DIRECTION_ASCENDING); + } + + if (defaultViewGroupName) { + const defaultViewGroup = viewGroups?.groups.find(g => g.name === defaultViewGroupName); + if (defaultViewGroup) { + await onGroupClick(defaultViewGroup); + } + } + + if (defaultViewFilterName) { + const defaultViewFilter = viewFilters?.filters.find( + f => f.name === defaultViewFilterName, + ); + if (defaultViewFilter) { + await onFilterClick(defaultViewFilter); + } + } if (alive) { setReadyToLoad(true); @@ -176,12 +214,22 @@ const CollectionView = ({ }); }; - sortEntries(); + sortGroupFilterEntries(); return () => { alive = false; }; - }, [collection, onSortClick, prevCollection, readyToLoad, sort]); + }, [ + collection, + onFilterClick, + onGroupClick, + onSortClick, + prevCollection, + readyToLoad, + sort, + viewFilters?.filters, + viewGroups?.groups, + ]); const collectionDescription = collection?.description; @@ -193,19 +241,19 @@ const CollectionView = ({
    {t(searchResultKey, { searchTerm, collection: collection?.label })}
    - + ) : ( <> {collection ? : null} ) { - const { collections } = state; - const { - isSearchResults, - isSingleSearchResult, - name, - searchTerm = '', - filterTerm = '', - t, - } = ownProps; - const collection = (name ? collections[name] : collections[0]) as Collection | undefined; - const sort = selectEntriesSort(state, collection?.name); - const sortableFields = selectSortableFields(collection, t); - const viewFilters = selectViewFilters(collection); - const viewGroups = selectViewGroups(collection); - const filter = selectEntriesFilter(collection?.name)(state); - const group = selectEntriesGroup(state, collection?.name); - const viewStyle = selectViewStyle(state); - - return { - isSearchResults, - isSingleSearchResult, - name, - searchTerm, - filterTerm, - collection, - collections, - sort, - sortableFields, - viewFilters, - viewGroups, - filter, - group, - viewStyle, - }; -} - -const mapDispatchToProps = { - sortByField: sortByFieldAction, - filterByField: filterByFieldAction, - changeViewStyle: changeViewStyleAction, - groupByField: groupByFieldAction, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); -export type CollectionViewProps = ConnectedProps; - -export default translate()(connector(CollectionView)) as ComponentType; +export default CollectionView; diff --git a/packages/core/src/components/collections/FilterControl.css b/packages/core/src/components/collections/FilterControl.css index 638f4aca..a388c9f5 100644 --- a/packages/core/src/components/collections/FilterControl.css +++ b/packages/core/src/components/collections/FilterControl.css @@ -4,11 +4,11 @@ } .CMS_FilterControl_filter-label { + color: var(--text-primary); + @apply ml-2 text-sm - font-medium - text-gray-800 - dark:text-gray-300; + font-medium; } .CMS_FilterControl_list-root { @@ -18,25 +18,25 @@ } .CMS_FilterControl_list-label { + color: var(--text-primary); + @apply text-lg - font-bold - text-gray-800 - dark:text-white; + font-bold; } .CMS_FilterControl_list-filter { + color: var(--text-primary); + @apply ml-1.5 font-medium flex - items-center - text-gray-800 - dark:text-gray-300; + items-center; } .CMS_FilterControl_list-filter-label { + color: var(--text-primary); + @apply ml-2 text-base - font-medium - text-gray-800 - dark:text-gray-300; + font-medium; } diff --git a/packages/core/src/components/collections/FilterControl.tsx b/packages/core/src/components/collections/FilterControl.tsx index df699fea..2aea73fb 100644 --- a/packages/core/src/components/collections/FilterControl.tsx +++ b/packages/core/src/components/collections/FilterControl.tsx @@ -1,13 +1,14 @@ import React, { useCallback, useMemo } from 'react'; -import { translate } from 'react-polyglot'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; +import Checkbox from '../common/checkbox/Checkbox'; import Menu from '../common/menu/Menu'; import MenuGroup from '../common/menu/MenuGroup'; import MenuItemButton from '../common/menu/MenuItemButton'; -import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface'; -import type { FC, MouseEvent } from 'react'; +import type { FilterMap, ViewFilter } from '@staticcms/core'; +import type { MouseEvent, FC } from 'react'; import './FilterControl.css'; @@ -28,13 +29,14 @@ export interface FilterControlProps { onFilterClick: ((viewFilter: ViewFilter) => void) | undefined; } -const FilterControl = ({ +const FilterControl: FC = ({ filter = {}, viewFilters = [], variant = 'menu', onFilterClick, - t, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]); const handleFilterClick = useCallback( @@ -79,8 +81,11 @@ const FilterControl = ({ {viewFilters.map(viewFilter => { @@ -90,14 +95,14 @@ const FilterControl = ({ - @@ -109,4 +114,4 @@ const FilterControl = ({ ); }; -export default translate()(FilterControl) as FC; +export default FilterControl; diff --git a/packages/core/src/components/collections/GroupControl.css b/packages/core/src/components/collections/GroupControl.css index 82aa3794..a99a2c9e 100644 --- a/packages/core/src/components/collections/GroupControl.css +++ b/packages/core/src/components/collections/GroupControl.css @@ -10,34 +10,35 @@ } .CMS_GroupControl_list-label { + color: var(--text-primary); + @apply text-lg - font-bold - text-gray-800 - dark:text-white; + font-bold; } .CMS_GroupControl_list-option { + color: var(--text-primary); + @apply ml-0.5 font-medium flex - items-center - text-gray-800 - dark:text-gray-300; + items-center; } .CMS_GroupControl_list-option-label { + color: var(--text-primary); + @apply ml-2 text-base - font-medium - text-gray-800 - dark:text-gray-300; + font-medium; } .CMS_GroupControl_list-option-checked-icon { + color: var(--primary-main); + @apply ml-2 w-6 - h-6 - text-blue-500; + h-6; } .CMS_GroupControl_list-option-not-checked { diff --git a/packages/core/src/components/collections/GroupControl.tsx b/packages/core/src/components/collections/GroupControl.tsx index c4c5381a..fb5040cb 100644 --- a/packages/core/src/components/collections/GroupControl.tsx +++ b/packages/core/src/components/collections/GroupControl.tsx @@ -1,13 +1,13 @@ import { Check as CheckIcon } from '@styled-icons/material/Check'; import React, { useCallback, useMemo } from 'react'; -import { translate } from 'react-polyglot'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import Menu from '../common/menu/Menu'; import MenuGroup from '../common/menu/MenuGroup'; import MenuItemButton from '../common/menu/MenuItemButton'; -import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface'; +import type { GroupMap, ViewGroup } from '@staticcms/core'; import type { FC, MouseEvent } from 'react'; import './GroupControl.css'; @@ -30,13 +30,14 @@ export interface GroupControlProps { onGroupClick: ((viewGroup: ViewGroup) => void) | undefined; } -const GroupControl = ({ +const GroupControl: FC = ({ viewGroups = [], group = {}, variant = 'menu', onGroupClick, - t, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]); const handleGroupClick = useCallback( @@ -76,8 +77,11 @@ const GroupControl = ({ return ( {viewGroups.map(viewGroup => ( @@ -85,7 +89,8 @@ const GroupControl = ({ key={viewGroup.id} onClick={() => onGroupClick?.(viewGroup)} endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined} - className={classes.option} + rootClassName={classes.option} + data-testid={`group-by-option-${viewGroup.label}`} > {viewGroup.label} @@ -95,4 +100,4 @@ const GroupControl = ({ ); }; -export default translate()(GroupControl) as FC; +export default GroupControl; diff --git a/packages/core/src/components/collections/NestedCollection.css b/packages/core/src/components/collections/NestedCollection.css index 6ad6026f..b9142cd3 100644 --- a/packages/core/src/components/collections/NestedCollection.css +++ b/packages/core/src/components/collections/NestedCollection.css @@ -4,7 +4,7 @@ & > .CMS_NestedCollection_link { & .CMS_NestedCollection_node-children-icon { @apply rotate-90 - transform; + transform; } } } @@ -29,7 +29,7 @@ & > .CMS_NestedCollection_link { & .CMS_NestedCollection_node-children-icon { @apply rotate-90 - transform; + transform; } } } @@ -51,9 +51,7 @@ .CMS_NestedCollection_node-children-icon { @apply transition-transform h-5 - w-5 - group-focus-within/active-list:text-blue-500 - group-hover/active-list:text-blue-500; + w-5; } .CMS_NestedCollection_node-children { diff --git a/packages/core/src/components/collections/NestedCollection.tsx b/packages/core/src/components/collections/NestedCollection.tsx index 9eca254e..4fa53287 100644 --- a/packages/core/src/components/collections/NestedCollection.tsx +++ b/packages/core/src/components/collections/NestedCollection.tsx @@ -10,9 +10,9 @@ import { getTreeData } from '@staticcms/core/lib/util/nested.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import NavLink from '../navbar/NavLink'; -import type { Collection, Entry } from '@staticcms/core/interface'; +import type { CollectionWithDefaults, Entry } from '@staticcms/core'; import type { TreeNodeData } from '@staticcms/core/lib/util/nested.util'; -import type { MouseEvent } from 'react'; +import type { FC, MouseEvent } from 'react'; import './NestedCollection.css'; @@ -38,7 +38,7 @@ function getNodeTitle(node: TreeNodeData) { } interface TreeNodeProps { - collection: Collection; + collection: CollectionWithDefaults; treeData: TreeNodeData[]; rootIsActive: boolean; path: string; @@ -46,14 +46,14 @@ interface TreeNodeProps { onToggle: ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => void; } -const TreeNode = ({ +const TreeNode: FC = ({ collection, treeData, rootIsActive, path, depth = 0, onToggle, -}: TreeNodeProps) => { +}) => { const collectionName = collection.name; const handleClick = useCallback( @@ -184,18 +184,18 @@ export function updateNode( } export interface NestedCollectionProps { - collection: Collection; + collection: CollectionWithDefaults; filterTerm: string; } -const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) => { +const NestedCollection: FC = ({ collection, filterTerm }) => { const entries = useEntries(collection); const [treeData, setTreeData] = useState(getTreeData(collection, entries)); const [useFilter, setUseFilter] = useState(true); const [prevRootIsActive, setPrevRootIsActive] = useState(false); - const [prevCollection, setPrevCollection] = useState(null); + const [prevCollection, setPrevCollection] = useState(null); const [prevEntries, setPrevEntries] = useState(null); const [prevPath, setPrevPath] = useState(null); diff --git a/packages/core/src/components/collections/SortControl.css b/packages/core/src/components/collections/SortControl.css index fa42eeb4..1367d74e 100644 --- a/packages/core/src/components/collections/SortControl.css +++ b/packages/core/src/components/collections/SortControl.css @@ -13,34 +13,35 @@ } .CMS_SortControl_list-label { + color: var(--text-primary); + @apply text-lg - font-bold - text-gray-800 - dark:text-white; + font-bold; } .CMS_SortControl_list-option { + color: var(--text-primary); + @apply ml-0.5 font-medium flex - items-center - text-gray-800 - dark:text-gray-300; + items-center; } .CMS_SortControl_list-option-label { + color: var(--text-primary); + @apply ml-2 text-base - font-medium - text-gray-800 - dark:text-gray-300; + font-medium; } .CMS_SortControl_list-option-sorted-icon { + color: var(--primary-main); + @apply ml-2 w-6 - h-6 - text-blue-500; + h-6; } .CMS_SortControl_list-option-not-sorted { diff --git a/packages/core/src/components/collections/SortControl.tsx b/packages/core/src/components/collections/SortControl.tsx index b8f4c167..928aece7 100644 --- a/packages/core/src/components/collections/SortControl.tsx +++ b/packages/core/src/components/collections/SortControl.tsx @@ -1,24 +1,19 @@ import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown'; import { KeyboardArrowUp as KeyboardArrowUpIcon } from '@styled-icons/material/KeyboardArrowUp'; import React, { useCallback, useMemo } from 'react'; -import { translate } from 'react-polyglot'; import { SORT_DIRECTION_ASCENDING, SORT_DIRECTION_DESCENDING, SORT_DIRECTION_NONE, } from '@staticcms/core/constants'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import Menu from '../common/menu/Menu'; import MenuGroup from '../common/menu/MenuGroup'; import MenuItemButton from '../common/menu/MenuItemButton'; -import type { - SortableField, - SortDirection, - SortMap, - TranslatedProps, -} from '@staticcms/core/interface'; +import type { SortableField, SortDirection, SortMap } from '@staticcms/core'; import type { FC, MouseEvent } from 'react'; import './SortControl.css'; @@ -52,13 +47,14 @@ export interface SortControlProps { onSortClick: ((key: string, direction?: SortDirection) => Promise) | undefined; } -const SortControl = ({ +const SortControl: FC = ({ fields = [], sort = {}, variant = 'menu', onSortClick, - t, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const selectedSort = useMemo(() => { if (!sort) { return { key: undefined, direction: undefined }; @@ -120,8 +116,10 @@ const SortControl = ({ return ( {fields.map(field => { @@ -139,7 +137,7 @@ const SortControl = ({ : KeyboardArrowDownIcon : undefined } - className={classes.option} + rootClassName={classes.option} > {field.label ?? field.name} @@ -150,4 +148,4 @@ const SortControl = ({ ); }; -export default translate()(SortControl) as FC; +export default SortControl; diff --git a/packages/core/src/components/collections/entries/Entries.classes.ts b/packages/core/src/components/collections/entries/Entries.classes.ts index 7533c834..2c1c5cd0 100644 --- a/packages/core/src/components/collections/entries/Entries.classes.ts +++ b/packages/core/src/components/collections/entries/Entries.classes.ts @@ -1,7 +1,7 @@ import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; const entriesClasses = generateClassNames('Entries', [ - 'root', + 'no-entries', 'group', 'group-content-wrapper', 'group-content', diff --git a/packages/core/src/components/collections/entries/Entries.css b/packages/core/src/components/collections/entries/Entries.css index 7f705080..2afcf6c0 100644 --- a/packages/core/src/components/collections/entries/Entries.css +++ b/packages/core/src/components/collections/entries/Entries.css @@ -1,9 +1,10 @@ -.CMS_Entries_root { +.CMS_Entries_no-entries { + color: var(--warning-contrast-color); + background: var(--warning-main); + @apply py-2 px-3 rounded-md - bg-yellow-300/75 - dark:bg-yellow-800/75 text-sm; } @@ -32,13 +33,13 @@ } .CMS_Entries_entry-listing-loading { + background: color-mix(in srgb, var(--background-dark) 50%, transparent); + @apply absolute inset-0 flex items-center - justify-center - bg-slate-50/50 - dark:bg-slate-900/50; + justify-center; } .CMS_Entries_entry-listing-grid { @@ -70,18 +71,16 @@ } .CMS_Entries_entry-listing-table { + background: var(--background-main); + border-color: var(--background-light); + @apply relative max-h-full h-full overflow-hidden p-1.5 - bg-white shadow-sm border - border-gray-100 - dark:bg-slate-800 - dark:border-gray-700/40 - dark:shadow-md rounded-xl; } @@ -91,14 +90,9 @@ overflow-auto; } -.CMS_Entries_entry-listing-table-row { - @apply hover:bg-gray-200 - dark:hover:bg-slate-700/70; -} - .CMS_Entries_entry-listing-local-backup { + color: var(--primary-light); + @apply w-5 - h-5 - text-blue-600 - dark:text-blue-300; + h-5; } diff --git a/packages/core/src/components/collections/entries/Entries.tsx b/packages/core/src/components/collections/entries/Entries.tsx index 8454b329..63965d50 100644 --- a/packages/core/src/components/collections/entries/Entries.tsx +++ b/packages/core/src/components/collections/entries/Entries.tsx @@ -1,13 +1,14 @@ import React, { useMemo } from 'react'; -import { translate } from 'react-polyglot'; import Loader from '@staticcms/core/components/common/progress/Loader'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import entriesClasses from './Entries.classes'; import EntryListing from './EntryListing'; +import type { CollectionWithDefaults, CollectionsWithDefaults, Entry } from '@staticcms/core'; import type { ViewStyle } from '@staticcms/core/constants/views'; -import type { Collection, Collections, Entry, TranslatedProps } from '@staticcms/core/interface'; import type Cursor from '@staticcms/core/lib/util/Cursor'; +import type { FC } from 'react'; import './Entries.css'; @@ -22,26 +23,27 @@ export interface BaseEntriesProps { } export interface SingleCollectionEntriesProps extends BaseEntriesProps { - collection: Collection; + collection: CollectionWithDefaults; } export interface MultipleCollectionEntriesProps extends BaseEntriesProps { - collections: Collections; + collections: CollectionsWithDefaults; } export type EntriesProps = SingleCollectionEntriesProps | MultipleCollectionEntriesProps; -const Entries = ({ +const Entries: FC = ({ entries, isFetching, viewStyle, cursor, filterTerm, handleCursorActions, - t, page, ...otherProps -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const loadingMessages = useMemo( () => [ t('collection.entries.loadingEntries'), @@ -84,7 +86,7 @@ const Entries = ({ ); } - return
    {t('collection.entries.noEntries')}
    ; + return
    {t('collection.entries.noEntries')}
    ; }; -export default translate()(Entries); +export default Entries; diff --git a/packages/core/src/components/collections/entries/EntriesCollection.tsx b/packages/core/src/components/collections/entries/EntriesCollection.tsx index 8599d984..2059cafb 100644 --- a/packages/core/src/components/collections/entries/EntriesCollection.tsx +++ b/packages/core/src/components/collections/entries/EntriesCollection.tsx @@ -1,23 +1,24 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { translate } from 'react-polyglot'; import { connect } from 'react-redux'; import { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/entries'; import useEntries from '@staticcms/core/lib/hooks/useEntries'; import useGroups from '@staticcms/core/lib/hooks/useGroups'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { Cursor } from '@staticcms/core/lib/util'; import classNames from '@staticcms/core/lib/util/classNames.util'; +import { selectUseWorkflow } from '@staticcms/core/reducers/selectors/config'; import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors'; import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries'; -import { useAppDispatch } from '@staticcms/core/store/hooks'; +import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import Button from '../../common/button/Button'; import Entries from './Entries'; import entriesClasses from './Entries.classes'; import type { ViewStyle } from '@staticcms/core/constants/views'; -import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface'; +import type { CollectionWithDefaults, Entry, GroupOfEntries } from '@staticcms/core'; import type { RootState } from '@staticcms/core/store'; -import type { ComponentType } from 'react'; +import type { FC } from 'react'; import type { t } from 'react-polyglot'; import type { ConnectedProps } from 'react-redux'; @@ -56,17 +57,18 @@ export function filterNestedEntries(path: string, collectionFolder: string, entr return filtered; } -const EntriesCollection = ({ +const EntriesCollection: FC = ({ collection, filterTerm, isFetching, viewStyle, cursor, page, - t, entriesLoaded, readyToLoad, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const dispatch = useAppDispatch(); const [prevReadyToLoad, setPrevReadyToLoad] = useState(false); @@ -75,6 +77,7 @@ const EntriesCollection = ({ const groups = useGroups(collection.name); const entries = useEntries(collection); + const useWorkflow = useAppSelector(selectUseWorkflow); const filteredEntries = useMemo(() => { if ('nested' in collection) { @@ -97,7 +100,15 @@ const EntriesCollection = ({ setPrevReadyToLoad(readyToLoad); setPrevCollection(collection); - }, [collection, dispatch, entriesLoaded, prevCollection, prevReadyToLoad, readyToLoad]); + }, [ + collection, + dispatch, + entriesLoaded, + prevCollection, + prevReadyToLoad, + readyToLoad, + useWorkflow, + ]); const handleCursorActions = useCallback( (action: string) => { @@ -128,6 +139,8 @@ const EntriesCollection = ({ variant={index === selectedGroup ? 'contained' : 'text'} onClick={handleGroupClick(index)} className={entriesClasses['group-button']} + aria-label={`group by ${title}`} + data-testid={`group-by-${title}`} > {title} @@ -141,7 +154,6 @@ const EntriesCollection = ({ collection={collection} entries={getGroupEntries(filteredEntries, groups[selectedGroup].paths)} isFetching={isFetching} - collectionName={collection.label} viewStyle={viewStyle} cursor={cursor} handleCursorActions={handleCursorActions} @@ -158,7 +170,6 @@ const EntriesCollection = ({ collection={collection} entries={filteredEntries} isFetching={isFetching} - collectionName={collection.label} viewStyle={viewStyle} cursor={cursor} handleCursorActions={handleCursorActions} @@ -169,7 +180,7 @@ const EntriesCollection = ({ }; interface EntriesCollectionOwnProps { - collection: Collection; + collection: CollectionWithDefaults; viewStyle: ViewStyle; readyToLoad: boolean; filterTerm: string; @@ -193,4 +204,4 @@ const mapDispatchToProps = {}; const connector = connect(mapStateToProps, mapDispatchToProps); export type EntriesCollectionProps = ConnectedProps; -export default connector(translate()(EntriesCollection) as ComponentType); +export default connector(EntriesCollection); diff --git a/packages/core/src/components/collections/entries/EntriesSearch.tsx b/packages/core/src/components/collections/entries/EntriesSearch.tsx index 5e1ecfac..6f4e9d21 100644 --- a/packages/core/src/components/collections/entries/EntriesSearch.tsx +++ b/packages/core/src/components/collections/entries/EntriesSearch.tsx @@ -11,20 +11,22 @@ import { selectSearchedEntries } from '@staticcms/core/reducers/selectors/entrie import Entries from './Entries'; import type { ViewStyle } from '@staticcms/core/constants/views'; -import type { Collections } from '@staticcms/core/interface'; +import type { CollectionsWithDefaults } from '@staticcms/core'; import type { RootState } from '@staticcms/core/store'; +import type { FC } from 'react'; import type { ConnectedProps } from 'react-redux'; -const EntriesSearch = ({ +const EntriesSearch: FC = ({ collections, entries, isFetching, page, searchTerm, + filterTerm, viewStyle, searchEntries, clearSearch, -}: EntriesSearchProps) => { +}) => { const collectionNames = useMemo(() => Object.keys(collections), [collections]); const getCursor = useCallback(() => { @@ -64,23 +66,25 @@ const EntriesSearch = ({ entries={entries} isFetching={isFetching} viewStyle={viewStyle} + filterTerm={filterTerm} /> ); }; interface EntriesSearchOwnProps { searchTerm: string; - collections: Collections; + filterTerm: string; + collections: CollectionsWithDefaults; viewStyle: ViewStyle; } function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) { - const { searchTerm, collections, viewStyle } = ownProps; + const { searchTerm, filterTerm, collections, viewStyle } = ownProps; const collectionNames = Object.keys(collections); const isFetching = state.search.isFetching; const page = state.search.page; const entries = selectSearchedEntries(state, collectionNames); - return { isFetching, page, collections, viewStyle, entries, searchTerm }; + return { isFetching, page, collections, viewStyle, entries, searchTerm, filterTerm }; } const mapDispatchToProps = { diff --git a/packages/core/src/components/collections/entries/EntryCard.css b/packages/core/src/components/collections/entries/EntryCard.css index 1c6cc772..c767f50e 100644 --- a/packages/core/src/components/collections/entries/EntryCard.css +++ b/packages/core/src/components/collections/entries/EntryCard.css @@ -3,6 +3,15 @@ w-full relative overflow-visible; + + &.CMS_EntryCard_no-margin { + & .CMS_EntryCard_content-wrapper { + inset: unset; + + @apply p-0 + w-full; + } + } } .CMS_EntryCard_content-wrapper { @@ -14,7 +23,8 @@ .CMS_EntryCard_content { @apply p-1 h-full - w-full; + w-full + relative; } .CMS_EntryCard_card { @@ -23,18 +33,47 @@ .CMS_EntryCard_card-content { @apply flex - w-full - items-center - justify-between; + flex-col + w-full; } -.CMS_EntryCard_card-summary { - @apply truncate; +.CMS_EntryCard_summary-wrapper { + @apply flex + gap-1 + w-full + flex-nowrap + items-center; +} + +.CMS_EntryCard_summary { + color: var(--text-primary); + + @apply truncate + flex-grow + font-bold; +} + +.CMS_EntryCard_description { + color: var(--text-primary); + + @apply truncate + text-sm; +} + +.CMS_EntryCard_date { + color: var(--text-secondary); + + @apply truncate + text-sm; } .CMS_EntryCard_local-backup-icon { + color: var(--primary-light); + @apply w-5 h-5 - text-blue-600 - dark:text-blue-300; + flex-shrink-0; +} + +.CMS_EntryCard_workflow-status { } diff --git a/packages/core/src/components/collections/entries/EntryCard.tsx b/packages/core/src/components/collections/entries/EntryCard.tsx index 17a507bb..303a0dd9 100644 --- a/packages/core/src/components/collections/entries/EntryCard.tsx +++ b/packages/core/src/components/collections/entries/EntryCard.tsx @@ -1,62 +1,87 @@ import { Info as InfoIcon } from '@styled-icons/material-outlined/Info'; +import format from 'date-fns/format'; +import parse from 'date-fns/parse'; import React, { useEffect, useMemo, useState } from 'react'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { getPreviewCard } from '@staticcms/core/lib/registry'; import { getEntryBackupKey } from '@staticcms/core/lib/util/backup.util'; +import classNames from '@staticcms/core/lib/util/classNames.util'; import { selectEntryCollectionTitle, - selectFields, + getFields, selectTemplateName, } from '@staticcms/core/lib/util/collection.util'; import localForage from '@staticcms/core/lib/util/localForage'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import { selectConfig } from '@staticcms/core/reducers/selectors/config'; -import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI'; import { useAppSelector } from '@staticcms/core/store/hooks'; import Card from '../../common/card/Card'; import CardActionArea from '../../common/card/CardActionArea'; import CardContent from '../../common/card/CardContent'; import CardMedia from '../../common/card/CardMedia'; import useWidgetsFor from '../../common/widget/useWidgetsFor'; +import WorkflowStatusPill from '../../workflow/WorkflowStatusPill'; import type { BackupEntry, - Collection, + CollectionWithDefaults, + DateTimeFormats, Entry, FileOrImageField, - TranslatedProps, -} from '@staticcms/core/interface'; -import type { FC } from 'react'; +} from '@staticcms/core'; +import type { FC, ReactNode } from 'react'; import './EntryCard.css'; export const classes = generateClassNames('EntryCard', [ 'root', + 'no-margin', 'content-wrapper', 'content', 'card', 'card-content', - 'card-summary', + 'summary-wrapper', + 'summary', + 'description', + 'date', 'local-backup-icon', + 'workflow-status', ]); export interface EntryCardProps { entry: Entry; - imageFieldName?: string | null | undefined; - collection: Collection; + imageFieldName: string | null | undefined; + descriptionFieldName: string | null | undefined; + dateFieldName: string | null | undefined; + dateFormats: DateTimeFormats | undefined; + collection: CollectionWithDefaults; + noMargin?: boolean; + backTo?: string; + children?: ReactNode; + useWorkflow: boolean; } -const EntryCard: FC> = ({ +const EntryCard: FC = ({ collection, entry, imageFieldName, - t, + descriptionFieldName, + dateFieldName, + dateFormats, + noMargin = false, + backTo, + children, + useWorkflow, }) => { + const t = useTranslate(); + const entryData = entry.data; const path = useMemo( - () => `/collections/${collection.name}/entries/${entry.slug}`, - [collection.name, entry.slug], + () => + `/collections/${collection.name}/entries/${entry.slug}${backTo ? `?backTo=${backTo}` : ''}`, + [backTo, collection.name, entry.slug], ); const imageField = useMemo( @@ -73,15 +98,40 @@ const EntryCard: FC> = ({ let i = imageFieldName ? (entryData?.[imageFieldName] as string | undefined) : undefined; if (i) { - i = encodeURI(i.trim()); + i = i.trim(); } return i; }, [entryData, imageFieldName]); + const description = useMemo(() => { + let d = descriptionFieldName + ? (entryData?.[descriptionFieldName] as string | undefined) + : undefined; + + if (d) { + d = d.trim(); + } + + return d; + }, [entryData, descriptionFieldName]); + + const date = useMemo(() => { + let d = dateFieldName ? (entryData?.[dateFieldName] as string | undefined) : undefined; + + if (d && dateFormats) { + const date = parse(d, dateFormats.storageFormat, new Date()); + if (!isNaN(date.getTime())) { + d = format(date, dateFormats.displayFormat); + } + } + + return d; + }, [dateFieldName, entryData, dateFormats]); + const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); - const fields = selectFields(collection, entry.slug); + const fields = useMemo(() => getFields(collection, entry.slug), [collection, entry.slug]); const config = useAppSelector(selectConfig); @@ -97,8 +147,6 @@ const EntryCard: FC> = ({ [templateName], ); - const theme = useAppSelector(selectTheme); - const [hasLocalBackup, setHasLocalBackup] = useState(false); useEffect(() => { if (config?.disable_local_backup) { @@ -130,7 +178,7 @@ const EntryCard: FC> = ({ if (PreviewCardComponent) { return ( -
    +
    @@ -141,10 +189,10 @@ const EntryCard: FC> = ({ entry={entry} widgetFor={widgetFor} widgetsFor={widgetsFor} - theme={theme} hasLocalBackup={hasLocalBackup} /> + {children}
    @@ -153,7 +201,7 @@ const EntryCard: FC> = ({ } return ( -
    +
    @@ -169,16 +217,27 @@ const EntryCard: FC> = ({ ) : null}
    -
    {summary}
    - {hasLocalBackup ? ( - - ) : null} +
    +
    {summary}
    + {hasLocalBackup ? ( + + ) : null} + {useWorkflow ? ( + + ) : null} +
    + {description ?
    {description}
    : null} + {date ?
    {String(date)}
    : null}
    + {children}
    diff --git a/packages/core/src/components/collections/entries/EntryListing.tsx b/packages/core/src/components/collections/entries/EntryListing.tsx index b8adddf9..298cbfba 100644 --- a/packages/core/src/components/collections/entries/EntryListing.tsx +++ b/packages/core/src/components/collections/entries/EntryListing.tsx @@ -2,21 +2,23 @@ import React, { useCallback, useMemo } from 'react'; import { VIEW_STYLE_TABLE } from '@staticcms/core/constants/views'; import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; -import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util'; +import { getInferredFields, getFields } from '@staticcms/core/lib/util/collection.util'; import { isNullish } from '@staticcms/core/lib/util/null.util'; import { toTitleCaseFromKey } from '@staticcms/core/lib/util/string.util'; +import { getDatetimeFormats } from '@staticcms/datetime/datetime.util'; import entriesClasses from './Entries.classes'; import EntryListingGrid from './EntryListingGrid'; import EntryListingTable from './EntryListingTable'; import type { ViewStyle } from '@staticcms/core/constants/views'; import type { - Collection, CollectionEntryData, - Collections, + CollectionWithDefaults, + CollectionsWithDefaults, + DateTimeField, Entry, Field, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; import type Cursor from '@staticcms/core/lib/util/Cursor'; import type { FC } from 'react'; @@ -31,11 +33,11 @@ export interface BaseEntryListingProps { } export interface SingleCollectionEntryListingProps extends BaseEntryListingProps { - collection: Collection; + collection: CollectionWithDefaults; } export interface MultipleCollectionEntryListingProps extends BaseEntryListingProps { - collections: Collections; + collections: CollectionsWithDefaults; } export type EntryListingProps = @@ -62,26 +64,7 @@ const EntryListing: FC = ({ }, [handleCursorActions, hasMore]); const inferFields = useCallback( - ( - collection?: Collection, - ): { - titleField?: string | null; - descriptionField?: string | null; - imageField?: string | null; - remainingFields?: Field[]; - } => { - if (!collection) { - return {}; - } - - const titleField = selectInferredField(collection, 'title'); - const descriptionField = selectInferredField(collection, 'description'); - const imageField = selectInferredField(collection, 'image'); - const fields = selectFields(collection); - const inferredFields = [titleField, descriptionField, imageField]; - const remainingFields = fields && fields.filter(f => inferredFields.indexOf(f.name) === -1); - return { titleField, descriptionField, imageField, remainingFields }; - }, + (collection?: CollectionWithDefaults) => getInferredFields(collection), [], ); @@ -110,10 +93,13 @@ const EntryListing: FC = ({ } const fieldNames = otherProps.collection.summary_fields; - const collectionFields = selectFields(otherProps.collection).reduce((acc, f) => { - acc[f.name] = f; - return acc; - }, {} as Record); + const collectionFields = getFields(otherProps.collection).reduce( + (acc, f) => { + acc[f.name] = f; + return acc; + }, + {} as Record, + ); return fieldNames.map(summaryField => { const field = collectionFields[summaryField]; @@ -130,9 +116,21 @@ const EntryListing: FC = ({ if ('collection' in otherProps) { const inferredFields = inferFields(otherProps.collection); + const dateField = + 'fields' in otherProps.collection + ? (otherProps.collection.fields?.find( + f => f.name === inferredFields.date && f.widget === 'datetime', + ) as DateTimeField) + : undefined; + + const formats = getDatetimeFormats(dateField); + return entries.map(entry => ({ collection: otherProps.collection, - imageFieldName: inferredFields.imageField, + imageFieldName: inferredFields.image, + descriptionFieldName: inferredFields.description, + dateFieldName: inferredFields.date, + dateFormats: formats, viewStyle, entry, key: entry.slug, @@ -146,13 +144,26 @@ const EntryListing: FC = ({ coll => coll.name === collectionName, ); - const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined; const inferredFields = inferFields(collection); + + const dateField = + collection && 'fields' in collection + ? (collection.fields?.find( + f => f.name === inferredFields.date && f.widget === 'datetime', + ) as DateTimeField) + : undefined; + + const formats = getDatetimeFormats(dateField); + + const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined; return collection ? { collection, entry, - imageFieldName: inferredFields.imageField, + imageFieldName: inferredFields.image, + descriptionFieldName: inferredFields.description, + dateFieldName: inferredFields.date, + dateFormats: formats, viewStyle, collectionLabel, key: entry.slug, @@ -173,7 +184,6 @@ const EntryListing: FC = ({ loadNext={handleLoadMore} canLoadMore={Boolean(hasMore && handleLoadMore)} isLoadingEntries={isLoadingEntries} - t={t} />
    ); @@ -186,7 +196,6 @@ const EntryListing: FC = ({ onLoadMore={handleLoadMore} canLoadMore={Boolean(hasMore && handleLoadMore)} isLoadingEntries={isLoadingEntries} - t={t} /> ); }; diff --git a/packages/core/src/components/collections/entries/EntryListingCardGrid.tsx b/packages/core/src/components/collections/entries/EntryListingCardGrid.tsx index 47e7ce01..4b1812d5 100644 --- a/packages/core/src/components/collections/entries/EntryListingCardGrid.tsx +++ b/packages/core/src/components/collections/entries/EntryListingCardGrid.tsx @@ -8,38 +8,39 @@ import { COLLECTION_CARD_MARGIN, COLLECTION_CARD_WIDTH, } from '@staticcms/core/constants/views'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { getPreviewCard } from '@staticcms/core/lib/registry'; import classNames from '@staticcms/core/lib/util/classNames.util'; import { selectTemplateName } from '@staticcms/core/lib/util/collection.util'; import { isNotNullish } from '@staticcms/core/lib/util/null.util'; +import { selectUseWorkflow } from '@staticcms/core/reducers/selectors/config'; +import { useAppSelector } from '@staticcms/core/store/hooks'; import entriesClasses from './Entries.classes'; import EntryCard from './EntryCard'; -import type { CollectionEntryData } from '@staticcms/core/interface'; +import type { CollectionEntryData } from '@staticcms/core'; import type { FC } from 'react'; -import type { t } from 'react-polyglot'; import type { GridChildComponentProps } from 'react-window'; export interface EntryListingCardGridProps { scrollContainerRef: React.MutableRefObject; entryData: CollectionEntryData[]; onScroll: () => void; - t: t; } export interface CardGridItemData { columnCount: number; cardHeights: number[]; entryData: CollectionEntryData[]; - t: t; + useWorkflow: boolean; } -const CardWrapper = ({ +const CardWrapper: FC> = ({ rowIndex, columnIndex, style, - data: { columnCount, cardHeights, entryData, t }, -}: GridChildComponentProps) => { + data: { columnCount, cardHeights, entryData, useWorkflow }, +}) => { const left = useMemo( () => parseFloat( @@ -82,7 +83,10 @@ const CardWrapper = ({ collection={data.collection} entry={data.entry} imageFieldName={data.imageFieldName} - t={t} + descriptionFieldName={data.descriptionFieldName} + dateFieldName={data.dateFieldName} + dateFormats={data.dateFormats} + useWorkflow={useWorkflow} />
    ); @@ -92,8 +96,11 @@ const EntryListingCardGrid: FC = ({ entryData, scrollContainerRef, onScroll, - t, }) => { + const t = useTranslate(); + + const useWorkflow = useAppSelector(selectUseWorkflow); + const [version, setVersion] = useState(0); const handleResize = useCallback(() => { @@ -195,6 +202,7 @@ const EntryListingCardGrid: FC = ({ entryData, cardHeights, columnCount, + useWorkflow, t, } as CardGridItemData } diff --git a/packages/core/src/components/collections/entries/EntryListingGrid.tsx b/packages/core/src/components/collections/entries/EntryListingGrid.tsx index 12e13a2e..12fbd10d 100644 --- a/packages/core/src/components/collections/entries/EntryListingGrid.tsx +++ b/packages/core/src/components/collections/entries/EntryListingGrid.tsx @@ -1,21 +1,20 @@ import React, { useCallback, useEffect, useRef } from 'react'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import { isNotNullish } from '@staticcms/core/lib/util/null.util'; import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; import { useAppSelector } from '@staticcms/core/store/hooks'; import entriesClasses from './Entries.classes'; import EntryListingCardGrid from './EntryListingCardGrid'; -import type { CollectionEntryData } from '@staticcms/core/interface'; +import type { CollectionEntryData } from '@staticcms/core'; import type { FC } from 'react'; -import type { t } from 'react-polyglot'; export interface EntryListingGridProps { entryData: CollectionEntryData[]; canLoadMore?: boolean; isLoadingEntries: boolean; onLoadMore: () => void; - t: t; } const EntryListingGrid: FC = ({ @@ -23,8 +22,9 @@ const EntryListingGrid: FC = ({ canLoadMore, isLoadingEntries, onLoadMore, - t, }) => { + const t = useTranslate(); + const gridContainerRef = useRef(null); const isFetching = useAppSelector(selectIsFetching); @@ -65,7 +65,6 @@ const EntryListingGrid: FC = ({ entryData={entryData} scrollContainerRef={gridContainerRef} onScroll={handleScroll} - t={t} />
    {isLoadingEntries ? ( diff --git a/packages/core/src/components/collections/entries/EntryListingTable.tsx b/packages/core/src/components/collections/entries/EntryListingTable.tsx index 69767f2d..2b75dca4 100644 --- a/packages/core/src/components/collections/entries/EntryListingTable.tsx +++ b/packages/core/src/components/collections/entries/EntryListingTable.tsx @@ -1,17 +1,18 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useVirtual } from 'react-virtual'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import classNames from '@staticcms/core/lib/util/classNames.util'; import { isNotNullish } from '@staticcms/core/lib/util/null.util'; +import { selectUseWorkflow } from '@staticcms/core/reducers/selectors/config'; import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; import { useAppSelector } from '@staticcms/core/store/hooks'; import Table from '../../common/table/Table'; import entriesClasses from './Entries.classes'; import EntryRow from './EntryRow'; -import type { CollectionEntryData } from '@staticcms/core/interface'; +import type { CollectionEntryData } from '@staticcms/core'; import type { FC } from 'react'; -import type { t } from 'react-polyglot'; export interface EntryListingTableProps { isSingleCollectionInList: boolean; @@ -23,7 +24,6 @@ export interface EntryListingTableProps { canLoadMore: boolean; isLoadingEntries: boolean; loadNext: () => void; - t: t; } const EntryListingTable: FC = ({ @@ -33,26 +33,32 @@ const EntryListingTable: FC = ({ canLoadMore, isLoadingEntries, loadNext, - t, }) => { + const t = useTranslate(); + const isFetching = useAppSelector(selectIsFetching); const tableContainerRef = useRef(null); - const { virtualItems: virtualRows, totalSize } = useVirtual({ - parentRef: tableContainerRef, - size: entryData.length, + const rowVirtualizer = useVirtualizer({ + getScrollElement: () => tableContainerRef.current, + count: entryData.length, overscan: 10, + estimateSize: () => 45, }); + const virtualRows = rowVirtualizer.getVirtualItems(); + const paddingTop = useMemo( () => (virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0), [virtualRows], ); const paddingBottom = useMemo( () => - virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0, - [totalSize, virtualRows], + virtualRows.length > 0 + ? rowVirtualizer.getTotalSize() - (virtualRows?.[virtualRows.length - 1]?.end || 0) + : 0, + [rowVirtualizer, virtualRows], ); const fetchMoreOnBottomReached = useCallback( @@ -73,6 +79,8 @@ const EntryListingTable: FC = ({ fetchMoreOnBottomReached(scrollHeight, scrollTop, clientHeight); }, [clientHeight, fetchMoreOnBottomReached, scrollHeight, scrollTop]); + const useWorkflow = useAppSelector(selectUseWorkflow); + const baseColumnHeaders = useMemo(() => { const cols = [...summaryFields.map(f => f.label), '']; @@ -80,8 +88,12 @@ const EntryListingTable: FC = ({ cols.unshift(t('collection.table.collection')); } + if (useWorkflow) { + cols.push(''); + } + return cols; - }, [isSingleCollectionInList, summaryFields, t]); + }, [isSingleCollectionInList, summaryFields, t, useWorkflow]); const columnFields = useMemo(() => [...summaryFields.map(f => f.name)], [summaryFields]); @@ -101,7 +113,7 @@ const EntryListingTable: FC = ({ )} - {virtualRows.map(virtualRow => { + {rowVirtualizer.getVirtualItems().map(virtualRow => { const data = entryData[virtualRow.index]; return ( > = ({ [collection.name, entry.slug], ); + const { default_locale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {}; + const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); - const fields = selectFields(collection, entry.slug); + const fields = useMemo(() => getFields(collection, entry.slug), [collection, entry.slug]); const config = useAppSelector(selectConfig); + const useWorkflow = useAppSelector(selectUseWorkflow); const templateName = useMemo( () => selectTemplateName(collection, entry.slug), [collection, entry.slug], ); - const theme = useAppSelector(selectTheme); - const [hasLocalBackup, setHasLocalBackup] = useState(false); useEffect(() => { if (config?.disable_local_backup) { @@ -105,14 +108,11 @@ const EntryRow: FC> = ({ return ( {field && FieldPreviewComponent ? ( - + ) : isNullish(value) ? ( '' + ) : field?.widget === 'relation' ? ( + ) : ( String(value) )} @@ -127,6 +127,11 @@ const EntryRow: FC> = ({ /> ) : null} + {useWorkflow ? ( + + + + ) : null} ); }; diff --git a/packages/core/src/components/collections/mobile/MobileCollectionControls.classes.ts b/packages/core/src/components/collections/mobile/MobileCollectionControls.classes.ts index f865be38..7627958b 100644 --- a/packages/core/src/components/collections/mobile/MobileCollectionControls.classes.ts +++ b/packages/core/src/components/collections/mobile/MobileCollectionControls.classes.ts @@ -4,7 +4,6 @@ const mobileCollectionControlsClasses = generateClassNames('MobileCollectionCont 'root', 'content', 'toggle', - 'toggle-icon', ]); export default mobileCollectionControlsClasses; diff --git a/packages/core/src/components/collections/mobile/MobileCollectionControls.css b/packages/core/src/components/collections/mobile/MobileCollectionControls.css index 13f067e8..bc990880 100644 --- a/packages/core/src/components/collections/mobile/MobileCollectionControls.css +++ b/packages/core/src/components/collections/mobile/MobileCollectionControls.css @@ -14,6 +14,8 @@ } .CMS_MobileCollectionControls_content { + background: var(--background-main); + @apply px-5 py-4 flex @@ -21,17 +23,10 @@ gap-6 h-full w-full - overflow-y-auto - bg-white - dark:bg-slate-800; + overflow-y-auto; } .CMS_MobileCollectionControls_toggle { @apply flex lg:!hidden; } - -.CMS_MobileCollectionControls_toggle-icon { - @apply w-5 - h-5; -} diff --git a/packages/core/src/components/collections/mobile/MobileCollectionControls.tsx b/packages/core/src/components/collections/mobile/MobileCollectionControls.tsx index 848e5f1a..5e7f0d79 100644 --- a/packages/core/src/components/collections/mobile/MobileCollectionControls.tsx +++ b/packages/core/src/components/collections/mobile/MobileCollectionControls.tsx @@ -29,12 +29,12 @@ const MobileCollectionControls: FC = props => { return ( <> - - + rootClassName={mobileCollectionControlsClasses.toggle} + aria-label="toggle menu" + /> = ({ mobileOpen, onMobileOpenToggle, showFilterControl, @@ -37,7 +38,7 @@ const MobileCollectionControlsDrawer = ({ sort, fields, onSortClick, -}: MobileCollectionControlsDrawerProps) => { +}) => { const container = useMemo( () => (typeof window !== 'undefined' ? window.document.body : undefined), [], diff --git a/packages/core/src/components/common/alert/Alert.css b/packages/core/src/components/common/alert/Alert.css index 5ea9a225..9e054bd5 100644 --- a/packages/core/src/components/common/alert/Alert.css +++ b/packages/core/src/components/common/alert/Alert.css @@ -11,11 +11,11 @@ } .CMS_Alert_content { + color: var(--text-secondary); + @apply px-6 pb-4 - text-sm - text-slate-500 - dark:text-slate-400; + text-sm; } .CMS_Alert_actions { diff --git a/packages/core/src/components/common/alert/Alert.tsx b/packages/core/src/components/common/alert/Alert.tsx index fd7e739a..33880d8d 100644 --- a/packages/core/src/components/common/alert/Alert.tsx +++ b/packages/core/src/components/common/alert/Alert.tsx @@ -1,13 +1,13 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { translate } from 'react-polyglot'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import AlertEvent from '@staticcms/core/lib/util/events/AlertEvent'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import { useWindowEvent } from '@staticcms/core/lib/util/window.util'; import Button from '../button/Button'; import Modal from '../modal/Modal'; -import type { TranslateProps } from 'react-polyglot'; +import type { FC } from 'react'; import './Alert.css'; @@ -30,7 +30,9 @@ export interface AlertDialogProps extends AlertProps { resolve: () => void; } -const AlertDialog = ({ t }: TranslateProps) => { +const AlertDialog: FC = () => { + const t = useTranslate(); + const [detail, setDetail] = useState(null); const { resolve, @@ -98,7 +100,7 @@ const AlertDialog = ({ t }: TranslateProps) => { ); }; -export const Alert = translate()(AlertDialog); +export const Alert = AlertDialog; const alert = (props: AlertProps) => { return new Promise(resolve => { diff --git a/packages/core/src/components/common/autocomplete/Autocomplete.css b/packages/core/src/components/common/autocomplete/Autocomplete.css index aff0aa11..fa3bf811 100644 --- a/packages/core/src/components/common/autocomplete/Autocomplete.css +++ b/packages/core/src/components/common/autocomplete/Autocomplete.css @@ -8,13 +8,11 @@ &.CMS_Autocomplete_disabled { & .CMS_Autocomplete_input { - @apply text-gray-300 - dark:text-gray-500; + color: var(--text-secondary); } & .CMS_Autocomplete_button-icon { - @apply text-gray-300/75 - dark:text-gray-600/75; + color: var(--text-secondary); } } } @@ -27,6 +25,8 @@ } .CMS_Autocomplete_input { + color: var(--text-primary); + @apply w-full bg-transparent border-none @@ -37,9 +37,7 @@ focus:ring-0 outline-none flex-grow - truncate - text-gray-800 - dark:text-gray-100; + truncate; } .CMS_Autocomplete_button-wrapper { @@ -53,54 +51,52 @@ } .CMS_Autocomplete_button-icon { - @apply h-5 - w-5 - text-gray-400; + color: var(--text-secondary); +} + +.CMS_Autocomplete_popper { + @apply z-[11] + after:-z-[1]; } .CMS_Autocomplete_options { + background: var(--background-main); + @apply max-h-60 w-full overflow-auto rounded-md - bg-white py-1 text-base shadow-md - ring-1 - ring-black - ring-opacity-5 focus:outline-none sm:text-sm - z-30 - dark:bg-slate-700 - dark:shadow-lg; + relative; } .CMS_Autocomplete_nothing { + color: var(--text-primary); + @apply relative cursor-default select-none py-2 - px-4 - text-gray-800 - dark:text-gray-300; + px-4; } .CMS_Autocomplete_option { + color: var(--text-primary); + @apply relative select-none py-2 pl-10 pr-4 - cursor-pointer - text-gray-800 - dark:text-gray-100; + cursor-pointer; } .CMS_Autocomplete_option-selected { - @apply bg-gray-100 - dark:bg-slate-600; + background: var(--background-dark); } .CMS_Autocomplete_option-selected { @@ -115,13 +111,14 @@ } .CMS_Autocomplete_checkmark { + color: var(--primary-main); + @apply absolute inset-y-0 left-0 flex items-center - pl-3 - text-blue-500; + pl-3; } .CMS_Autocomplete_checkmark-icon { diff --git a/packages/core/src/components/common/autocomplete/Autocomplete.tsx b/packages/core/src/components/common/autocomplete/Autocomplete.tsx index bdf98aaa..1de9e01d 100644 --- a/packages/core/src/components/common/autocomplete/Autocomplete.tsx +++ b/packages/core/src/components/common/autocomplete/Autocomplete.tsx @@ -12,7 +12,7 @@ import { isNullish } from '@staticcms/core/lib/util/null.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import IconButton from '../button/IconButton'; -import type { MouseEvent, ReactNode, Ref } from 'react'; +import type { FC, MouseEvent, ReactNode, Ref } from 'react'; import './Autocomplete.css'; @@ -27,6 +27,7 @@ export const classes = generateClassNames('Autocomplete', [ 'button-icon', 'options', 'nothing', + 'popper', 'option', 'option-selected', 'option-label', @@ -62,7 +63,7 @@ export interface AutocompleteProps { onChange: (value: string | string[] | undefined) => void; } -const Autocomplete = ({ +const Autocomplete: FC = ({ label, value, options, @@ -72,7 +73,7 @@ const Autocomplete = ({ endAdornment, onChange, onQuery, -}: AutocompleteProps) => { +}) => { const [inputValue, setInputValue] = useState(''); const debouncedOnQuery = useDebouncedCallback(onQuery, 200); @@ -120,11 +121,14 @@ const Autocomplete = ({ const finalOptions = useMemo(() => options.map(getOptionLabelAndValue), [options]); const optionsByValue = useMemo( () => - finalOptions.reduce((acc, option) => { - acc[option.value] = option; + finalOptions.reduce( + (acc, option) => { + acc[option.value] = option; - return acc; - }, {} as Record), + return acc; + }, + {} as Record, + ), [finalOptions], ); @@ -202,29 +206,31 @@ const Autocomplete = ({
    {endAdornment} - + rootClassName={classes.button} + iconClassName={classes['button-icon']} + aria-label="open options" + /> {!required ? ( - + rootClassName={classes.button} + iconClassName={classes['button-icon']} + aria-label="clear" + /> ) : null}
    {anchorEl && ( - +
      & { - children: FC<{ className?: string }>; -}; +export interface BaseIconButtonProps { + rootClassName?: string; + iconClassName?: string; + icon: FC<{ className?: string }>; +} -const IconButton = ({ children, size = 'medium', className, ...otherProps }: ButtonLinkProps) => { +export type IconButtonProps = Omit & BaseIconButtonProps; + +export type IconButtonInternalLinkProps = Omit & + BaseIconButtonProps; + +export type IconButtonExternalLinkProps = Omit & + BaseIconButtonProps; + +export type IconLinkProps = IconButtonInternalLinkProps | IconButtonExternalLinkProps; + +export type IconButtonLinkProps = IconButtonProps | IconLinkProps; + +const IconButton: FC = ({ + icon: Icon, + size = 'medium', + rootClassName, + iconClassName, + ...otherProps +}) => { return ( ); }; diff --git a/packages/core/src/components/common/button/useButtonClassNames.tsx b/packages/core/src/components/common/button/useButtonClassNames.tsx index 5a9daa39..ea393928 100644 --- a/packages/core/src/components/common/button/useButtonClassNames.tsx +++ b/packages/core/src/components/common/button/useButtonClassNames.tsx @@ -39,6 +39,7 @@ const classes: Record< success: 'CMS_Button_contained-success', error: 'CMS_Button_contained-error', warning: 'CMS_Button_contained-warning', + info: 'CMS_Button_contained-info', }, outlined: { primary: 'CMS_Button_outlined-primary', @@ -46,6 +47,7 @@ const classes: Record< success: 'CMS_Button_outlined-success', error: 'CMS_Button_outlined-error', warning: 'CMS_Button_outlined-warning', + info: 'CMS_Button_outlined-info', }, text: { primary: 'CMS_Button_text-primary', @@ -53,6 +55,7 @@ const classes: Record< success: 'CMS_Button_text-success', error: 'CMS_Button_text-error', warning: 'CMS_Button_text-warning', + info: 'CMS_Button_text-info', }, }; diff --git a/packages/core/src/components/common/card/Card.classes.ts b/packages/core/src/components/common/card/Card.classes.ts index 162e4165..d503e2dc 100644 --- a/packages/core/src/components/common/card/Card.classes.ts +++ b/packages/core/src/components/common/card/Card.classes.ts @@ -1,5 +1,12 @@ import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; -const cardClasses = generateClassNames('Card', ['root', 'header', 'content', 'media', 'actions']); +const cardClasses = generateClassNames('Card', [ + 'root', + 'header', + 'content', + 'media', + 'link-action', + 'button-action', +]); export default cardClasses; diff --git a/packages/core/src/components/common/card/Card.css b/packages/core/src/components/common/card/Card.css index cea77a55..aafb4b3c 100644 --- a/packages/core/src/components/common/card/Card.css +++ b/packages/core/src/components/common/card/Card.css @@ -1,32 +1,30 @@ .CMS_Card_root { - @apply bg-white - border - border-gray-100 + background: var(--background-main); + border-color: var(--background-light); + + @apply border rounded-lg shadow-sm - dark:bg-slate-800 - dark:border-gray-700/40 - dark:shadow-md flex flex-col h-full; } .CMS_Card_header { + color: var(--text-primary); + @apply mb-2 text-2xl font-bold - tracking-tight - text-gray-800 - dark:text-white; + tracking-tight; } .CMS_Card_content { + color: var(--text-primary); + @apply w-full - p-5 - font-normal - text-gray-800 - dark:text-gray-300; + p-4 + font-normal; } .CMS_Card_media { @@ -38,7 +36,7 @@ object-cover; } -.CMS_Card_actions { +.CMS_Card_link-action { @apply h-full w-full relative @@ -46,10 +44,34 @@ flex-col rounded-lg justify-start - hover:bg-gray-200 - dark:hover:bg-slate-700/70 focus:outline-none - focus:ring-4 - focus:ring-gray-200 - dark:focus:ring-slate-700; + focus:ring-4; + + &:hover { + background: color-mix(in srgb, var(--primary-main) 15%, transparent); + } + + &:focus { + --tw-ring-color: color-mix(in srgb, var(--primary-light) 50%, transparent); + } +} + +.CMS_Card_button-action { + @apply h-full + w-full + relative + flex + flex-col + rounded-lg + justify-start + focus:outline-none + focus:ring-4; + + &:hover { + background: color-mix(in srgb, var(--primary-main) 15%, transparent); + } + + &:focus { + --tw-ring-color: color-mix(in srgb, var(--primary-light) 50%, transparent); + } } diff --git a/packages/core/src/components/common/card/Card.tsx b/packages/core/src/components/common/card/Card.tsx index ccbe9fd0..d9449c6b 100644 --- a/packages/core/src/components/common/card/Card.tsx +++ b/packages/core/src/components/common/card/Card.tsx @@ -3,7 +3,7 @@ import React from 'react'; import classNames from '@staticcms/core/lib/util/classNames.util'; import cardClasses from './Card.classes'; -import type { ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; import './Card.css'; @@ -13,7 +13,7 @@ interface CardProps { title?: string; } -const Card = ({ children, className, title }: CardProps) => { +const Card: FC = ({ children, className, title }) => { return (
      {children} diff --git a/packages/core/src/components/common/card/CardActionArea.tsx b/packages/core/src/components/common/card/CardActionArea.tsx index e9351c5e..8bf4643f 100644 --- a/packages/core/src/components/common/card/CardActionArea.tsx +++ b/packages/core/src/components/common/card/CardActionArea.tsx @@ -1,18 +1,40 @@ import React from 'react'; import { Link } from 'react-router-dom'; +import classNames from '@staticcms/core/lib/util/classNames.util'; import cardClasses from './Card.classes'; -import type { ReactNode } from 'react'; +import type { FC, MouseEvent, ReactNode } from 'react'; -interface CardActionAreaProps { - to: string; +interface BaseCardActionAreaProps { children: ReactNode; + className?: string; } -const CardActionArea = ({ to, children }: CardActionAreaProps) => { +interface CardActionAreaPropsLink extends BaseCardActionAreaProps { + to: string; +} + +interface CardActionAreaPropsButton extends BaseCardActionAreaProps { + onClick: (event: MouseEvent) => void; +} + +export type CardActionAreaProps = CardActionAreaPropsLink | CardActionAreaPropsButton; + +const CardActionArea: FC = ({ children, className, ...otherProps }) => { + if ('onClick' in otherProps) { + return ( + + ); + } + return ( - + {children} ); diff --git a/packages/core/src/components/common/card/CardContent.tsx b/packages/core/src/components/common/card/CardContent.tsx index 143a5e0c..ad1df136 100644 --- a/packages/core/src/components/common/card/CardContent.tsx +++ b/packages/core/src/components/common/card/CardContent.tsx @@ -2,14 +2,14 @@ import React from 'react'; import cardClasses from './Card.classes'; -import type { ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; interface CardContentProps { children: ReactNode; } -const CardContent = ({ children }: CardContentProps) => { - return

      {children}

      ; +const CardContent: FC = ({ children }) => { + return
      {children}
      ; }; export default CardContent; diff --git a/packages/core/src/components/common/card/CardHeader.tsx b/packages/core/src/components/common/card/CardHeader.tsx index e0714e5f..008e3fd0 100644 --- a/packages/core/src/components/common/card/CardHeader.tsx +++ b/packages/core/src/components/common/card/CardHeader.tsx @@ -2,13 +2,13 @@ import React from 'react'; import cardClasses from './Card.classes'; -import type { ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; interface CardHeaderProps { children: ReactNode; } -const CardHeader = ({ children }: CardHeaderProps) => { +const CardHeader: FC = ({ children }) => { return
      {children}
      ; }; diff --git a/packages/core/src/components/common/card/CardMedia.tsx b/packages/core/src/components/common/card/CardMedia.tsx index 9e291b00..78296463 100644 --- a/packages/core/src/components/common/card/CardMedia.tsx +++ b/packages/core/src/components/common/card/CardMedia.tsx @@ -5,18 +5,18 @@ import cardClasses from './Card.classes'; import type { BaseField, - Collection, + CollectionWithDefaults, Entry, MediaField, UnknownField, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; interface CardMediaProps { image: string; width?: string | number; height?: string | number; alt?: string; - collection?: Collection; + collection?: CollectionWithDefaults; field?: MediaField; entry?: Entry; } diff --git a/packages/core/src/components/common/checkbox/Checkbox.css b/packages/core/src/components/common/checkbox/Checkbox.css index 3c24edca..0c970a30 100644 --- a/packages/core/src/components/common/checkbox/Checkbox.css +++ b/packages/core/src/components/common/checkbox/Checkbox.css @@ -9,18 +9,30 @@ & .CMS_Checkbox_input { & + .CMS_Checkbox_custom-input { - @apply bg-blue-600/25 - after:border-gray-500/75; + background: var(--primary-main); } } & .CMS_Checkbox_custom-input { - @apply bg-gray-100/75 - dark:bg-gray-700/75; + background: var(--common-gray); } } } +.CMS_Checkbox_md { + & .CMS_Checkbox_custom-input { + @apply w-6 + h-6; + } +} + +.CMS_Checkbox_sm { + & .CMS_Checkbox_custom-input { + @apply w-4 + h-4; + } +} + .CMS_Checkbox_input { @apply sr-only; @@ -28,39 +40,38 @@ -webkit-tap-highlight-color: transparent; &:focus + .CMS_Checkbox_custom-input { - @apply ring-4 - ring-blue-300 - dark:ring-blue-800; + --tw-ring-color: color-mix(in srgb, var(--primary-light) 50%, transparent); + + @apply ring-4; } &:checked + .CMS_Checkbox_custom-input { - @apply after:translate-x-full - bg-blue-600 - after:border-white; + background: var(--primary-main); + + @apply after:translate-x-full; } } .CMS_Checkbox_custom-input { - @apply w-6 - h-6 - text-blue-600 - border-gray-300 - rounded - focus:ring-blue-500 - dark:focus:ring-blue-600 - dark:ring-offset-gray-800 + color: var(--primary-light); + background: color-mix(in srgb, var(--primary-contrast-color) 50%, transparent); + border-color: var(--background-light); + + @apply rounded focus:ring-2 - dark:border-gray-600 select-none flex items-center - justify-center - bg-gray-100 - dark:bg-gray-700; + justify-center; + + &:focus { + --tw-ring-color: color-mix(in srgb, var(--primary-light) 50%, transparent); + } } .CMS_Checkbox_checkmark { + color: var(--primary-contrast-color); + @apply w-5 - h-5 - text-white; + h-5; } diff --git a/packages/core/src/components/common/checkbox/Checkbox.tsx b/packages/core/src/components/common/checkbox/Checkbox.tsx index 19b8f259..a673a54e 100644 --- a/packages/core/src/components/common/checkbox/Checkbox.tsx +++ b/packages/core/src/components/common/checkbox/Checkbox.tsx @@ -10,6 +10,8 @@ import './Checkbox.css'; export const classes = generateClassNames('Checkbox', [ 'root', + 'sm', + 'md', 'disabled', 'input', 'custom-input', @@ -17,12 +19,22 @@ export const classes = generateClassNames('Checkbox', [ ]); export interface CheckboxProps { + id?: string; + size?: 'sm' | 'md'; checked: boolean; disabled?: boolean; - onChange: ChangeEventHandler; + readOnly?: boolean; + onChange?: ChangeEventHandler; } -const Checkbox: FC = ({ checked, disabled = false, onChange }) => { +const Checkbox: FC = ({ + id, + size = 'md', + checked, + disabled = false, + readOnly = false, + onChange, +}) => { const inputRef = useRef(null); const handleNoop = useCallback((event: KeyboardEvent | MouseEvent) => { @@ -46,17 +58,19 @@ const Checkbox: FC = ({ checked, disabled = false, onChange }) => return ( ) : null, [finalCursor, disabled, hasErrors, label, variant], @@ -106,6 +109,7 @@ const Field: FC = ({ variant={variant} cursor={finalCursor} disabled={disabled} + className={classes.hint} > {hint} @@ -126,7 +130,7 @@ const Field: FC = ({ finalCursor === 'pointer' && classes['cursor-pointer'], finalCursor === 'text' && classes['cursor-text'], finalCursor === 'default' && classes['cursor-default'], - hasErrors ? classes.error : `group/active`, + hasErrors && classes.error, ), [rootClassName, noHightlight, disabled, noPadding, finalCursor, hasErrors], ); @@ -157,7 +161,7 @@ const Field: FC = ({ } return ( -
      +
      {renderedLabel} {children} diff --git a/packages/core/src/components/common/field/Hint.css b/packages/core/src/components/common/field/Hint.css index 93ccbe18..7ab12fbb 100644 --- a/packages/core/src/components/common/field/Hint.css +++ b/packages/core/src/components/common/field/Hint.css @@ -2,23 +2,6 @@ @apply w-full text-xs; - &:not(.CMS_Hint_error) { - &.CMS_Hint_disabled { - @apply text-slate-300 - dark:text-slate-600; - } - - &:not(.CMS_Hint_disabled) { - @apply text-slate-500 - dark:text-slate-400; - } - } - - &:not(.CMS_Hint_disabled) { - @apply group-focus-within/active:text-blue-500 - group-hover/active:text-blue-500; - } - &:not(.CMS_Hint_inline) { @apply px-3 pt-1; @@ -44,7 +27,3 @@ .CMS_Hint_cursor-default { @apply cursor-default; } - -.CMS_Hint_error { - @apply text-red-500; -} diff --git a/packages/core/src/components/common/field/Hint.tsx b/packages/core/src/components/common/field/Hint.tsx index b6b512ed..6f2075b9 100644 --- a/packages/core/src/components/common/field/Hint.tsx +++ b/packages/core/src/components/common/field/Hint.tsx @@ -13,11 +13,9 @@ import './Hint.css'; export const classes = generateClassNames('Hint', [ 'root', 'inline', - 'disabled', 'cursor-pointer', 'cursor-text', 'cursor-default', - 'error', 'link', ]); @@ -32,7 +30,6 @@ export interface HintProps { const Hint: FC = ({ children, - hasErrors, variant = 'default', cursor = 'default', className, @@ -49,11 +46,9 @@ const Hint: FC = ({ data-testid="hint" className={classNames( classes.root, - disabled && classes.disabled, finalCursor === 'pointer' && classes['cursor-pointer'], finalCursor === 'text' && classes['cursor-text'], finalCursor === 'default' && classes['cursor-default'], - hasErrors && classes.error, variant === 'inline' && classes.inline, className, )} diff --git a/packages/core/src/components/common/field/Label.css b/packages/core/src/components/common/field/Label.css index 9cddad20..e7b9ac28 100644 --- a/packages/core/src/components/common/field/Label.css +++ b/packages/core/src/components/common/field/Label.css @@ -2,25 +2,7 @@ @apply w-full flex text-xs - font-bold - dark:font-semibold; - - &:not(.CMS_Label_error) { - &.CMS_Label_disabled { - @apply text-slate-300 - dark:text-slate-600; - } - - &:not(.CMS_Label_disabled) { - @apply text-slate-500 - dark:text-slate-400; - } - } - - &:not(.CMS_Label_disabled) { - @apply group-focus-within/active:text-blue-500 - group-hover/active:text-blue-500; - } + font-bold; &:not(.CMS_Label_inline) { @apply px-3 @@ -39,7 +21,3 @@ .CMS_Label_cursor-default { @apply cursor-default; } - -.CMS_Label_error { - @apply text-red-500; -} diff --git a/packages/core/src/components/common/field/Label.tsx b/packages/core/src/components/common/field/Label.tsx index d6eca099..9a1fe829 100644 --- a/packages/core/src/components/common/field/Label.tsx +++ b/packages/core/src/components/common/field/Label.tsx @@ -10,11 +10,9 @@ import './Label.css'; export const classes = generateClassNames('Label', [ 'root', - 'disabled', 'cursor-pointer', 'cursor-text', 'cursor-default', - 'error', 'inline', ]); @@ -32,7 +30,6 @@ export interface LabelProps { const Label: FC = ({ htmlFor, children, - hasErrors = false, variant = 'default', cursor = 'default', className, @@ -47,11 +44,9 @@ const Label: FC = ({ data-testid={dataTestId ?? 'label'} className={classNames( classes.root, - disabled && classes.disabled, finalCursor === 'pointer' && classes['cursor-pointer'], finalCursor === 'text' && classes['cursor-text'], finalCursor === 'default' && classes['cursor-default'], - hasErrors && classes.error, variant === 'inline' && classes.inline, className, )} diff --git a/packages/core/src/components/common/image/Image.css b/packages/core/src/components/common/image/Image.css index 1f927246..e738c9fc 100644 --- a/packages/core/src/components/common/image/Image.css +++ b/packages/core/src/components/common/image/Image.css @@ -6,11 +6,11 @@ } &.CMS_Image_empty { + border-color: var(--background-main); + @apply p-10 rounded-md border - max-w-full - border-gray-200/75 - dark:border-slate-600/75; + max-w-full; } } diff --git a/packages/core/src/components/common/image/Image.tsx b/packages/core/src/components/common/image/Image.tsx index 8af76c1b..a81d45ce 100644 --- a/packages/core/src/components/common/image/Image.tsx +++ b/packages/core/src/components/common/image/Image.tsx @@ -10,11 +10,11 @@ import { useAppSelector } from '@staticcms/core/store/hooks'; import type { BaseField, - Collection, + CollectionWithDefaults, Entry, MediaField, UnknownField, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; import type { CSSProperties } from 'react'; import './Image.css'; @@ -26,7 +26,7 @@ export interface ImageProps { alt?: string; className?: string; style?: CSSProperties; - collection?: Collection; + collection?: CollectionWithDefaults; field?: MediaField; entry?: Entry; 'data-testid'?: string; diff --git a/packages/core/src/components/common/link/Link.tsx b/packages/core/src/components/common/link/Link.tsx index aa593278..0c9d5f16 100644 --- a/packages/core/src/components/common/link/Link.tsx +++ b/packages/core/src/components/common/link/Link.tsx @@ -4,12 +4,13 @@ import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft'; import { useAppSelector } from '@staticcms/core/store/hooks'; -import type { Collection, MediaField } from '@staticcms/core/interface'; +import type { CollectionWithDefaults, MediaField } from '@staticcms/core'; +import type { FC } from 'react'; export interface LinkProps { href: string | undefined | null; children: string; - collection?: Collection; + collection?: CollectionWithDefaults; field?: F; 'data-testid'?: string; } @@ -36,7 +37,7 @@ export const withMdxLink = ({ collection, field, }: Pick, 'collection' | 'field'>) => { - const MdxLink = ({ children, ...props }: Omit, 'collection' | 'field'>) => ( + const MdxLink: FC, 'collection' | 'field'>> = ({ children, ...props }) => ( {children} diff --git a/packages/core/src/components/common/menu/Menu.css b/packages/core/src/components/common/menu/Menu.css index d6cfcbaf..a6e30092 100644 --- a/packages/core/src/components/common/menu/Menu.css +++ b/packages/core/src/components/common/menu/Menu.css @@ -49,21 +49,22 @@ } .CMS_Menu_menu { + background: var(--background-main); + border-color: var(--background-light); + + & > :not([hidden]) ~ :not([hidden]) { + border-color: var(--background-divider); + } + @apply absolute right-0 z-40 - w-56 + min-w-[224px] + max-w-[80%] origin-top-right rounded-md - bg-white - dark:bg-slate-800 shadow-md border - border-gray-200 focus:outline-none - divide-y - divide-gray-100 - dark:border-gray-700 - dark:divide-gray-600 - dark:shadow-lg; + divide-y; } diff --git a/packages/core/src/components/common/menu/Menu.tsx b/packages/core/src/components/common/menu/Menu.tsx index 10ddfd4a..51c10dc2 100644 --- a/packages/core/src/components/common/menu/Menu.tsx +++ b/packages/core/src/components/common/menu/Menu.tsx @@ -43,9 +43,10 @@ export interface MenuProps { disabled?: boolean; keepMounted?: boolean; 'data-testid'?: string; + 'aria-label': string; } -const Menu = ({ +const Menu: FC = ({ label, startIcon: StartIcon, variant = 'contained', @@ -63,7 +64,8 @@ const Menu = ({ disabled = false, keepMounted = false, 'data-testid': dataTestId, -}: MenuProps) => { + 'aria-label': ariaLabel, +}) => { const calculatedButtonClassName = useButtonClassNames(variant, color, size, rounded); const menuButtonClassNames = useMemo( @@ -87,6 +89,7 @@ const Menu = ({ data-testid={dataTestId} className={menuButtonClassNames} disabled={disabled} + aria-label={ariaLabel} > {StartIcon ? ( diff --git a/packages/core/src/components/common/menu/MenuGroup.css b/packages/core/src/components/common/menu/MenuGroup.css index 116dd229..d1914705 100644 --- a/packages/core/src/components/common/menu/MenuGroup.css +++ b/packages/core/src/components/common/menu/MenuGroup.css @@ -1,6 +1,7 @@ .CMS_MenuGroup_root { + border-color: var(--background-divider); + @apply py-1 border-b - border-gray-200 - dark:border-slate-700; + last:border-0; } diff --git a/packages/core/src/components/common/menu/MenuGroup.tsx b/packages/core/src/components/common/menu/MenuGroup.tsx index 25b245fa..c4bbb39b 100644 --- a/packages/core/src/components/common/menu/MenuGroup.tsx +++ b/packages/core/src/components/common/menu/MenuGroup.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; -import type { ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; import './MenuGroup.css'; @@ -12,7 +12,7 @@ export interface MenuGroupProps { children: ReactNode | ReactNode[]; } -const MenuGroup = ({ children }: MenuGroupProps) => { +const MenuGroup: FC = ({ children }) => { return
      {children}
      ; }; diff --git a/packages/core/src/components/common/menu/MenuItemButton.css b/packages/core/src/components/common/menu/MenuItemButton.css index 1b9d0892..ec3f9b55 100644 --- a/packages/core/src/components/common/menu/MenuItemButton.css +++ b/packages/core/src/components/common/menu/MenuItemButton.css @@ -7,61 +7,87 @@ flex items-center justify-between - cursor-pointer; + cursor-pointer + whitespace-nowrap; &:not(.CMS_MenuItemButton_disabled) { &.CMS_MenuItemButton_active { - @apply bg-slate-200 - dark:bg-slate-600; + background: var(--background-light); } } &.CMS_MenuItemButton_default { - @apply text-gray-800 - dark:text-gray-300; + color: var(--text-primary); &.CMS_MenuItemButton_disabled { - @apply text-gray-500 - dark:text-gray-700; + color: var(--text-disabled); } &:not(.CMS_MenuItemButton_disabled) { - @apply hover:bg-gray-200 - dark:hover:bg-slate-600; + &:hover { + color: var(--primary-contrast-color); + background: var(--primary-main); + } + } + } + + &.CMS_MenuItemButton_success { + color: var(--success-main); + + &.CMS_MenuItemButton_disabled { + background: color-mix(in srgb, var(--success-main) 50%, transparent); + } + + &:not(.CMS_MenuItemButton_disabled) { + &:hover { + color: var(--success-contrast-color); + background: var(--success-main); + } } } &.CMS_MenuItemButton_warning { - @apply text-yellow-600 - dark:text-yellow-500; + color: var(--warning-main); &.CMS_MenuItemButton_disabled { - @apply text-yellow-300 - dark:hover:bg-yellow-800; + background: color-mix(in srgb, var(--warning-main) 50%, transparent); } &:not(.CMS_MenuItemButton_disabled) { - @apply hover:text-white - hover:bg-yellow-500 - dark:hover:text-yellow-100 - dark:hover:bg-yellow-600; + &:hover { + color: var(--warning-contrast-color); + background: var(--warning-main); + } } } &.CMS_MenuItemButton_error { - @apply text-red-500 - dark:text-red-500; + color: var(--error-main); &.CMS_MenuItemButton_disabled { - @apply text-red-200 - dark:hover:bg-red-800; + background: color-mix(in srgb, var(--error-main) 50%, transparent); } &:not(.CMS_MenuItemButton_disabled) { - @apply hover:text-white - hover:bg-red-500 - dark:hover:text-red-100 - dark:hover:bg-red-600; + &:hover { + color: var(--error-contrast-color); + background: var(--error-main); + } + } + } + + &.CMS_MenuItemButton_info { + color: var(--info-main); + + &.CMS_MenuItemButton_disabled { + background: color-mix(in srgb, var(--info-main) 50%, transparent); + } + + &:not(.CMS_MenuItemButton_disabled) { + &:hover { + color: var(--info-contrast-color); + background: var(--info-main); + } } } } diff --git a/packages/core/src/components/common/menu/MenuItemButton.tsx b/packages/core/src/components/common/menu/MenuItemButton.tsx index d80e642e..9ac99d80 100644 --- a/packages/core/src/components/common/menu/MenuItemButton.tsx +++ b/packages/core/src/components/common/menu/MenuItemButton.tsx @@ -24,7 +24,8 @@ export interface MenuItemButtonProps { active?: boolean; onClick: (event: MouseEvent) => void; children: ReactNode; - className?: string; + rootClassName?: string; + contentClassName?: string; disabled?: boolean; startIcon?: FC<{ className?: string }>; endIcon?: FC<{ className?: string }>; @@ -32,23 +33,24 @@ export interface MenuItemButtonProps { 'data-testid'?: string; } -const MenuItemButton = ({ +const MenuItemButton: FC = ({ active = false, onClick, children, - className, + rootClassName, + contentClassName, disabled = false, startIcon: StartIcon, endIcon: EndIcon, color = 'default', 'data-testid': dataTestId, -}: MenuItemButtonProps) => { +}) => { return ( -
      +
      {StartIcon ? : null} {children}
      diff --git a/packages/core/src/components/common/menu/MenuItemLink.css b/packages/core/src/components/common/menu/MenuItemLink.css index 68d55d77..016fa564 100644 --- a/packages/core/src/components/common/menu/MenuItemLink.css +++ b/packages/core/src/components/common/menu/MenuItemLink.css @@ -1,20 +1,22 @@ .CMS_MenuItemLink_root { + color: var(--text-primary); + + &:hover { + color: var(--primary-contrast-color); + background: var(--primary-main); + } + @apply px-4 py-2 text-sm - text-gray-800 - dark:text-gray-300 w-full text-left flex items-center - justify-between - hover:bg-slate-100 - dark:hover:bg-slate-900; + justify-between; &.CMS_MenuItemLink_active { - @apply bg-slate-100 - dark:bg-slate-900; + background: var(--background-light); } } diff --git a/packages/core/src/components/common/menu/MenuItemLink.tsx b/packages/core/src/components/common/menu/MenuItemLink.tsx index 39b06f12..4a43bece 100644 --- a/packages/core/src/components/common/menu/MenuItemLink.tsx +++ b/packages/core/src/components/common/menu/MenuItemLink.tsx @@ -26,14 +26,14 @@ export interface MenuItemLinkProps { endIcon?: FC<{ className?: string }>; } -const MenuItemLink = ({ +const MenuItemLink: FC = ({ href, children, className, active = false, startIcon: StartIcon, endIcon: EndIcon, -}: MenuItemLinkProps) => { +}) => { return ( = ({ @@ -30,23 +34,14 @@ const Pill: FC = ({ disabled = false, color = 'default', }) => { - const colorClassNames = useMemo(() => { - switch (color) { - case 'primary': - return classes.primary; - default: - return classes.default; - } - }, [color]); - return ( {children} diff --git a/packages/core/src/components/common/progress/CircularProgress.css b/packages/core/src/components/common/progress/CircularProgress.css index 4a472c98..4d1ef784 100644 --- a/packages/core/src/components/common/progress/CircularProgress.css +++ b/packages/core/src/components/common/progress/CircularProgress.css @@ -1,8 +1,8 @@ .CMS_CircularProgress_svg { - @apply text-gray-200 - animate-spin - dark:text-gray-600 - fill-blue-600; + color: var(--text-secondary); + fill: var(--primary-main); + + @apply animate-spin; &.CMS_CircularProgress_md { @apply w-8 diff --git a/packages/core/src/components/common/progress/Loader.css b/packages/core/src/components/common/progress/Loader.css index a8928d44..c0f1965f 100644 --- a/packages/core/src/components/common/progress/Loader.css +++ b/packages/core/src/components/common/progress/Loader.css @@ -1,11 +1,11 @@ .CMS_Loader_root { + background: var(--background-dark); + @apply absolute inset-0 flex flex-col gap-2 items-center - justify-center - bg-slate-50 - dark:bg-slate-900; + justify-center; } diff --git a/packages/core/src/components/common/progress/Loader.tsx b/packages/core/src/components/common/progress/Loader.tsx index f3c10491..8d61b2e8 100644 --- a/packages/core/src/components/common/progress/Loader.tsx +++ b/packages/core/src/components/common/progress/Loader.tsx @@ -3,6 +3,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import CircularProgress from './CircularProgress'; +import type { FC } from 'react'; + import './Loader.css'; export const classes = generateClassNames('Loader', ['root']); @@ -11,7 +13,7 @@ export interface LoaderProps { children: string | string[] | undefined; } -const Loader = ({ children }: LoaderProps) => { +const Loader: FC = ({ children }) => { const [currentItem, setCurrentItem] = useState(0); const text = useMemo(() => { diff --git a/packages/core/src/components/common/select/Option.css b/packages/core/src/components/common/select/Option.css index d0e39bb3..1279c4dc 100644 --- a/packages/core/src/components/common/select/Option.css +++ b/packages/core/src/components/common/select/Option.css @@ -1,15 +1,18 @@ .CMS_SelectOption_root { + color: var(--text-primary); + @apply relative select-none py-2 px-4 - cursor-pointer - text-gray-800 - hover:bg-blue-500 - dark:text-gray-100; + cursor-pointer; + + &:hover { + background: var(--primary-main); + } &.CMS_SelectOption_selected { - @apply bg-blue-400/75; + background: var(--primary-main); & .CMS_SelectOption_label { @apply font-medium; diff --git a/packages/core/src/components/common/select/Select.css b/packages/core/src/components/common/select/Select.css index bcc5b472..f2d1f486 100644 --- a/packages/core/src/components/common/select/Select.css +++ b/packages/core/src/components/common/select/Select.css @@ -4,15 +4,13 @@ &.CMS_Select_disabled { & .CMS_Select_input { - @apply text-gray-300/75 - dark:text-gray-600/75; + color: var(--text-secondary); } & .CMS_Select_value { & .CMS_Select_dropdown { & .CMS_Select_dropdown-icon { - @apply text-gray-300/75 - dark:text-gray-600/75; + color: var(--text-secondary); } } } @@ -43,12 +41,15 @@ } .CMS_Select_dropdown-icon { + color: var(--text-secondary); + @apply h-5 - w-5 - text-gray-400; + w-5; } .CMS_Select_input { + color: var(--text-primary); + @apply flex items-center text-sm @@ -57,25 +58,19 @@ min-h-8 px-4 py-1.5 - w-full - text-gray-800 - dark:text-gray-100; + w-full; } .CMS_Select_popper { + background: var(--background-main); + @apply max-h-60 overflow-auto rounded-md - bg-white py-1 text-base shadow-md - ring-1 - ring-black - ring-opacity-5 focus:outline-none sm:text-sm - z-[100] - dark:bg-slate-700 - dark:shadow-lg; + z-[100]; } diff --git a/packages/core/src/components/common/select/Select.tsx b/packages/core/src/components/common/select/Select.tsx index a8b50732..13ece757 100644 --- a/packages/core/src/components/common/select/Select.tsx +++ b/packages/core/src/components/common/select/Select.tsx @@ -39,7 +39,10 @@ function getOptionLabelAndValue(option: number | string | Option): Option { return { label: String(option), value: option }; } -export type SelectChangeEventHandler = (value: number | string | (number | string)[]) => void; +export type SelectChangeEventHandler = ( + value: number | string | (number | string)[], + event: MouseEvent | KeyboardEvent | FocusEvent | null, +) => void; export interface SelectProps { label?: ReactNode | ReactNode[]; @@ -80,7 +83,7 @@ const Select = forwardRef( ); const handleChange = useCallback( - (_event: MouseEvent | KeyboardEvent | FocusEvent | null, selectedValue: number | string) => { + (event: MouseEvent | KeyboardEvent | FocusEvent | null, selectedValue: number | string) => { if (Array.isArray(value)) { const newValue = [...value]; const index = newValue.indexOf(selectedValue); @@ -90,12 +93,12 @@ const Select = forwardRef( newValue.push(selectedValue); } - onChange(newValue); + onChange(newValue, event); setOpen(false); return; } - onChange(selectedValue); + onChange(selectedValue, event); setOpen(false); }, [onChange, value], diff --git a/packages/core/src/components/common/switch/Switch.css b/packages/core/src/components/common/switch/Switch.css index c706db24..fa720f91 100644 --- a/packages/core/src/components/common/switch/Switch.css +++ b/packages/core/src/components/common/switch/Switch.css @@ -8,17 +8,26 @@ @apply cursor-default; & .CMS_Switch_toggle { - @apply peer-checked:bg-blue-600/25 - after:bg-gray-500/75 - after:border-gray-500/75 - peer-checked:after:border-gray-500/75; + &:after { + background: var(--primary-contrast-color); + border-color: transparent; + } + } + + &.CMS_Switch_input { + &:checked + .CMS_Switch_toggle { + background: var(--primary-contrast-color); + border-color: var(--primary-contrast-color); + } } } &:not(.CMS_Switch_disabled) { & .CMS_Switch_toggle { - @apply after:bg-white - after:border-gray-300; + &:after { + background: var(--primary-contrast-color); + border-color: var(--background-dark); + } } } } @@ -27,24 +36,29 @@ @apply sr-only; &:focus + .CMS_Switch_toggle { - @apply ring-4 - ring-blue-300 - dark:ring-blue-800; + --tw-ring-color: color-mix(in srgb, var(--primary-light) 50%, transparent); + + @apply ring-4; } &:checked + .CMS_Switch_toggle { - @apply bg-blue-600 - after:border-white - after:translate-x-full; + background: var(--primary-main); + + &:after { + background: var(--primary-contrast-color); + border-color: var(--primary-contrast-color); + } + + @apply after:translate-x-full; } } .CMS_Switch_toggle { + background: color-mix(in srgb, var(--text-disabled) 50%, transparent); + @apply w-11 h-6 - bg-slate-200 rounded-full - dark:bg-slate-700 after:content-[''] after:absolute after:top-0.5 after:left-[2px] @@ -52,14 +66,13 @@ after:rounded-full after:h-5 after:w-5 - after:transition-all - dark:border-gray-600; + after:transition-all; } .CMS_Switch_label { + color: var(--text-primary); + @apply ml-3 text-sm - font-medium - text-gray-800 - dark:text-gray-300; + font-medium; } diff --git a/packages/core/src/components/common/table/Table.css b/packages/core/src/components/common/table/Table.css index 8588f81f..833122f7 100644 --- a/packages/core/src/components/common/table/Table.css +++ b/packages/core/src/components/common/table/Table.css @@ -3,11 +3,11 @@ } .CMS_Table_table { + color: var(--text-primary); + @apply w-full text-sm - text-left - text-gray-500 - dark:text-gray-300; + text-left; } .CMS_Table_header { @@ -27,37 +27,34 @@ } .CMS_Table_header-cell-content { + color: var(--text-primary); + background: var(--background-main); + border-color: var(--background-light); + @apply px-4 py-3 - text-gray-800 - border-gray-100 border-b - bg-white - dark:text-white - dark:border-gray-700 - dark:bg-slate-800 text-[14px] truncate w-full; } .CMS_Table_body-row { + background: var(--background-main); + border-color: color-mix(in srgb, var(--background-divider) 25%, transparent); + + &:focus, + &:hover { + background: color-mix(in srgb, var(--primary-main) 15%, transparent); + border-color: color-mix(in srgb, var(--primary-main) 15%, transparent); + } + @apply border-t - first:border-t-0 - border-gray-100 - dark:border-gray-700 - bg-white - hover:bg-slate-50 - dark:bg-slate-800 - dark:hover:bg-slate-700 - focus:outline-none - focus:bg-gray-100 - focus:dark:bg-slate-700; + focus:outline-none; } .CMS_Table_body-cell { - @apply text-gray-500 - dark:text-gray-300; + color: var(--text-primary); &.CMS_Table_body-cell-has-link { @apply p-0; @@ -69,10 +66,10 @@ } &.CMS_Table_body-cell-emphasis { + color: var(--text-primary); + @apply font-medium - text-gray-800 - whitespace-nowrap - dark:text-white; + whitespace-nowrap; } &.CMS_Table_body-cell-shrink { diff --git a/packages/core/src/components/common/table/Table.tsx b/packages/core/src/components/common/table/Table.tsx index c61da8c0..0e754a60 100644 --- a/packages/core/src/components/common/table/Table.tsx +++ b/packages/core/src/components/common/table/Table.tsx @@ -3,7 +3,7 @@ import React from 'react'; import tableClasses from './Table.classes'; import TableHeaderCell from './TableHeaderCell'; -import type { ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; import './Table.css'; @@ -12,7 +12,7 @@ interface TableCellProps { children: ReactNode[]; } -const TableCell = ({ columns, children }: TableCellProps) => { +const TableCell: FC = ({ columns, children }) => { return (
      diff --git a/packages/core/src/components/common/table/TableCell.tsx b/packages/core/src/components/common/table/TableCell.tsx index 2e8810fe..898f6535 100644 --- a/packages/core/src/components/common/table/TableCell.tsx +++ b/packages/core/src/components/common/table/TableCell.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'; import classNames from '@staticcms/core/lib/util/classNames.util'; import tableClasses from './Table.classes'; -import type { ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; interface TableCellProps { children: ReactNode; @@ -13,7 +13,7 @@ interface TableCellProps { shrink?: boolean; } -const TableCell = ({ children, emphasis = false, to, shrink = false }: TableCellProps) => { +const TableCell: FC = ({ children, emphasis = false, to, shrink = false }) => { const content = useMemo(() => { if (to) { return ( diff --git a/packages/core/src/components/common/table/TableHeaderCell.tsx b/packages/core/src/components/common/table/TableHeaderCell.tsx index 8dd5dfc6..50defceb 100644 --- a/packages/core/src/components/common/table/TableHeaderCell.tsx +++ b/packages/core/src/components/common/table/TableHeaderCell.tsx @@ -3,13 +3,13 @@ import React from 'react'; import { isEmpty } from '@staticcms/core/lib/util/string.util'; import tableClasses from './Table.classes'; -import type { ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; interface TableHeaderCellProps { children: ReactNode; } -const TableHeaderCell = ({ children }: TableHeaderCellProps) => { +const TableHeaderCell: FC = ({ children }) => { return (
      diff --git a/packages/core/src/components/common/table/TableRow.tsx b/packages/core/src/components/common/table/TableRow.tsx index c25c92a4..da270112 100644 --- a/packages/core/src/components/common/table/TableRow.tsx +++ b/packages/core/src/components/common/table/TableRow.tsx @@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom'; import classNames from '@staticcms/core/lib/util/classNames.util'; import tableClasses from './Table.classes'; -import type { KeyboardEvent, ReactNode } from 'react'; +import type { FC, KeyboardEvent, ReactNode } from 'react'; interface TableRowProps { children: ReactNode; @@ -12,7 +12,7 @@ interface TableRowProps { to?: string; } -const TableRow = ({ children, className, to }: TableRowProps) => { +const TableRow: FC = ({ children, className, to }) => { const navigate = useNavigate(); const handleKeyDown = useCallback( (event: KeyboardEvent) => { diff --git a/packages/core/src/components/common/text-field/TextArea.css b/packages/core/src/components/common/text-field/TextArea.css index 5b9c8221..15ce0d75 100644 --- a/packages/core/src/components/common/text-field/TextArea.css +++ b/packages/core/src/components/common/text-field/TextArea.css @@ -4,15 +4,17 @@ } .CMS_TextArea_input { + color: var(--text-primary); + + &:disabled { + color: var(--text-disabled); + } + @apply w-full min-h-[80px] px-3 bg-transparent outline-none text-sm - font-medium - text-gray-800 - dark:text-gray-100 - disabled:text-gray-300 - dark:disabled:text-gray-500; + font-medium; } diff --git a/packages/core/src/components/common/text-field/TextField.css b/packages/core/src/components/common/text-field/TextField.css index 401782d2..2a090bd2 100644 --- a/packages/core/src/components/common/text-field/TextField.css +++ b/packages/core/src/components/common/text-field/TextField.css @@ -8,33 +8,33 @@ text-sm; &.CMS_TextField_borderless { + color: var(--text-primary); + + &:disabled { + color: var(--text-disabled); + } + @apply h-6 px-3 bg-transparent outline-none - font-medium - text-gray-800 - disabled:text-gray-300 - dark:text-gray-100 - dark:disabled:text-gray-500; + font-medium; } &.CMS_TextField_contained { - @apply bg-gray-50 - border - border-gray-300 - text-gray-800 + color: var(--text-primary); + background: var(--background-main); + border-color: var(--background-light); + + &:hover, + &:focus-within { + border-color: var(--primary-main); + } + + @apply border rounded-lg - focus:ring-blue-500 - focus:border-blue-500 block - p-2.5 - dark:bg-gray-700 - dark:border-gray-600 - dark:placeholder-gray-400 - dark:text-white - dark:focus:ring-blue-500 - dark:focus:border-blue-500; + p-2.5; } &.CMS_TextField_cursor-pointer { diff --git a/packages/core/src/components/common/view-style/ViewStyleControl.css b/packages/core/src/components/common/view-style/ViewStyleControl.css index d8558679..bd2088bf 100644 --- a/packages/core/src/components/common/view-style/ViewStyleControl.css +++ b/packages/core/src/components/common/view-style/ViewStyleControl.css @@ -4,15 +4,3 @@ gap-1.5 lg:mr-1; } - -.CMS_ViewStyleControl_button { - &.CMS_ViewStyleControl_active { - @apply text-blue-500 - dark:text-blue-500; - } -} - -.CMS_ViewStyleControl_icon { - @apply h-5 - w-5; -} diff --git a/packages/core/src/components/common/view-style/ViewStyleControl.tsx b/packages/core/src/components/common/view-style/ViewStyleControl.tsx index b2ff246d..4a35bf81 100644 --- a/packages/core/src/components/common/view-style/ViewStyleControl.tsx +++ b/packages/core/src/components/common/view-style/ViewStyleControl.tsx @@ -3,40 +3,40 @@ import { TableRows as TableRowsIcon } from '@styled-icons/material-rounded/Table import React from 'react'; import { VIEW_STYLE_GRID, VIEW_STYLE_TABLE } from '@staticcms/core/constants/views'; -import classNames from '@staticcms/core/lib/util/classNames.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import IconButton from '../button/IconButton'; import type { ViewStyle } from '@staticcms/core/constants/views'; +import type { FC } from 'react'; import './ViewStyleControl.css'; -export const classes = generateClassNames('ViewStyleControl', ['root', 'button', 'active', 'icon']); +export const classes = generateClassNames('ViewStyleControl', ['root', 'button', 'icon']); interface ViewStyleControlPros { viewStyle: ViewStyle; onChangeViewStyle: (viewStyle: ViewStyle) => void; } -const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => { +const ViewStyleControl: FC = ({ viewStyle, onChangeViewStyle }) => { return (
      onChangeViewStyle(VIEW_STYLE_TABLE)} - > - - + /> onChangeViewStyle(VIEW_STYLE_GRID)} - > - - + />
      ); }; diff --git a/packages/core/src/components/common/widget/PreviewHOC.tsx b/packages/core/src/components/common/widget/PreviewHOC.tsx index 78b2d4b1..c31c8f73 100644 --- a/packages/core/src/components/common/widget/PreviewHOC.tsx +++ b/packages/core/src/components/common/widget/PreviewHOC.tsx @@ -1,6 +1,7 @@ import { cloneElement, createElement, isValidElement } from 'react'; -import type { WidgetPreviewComponent, WidgetPreviewProps } from '@staticcms/core/interface'; +import type { WidgetPreviewComponent, WidgetPreviewProps } from '@staticcms/core'; +import type { FC } from 'react'; // eslint-disable-next-line @typescript-eslint/no-explicit-any interface PreviewHOCProps extends Omit { @@ -8,7 +9,7 @@ interface PreviewHOCProps extends Omit { previewComponent: WidgetPreviewComponent; } -const PreviewHOC = ({ previewComponent, ...props }: PreviewHOCProps) => { +const PreviewHOC: FC = ({ previewComponent, ...props }) => { if (!previewComponent) { return null; } else if (isValidElement(previewComponent)) { diff --git a/packages/core/src/components/common/widget/useWidgetsFor.tsx b/packages/core/src/components/common/widget/useWidgetsFor.tsx index 46a458bb..fe3b4d69 100644 --- a/packages/core/src/components/common/widget/useWidgetsFor.tsx +++ b/packages/core/src/components/common/widget/useWidgetsFor.tsx @@ -1,25 +1,23 @@ import React, { useCallback } from 'react'; -import { useInferredFields } from '@staticcms/core/lib/util/collection.util'; -import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI'; -import { useAppSelector } from '@staticcms/core/store/hooks'; +import { useInferredFieldsByName } from '@staticcms/core/lib/util/collection.util'; import getWidgetFor from './widgetFor'; import type { - Collection, - Config, + CollectionWithDefaults, + ConfigWithDefaults, Entry, EntryData, Field, ObjectValue, WidgetFor, WidgetsFor, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; import type { ReactNode } from 'react'; export default function useWidgetsFor( - config: Config | undefined, - collection: Collection, + config: ConfigWithDefaults | undefined, + collection: CollectionWithDefaults, fields: Field[], entry: Entry, data: EntryData = entry.data, @@ -27,28 +25,16 @@ export default function useWidgetsFor( widgetFor: WidgetFor; widgetsFor: WidgetsFor; } { - const inferredFields = useInferredFields(collection); - - const theme = useAppSelector(selectTheme); + const inferredFields = useInferredFieldsByName(collection); const widgetFor = useCallback( (name: string): ReturnType> => { if (!config) { return null; } - return getWidgetFor( - config, - collection, - name, - fields, - entry, - theme, - inferredFields, - fields, - data, - ); + return getWidgetFor(config, collection, name, fields, entry, inferredFields, fields, data); }, - [collection, config, data, entry, fields, inferredFields, theme], + [collection, config, data, entry, fields, inferredFields], ); /** @@ -88,25 +74,27 @@ export default function useWidgetsFor( return finalValue .filter((val: unknown) => typeof val === 'object') .map((val: ObjectValue) => { - const widgets = nestedFields.reduce((acc, field, index) => { - acc[field.name] = ( -
      - {getWidgetFor( - config, - collection, - field.name, - fields, - entry, - theme, - inferredFields, - nestedFields, - val, - index, - )} -
      - ); - return acc; - }, {} as Record); + const widgets = nestedFields.reduce( + (acc, field, index) => { + acc[field.name] = ( +
      + {getWidgetFor( + config, + collection, + field.name, + fields, + entry, + inferredFields, + nestedFields, + val, + index, + )} +
      + ); + return acc; + }, + {} as Record, + ); return { data: val, widgets }; }); } @@ -120,28 +108,30 @@ export default function useWidgetsFor( return { data: value, - widgets: nestedFields.reduce((acc, field, index) => { - acc[field.name] = ( -
      - {getWidgetFor( - config, - collection, - field.name, - fields, - entry, - theme, - inferredFields, - nestedFields, - value, - index, - )} -
      - ); - return acc; - }, {} as Record), + widgets: nestedFields.reduce( + (acc, field, index) => { + acc[field.name] = ( +
      + {getWidgetFor( + config, + collection, + field.name, + fields, + entry, + inferredFields, + nestedFields, + value, + index, + )} +
      + ); + return acc; + }, + {} as Record, + ), }; }, - [collection, config, entry, fields, inferredFields, theme], + [collection, config, entry, fields, inferredFields], ); return { diff --git a/packages/core/src/components/common/widget/widgetFor.css b/packages/core/src/components/common/widget/widgetFor.css index 68ae1a43..da1f2fee 100644 --- a/packages/core/src/components/common/widget/widgetFor.css +++ b/packages/core/src/components/common/widget/widgetFor.css @@ -1,4 +1,3 @@ .CMS_WidgetPreview_label { - @apply text-slate-500 - dark:text-slate-400; + color: var(--text-secondary); } diff --git a/packages/core/src/components/common/widget/widgetFor.tsx b/packages/core/src/components/common/widget/widgetFor.tsx index 4d7523ac..7b9c9e57 100644 --- a/packages/core/src/components/common/widget/widgetFor.tsx +++ b/packages/core/src/components/common/widget/widgetFor.tsx @@ -8,8 +8,8 @@ import { getTypedFieldForValue } from '@staticcms/list/typedListHelpers'; import PreviewHOC from './PreviewHOC'; import type { - Collection, - Config, + CollectionWithDefaults, + ConfigWithDefaults, Entry, EntryData, Field, @@ -19,7 +19,7 @@ import type { ValueOrNestedValue, Widget, WidgetPreviewComponent, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; import type { ReactFragment, ReactNode } from 'react'; import './widgetFor.css'; @@ -33,12 +33,11 @@ export const classes = generateClassNames('WidgetPreview', ['label']); * exposed for use in custom preview templates. */ export default function getWidgetFor( - config: Config, - collection: Collection, + config: ConfigWithDefaults, + collection: CollectionWithDefaults, name: string, fields: Field[], entry: Entry, - theme: 'dark' | 'light', inferredFields: Record, widgetFields: Field[] = fields, values: EntryData = entry.data, @@ -62,7 +61,6 @@ export default function getWidgetFor( collection, fields, entry, - theme, inferredFields, field.fields, value as EntryData | EntryData[], @@ -76,7 +74,6 @@ export default function getWidgetFor( collection, field, entry, - theme, inferredFields, value as EntryData[], ), @@ -111,7 +108,7 @@ export default function getWidgetFor( } return renderedValue - ? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, theme, idx) + ? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, idx) : null; } @@ -119,11 +116,10 @@ export default function getWidgetFor( * Retrieves widgets for nested fields (children of object/list fields) */ function getNestedWidgets( - config: Config, - collection: Collection, + config: ConfigWithDefaults, + collection: CollectionWithDefaults, fields: Field[], entry: Entry, - theme: 'dark' | 'light', inferredFields: Record, widgetFields: Field[], values: EntryData | EntryData[], @@ -136,7 +132,6 @@ function getNestedWidgets( collection, fields, entry, - theme, inferredFields, widgetFields, value, @@ -150,7 +145,6 @@ function getNestedWidgets( collection, fields, entry, - theme, inferredFields, widgetFields, values, @@ -161,11 +155,10 @@ function getNestedWidgets( * Retrieves widgets for nested fields (children of object/list fields) */ function getTypedNestedWidgets( - config: Config, - collection: Collection, + config: ConfigWithDefaults, + collection: CollectionWithDefaults, field: ListField, entry: Entry, - theme: 'dark' | 'light', inferredFields: Record, values: EntryData[], ) { @@ -181,7 +174,6 @@ function getTypedNestedWidgets( collection, itemType.fields, entry, - theme, inferredFields, itemType.fields, value, @@ -195,11 +187,10 @@ function getTypedNestedWidgets( * Use getWidgetFor as a mapping function for recursive widget retrieval */ function widgetsForNestedFields( - config: Config, - collection: Collection, + config: ConfigWithDefaults, + collection: CollectionWithDefaults, fields: Field[], entry: Entry, - theme: 'dark' | 'light', inferredFields: Record, widgetFields: Field[], values: EntryData, @@ -213,7 +204,6 @@ function widgetsForNestedFields( field.name, fields, entry, - theme, inferredFields, widgetFields, values, @@ -224,12 +214,11 @@ function widgetsForNestedFields( } function getWidget( - config: Config, + config: ConfigWithDefaults, field: RenderedField, - collection: Collection, + collection: CollectionWithDefaults, value: ValueOrNestedValue | ReactNode, entry: Entry, - theme: 'dark' | 'light', idx: number | null = null, ) { if (!field.widget) { @@ -265,7 +254,6 @@ function getWidget( collection={collection} value={finalValue} entry={entry} - theme={theme} /> ); } diff --git a/packages/core/src/components/entry-editor/Editor.tsx b/packages/core/src/components/entry-editor/Editor.tsx index daf96961..2ba05d23 100644 --- a/packages/core/src/components/entry-editor/Editor.tsx +++ b/packages/core/src/components/entry-editor/Editor.tsx @@ -1,10 +1,7 @@ import { createHashHistory } from 'history'; import debounce from 'lodash/debounce'; -import isEqual from 'lodash/isEqual'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { translate } from 'react-polyglot'; -import { connect } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { createDraftDuplicateFromEntry, @@ -12,51 +9,56 @@ import { deleteDraftLocalBackup, deleteEntry, deleteLocalBackup, + loadBackup, loadEntry, - loadLocalBackup, persistEntry, persistLocalBackup, retrieveLocalBackup, } from '@staticcms/core/actions/entries'; import { loadMedia } from '@staticcms/core/actions/mediaLibrary'; import { loadScroll, toggleScroll } from '@staticcms/core/actions/scroll'; +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; +import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback'; import useEntryCallback from '@staticcms/core/lib/hooks/useEntryCallback'; -import { getFileFromSlug, selectFields } from '@staticcms/core/lib/util/collection.util'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; +import { getFields, getFileFromSlug } from '@staticcms/core/lib/util/collection.util'; import { useWindowEvent } from '@staticcms/core/lib/util/window.util'; -import { selectConfig } from '@staticcms/core/reducers/selectors/config'; +import { selectCollection } from '@staticcms/core/reducers/selectors/collections'; +import { selectConfig, selectUseWorkflow } from '@staticcms/core/reducers/selectors/config'; +import { selectUnpublishedEntry } from '@staticcms/core/reducers/selectors/editorialWorkflow'; import { selectEntry } from '@staticcms/core/reducers/selectors/entries'; +import { selectEntryDraft } from '@staticcms/core/reducers/selectors/entryDraft'; +import { selectIsScrolling } from '@staticcms/core/reducers/selectors/scroll'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; +import { addSnackbar } from '@staticcms/core/store/slices/snackbars'; +import { + deleteUnpublishedEntry, + loadUnpublishedEntry, + persistUnpublishedEntry, + publishUnpublishedEntry, + unpublishPublishedEntry, + updateUnpublishedEntryStatus, +} from '../../actions/editorialWorkflow'; +import alert from '../common/alert/Alert'; import confirm from '../common/confirm/Confirm'; import Loader from '../common/progress/Loader'; import MediaLibraryModal from '../media-library/MediaLibraryModal'; import EditorInterface from './EditorInterface'; -import type { - Collection, - EditorPersistOptions, - Entry, - TranslatedProps, -} from '@staticcms/core/interface'; -import type { RootState } from '@staticcms/core/store'; +import type { CollectionWithDefaults, EditorPersistOptions, Entry } from '@staticcms/core'; import type { Blocker } from 'history'; -import type { ComponentType, FC } from 'react'; -import type { ConnectedProps } from 'react-redux'; +import type { FC } from 'react'; + +export interface EditorProps { + name: string; + slug?: string; + newRecord: boolean; +} + +const Editor: FC = ({ name: collectionName, slug, newRecord }) => { + const t = useTranslate(); + const [searchParams] = useSearchParams(); -const Editor: FC> = ({ - entry, - entryDraft, - fields, - collection, - hasChanged, - displayUrl, - isModification, - draftKey, - slug, - localBackup, - scrollSyncActive, - newRecord, - t, -}) => { const [version, setVersion] = useState(0); const history = createHashHistory(); @@ -65,11 +67,46 @@ const Editor: FC> = ({ const navigate = useNavigate(); const config = useAppSelector(selectConfig); + const entryDraft = useAppSelector(selectEntryDraft); + + const collection = useAppSelector(state => selectCollection(state, collectionName)); + + const useWorkflow = useAppSelector(selectUseWorkflow); + + const unPublishedEntry = useAppSelector(state => + selectUnpublishedEntry(state, collectionName, slug), + ); + const hasUnpublishedEntry = useMemo( + () => Boolean(useWorkflow && unPublishedEntry), + [unPublishedEntry, useWorkflow], + ); + const currentStatus = useMemo( + () => unPublishedEntry && unPublishedEntry.status, + [unPublishedEntry], + ); + const isModification = useMemo( + () => entryDraft.entry?.isModification ?? false, + [entryDraft.entry?.isModification], + ); + const hasChanged = useMemo(() => entryDraft.hasChanged, [entryDraft.hasChanged]); + const isUpdatingStatus = useMemo( + () => Boolean(entryDraft.entry?.isUpdatingStatus), + [entryDraft.entry?.isUpdatingStatus], + ); + const isPublishing = useMemo( + () => Boolean(entryDraft.entry?.isPublishing), + [entryDraft.entry?.isPublishing], + ); + + const entry = useAppSelector(state => selectEntry(state, collectionName, slug)); + const fields = useMemo(() => getFields(collection, slug), [collection, slug]); + + const scrollSyncActive = useAppSelector(selectIsScrolling); const createBackup = useMemo( () => - debounce(function (entry: Entry, collection: Collection) { - if (config?.disable_local_backup) { + debounce(function (entry: Entry, collection: CollectionWithDefaults) { + if (config?.disable_local_backup || !slug) { return; } @@ -80,7 +117,7 @@ const Editor: FC> = ({ ); const deleteBackup = useCallback(() => { - if (config?.disable_local_backup) { + if (!collection || config?.disable_local_backup) { return; } @@ -96,7 +133,7 @@ const Editor: FC> = ({ (opts: EditorPersistOptions = {}) => { const { createNew = false, duplicate = false } = opts; - if (!entryDraft.entry) { + if (!collection || !entryDraft.entry) { return; } @@ -104,22 +141,28 @@ const Editor: FC> = ({ setTimeout(async () => { try { - await dispatch(persistEntry(collection, slug, navigate)); - setVersion(version + 1); - deleteBackup(); + if (useWorkflow) { + await dispatch( + persistUnpublishedEntry(collection, slug, hasUnpublishedEntry, navigate), + ); + } else { + await dispatch(persistEntry(collection, slug, navigate)); + } + setVersion(version + 1); + if (createNew) { if (duplicate && entryDraft.entry) { + navigate(`/collections/${collection.name}/new?duplicate=true`, { replace: true }); dispatch(createDraftDuplicateFromEntry(entryDraft.entry)); - navigate(`/collections/${collection.name}/new`, { replace: true }); } else { setSubmitted(false); setTimeout(async () => { await dispatch(loadMedia()); - dispatch(createEmptyDraft(collection, location.search)); - setVersion(version + 1); - navigate(`/collections/${collection.name}/new`, { replace: true }); + navigate(`/collections/${collection.name}/new`, { + replace: true, + }); }, 100); } } @@ -127,19 +170,139 @@ const Editor: FC> = ({ } catch (e) {} }, 100); }, - [collection, deleteBackup, dispatch, entryDraft.entry, navigate, slug, version], + [ + collection, + deleteBackup, + dispatch, + entryDraft.entry, + hasUnpublishedEntry, + navigate, + slug, + useWorkflow, + version, + ], ); + const debouncedHandlePersistEntry = useDebouncedCallback(handlePersistEntry, 250); + + const handleChangeStatus = useCallback( + (newStatus: WorkflowStatus) => { + if (!collection || !slug || !currentStatus) { + return; + } + + if (entryDraft.hasChanged) { + alert({ + title: 'editor.editor.onUpdatingWithUnsavedChangesTitle', + body: { + key: 'editor.editor.onUpdatingWithUnsavedChangesBody', + }, + }); + return; + } + dispatch(updateUnpublishedEntryStatus(collection.name, slug, currentStatus, newStatus)); + }, + [collection, currentStatus, dispatch, entryDraft.hasChanged, slug], + ); + + const handlePublishEntry = useCallback( + async (opts: { createNew?: boolean; duplicate?: boolean } = {}) => { + if (!collection || !slug || !entryDraft.entry) { + return; + } + + const { createNew = false, duplicate = false } = opts; + + if (currentStatus !== WorkflowStatus.PENDING_PUBLISH) { + alert({ + title: 'editor.editor.onPublishingNotReadyTitle', + body: { + key: 'editor.editor.onPublishingNotReadyBody', + }, + }); + return; + } + + if (entryDraft.hasChanged) { + alert({ + title: 'editor.editor.onPublishingWithUnsavedChangesTitle', + body: { + key: 'editor.editor.onPublishingWithUnsavedChangesBody', + }, + }); + return; + } + + if ( + !(await confirm({ + title: 'editor.editor.onPublishingTitle', + body: 'editor.editor.onPublishingBody', + })) + ) { + return; + } + + await dispatch(publishUnpublishedEntry(collection.name, slug, navigate)); + + deleteBackup(); + + if (createNew) { + navigate(`/collections/${collection.name}/new?duplicate=true`, { replace: true }); + return; + } + + if (duplicate) { + dispatch(createDraftDuplicateFromEntry(entryDraft.entry)); + navigate(`/collections/${collection.name}/new?duplicate=true`, { replace: true }); + return; + } + }, + [ + collection, + currentStatus, + deleteBackup, + dispatch, + entryDraft.entry, + entryDraft.hasChanged, + navigate, + slug, + ], + ); + + const handleUnpublishEntry = useCallback(async () => { + if (!collection || !slug) { + return; + } + + if ( + !(await confirm({ + title: 'editor.editor.onUnpublishingTitle', + body: 'editor.editor.onUnpublishingBody', + color: 'error', + })) + ) { + return; + } + + await dispatch(unpublishPublishedEntry(collection, slug)); + + return navigate(`/collections/${collection.name}?noredirect`); + }, [collection, dispatch, navigate, slug]); + const handleDuplicateEntry = useCallback(() => { - if (!entryDraft.entry) { + if (!collection || !entryDraft.entry) { return; } dispatch(createDraftDuplicateFromEntry(entryDraft.entry)); navigate(`/collections/${collection.name}/new?duplicate=true`, { replace: true }); - }, [collection.name, dispatch, entryDraft.entry, navigate]); + }, [collection, dispatch, entryDraft.entry, navigate]); const handleDeleteEntry = useCallback(async () => { + if (!collection) { + return; + } + if (entryDraft.hasChanged) { if ( !(await confirm({ @@ -161,47 +324,96 @@ const Editor: FC> = ({ } if (!slug || newRecord) { - return navigate(`/collections/${collection.name}`); + return navigate(`/collections/${collection.name}?noredirect`); } setTimeout(async () => { await dispatch(deleteEntry(collection, slug)); deleteBackup(); - return navigate(`/collections/${collection.name}`); + dispatch( + addSnackbar({ + type: 'success', + message: { + key: `ui.toast.${useWorkflow ? 'onDeletePublishedEntry' : 'entryDeleted'}`, + }, + }), + ); + return navigate(`/collections/${collection.name}?noredirect`); }, 0); - }, [collection, deleteBackup, dispatch, entryDraft.hasChanged, navigate, newRecord, slug]); + }, [ + collection, + deleteBackup, + dispatch, + entryDraft.hasChanged, + navigate, + newRecord, + slug, + useWorkflow, + ]); - const [prevLocalBackup, setPrevLocalBackup] = useState< - | { - entry: Entry; - } - | undefined - >(); - - useEffect(() => { - if (config?.disable_local_backup) { + const handleDeleteUnpublishedChanges = useCallback(async () => { + if (!collection) { return; } - if ( - !prevLocalBackup && - localBackup && - (!isEqual(localBackup.entry.data, entryDraft.entry?.data) || - !isEqual(localBackup.entry.meta, entryDraft.entry?.meta)) + if (entryDraft.hasChanged) { + if ( + entryDraft.hasChanged && + !(await confirm({ + title: 'editor.editor.onDeleteUnpublishedChangesWithUnsavedChangesTitle', + body: 'editor.editor.onDeleteUnpublishedChangesWithUnsavedChangesBody', + color: 'error', + })) + ) { + return; + } + } else if ( + !(await confirm({ + title: 'editor.editor.onDeleteUnpublishedChangesTitle', + body: 'editor.editor.onDeleteUnpublishedChangesBody', + color: 'error', + })) ) { - const updateLocalBackup = async () => { - dispatch(loadLocalBackup()); - setVersion(version + 1); - }; - - updateLocalBackup(); + return; } - setPrevLocalBackup(localBackup); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config?.disable_local_backup, deleteBackup, dispatch, localBackup, prevLocalBackup, version]); + if (!slug || newRecord) { + return navigate(`/collections/${collection.name}?noredirect`); + } + + setTimeout(async () => { + await dispatch(deleteUnpublishedEntry(collection.name, slug)); + deleteBackup(); + dispatch( + addSnackbar({ + type: 'success', + message: { + key: 'ui.toast.onDeleteUnpublishedChanges', + }, + }), + ); + if (isModification) { + dispatch(loadEntry(collection, slug)); + } else { + return navigate(`/collections/${collection.name}?noredirect`); + } + }, 0); + }, [ + collection, + deleteBackup, + dispatch, + entryDraft.hasChanged, + isModification, + navigate, + newRecord, + slug, + ]); useEffect(() => { + if (!collection || submitted) { + return; + } + if (hasChanged && entryDraft.entry) { createBackup(entryDraft.entry, collection); } @@ -209,9 +421,13 @@ const Editor: FC> = ({ return () => { createBackup.flush(); }; - }, [collection, createBackup, entryDraft.entry, hasChanged]); + }, [collection, createBackup, entryDraft.entry, hasChanged, submitted]); const hasLivePreview = useMemo(() => { + if (!collection) { + return false; + } + let livePreview = typeof collection.editor?.live_preview === 'string'; if ('files' in collection) { @@ -236,20 +452,42 @@ const Editor: FC> = ({ }, }); - const [prevCollection, setPrevCollection] = useState(null); + const [prevCollection, setPrevCollection] = useState(null); const [prevSlug, setPrevSlug] = useState(null); useEffect(() => { + if (!collection) { + return; + } + if (newRecord && slug !== prevSlug) { setTimeout(async () => { await dispatch(loadMedia()); - dispatch(createEmptyDraft(collection, location.search)); + + if ( + !searchParams.has('duplicate') || + searchParams.get('duplicate') !== 'true' || + entryDraft.entry === undefined + ) { + await dispatch(createEmptyDraft(collection, location.search)); + } }); } else if (!newRecord && slug && (prevCollection !== collection || prevSlug !== slug)) { - setTimeout(() => { - if (!config?.disable_local_backup) { - dispatch(retrieveLocalBackup(collection, slug)); + setTimeout(async () => { + if (useWorkflow) { + await dispatch(loadUnpublishedEntry(collection, slug)); + } else { + await dispatch(loadEntry(collection, slug)); } - dispatch(loadEntry(collection, slug)); + + if (!config?.disable_local_backup) { + await dispatch(retrieveLocalBackup(collection, slug)); + } + if (submitted && config?.disable_local_backup) { + return; + } + + dispatch(loadBackup()); + setVersion(version + 1); }); } @@ -264,6 +502,10 @@ const Editor: FC> = ({ dispatch, newRecord, config?.disable_local_backup, + useWorkflow, + submitted, + version, + searchParams, ]); const leaveMessage = useMemo(() => t('editor.editor.onLeavePage'), [t]); @@ -283,6 +525,10 @@ const Editor: FC> = ({ const navigationBlocker: Blocker = useCallback( ({ location, action }) => { + if (!collection) { + return; + } + /** * New entry being saved and redirected to it's new slug based url. */ @@ -303,7 +549,7 @@ const Editor: FC> = ({ } }, [ - collection.name, + collection, entryDraft.entry?.isPersisting, entryDraft.entry?.newRecord, hasChanged, @@ -317,7 +563,7 @@ const Editor: FC> = ({ return () => { unblock(); }; - }, [collection.name, history, navigationBlocker]); + }, [collection?.name, history, navigationBlocker]); const handleToggleScroll = useCallback(async () => { await dispatch(toggleScroll()); @@ -337,7 +583,12 @@ const Editor: FC> = ({

      {entry.error}

      ); - } else if (entryDraft == null || entryDraft.entry === undefined || (entry && entry.isFetching)) { + } else if ( + !collection || + entryDraft == null || + entryDraft.entry === undefined || + (entry && entry.isFetching) + ) { return {t('editor.editor.loadingEntry')}; } @@ -345,16 +596,15 @@ const Editor: FC> = ({ <> > = ({ onDiscardDraft={handleDiscardDraft} submitted={submitted} slug={slug} - t={t} + currentStatus={currentStatus} + isUpdatingStatus={isUpdatingStatus} + onChangeStatus={handleChangeStatus} + onPublish={handlePublishEntry} + onUnPublish={handleUnpublishEntry} + onDeleteUnpublishedChanges={handleDeleteUnpublishedChanges} + hasUnpublishedChanges={hasUnpublishedEntry} + isPublishing={isPublishing} /> ); }; -interface CollectionViewOwnProps { - name: string; - slug?: string; - newRecord: boolean; -} - -function mapStateToProps(state: RootState, ownProps: CollectionViewOwnProps) { - const { collections, entryDraft, config, entries, scroll } = state; - const { name, slug } = ownProps; - const collection = collections[name]; - const collectionName = collection.name; - const fields = selectFields(collection, slug); - const entry = !slug ? null : selectEntry(state, collectionName, slug); - const hasChanged = entryDraft.hasChanged; - const displayUrl = config.config?.display_url; - const isModification = entryDraft.entry?.isModification ?? false; - const collectionEntriesLoaded = Boolean(entries.pages[collectionName]); - const localBackup = entryDraft.localBackup; - const draftKey = entryDraft.key; - return { - ...ownProps, - collection, - collections, - entryDraft, - fields, - entry, - hasChanged, - displayUrl, - isModification, - collectionEntriesLoaded, - localBackup, - draftKey, - scrollSyncActive: scroll.isScrolling, - }; -} - -const connector = connect(mapStateToProps); -export type EditorProps = ConnectedProps; - -export default connector(translate()(Editor) as ComponentType); +export default Editor; diff --git a/packages/core/src/components/entry-editor/EditorInterface.css b/packages/core/src/components/entry-editor/EditorInterface.css index be4db87e..37f797aa 100644 --- a/packages/core/src/components/entry-editor/EditorInterface.css +++ b/packages/core/src/components/entry-editor/EditorInterface.css @@ -1,56 +1,40 @@ +& .CMS_Editor_root { + @apply md:h-main + !hidden + lg:!flex; + + &:not(.CMS_Editor_wrapper-i18n-side-by-side) { + @apply h-full; + } + + &.CMS_Editor_wrapper-i18n-side-by-side { + @apply h-full; + } +} + .CMS_Editor_default { @apply w-full; &.CMS_Editor_split-view { @apply overflow-y-auto - h-main-mobile md:h-main; } - - & .CMS_Editor_mobile-preview { - @apply hidden - lg:block; - } } -& .CMS_Editor_i18n { - @apply hidden - w-full - overflow-y-auto - h-main-mobile - md:h-main; +.CMS_Editor_i18n-panel { + border-color: var(--background-main); + + @apply border-l; +} + +.CMS_Editor_i18n { + @apply w-full + hidden; &.CMS_Editor_i18n-active { - @apply flex; - } -} - -& .CMS_Editor_mobile-i18n { - @apply flex - w-full - overflow-y-auto - h-main-mobile; -} - -& .CMS_Editor_root { - &:not(.CMS_Editor_wrapper-i18n-side-by-side) { - @apply grid - h-full; - - &.CMS_Editor_compact { - @apply lg:grid-cols-editor; - } - - &:not(.CMS_Editor_compact) { - @apply lg:grid-cols-2; - } - } - - &.CMS_Editor_wrapper-i18n-side-by-side { - @apply grid-cols-2 - h-full - hidden - lg:grid; + @apply flex + overflow-y-auto + h-main; } } @@ -67,3 +51,76 @@ max-w-full; } } + +.CMS_Editor_resize-handle { + @apply bg-transparent + outline-none + flex-[0_0_1rem] + justify-stretch + m-1.5 + flex + items-center + rounded + transition-colors; + + &:hover { + background: color-mix(in srgb, var(--primary-main) 15%, transparent); + + & .CMS_Editor_resize-handle-icon { + @apply opacity-100; + } + } +} + +.CMS_Editor_resize-handle-icon { + color: var(--text-primary); + + @apply w-4 + h-4 + rotate-90 + transition-opacity + opacity-20; +} + +.CMS_Editor_mobile-root { + @apply w-full + overflow-y-auto + h-main-mobile; + + &.CMS_Editor_workflow { + @apply h-main-mobile-workflow; + } + + &:not(.CMS_Editor_mobile-preview-active) { + & .CMS_Editor_mobile-preview { + @apply hidden; + } + } + + &.CMS_Editor_mobile-preview-active { + & .CMS_Editor_i18n, + & .CMS_Editor_default { + @apply hidden; + } + } +} + +.CMS_Editor_mobile-preview { + @apply flex + w-full + overflow-y-auto + h-main-mobile; +} + +.CMS_Editor_mobile-workflow-controls { + background: color-mix(in srgb, var(--background-main) 75%, transparent); + + @apply px-2 + py-2.5 + h-[50px] + flex + items-center + justify-between + drop-shadow-lg + md:hidden; +} diff --git a/packages/core/src/components/entry-editor/EditorInterface.tsx b/packages/core/src/components/entry-editor/EditorInterface.tsx index 901d65bc..fb7e146c 100644 --- a/packages/core/src/components/entry-editor/EditorInterface.tsx +++ b/packages/core/src/components/entry-editor/EditorInterface.tsx @@ -1,4 +1,6 @@ +import { DragHandle as DragHandleIcon } from '@styled-icons/material/DragHandle'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { ScrollSyncPane } from 'react-scroll-sync'; import { EDITOR_SIZE_COMPACT } from '@staticcms/core/constants/views'; @@ -13,21 +15,27 @@ import { } from '@staticcms/core/lib/util/collection.util'; import { customPathFromSlug } from '@staticcms/core/lib/util/nested.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; -import { selectConfig } from '@staticcms/core/reducers/selectors/config'; +import { selectConfig, selectUseWorkflow } from '@staticcms/core/reducers/selectors/config'; +import { + selectIsFetching, + selectUseOpenAuthoring, +} from '@staticcms/core/reducers/selectors/globalUI'; import { useAppSelector } from '@staticcms/core/store/hooks'; import MainView from '../MainView'; import EditorToolbar from './EditorToolbar'; +import EditorWorkflowToolbarButtons from './EditorWorkflowToolbarButtons'; import EditorControlPane from './editor-control-pane/EditorControlPane'; import EditorPreviewPane from './editor-preview-pane/EditorPreviewPane'; +import type { WorkflowStatus } from '@staticcms/core/constants/publishModes'; import type { - Collection, + CollectionWithDefaults, EditorPersistOptions, Entry, Field, FieldsErrors, - TranslatedProps, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; +import type { FC } from 'react'; import './EditorInterface.css'; @@ -35,18 +43,27 @@ export const classes = generateClassNames('Editor', [ 'root', 'default', 'i18n', + 'i18n-panel', 'i18n-active', - 'mobile-i18n', 'split-view', - 'mobile-preview', 'wrapper-preview', 'wrapper-i18n-side-by-side', 'compact', 'toolbar', 'content', 'content-wrapper', + 'resize-handle', + 'resize-handle-icon', + 'mobile-root', + 'workflow', + 'mobile-preview', + 'mobile-preview-active', + 'mobile-workflow-controls', ]); +const COMPACT_EDITOR_DEFAULT_WIDTH = 450; +const MIN_PREVIEW_SIZE = 300; + const PREVIEW_VISIBLE = 'cms.preview-visible'; const I18N_VISIBLE = 'cms.i18n-visible'; @@ -58,13 +75,13 @@ interface EditorContentProps { editorWithPreview: JSX.Element; } -const EditorContent = ({ +const EditorContent: FC = ({ i18nActive, previewActive, editor, editorSideBySideLocale, editorWithPreview, -}: EditorContentProps) => { +}) => { if (i18nActive) { return editorSideBySideLocale; } else if (previewActive) { @@ -81,14 +98,13 @@ const EditorContent = ({ interface EditorInterfaceProps { draftKey: string; entry: Entry; - collection: Collection; + collection: CollectionWithDefaults; fields: Field[] | undefined; fieldsErrors: FieldsErrors; onPersist: (opts?: EditorPersistOptions) => void; onDelete: () => Promise; onDuplicate: () => void; hasChanged: boolean; - displayUrl: string | undefined; isNewEntry: boolean; isModification: boolean; toggleScroll: () => Promise; @@ -97,9 +113,17 @@ interface EditorInterfaceProps { submitted: boolean; slug: string | undefined; onDiscardDraft: () => void; + currentStatus: WorkflowStatus | undefined; + isUpdatingStatus: boolean; + onChangeStatus: (status: WorkflowStatus) => void; + hasUnpublishedChanges: boolean; + isPublishing: boolean; + onPublish: (opts?: EditorPersistOptions) => Promise; + onUnPublish: () => Promise; + onDeleteUnpublishedChanges: () => Promise; } -const EditorInterface = ({ +const EditorInterface: FC = ({ collection, entry, fields = [], @@ -108,26 +132,47 @@ const EditorInterface = ({ onDuplicate, onPersist, hasChanged, - displayUrl, isNewEntry, isModification, draftKey, scrollSyncActive, - t, loadScroll, toggleScroll, submitted, slug, onDiscardDraft, -}: TranslatedProps) => { + currentStatus, + isUpdatingStatus, + onChangeStatus, + hasUnpublishedChanges, + isPublishing, + onPublish, + onUnPublish, + onDeleteUnpublishedChanges, +}) => { const config = useAppSelector(selectConfig); + const useWorkflow = useAppSelector(selectUseWorkflow); + const useOpenAuthoring = useAppSelector(selectUseOpenAuthoring); const isSmallScreen = useIsSmallScreen(); - const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {}; + const isLoading = useAppSelector(selectIsFetching); + const disabled = useMemo( + () => + Boolean( + isLoading || entry.isPersisting || isPublishing || isUpdatingStatus || entry.isDeleting, + ), + [entry.isDeleting, entry.isPersisting, isLoading, isPublishing, isUpdatingStatus], + ); + const editorDisabled = useMemo( + () => Boolean(disabled || entry.openAuthoring), + [disabled, entry.openAuthoring], + ); + + const { locales, default_locale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {}; const translatedLocales = useMemo( - () => (isSmallScreen ? locales : locales?.filter(locale => locale !== defaultLocale)) ?? [], - [isSmallScreen, locales, defaultLocale], + () => (isSmallScreen ? locales : locales?.filter(locale => locale !== default_locale)) ?? [], + [isSmallScreen, locales, default_locale], ); const [previewActive, setPreviewActive] = useState( @@ -141,12 +186,12 @@ const EditorInterface = ({ ); const [selectedLocale, setSelectedLocale] = useState( - (i18nActive ? translatedLocales?.[0] : defaultLocale) ?? 'en', + (i18nActive ? translatedLocales?.[0] : default_locale) ?? 'en', ); useEffect(() => { - setSelectedLocale((i18nActive ? translatedLocales?.[0] : defaultLocale) ?? 'en'); - }, [defaultLocale, i18nActive, translatedLocales]); + setSelectedLocale((i18nActive ? translatedLocales?.[0] : default_locale) ?? 'en'); + }, [default_locale, i18nActive, translatedLocales]); useEffect(() => { loadScroll(); @@ -155,12 +200,19 @@ const EditorInterface = ({ const handleOnPersist = useCallback( async (opts: EditorPersistOptions = {}) => { const { createNew = false, duplicate = false } = opts; - // await switchToDefaultLocale(); onPersist({ createNew, duplicate }); }, [onPersist], ); + const handleOnPublish = useCallback( + async (opts: EditorPersistOptions = {}) => { + const { createNew = false, duplicate = false } = opts; + onPublish({ createNew, duplicate }); + }, + [onPublish], + ); + const handleToggleScrollSync = useCallback(() => { toggleScroll(); }, [toggleScroll]); @@ -169,10 +221,10 @@ const EditorInterface = ({ const newI18nActive = !i18nActive; setI18nActive(newI18nActive); setSelectedLocale(selectedLocale => - newI18nActive && selectedLocale === defaultLocale ? translatedLocales?.[0] : selectedLocale, + newI18nActive && selectedLocale === default_locale ? translatedLocales?.[0] : selectedLocale, ); localStorage.setItem(I18N_VISIBLE, `${newI18nActive}`); - }, [i18nActive, setSelectedLocale, translatedLocales, defaultLocale]); + }, [i18nActive, setSelectedLocale, translatedLocales, default_locale]); const handleTogglePreview = useCallback(() => { let newPreviewActive = true; @@ -190,6 +242,8 @@ const EditorInterface = ({ setSelectedLocale(locale); }, []); + const file = getFileFromSlug(collection, entry.slug); + const { livePreviewUrlTemplate, showPreviewToggle, previewInFrame, editorSize } = useMemo(() => { let livePreviewUrlTemplate = typeof collection.editor?.live_preview === 'string' ? collection.editor.live_preview : false; @@ -209,8 +263,6 @@ const EditorInterface = ({ } if ('files' in collection) { - const file = getFileFromSlug(collection, entry.slug); - if (file?.editor) { if (typeof file.editor.live_preview === 'string') { livePreviewUrlTemplate = file.editor.live_preview; @@ -238,7 +290,7 @@ const EditorInterface = ({ previewInFrame: frame, editorSize: size, }; - }, [collection, config?.slug, entry]); + }, [collection, config?.slug, entry, file?.editor]); const finalPreviewActive = useMemo( () => showPreviewToggle && previewActive, @@ -259,12 +311,11 @@ const EditorInterface = ({ const editor = useMemo( () => (
      ), [ - defaultLocale, + default_locale, finalPreviewActive, i18nActive, - showMobilePreview, collection, entry, fields, @@ -296,64 +346,63 @@ const EditorInterface = ({ i18nEnabled, handleLocaleChange, slug, - t, + editorDisabled, ], ); const editorLocale = useMemo( () => locales - ?.filter(locale => isSmallScreen || locale !== defaultLocale) + ?.filter(locale => isSmallScreen || locale !== default_locale) .map(locale => ( -
      - -
      + +
      + +
      +
      )), [ + locales, + default_locale, + selectedLocale, collection, - defaultLocale, entry, fields, fieldsErrors, handleLocaleChange, isSmallScreen, - locales, - selectedLocale, submitted, - t, + editorDisabled, ], ); const previewEntry = useMemo( () => - collectHasI18n ? getPreviewEntry(collection, entry, selectedLocale, defaultLocale) : entry, - [collectHasI18n, collection, defaultLocale, entry, selectedLocale], + collectHasI18n ? getPreviewEntry(collection, entry, selectedLocale, default_locale) : entry, + [collectHasI18n, collection, default_locale, entry, selectedLocale], ); - const editorWithPreview = ( -
      - {editor} + const mobilePreview = ( +
      ); - const editorSideBySideLocale = isSmallScreen ? ( - <>{editorLocale} - ) : ( -
      - {editor} - - <>{editorLocale} - -
      + const editorWithPreview = ( + <> + {!isSmallScreen ? ( + + + {editor} + + + + + + + + + ) : ( +
      + {editor} + {mobilePreview} +
      + )} + + ); + + const editorSideBySideLocale = ( + <> + {!isSmallScreen ? ( + + + {editor} + + + + + + + <>{editorLocale} + + + + ) : ( +
      + {editorLocale} + {mobilePreview} +
      + )} + ); const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); @@ -382,7 +509,9 @@ const EditorInterface = ({ () => customPathFromSlug(collection, entry.slug), [collection, entry.slug], ); - const breadcrumbs = useBreadcrumbs(collection, nestedFieldPath, { isNewEntry, summary, t }); + const breadcrumbs = useBreadcrumbs(collection, nestedFieldPath, { isNewEntry, summary }); + + const isPersisting = useMemo(() => Boolean(entry.isPersisting), [entry.isPersisting]); return ( handleOnPersist({ createNew: true })} onPersistAndDuplicate={() => handleOnPersist({ createNew: true, duplicate: true })} onDelete={onDelete} onDuplicate={onDuplicate} hasChanged={hasChanged} - displayUrl={displayUrl} collection={collection} isNewEntry={isNewEntry} isModification={isModification} @@ -416,9 +543,36 @@ const EditorInterface = ({ onMobilePreviewToggle={toggleMobilePreview} className={classes.toolbar} onDiscardDraft={onDiscardDraft} + currentStatus={currentStatus} + isUpdatingStatus={isUpdatingStatus} + onChangeStatus={onChangeStatus} + hasUnpublishedChanges={hasUnpublishedChanges} + isPublishing={isPublishing} + onDeleteUnpublishedChanges={onDeleteUnpublishedChanges} + onPublish={onPublish} + onUnPublish={onUnPublish} + onPublishAndNew={() => handleOnPublish({ createNew: true })} + onPublishAndDuplicate={() => handleOnPublish({ createNew: true, duplicate: true })} + disabled={disabled} /> } > + {useWorkflow ? ( +
      + +
      + ) : null} { +const EditorRoute: FC = ({ newRecord = false, collections }) => { const { name, ...params } = useParams(); const slug = params['*']; @@ -22,7 +23,7 @@ const EditorRoute = ({ newRecord = false, collections }: EditorRouteProps) => { return !collections[name]; }, [collections, name]); - const defaultPath = useMemo(() => getDefaultPath(collections), [collections]); + const defaultPath = useDefaultPath(collections); if (shouldRedirect || !name || (!newRecord && !slug)) { return ; diff --git a/packages/core/src/components/entry-editor/EditorToolbar.css b/packages/core/src/components/entry-editor/EditorToolbar.css index 0dc546d0..b01dfdc0 100644 --- a/packages/core/src/components/entry-editor/EditorToolbar.css +++ b/packages/core/src/components/entry-editor/EditorToolbar.css @@ -22,21 +22,11 @@ lg:!hidden; } -.CMS_EditorToolbar_preview-toggle-icon { - @apply w-5 - h-5; -} - .CMS_EditorToolbar_delete-button { @apply flex lg:!hidden; } -.CMS_EditorToolbar_delete-button-icon { - @apply w-5 - h-5; -} - .CMS_EditorToolbar_publish-button { } @@ -49,3 +39,10 @@ @apply hidden md:block; } + +.CMS_EditorToolbar_workflow-controls { + @apply hidden + md:flex + gap-2 + items-center; +} diff --git a/packages/core/src/components/entry-editor/EditorToolbar.tsx b/packages/core/src/components/entry-editor/EditorToolbar.tsx index 7c161355..93c429b8 100644 --- a/packages/core/src/components/entry-editor/EditorToolbar.tsx +++ b/packages/core/src/components/entry-editor/EditorToolbar.tsx @@ -7,22 +7,30 @@ import { Height as HeightIcon } from '@styled-icons/material-rounded/Height'; import { Check as CheckIcon } from '@styled-icons/material/Check'; import { MoreVert as MoreVertIcon } from '@styled-icons/material/MoreVert'; import { Publish as PublishIcon } from '@styled-icons/material/Publish'; +import { Unpublished as UnpublishedIcon } from '@styled-icons/material/Unpublished'; import React, { useCallback, useMemo } from 'react'; -import { translate } from 'react-polyglot'; +import { loadUnpublishedEntry } from '@staticcms/core/actions/editorialWorkflow'; import { deleteLocalBackup, loadEntry } from '@staticcms/core/actions/entries'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import classNames from '@staticcms/core/lib/util/classNames.util'; -import { selectAllowDeletion } from '@staticcms/core/lib/util/collection.util'; +import { selectAllowDeletion, selectAllowPublish } from '@staticcms/core/lib/util/collection.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; -import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; +import { selectUseWorkflow } from '@staticcms/core/reducers/selectors/config'; +import { + selectIsFetching, + selectUseOpenAuthoring, +} from '@staticcms/core/reducers/selectors/globalUI'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import IconButton from '../common/button/IconButton'; import confirm from '../common/confirm/Confirm'; import Menu from '../common/menu/Menu'; import MenuGroup from '../common/menu/MenuGroup'; import MenuItemButton from '../common/menu/MenuItemButton'; +import EditorWorkflowToolbarButtons from './EditorWorkflowToolbarButtons'; -import type { Collection, EditorPersistOptions, TranslatedProps } from '@staticcms/core/interface'; +import type { WorkflowStatus } from '@staticcms/core/constants/publishModes'; +import type { CollectionWithDefaults, EditorPersistOptions } from '@staticcms/core'; import type { FC, MouseEventHandler } from 'react'; import './EditorToolbar.css'; @@ -33,25 +41,23 @@ export const classes = generateClassNames('EditorToolbar', [ 'more-menu-button', 'more-menu-label-icon', 'preview-toggle', - 'preview-toggle-icon', 'delete-button', - 'delete-button-icon', 'publish-button', 'publish-button-icon', 'publish-button-label', + 'workflow-controls', ]); export interface EditorToolbarProps { isPersisting?: boolean; - isDeleting?: boolean; onPersist: (opts?: EditorPersistOptions) => Promise; onPersistAndNew: () => Promise; onPersistAndDuplicate: () => Promise; onDelete: () => Promise; onDuplicate: () => void; hasChanged: boolean; - displayUrl: string | undefined; - collection: Collection; + hasUnpublishedChanges: boolean; + collection: CollectionWithDefaults; isNewEntry: boolean; isModification?: boolean; showPreviewToggle: boolean; @@ -67,20 +73,29 @@ export interface EditorToolbarProps { showMobilePreview: boolean; onMobilePreviewToggle: () => void; onDiscardDraft: () => void; + currentStatus: WorkflowStatus | undefined; + isUpdatingStatus: boolean; + onChangeStatus: (status: WorkflowStatus) => void; + isPublishing: boolean; + onPublish: (opts?: EditorPersistOptions) => Promise; + onUnPublish: () => Promise; + onDeleteUnpublishedChanges: () => Promise; + onPublishAndNew: () => Promise; + onPublishAndDuplicate: () => Promise; + disabled: boolean; } -const EditorToolbar = ({ +const EditorToolbar: FC = ({ hasChanged, - // TODO displayUrl, collection, onDuplicate, - // TODO isPersisting, + isPersisting = false, onPersist, onPersistAndDuplicate, onPersistAndNew, isNewEntry, + isModification, onDelete, - t, showPreviewToggle, previewActive, scrollSyncActive, @@ -94,15 +109,36 @@ const EditorToolbar = ({ showMobilePreview, onMobilePreviewToggle, onDiscardDraft, -}: TranslatedProps) => { + currentStatus, + isUpdatingStatus, + onChangeStatus, + hasUnpublishedChanges, + isPublishing, + onPublish, + onUnPublish, + onDeleteUnpublishedChanges, + onPublishAndNew, + onPublishAndDuplicate, + disabled, +}) => { + const t = useTranslate(); + + const useOpenAuthoring = useAppSelector(selectUseOpenAuthoring); + const canCreate = useMemo( () => ('folder' in collection && collection.create) ?? false, [collection], ); const canDelete = useMemo(() => selectAllowDeletion(collection), [collection]); + const canPublish = useMemo( + () => selectAllowPublish(collection, slug) && !useOpenAuthoring, + [collection, slug, useOpenAuthoring], + ); const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]); const isLoading = useAppSelector(selectIsFetching); + const useWorkflow = useAppSelector(selectUseWorkflow); + const dispatch = useAppDispatch(); const handleDiscardDraft = useCallback(async () => { @@ -120,17 +156,53 @@ const EditorToolbar = ({ }) ) { await dispatch(deleteLocalBackup(collection, slug)); - await dispatch(loadEntry(collection, slug)); + if (useWorkflow) { + await dispatch(loadUnpublishedEntry(collection, slug)); + } else { + await dispatch(loadEntry(collection, slug)); + } onDiscardDraft(); } - }, [collection, dispatch, onDiscardDraft, slug]); + }, [collection, dispatch, onDiscardDraft, slug, useWorkflow]); + + const handlePublishClick = useCallback(() => { + if (useWorkflow) { + onPublish(); + return; + } + + onPersist(); + }, [onPersist, onPublish, useWorkflow]); + + const handlePublishAndNew = useCallback(() => { + if (useWorkflow) { + onPublishAndNew(); + return; + } + + onPersistAndNew(); + }, [onPersistAndNew, onPublishAndNew, useWorkflow]); + + const handlePublishAndDuplicate = useCallback(() => { + if (useWorkflow) { + onPublishAndDuplicate(); + return; + } + + onPersistAndDuplicate(); + }, [onPersistAndDuplicate, onPublishAndDuplicate, useWorkflow]); const menuItems: JSX.Element[][] = useMemo(() => { const items: JSX.Element[] = []; - if (!isPublished) { + if ((!useWorkflow && !isPublished) || (useWorkflow && hasUnpublishedChanges)) { items.push( - onPersist()} startIcon={PublishIcon}> + {t('editor.editorToolbar.publishNow')} , ); @@ -139,15 +211,17 @@ const EditorToolbar = ({ items.push( {t('editor.editorToolbar.publishAndCreateNew')} , {t('editor.editorToolbar.publishAndDuplicate')} , @@ -155,53 +229,115 @@ const EditorToolbar = ({ } } else if (canCreate) { items.push( - + {t('editor.editorToolbar.duplicate')} , ); } - if (hasChanged) { - return [ - items, - [ - - {t('editor.editorToolbar.discardChanges')} - , - ], - ]; + const groups = [items]; + + if (useWorkflow && canCreate && canPublish && canDelete) { + groups.push([ + + {t('editor.editorToolbar.unpublish')} + , + ]); } - return [items]; + return groups; }, [ canCreate, - handleDiscardDraft, - hasChanged, + canDelete, + canPublish, + handlePublishAndDuplicate, + handlePublishAndNew, + handlePublishClick, + hasUnpublishedChanges, isPublished, onDuplicate, - onPersist, - onPersistAndDuplicate, - onPersistAndNew, + onUnPublish, t, + useWorkflow, ]); - return useMemo( - () => ( -
      - {showI18nToggle || showPreviewToggle || canDelete ? ( - } - variant="text" - rootClassName={classes['more-menu']} - buttonClassName={classes['more-menu-button']} - hideDropdownIcon - > + const workflowDeleteLabel = useMemo(() => { + if (hasUnpublishedChanges) { + if (isModification) { + return 'editor.editorToolbar.deleteUnpublishedChanges'; + } + + if (isNewEntry || !isModification) { + return 'editor.editorToolbar.deleteUnpublishedEntry'; + } + + return; + } + + if (isNewEntry) { + return; + } + + if (!isModification) { + return 'editor.editorToolbar.deletePublishedEntry'; + } + }, [hasUnpublishedChanges, isModification, isNewEntry]); + + const publishLabel = useMemo(() => { + if (useWorkflow) { + if (isPublishing) { + return 'editor.editorToolbar.publishing'; + } + + if (hasUnpublishedChanges) { + return 'editor.editorToolbar.publish'; + } + + if (!isNewEntry) { + return 'editor.editorToolbar.published'; + } + + return; + } + + if (isPersisting) { + return 'editor.editorToolbar.publishing'; + } + + if (isPublished) { + return 'editor.editorToolbar.published'; + } + + return 'editor.editorToolbar.publish'; + }, [hasUnpublishedChanges, isNewEntry, isPersisting, isPublished, isPublishing, useWorkflow]); + + return ( +
      + {showI18nToggle || showPreviewToggle || canDelete || hasChanged ? ( + } + color="secondary" + variant="text" + rootClassName={classes['more-menu']} + buttonClassName={classes['more-menu-button']} + hideDropdownIcon + aria-label="more options dropdown" + disabled={disabled} + data-testid="editor-extra-menu" + > + {showI18nToggle || showPreviewToggle ? ( {showI18nToggle && ( {t('editor.editorInterface.toggleScrollSync')} @@ -235,76 +371,102 @@ const EditorToolbar = ({ )} - {canDelete ? ( - - - {t('editor.editorToolbar.deleteEntry')} - - - ) : null} - - ) : null} - {showPreviewToggle ? ( - - - - ) : null} - {canDelete ? ( - - - - ) : null} + ) : null} + {hasChanged ? ( + + + {t('editor.editorToolbar.discardChanges')} + + + ) : null} + {canDelete && + (!useOpenAuthoring || hasUnpublishedChanges) && + (!useWorkflow || workflowDeleteLabel) ? ( + + + {useWorkflow ? t(workflowDeleteLabel!) : t('editor.editorToolbar.deleteEntry')} + + + ) : null} +
      + ) : null} + {showPreviewToggle ? ( + + ) : null} + {canDelete ? ( + + ) : null} + {useWorkflow ? ( +
      + +
      + ) : null} + {!useOpenAuthoring && publishLabel ? ( {menuItems.map((group, index) => ( {group} ))} -
      - ), - [ - className, - showI18nToggle, - showPreviewToggle, - canDelete, - toggleI18n, - i18nActive, - t, - togglePreview, - isLoading, - previewActive, - toggleScrollSync, - scrollSyncActive, - onDelete, - showMobilePreview, - onMobilePreviewToggle, - isPublished, - menuItems, - ], + ) : null} +
      ); }; -export default translate()(EditorToolbar) as FC; +export default EditorToolbar; diff --git a/packages/core/src/components/entry-editor/EditorWorkflowToolbarButtons.css b/packages/core/src/components/entry-editor/EditorWorkflowToolbarButtons.css new file mode 100644 index 00000000..3122e481 --- /dev/null +++ b/packages/core/src/components/entry-editor/EditorWorkflowToolbarButtons.css @@ -0,0 +1,8 @@ +.CMS_EditorWorkflowToolbarButtons_not-checked { + @apply ms-7; +} + +.CMS_EditorWorkflowToolbarButtons_status-label { + @apply flex + items-center; +} diff --git a/packages/core/src/components/entry-editor/EditorWorkflowToolbarButtons.tsx b/packages/core/src/components/entry-editor/EditorWorkflowToolbarButtons.tsx new file mode 100644 index 00000000..98cddf95 --- /dev/null +++ b/packages/core/src/components/entry-editor/EditorWorkflowToolbarButtons.tsx @@ -0,0 +1,151 @@ +import { Check as CheckIcon } from '@styled-icons/material/Check'; +import React, { useCallback, useMemo } from 'react'; + +import { WorkflowStatus } from '@staticcms/core/constants/publishModes'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; +import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; +import Button from '../common/button/Button'; +import Menu from '../common/menu/Menu'; +import MenuGroup from '../common/menu/MenuGroup'; +import MenuItemButton from '../common/menu/MenuItemButton'; +import Pill from '../common/pill/Pill'; + +import type { FC } from 'react'; +import type { PillProps } from '../common/pill/Pill'; + +import './EditorWorkflowToolbarButtons.css'; + +const classes = generateClassNames('EditorWorkflowToolbarButtons', ['not-checked', 'status-label']); + +export interface EditorWorkflowToolbarButtonsProps { + hasChanged: boolean; + isPersisting: boolean; + onPersist: () => void; + currentStatus: WorkflowStatus | undefined; + isUpdatingStatus: boolean; + onChangeStatus: (status: WorkflowStatus) => void; + disabled: boolean; + isLoading: boolean; + mobile?: boolean; + useOpenAuthoring: boolean; +} + +const EditorWorkflowToolbarButtons: FC = ({ + hasChanged, + isPersisting, + onPersist, + currentStatus, + isUpdatingStatus, + onChangeStatus, + disabled, + isLoading, + mobile, + useOpenAuthoring, +}) => { + const t = useTranslate(); + + const statusToTranslation = useMemo( + () => ({ + [WorkflowStatus.DRAFT]: t('editor.editorToolbar.draft'), + [WorkflowStatus.PENDING_REVIEW]: t('editor.editorToolbar.inReview'), + [WorkflowStatus.PENDING_PUBLISH]: t('editor.editorToolbar.ready'), + }), + [t], + ); + + const statusToPillColor: Record = useMemo( + () => ({ + [WorkflowStatus.DRAFT]: 'info', + [WorkflowStatus.PENDING_REVIEW]: 'warning', + [WorkflowStatus.PENDING_PUBLISH]: 'success', + }), + [], + ); + + const handleSave = useCallback(() => { + if (!hasChanged) { + return; + } + + onPersist(); + }, [hasChanged, onPersist]); + + return ( + <> + {currentStatus ? ( + useOpenAuthoring ? ( + <> + + {statusToTranslation[currentStatus]} + + {currentStatus === WorkflowStatus.DRAFT ? ( + + ) : null} + + ) : ( + + + onChangeStatus(WorkflowStatus.DRAFT)} + startIcon={currentStatus === WorkflowStatus.DRAFT ? CheckIcon : undefined} + contentClassName={ + currentStatus !== WorkflowStatus.DRAFT ? classes['not-checked'] : '' + } + > + {statusToTranslation[WorkflowStatus.DRAFT]} + + onChangeStatus(WorkflowStatus.PENDING_REVIEW)} + startIcon={currentStatus === WorkflowStatus.PENDING_REVIEW ? CheckIcon : undefined} + contentClassName={ + currentStatus !== WorkflowStatus.PENDING_REVIEW ? classes['not-checked'] : '' + } + > + {statusToTranslation[WorkflowStatus.PENDING_REVIEW]} + + onChangeStatus(WorkflowStatus.PENDING_PUBLISH)} + startIcon={currentStatus === WorkflowStatus.PENDING_PUBLISH ? CheckIcon : undefined} + contentClassName={ + currentStatus !== WorkflowStatus.PENDING_PUBLISH ? classes['not-checked'] : '' + } + > + {statusToTranslation[WorkflowStatus.PENDING_PUBLISH]} + + + + ) + ) : mobile ? ( +
      + ) : null} + + + ); +}; + +export default EditorWorkflowToolbarButtons; diff --git a/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx b/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx index f61ca6bf..b602133a 100644 --- a/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx +++ b/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx @@ -1,7 +1,6 @@ import { isEqual } from 'lodash'; import isEmpty from 'lodash/isEmpty'; import React, { createElement, useCallback, useEffect, useMemo, useState } from 'react'; -import { translate } from 'react-polyglot'; import { connect } from 'react-redux'; import { @@ -12,6 +11,7 @@ import { import { query as queryAction } from '@staticcms/core/actions/search'; import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback'; import useMemoCompare from '@staticcms/core/lib/hooks/useMemoCompare'; +import useTranslate from '@staticcms/core/lib/hooks/useTranslate'; import useUUID from '@staticcms/core/lib/hooks/useUUID'; import { isFieldDuplicate, isFieldHidden } from '@staticcms/core/lib/i18n'; import { resolveWidget } from '@staticcms/core/lib/registry'; @@ -22,7 +22,6 @@ import { isNotNullish } from '@staticcms/core/lib/util/null.util'; import { generateClassNames } from '@staticcms/core/lib/util/theming.util'; import { validate } from '@staticcms/core/lib/util/validation.util'; import { selectFieldErrors } from '@staticcms/core/reducers/selectors/entryDraft'; -import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI'; import { selectIsLoadingAsset } from '@staticcms/core/reducers/selectors/medias'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; @@ -30,20 +29,19 @@ import type { Field, FieldsErrors, I18nSettings, - TranslatedProps, UnknownField, ValueOrNestedValue, Widget, -} from '@staticcms/core/interface'; +} from '@staticcms/core'; import type { RootState } from '@staticcms/core/store'; -import type { ComponentType } from 'react'; +import type { FC } from 'react'; import type { ConnectedProps } from 'react-redux'; import './EditorControl.css'; export const classes = generateClassNames('EditorControl', ['root', 'hidden']); -const EditorControl = ({ +const EditorControl: FC = ({ collection, collectionFile, config: configState, @@ -51,12 +49,11 @@ const EditorControl = ({ field, fieldsErrors, submitted, - disabled = false, + disabled, parentDuplicate = false, locale, parentPath, query, - t, value: storageValue, forList = false, listItemPath, @@ -66,7 +63,9 @@ const EditorControl = ({ fieldName, isMeta = false, controlled = false, -}: TranslatedProps) => { +}) => { + const t = useTranslate(); + const dispatch = useAppDispatch(); const id = useUUID(); @@ -74,8 +73,6 @@ const EditorControl = ({ const widgetName = field.widget; const widget = resolveWidget(widgetName) as Widget; - const theme = useAppSelector(selectTheme); - const path = useMemo( () => parentPath.length > 0 ? `${parentPath}.${fieldName ?? field.name}` : fieldName ?? field.name, @@ -84,7 +81,7 @@ const EditorControl = ({ const finalStorageValue = useMemoCompare(storageValue, isEqual); - const [internalValue, setInternalValue] = useState( + const [internalValue, setInternalValue] = useState(() => widget.converters.deserialize(finalStorageValue, field), ); @@ -92,11 +89,7 @@ const EditorControl = ({ !isEmpty(widget.getValidValue(internalValue, field as UnknownField)), ); - const fieldErrorsSelector = useMemo( - () => selectFieldErrors(path, i18n, isMeta), - [i18n, isMeta, path], - ); - const errors = useAppSelector(fieldErrorsSelector); + const errors = useAppSelector(state => selectFieldErrors(state, path, i18n, isMeta)); const hasErrors = (submitted || dirty) && Boolean(errors.length); @@ -127,7 +120,12 @@ const EditorControl = ({ return; } - if ((!dirty && !submitted) || disabled || i18nDisabled) { + if ( + (!dirty && !submitted) || + disabled || + i18nDisabled || + (forList && field.widget === 'object' && field.fields.length === 1) + ) { return; } @@ -151,6 +149,8 @@ const EditorControl = ({ disabled, isMeta, i18nDisabled, + forList, + forSingleList, ]); const clearChildValidation = useCallback(() => { @@ -212,7 +212,10 @@ const EditorControl = ({ } return ( -