diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..9e9897278 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,69 @@ +name: Deploy +on: + push: + branches: + - prod + - dev + +jobs: + test: + name: Deploy + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup + uses: ./.github/actions/setup + + - run: pwd + working-directory: ${{ runner.home }} + + - name: Extract branch name + shell: bash + run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT + id: extract_branch + + - name: Prepare deployment package + run: pnpm turbo:prep && pnpm deploy --filter "@custom/website" ../deploy --prod + env: + VITE_DECAP_REPO: ${{ github.repository }} + VITE_DECAP_BRANCH: ${{ steps.extract_branch.outputs.branch }} + + - name: Build + run: pnpm run --filter @custom/website build + working-directory: ../deploy + env: + CLOUDINARY_API_KEY: ${{ vars.CLOUDINARY_API_KEY }} + CLOUDINARY_API_SECRET: ${{ secrets.CLOUDINARY_API_SECRET }} + CLOUDINARY_CLOUDNAME: ${{ secrets.CLOUDINARY_CLOUDNAME }} + GATSBY_PUBLIC_URL: ${{ vars.GATSBY_PUBLIC_URL }} + + - name: Check for Netlify auth token + id: netlify-check + shell: bash + run: | + if [ "${{ secrets.NETLIFY_AUTH_TOKEN }}" != '' ]; then + echo "available=true" >> $GITHUB_OUTPUT; + else + echo "available=false" >> $GITHUB_OUTPUT; + fi + + - name: Deploy to dev + run: pnpm netlify deploy --prod --filter "@custom/website" + working-directory: ../deploy + if: github.ref == 'refs/heads/dev' && steps.netlify-check.outputs.available == 'true' && vars.NETLIFY_DEV_ID != '' + env: + NETLIFY_SITE_ID: ${{ vars.NETLIFY_DEV_ID }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + + - name: Deploy to prod + run: pnpm netlify deploy --prod --filter "@custom/website" + working-directory: ../deploy + if: github.ref == 'refs/heads/prod' && steps.netlify-check.outputs.available == 'true' && vars.NETLIFY_PROD_ID != '' + env: + NETLIFY_SITE_ID: ${{ vars.NETLIFY_PROD_ID }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + diff --git a/.github/workflows/high_content_volume.yml b/.github/workflows/high_content_volume.yml new file mode 100644 index 000000000..02edab017 --- /dev/null +++ b/.github/workflows/high_content_volume.yml @@ -0,0 +1,50 @@ +name: High Content Volume +on: + workflow_dispatch: + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 1 + + - name: Setup + uses: ./.github/actions/setup + + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v2 + with: + server-token: 'local' + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare + run: pnpm turbo:prep + env: + TURBO_API: 'http://127.0.0.1:9080' + TURBO_TOKEN: 'local' + TURBO_TEAM: 'local' + + - name: 'Drupal: Start' + run: pnpm --filter "@custom/cms" start & + + - name: 'Gatsby: Clean' + run: pnpm --filter "@custom/website" clean + + - name: 'Drupal: Create content' + run: pnpm --filter "@custom/cms" drush php:script scripts/create-lots-of-content/run.php + + - name: 'Gatsby: Full build' + run: pnpm --filter "@custom/website" build:gatsby + + - name: 'Gatsby: Incremental build without new content' + run: pnpm --filter "@custom/website" build:gatsby + + - name: 'Drupal: Create more content' + run: pnpm --filter "@custom/cms" drush php:script scripts/create-lots-of-content/create-100-pages.php + + - name: 'Gatsby: Incremental build with new content' + run: pnpm --filter "@custom/website" build:gatsby diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 41f768f27..a8f39a873 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,16 @@ jobs: path: tests/e2e/playwright-report/ retention-days: 3 + - name: Check for Chromatic project token + id: chromatic-check + shell: bash + run: | + if [ "${{ secrets.CHROMATIC_PROJECT_TOKEN }}" != '' ]; then + echo "available=true" >> $GITHUB_OUTPUT; + else + echo "available=false" >> $GITHUB_OUTPUT; + fi + - name: Publish to Chromatic uses: chromaui/action@v1 with: @@ -49,6 +59,7 @@ jobs: storybookBaseDir: packages/ui onlyChanged: true exitOnceUploaded: true + if: ${{ steps.chromatic-check.outputs.available == 'true' }} - name: Deploy storybook to netlify run: diff --git a/.gitpod.Dockerfile b/.gitpod.Dockerfile index b4a9db06c..913f8189c 100644 --- a/.gitpod.Dockerfile +++ b/.gitpod.Dockerfile @@ -1,4 +1,11 @@ FROM gitpod/workspace-full + +RUN bash -c 'VERSION="18.19.0" \ + && source $HOME/.nvm/nvm.sh && nvm install $VERSION \ + && nvm use $VERSION && nvm alias default $VERSION' + +RUN echo "nvm use default &>/dev/null" >> ~/.bashrc.d/51-nvm-fix + RUN sudo update-alternatives --set php $(which php8.2) RUN sudo install-packages php8.2-gd php8.2-mbstring php8.2-curl php8.2-sqlite3 php8.2-zip php8.2-xdebug php8.2-imagick RUN pnpx playwright@1.32.3 install-deps @@ -11,14 +18,9 @@ RUN /home/gitpod/.deno/bin/deno completions bash > /home/gitpod/.bashrc.d/90-den echo 'export DENO_INSTALL="/home/gitpod/.deno"' >> /home/gitpod/.bashrc.d/90-deno && \ echo 'export PATH="$DENO_INSTALL/bin:$PATH"' >> /home/gitpod/.bashrc.d/90-deno -# Install neovim and helpers -RUN wget https://github.com/neovim/neovim/releases/download/v0.9.2/nvim-linux64.tar.gz && \ - tar xzf nvim-linux64.tar.gz && \ - sudo mv nvim-linux64 /usr/local/nvim && \ - sudo ln -s /usr/local/nvim/bin/nvim /usr/local/bin/nvim && \ - rm -rf nvim-linux64.tar.gz -RUN sudo apt-get install -y fd-find -RUN npm install -g neovim +RUN sudo add-apt-repository ppa:maveonair/helix-editor && \ + sudo apt update && \ + sudo apt install helix # Install phpactor RUN curl -Lo phpactor.phar https://github.com/phpactor/phpactor/releases/latest/download/phpactor.phar diff --git a/.idea/.gitignore b/.idea/.gitignore index 03d7c2646..a87f3fbd7 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -7,3 +7,5 @@ /dataSources/ /dataSources.local.xml /markdown.xml +# GitHub Copilot persisted chat sessions +/copilot/chatSessions diff --git a/.idea/prettier.xml b/.idea/prettier.xml index d6de67d37..6e16fd106 100644 --- a/.idea/prettier.xml +++ b/.idea/prettier.xml @@ -3,6 +3,6 @@ \ No newline at end of file diff --git a/.idea/runConfigurations/Test_Format.xml b/.idea/runConfigurations/Fix_Formatting.xml similarity index 65% rename from .idea/runConfigurations/Test_Format.xml rename to .idea/runConfigurations/Fix_Formatting.xml index d9d140b46..0189aa8e3 100644 --- a/.idea/runConfigurations/Test_Format.xml +++ b/.idea/runConfigurations/Fix_Formatting.xml @@ -1,9 +1,9 @@ - + - - \ No newline at end of file + diff --git a/apps/decap/package.json b/apps/decap/package.json index 3d7ef6e86..4ed3ea046 100644 --- a/apps/decap/package.json +++ b/apps/decap/package.json @@ -2,8 +2,6 @@ "name": "@custom/decap", "private": true, "version": "0.0.0", - "main": "build/index.js", - "types": "build/index.d.ts", "type": "module", "scripts": { "dev": "vite --host", @@ -13,9 +11,13 @@ "test:static": "tsc --noEmit && eslint \"**/*.{ts,tsx,js,jsx}\" --ignore-path=\"./.eslintignore\"", "test:unit": "vitest run --passWithNoTests" }, + "peerDependencies": { + "@amazeelabs/gatsby-source-silverback": "*" + }, "dependencies": { "@amazeelabs/cloudinary-responsive-image": "^1.6.15", - "@amazeelabs/graphql-directives": "^1.2.3", + "@amazeelabs/gatsby-plugin-static-dirs": "^1.0.1", + "@amazeelabs/graphql-directives": "^1.3.2", "@custom/schema": "workspace:*", "@custom/ui": "workspace:*", "decap-cms-app": "^3.0.12", @@ -33,7 +35,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "@amazeelabs/gatsby-source-silverback": "^1.13.7", + "@amazeelabs/decap-cms-backend-token-auth": "^1.1.7", "@types/node": "^18", "@types/react": "^18.2.46", "@types/react-dom": "^18.2.18", diff --git a/apps/decap/src/collections/page.test.ts b/apps/decap/src/collections/page.test.ts index 486fb063c..c7d2bb4b0 100644 --- a/apps/decap/src/collections/page.test.ts +++ b/apps/decap/src/collections/page.test.ts @@ -1,12 +1,14 @@ -import { dirname, resolve } from 'path'; -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import { getPages } from '..'; +vi.mock('../helpers/path', () => ({ + path: `${new URL(import.meta.url).pathname + .split('/') + .slice(0, -1) + .join('/')}/../..`, +})); + test('getPages', () => { - const dir = resolve( - dirname(new URL(import.meta.url).pathname), - '../../data/page', - ); - expect(() => getPages(dir)).not.toThrow(); + expect(() => getPages()).not.toThrow(); }); diff --git a/apps/decap/src/collections/page.ts b/apps/decap/src/collections/page.ts index 22737e71f..f7d441f10 100644 --- a/apps/decap/src/collections/page.ts +++ b/apps/decap/src/collections/page.ts @@ -12,6 +12,7 @@ import yaml from 'yaml'; import { z } from 'zod'; import { transformMarkdown } from '../helpers/markdown'; +import { path } from '../helpers/path'; // ============================================================================= // Decap CMS collection definition. @@ -191,40 +192,38 @@ export const pageSchema = z.object({ content: z.array(z.union([BlockMarkupSchema, BlockMediaImageSchema])), }); -export const getPages: (dir: string) => SilverbackSource = - (dir: string) => () => { - const pages: Array<[string, DecapPageSource & { _decap_id: string }]> = []; - fs.readdirSync(dir) - .filter((file) => file.endsWith('.yml')) - .forEach((file) => { - const content = yaml.parse(fs.readFileSync(`${dir}/${file}`, 'utf-8')); - const id = Object.values(content) - .map((page: any) => page.id) - .filter((id) => !!id) - .pop(); - Object.keys(content).forEach((lang) => { - if (Object.keys(content[lang]).length < 2) { - return; - } - const input = { - ...content[lang], - id, - locale: lang, - }; - const page = pageSchema.safeParse(input); - if (page.success) { - pages.push([ - `${page.data.id}:${lang}`, - { ...page.data, _decap_id: id }, - ]); - } else { - console.warn(`Error parsing ${file} (${lang}):`); - console.warn(page.error.message); - console.warn('Input:', content[lang]); - } - }); +export const getPages: SilverbackSource = () => { + const dir = `${path}/data/page`; + const pages: Array<[string, DecapPageSource & { _decap_id: string }]> = []; + fs.readdirSync(dir) + .filter((file) => file.endsWith('.yml')) + .forEach((file) => { + const content = yaml.parse(fs.readFileSync(`${dir}/${file}`, 'utf-8')); + const id = Object.values(content) + .map((page: any) => page.id) + .filter((id) => !!id) + .pop(); + Object.keys(content).forEach((lang) => { + if (Object.keys(content[lang]).length < 2) { + return; + } + const input = { + ...content[lang], + id, + locale: lang, + }; + const page = pageSchema.safeParse(input); + if (page.success) { + pages.push([ + `${page.data.id}:${lang}`, + { ...page.data, _decap_id: id }, + ]); + } else { + console.warn(`Error parsing ${file} (${lang}):`); + console.warn(page.error.message); + console.warn('Input:', content[lang]); + } }); - return pages; - }; - -export function getPageTranslations() {} + }); + return pages; +}; diff --git a/apps/decap/src/collections/translatables.test.ts b/apps/decap/src/collections/translatables.test.ts index be0703125..37ec5eb3a 100644 --- a/apps/decap/src/collections/translatables.test.ts +++ b/apps/decap/src/collections/translatables.test.ts @@ -1,13 +1,14 @@ -import { dirname, resolve } from 'node:path'; -import { afterEach } from 'node:test'; - import { expect, test, vi } from 'vitest'; import { getTranslatables } from './translatables'; -afterEach(vi.resetAllMocks); +vi.mock('../helpers/path', () => ({ + path: `${new URL(import.meta.url).pathname + .split('/') + .slice(0, -1) + .join('/')}/../..`, +})); test('getTranslatables', () => { - const dir = resolve(dirname(new URL(import.meta.url).pathname), '../../data'); - expect(getTranslatables(dir)).not.toThrow(); + expect(() => getTranslatables()).not.toThrow(); }); diff --git a/apps/decap/src/collections/translatables.ts b/apps/decap/src/collections/translatables.ts index b9b096f7d..cf1c3c289 100644 --- a/apps/decap/src/collections/translatables.ts +++ b/apps/decap/src/collections/translatables.ts @@ -7,6 +7,7 @@ import yaml from 'yaml'; import { z } from 'zod'; import rawTranslatables from '../../node_modules/@custom/ui/build/translatables.json'; +import { path } from '../helpers/path'; // Validate that translatables.json contains what we expect. const translationSources = z @@ -44,20 +45,21 @@ export const Translatables: CmsCollection = { ], }; -export function getTranslatables( - dir: string, -): SilverbackSource { - return function () { - const rawTranslations = yaml.parse( - fs.readFileSync(`${dir}/translatables.yml`, 'utf-8'), - ); - return z - .record(z.record(z.string())) - .transform((data) => { - const translations: Array<[string, DecapTranslatableStringSource]> = []; - Object.keys(data).forEach((langcode) => { - Object.keys(data[langcode]).forEach((key) => { - Object.keys(data).forEach((locale) => { +export const getTranslatables: SilverbackSource< + DecapTranslatableStringSource +> = () => { + const dir = `${path}/data`; + const rawTranslations = yaml.parse( + fs.readFileSync(`${dir}/translatables.yml`, 'utf-8'), + ); + return z + .record(z.record(z.string())) + .transform((data) => { + const translations: Array<[string, DecapTranslatableStringSource]> = []; + Object.keys(data).forEach((langcode) => { + Object.keys(data[langcode]).forEach((key) => { + Object.keys(data).forEach((locale) => { + if (translationSources[key]) { translations.push([ `${key}:${locale}`, { @@ -67,11 +69,11 @@ export function getTranslatables( translation: data[locale][key], }, ]); - }); + } }); }); - return translations; - }) - .parse(rawTranslations); - }; -} + }); + return translations; + }) + .parse(rawTranslations); +}; diff --git a/apps/decap/src/helpers/frame.tsx b/apps/decap/src/helpers/frame.tsx index dc514b895..192a4ab00 100644 --- a/apps/decap/src/helpers/frame.tsx +++ b/apps/decap/src/helpers/frame.tsx @@ -1,4 +1,4 @@ -import { FrameQuery, Locale, registerExecutor, Url } from '@custom/schema'; +import { FrameQuery, Locale, OperationExecutor, Url } from '@custom/schema'; import { NavigationItemSource } from '@custom/schema/source'; import { Frame } from '@custom/ui/routes/Frame'; import { PropsWithChildren } from 'react'; @@ -15,20 +15,26 @@ const menuItems = (amount: number) => ); export function PreviewFrame({ children }: PropsWithChildren) { - registerExecutor(FrameQuery, () => ({ - mainNavigation: [ - { - locale: Locale.En, - items: menuItems(4), - }, - ], - footerNavigation: [ - { - locale: Locale.En, - items: menuItems(4), - }, - ], - stringTranslations: [], - })); - return {children}; + return ( + + {children} + + ); } diff --git a/apps/decap/src/helpers/path.ts b/apps/decap/src/helpers/path.ts new file mode 100644 index 000000000..b959bfc12 --- /dev/null +++ b/apps/decap/src/helpers/path.ts @@ -0,0 +1,4 @@ +export const path = `${new URL(import.meta.url).pathname + .split('/') + .slice(0, -1) + .join('/')}/..`; diff --git a/apps/decap/src/main.tsx b/apps/decap/src/main.tsx index e257cc7cf..8514165f0 100644 --- a/apps/decap/src/main.tsx +++ b/apps/decap/src/main.tsx @@ -1,7 +1,8 @@ +import { TokenAuthBackend } from '@amazeelabs/decap-cms-backend-token-auth/backend'; import { Locale, + OperationExecutor, PreviewDecapPageQuery, - registerExecutor, ViewPageQuery, } from '@custom/schema'; import { Page } from '@custom/ui/routes/Page'; @@ -18,26 +19,43 @@ const default_locale = locales.includes('en') ? 'en' : locales[0]; CMS.registerPreviewStyle(css, { raw: true }); CMS.registerWidget('uuid', UuidWidget); +CMS.registerBackend('token-auth', TokenAuthBackend); + +if ( + window.location.hostname !== 'localhost' && + !import.meta.env.DEV && + (!import.meta.env.VITE_DECAP_REPO || !import.meta.env.VITE_DECAP_BRANCH) +) { + console.error( + "VITE_DECAP_REPO and VITE_DECAP_BRANCH environment variables are missing. Can't connect to the repository.", + ); +} CMS.init({ config: { - publish_mode: 'simple', + load_config_file: false, + publish_mode: 'editorial_workflow', media_folder: 'apps/decap/media', + // @ts-ignore backend: import.meta.env.DEV - ? // In development, use the in-memory backend. - { + ? { + // In development, use the in-memory backend. name: 'test-repo', } - : window.location.hostname === 'localhost' - ? // On localhost, use the proxy backend. - { + : window.location.hostname === 'localhost' || + !import.meta.env.VITE_DECAP_REPO || + !import.meta.env.VITE_DECAP_BRANCH + ? { + // On localhost, use the proxy backend that writes to files. name: 'proxy', proxy_url: 'http://localhost:8081/api/v1', } - : // Otherwise, its production. Use the Git Gateway backend. - { - name: 'git-gateway', - branch: 'release', + : { + // Otherwise, its production. Use the token auth backend. + name: 'token-auth', + api_root: '/.netlify/functions/github-proxy', + repo: import.meta.env.VITE_DECAP_REPO, + branch: import.meta.env.VITE_DECAP_BRANCH, }, i18n: { structure: 'single_file', @@ -76,8 +94,11 @@ CMS.registerPreviewTemplate( PreviewDecapPageQuery, pageSchema, (data) => { - registerExecutor(ViewPageQuery, { page: data.preview }); - return ; + return ( + + + + ); }, 'previewDecapPage', ), diff --git a/apps/decap/turbo.json b/apps/decap/turbo.json index af21adb24..84897ed6e 100644 --- a/apps/decap/turbo.json +++ b/apps/decap/turbo.json @@ -4,16 +4,19 @@ "pipeline": { "prep:vite": { "dependsOn": ["^prep"], - "inputs": ["src/**", "vite.config.js"], - "outputs": ["build/**"] + "inputs": ["src/**", "vite.config.ts", "index.html"], + "outputs": ["dist/**"] }, "prep:scripts": { "dependsOn": ["^prep"], - "inputs": ["src/**", "tsup.config.js"], - "outputs": ["dist/**"] + "inputs": ["src/**", "tsup.config.ts"], + "outputs": ["build/**"] }, "prep": { "dependsOn": ["prep:vite", "prep:scripts"] + }, + "test:integration": { + "dependsOn": ["@custom-tests/e2e#test:integration:decap"] } } } diff --git a/apps/website/gatsby-browser.ts b/apps/website/gatsby-browser.ts index 176e43b2b..971084011 100644 --- a/apps/website/gatsby-browser.ts +++ b/apps/website/gatsby-browser.ts @@ -1,14 +1,7 @@ import '@custom/ui/styles.css'; -import { registerExecutor } from '@custom/schema'; import { GatsbyBrowser } from 'gatsby'; -import { drupalExecutor } from './src/utils/drupal-executor'; - -export const onClientEntry: GatsbyBrowser['onClientEntry'] = async () => { - registerExecutor(drupalExecutor(`/graphql`)); -}; - export const shouldUpdateScroll: GatsbyBrowser['shouldUpdateScroll'] = ( args, ) => { diff --git a/apps/website/gatsby-config.mjs b/apps/website/gatsby-config.mjs index 9236caa7c..74bc5a9fc 100644 --- a/apps/website/gatsby-config.mjs +++ b/apps/website/gatsby-config.mjs @@ -6,14 +6,7 @@ // TS file name should be different from gastby-config.ts, otherwise Gatsby will // pick it up instead of the JS file. -import { getPages, getTranslatables } from '@custom/decap'; -import autoload from '@custom/schema/gatsby-autoload'; -import { resolve } from 'path'; - -const dir = resolve('node_modules/@custom/decap/data'); - -process.env.GATSBY_DRUPAL_URL = - process.env.DRUPAL_EXTERNAL_URL || 'http://127.0.0.1:8888'; +import { existsSync } from 'fs'; process.env.NETLIFY_URL = process.env.NETLIFY_URL || 'http://127.0.0.1:8000'; @@ -21,15 +14,67 @@ process.env.CLOUDINARY_API_KEY = process.env.CLOUDINARY_API_KEY || 'test'; process.env.CLOUDINARY_API_SECRET = process.env.CLOUDINARY_API_SECRET || 'test'; process.env.CLOUDINARY_CLOUDNAME = process.env.CLOUDINARY_CLOUDNAME || 'demo'; +/** + * + * @type {import('gatsby').GatsbyConfig['plugins']} + */ +const plugins = [ + 'gatsby-plugin-uninline-styles', + 'gatsby-plugin-pnpm', + 'gatsby-plugin-layout', + 'gatsby-plugin-sharp', + { + resolve: '@amazeelabs/gatsby-plugin-static-dirs', + options: { + directories: { + 'node_modules/@custom/ui/build/styles.css': '/styles.css', + 'node_modules/@custom/ui/build/iframe.css': '/iframe.css', + 'node_modules/@custom/ui/static/public': '/', + }, + }, + }, + { + resolve: '@amazeelabs/gatsby-plugin-operations', + options: { + operations: './node_modules/@custom/schema/build/operations.json', + }, + }, + { + resolve: 'gatsby-plugin-netlify', + options: { + // To avoid "X-Frame-Options: DENY" and let it work in the preview + // iframe. + mergeSecurityHeaders: false, + }, + }, + { + // TODO: Move preview to Drupal and remove this. + resolve: 'gatsby-plugin-sitemap', + options: { + excludes: ['/__preview/**'], + }, + }, + { + resolve: 'gatsby-plugin-robots-txt', + options: { + policy: [{ userAgent: '*', allow: '/', disallow: [] }], + }, + }, + { + resolve: '@amazeelabs/gatsby-source-silverback', + options: { + schema_configuration: './graphqlrc.yml', + }, + }, + '@custom/cms', + '@custom/decap', +]; + /** * @type {import('gatsby').GatsbyConfig} */ export default { trailingSlash: 'ignore', - proxy: { - prefix: '/sites/default/files', - url: process.env.DRUPAL_EXTERNAL_URL || 'http://127.0.0.1:8888', - }, flags: { PARTIAL_HYDRATION: false, }, @@ -37,65 +82,5 @@ export default { // For gatsby-plugin-sitemap and gatsby-plugin-robots-txt. siteUrl: process.env.NETLIFY_URL, }, - plugins: [ - 'gatsby-plugin-uninline-styles', - 'gatsby-plugin-pnpm', - 'gatsby-plugin-layout', - 'gatsby-plugin-sharp', - { - resolve: '@amazeelabs/gatsby-plugin-static-dirs', - options: { - directories: { - 'node_modules/@custom/ui/build/styles.css': '/styles.css', - 'node_modules/@custom/ui/static/public': '/', - 'node_modules/@custom/decap/dist': '/admin', - 'node_modules/@custom/decap/media': '/media', - }, - }, - }, - { - resolve: '@amazeelabs/gatsby-plugin-operations', - options: { - operations: './node_modules/@custom/schema/build/operations.json', - }, - }, - { - resolve: '@amazeelabs/gatsby-source-silverback', - options: { - drupal_url: process.env.DRUPAL_INTERNAL_URL || 'http://127.0.0.1:8888', - drupal_external_url: - // File requests are proxied through netlify. - process.env.NETLIFY_URL || 'http://127.0.0.1:8000', - graphql_path: '/graphql', - auth_key: 'cfdb0555111c0f8924cecab028b53474', - type_prefix: '', - schema_configuration: './graphqlrc.yml', - directives: autoload, - sources: { - getPages: getPages(`${dir}/page`), - getTranslatables: getTranslatables(dir), - }, - }, - }, - { - resolve: 'gatsby-plugin-netlify', - options: { - // To avoid "X-Frame-Options: DENY" and let it work in the preview - // iframe. - mergeSecurityHeaders: false, - }, - }, - { - resolve: 'gatsby-plugin-sitemap', - options: { - excludes: ['/__preview/**'], - }, - }, - { - resolve: 'gatsby-plugin-robots-txt', - options: { - policy: [{ userAgent: '*', allow: '/', disallow: [] }], - }, - }, - ], + plugins, }; diff --git a/apps/website/gatsby-node.mjs b/apps/website/gatsby-node.mjs index aaaf21162..ffce8e751 100644 --- a/apps/website/gatsby-node.mjs +++ b/apps/website/gatsby-node.mjs @@ -34,19 +34,6 @@ function isDefined(val) { * @type {import('gatsby').GatsbyNode['createPages']} */ export const createPages = async ({ actions }) => { - // Rewrite file requests to Drupal. - actions.createRedirect({ - fromPath: '/sites/default/files/*', - toPath: `${process.env.GATSBY_DRUPAL_URL}/sites/default/files/:splat`, - statusCode: 200, - }); - - actions.createRedirect({ - fromPath: '/graphql', - toPath: `${process.env.GATSBY_DRUPAL_URL}/graphql`, - statusCode: 200, - }); - // Grab Home- and 404 pages. const homePages = ( @@ -73,13 +60,13 @@ export const createPages = async ({ actions }) => { }); // Create a list of paths that we don't want to render regularly. - // 404 and homepages are dealt with differrently. + // 404 and homepages are dealt with differently. const skipPaths = [ ...(homePages.map((page) => page.path) || []), ...(notFoundPages.map((page) => page.path) || []), ]; - // Run the query that lists all pages, both decap and Drupal. + // Run the query that lists all pages, both Decap and Drupal. const pages = await graphqlQuery(ListPagesQuery); // Create a gatsby page for each of these pages. @@ -102,6 +89,14 @@ export const createPages = async ({ actions }) => { }); }); + // Create a inquiry page in each language. + Object.values(Locale).forEach((locale) => { + actions.createPage({ + path: `/${locale}/inquiry`, + component: resolve(`./src/templates/inquiry.tsx`), + }); + }); + // Broken Gatsby links will attempt to load page-data.json files, which don't exist // and also should not be piped into the strangler function. Thats why they // are caught right here. @@ -111,25 +106,6 @@ export const createPages = async ({ actions }) => { statusCode: 404, }); - // Proxy Drupal webforms. - Object.values(Locale).forEach((locale) => { - actions.createRedirect({ - fromPath: `/${locale}/form/*`, - toPath: `${process.env.GATSBY_DRUPAL_URL}/${locale}/form/:splat`, - statusCode: 200, - }); - }); - - // Additionally proxy themes and modules as they can have additional - // non-aggregated assets. - ['themes', 'modules'].forEach((path) => { - actions.createRedirect({ - fromPath: `/${path}/*`, - toPath: `${process.env.GATSBY_DRUPAL_URL}/${path}/:splat`, - statusCode: 200, - }); - }); - // Any unhandled requests are handed to strangler, which will try to pass // them to all registered legacy systems and return 404 if none of them // respond. diff --git a/apps/website/gatsby-ssr.ts b/apps/website/gatsby-ssr.ts index 367db03f6..208aaf44f 100644 --- a/apps/website/gatsby-ssr.ts +++ b/apps/website/gatsby-ssr.ts @@ -1,13 +1,10 @@ -import { Locale, registerExecutor } from '@custom/schema'; +import { Locale } from '@custom/schema'; import { GatsbySSR } from 'gatsby'; -import { drupalExecutor } from './src/utils/drupal-executor'; - export const onRenderBody: GatsbySSR['onRenderBody'] = ({ setHtmlAttributes, pathname, }) => { - registerExecutor(drupalExecutor(`/graphql`)); const locales = Object.values(Locale); if (locales.length === 1) { // Single-language project. diff --git a/apps/website/has-drupal.mjs b/apps/website/has-drupal.mjs new file mode 100644 index 000000000..a448969c5 --- /dev/null +++ b/apps/website/has-drupal.mjs @@ -0,0 +1,6 @@ +import config from './gatsby-config.mjs'; + +if (config.plugins?.filter((plugin) => plugin === '@custom/cms').length) { + process.exit(0); +} +process.exit(1); diff --git a/apps/website/netlify.toml b/apps/website/netlify.toml index 66e2bafe3..58f3e6585 100644 --- a/apps/website/netlify.toml +++ b/apps/website/netlify.toml @@ -1,9 +1,24 @@ [dev] autoLaunch = false +[functions] + directory = "netlify/functions" + +[build] + publish = "public" + edge_functions = "netlify/edge-functions" + [functions.strangler] included_files = ["public/404.html"] [[edge_functions]] path = "/" - function = "homepage-redirect" \ No newline at end of file + function = "homepage-redirect" + +[[edge_functions]] + path = "/.netlify/functions/github-proxy" + function = "github-proxy-auth" + +[[edge_functions]] + path = "/.netlify/functions/github-proxy/*" + function = "github-proxy-auth" diff --git a/apps/website/netlify/edge-functions/github-proxy-auth.ts b/apps/website/netlify/edge-functions/github-proxy-auth.ts new file mode 100644 index 000000000..84a601ede --- /dev/null +++ b/apps/website/netlify/edge-functions/github-proxy-auth.ts @@ -0,0 +1,39 @@ +import type { Context } from '@netlify/edge-functions'; + +// For some reason pnpm package imports break in edge handlers. +import { + JwtEncoder, + PostmarkEmailBackend, + TokenAuthHandler, +} from '../../node_modules/@amazeelabs/token-auth-middleware/build/index.js'; + +export default async (request: Request, context: Context) => { + if ( + !(Netlify.env.has('JWT_SECRET') && Netlify.env.has('POSTMARK_API_TOKEN')) + ) { + throw new Error( + 'Missing environment variables JWT_SECRET and POSTMARK_API_TOKEN.', + ); + } + + const encoder = new JwtEncoder(Netlify.env.get('JWT_SECRET') as string); + const backend = new PostmarkEmailBackend( + { + // Grant access to everybody @amazeelabs.com. + '*@amazeelabs.com': '*', + }, + 'noreply@amazeelabs.com', + Netlify.env.get('POSTMARK_API_TOKEN') as string, + 'login-link', + ); + + const handler = new TokenAuthHandler( + '/.netlify/functions/github-proxy', + encoder, + backend, + { + tokenLifetime: 300, + }, + ); + return handler.handle(request, context.next); +}; diff --git a/apps/website/netlify/functions/github-proxy.mts b/apps/website/netlify/functions/github-proxy.mts new file mode 100644 index 000000000..f7a7e2008 --- /dev/null +++ b/apps/website/netlify/functions/github-proxy.mts @@ -0,0 +1,10 @@ +import type { Context, Config } from '@netlify/functions'; +import { githubProxy } from '@amazeelabs/decap-cms-backend-token-auth/proxy'; + +export default function (request: Request, context: Context) { + if (!process.env.DECAP_GITHUB_TOKEN) { + throw new Error('Missing environment variable DECAP_GITHUB_TOKEN.'); + } + return githubProxy(request, process.env.DECAP_GITHUB_TOKEN, '/.netlify/functions/github-proxy'); +} + diff --git a/apps/website/package.json b/apps/website/package.json index 68fe31751..2024acb54 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -4,11 +4,14 @@ "dependencies": { "@amazeelabs/bridge-gatsby": "^1.2.7", "@amazeelabs/cloudinary-responsive-image": "^1.6.15", + "@amazeelabs/decap-cms-backend-token-auth": "^1.1.7", "@amazeelabs/gatsby-plugin-operations": "^1.1.3", "@amazeelabs/gatsby-plugin-static-dirs": "^1.0.1", - "@amazeelabs/gatsby-source-silverback": "^1.13.12", + "@amazeelabs/gatsby-source-silverback": "^1.14.0", "@amazeelabs/publisher": "^2.4.17", "@amazeelabs/strangler-netlify": "^1.1.9", + "@amazeelabs/token-auth-middleware": "^1.1.1", + "@custom/cms": "workspace:*", "@custom/decap": "workspace:*", "@custom/schema": "workspace:*", "@custom/ui": "workspace:*", @@ -24,11 +27,13 @@ "gatsby-source-filesystem": "^5.13.0", "image-size": "^1.1.1", "mime-types": "^2.1.35", - "netlify-cli": "^17.11.0", + "netlify-cli": "^17.21.1", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@netlify/edge-functions": "^2.3.1", + "@netlify/functions": "^2.6.0", "@testing-library/react": "^14.1.2", "@types/react": "^18.2.46", "@types/react-dom": "^18.2.18", @@ -39,11 +44,11 @@ }, "scripts": { "test:static": "tsc --noEmit && eslint '**/*.{ts,tsx,js,jsx}' --ignore-path='./.gitignore'", - "build:gatsby": "pnpm build:dotenv && gatsby build", - "build:dotenv": "rm -rf .env && echo \"DRUPAL_EXTERNAL_URL='$DRUPAL_EXTERNAL_URL'\nDRUPAL_INTERNAL_URL='$DRUPAL_INTERNAL_URL'\" >> .env", - "rebuild": "gatsby build", - "start:cms": "pnpm run --filter @custom/cms start", - "build": "CLOUDINARY_CLOUDNAME=test start-test start:cms 8888 build:gatsby", + "full-rebuild": "pnpm clean && pnpm build:gatsby", + "start:drupal": "pnpm run --filter @custom/cms start", + "build:drupal": "CLOUDINARY_CLOUDNAME=test start-test start:drupal 8888 build:gatsby", + "build:gatsby": "gatsby build", + "build": "if node has-drupal.mjs; then pnpm build:drupal; else pnpm build:gatsby; fi", "start": "publisher", "serve": "netlify dev --cwd=. --dir=public --port=8000", "dev": "pnpm clean && publisher", diff --git a/apps/website/publisher.config.ts b/apps/website/publisher.config.ts index abaf98f01..6c5d95797 100644 --- a/apps/website/publisher.config.ts +++ b/apps/website/publisher.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ // cannot report it. // Workaround: Do a double build on the first build. 'if test -d public; then echo "Single build" && pnpm build:gatsby; else echo "Double build" && pnpm build:gatsby && pnpm build:gatsby; fi' - : 'pnpm build:gatsby', + : 'DRUPAL_EXTERNAL_URL=http://127.0.0.1:8888 pnpm build:gatsby', outputTimeout: 1000 * 60 * 10, }, clean: 'pnpm clean', diff --git a/apps/website/src/layouts/index.tsx b/apps/website/src/layouts/index.tsx index cfd2650d1..6bc8438cf 100644 --- a/apps/website/src/layouts/index.tsx +++ b/apps/website/src/layouts/index.tsx @@ -1,14 +1,21 @@ import { graphql, useStaticQuery } from '@amazeelabs/gatsby-plugin-operations'; -import { FrameQuery, registerExecutor } from '@custom/schema'; +import { FrameQuery, OperationExecutor } from '@custom/schema'; import { Frame } from '@custom/ui/routes/Frame'; import React, { PropsWithChildren } from 'react'; +import { drupalExecutor } from '../utils/drupal-executor'; + export default function Layout({ children, }: PropsWithChildren<{ locale: string; }>) { const data = useStaticQuery(graphql(FrameQuery)); - registerExecutor(FrameQuery, data); - return {children}; + return ( + + + {children} + + + ); } diff --git a/apps/website/src/pages/404.tsx b/apps/website/src/pages/404.tsx index a48ae4152..a7062457c 100644 --- a/apps/website/src/pages/404.tsx +++ b/apps/website/src/pages/404.tsx @@ -1,5 +1,5 @@ import { graphql } from '@amazeelabs/gatsby-plugin-operations'; -import { NotFoundPageQuery, registerExecutor } from '@custom/schema'; +import { NotFoundPageQuery, OperationExecutor } from '@custom/schema'; import { NotFoundPage } from '@custom/ui/routes/NotFoundPage'; import { PageProps } from 'gatsby'; import React from 'react'; @@ -7,6 +7,9 @@ import React from 'react'; export const query = graphql(NotFoundPageQuery); export default function Index({ data }: PageProps) { - registerExecutor(NotFoundPageQuery, {}, data); - return ; + return ( + + + + ); } diff --git a/apps/website/src/preview/page.tsx b/apps/website/src/preview/page.tsx index 297f4fd92..dc2a1dac5 100644 --- a/apps/website/src/preview/page.tsx +++ b/apps/website/src/preview/page.tsx @@ -1,6 +1,6 @@ import { + OperationExecutor, PreviewDrupalPageQuery, - registerExecutor, ViewPageQuery, } from '@custom/schema'; import { Page } from '@custom/ui/routes/Page'; @@ -17,15 +17,21 @@ const previewExecutor = drupalExecutor( export default function PagePreview() { const { nid, rid, lang } = usePreviewParameters(); if (nid && rid && lang) { - registerExecutor(ViewPageQuery, async () => { - const data = await previewExecutor(PreviewDrupalPageQuery, { - id: nid, - locale: lang, - rid, - }); - return { page: data.preview }; - }); - return ; + return ( + { + const data = await previewExecutor(PreviewDrupalPageQuery, { + id: nid, + locale: lang, + rid, + }); + return { page: data.preview }; + }} + > + + + ); } return null; } diff --git a/apps/website/src/templates/home.tsx b/apps/website/src/templates/home.tsx index 0db9bce01..22096d7e6 100644 --- a/apps/website/src/templates/home.tsx +++ b/apps/website/src/templates/home.tsx @@ -1,5 +1,5 @@ import { graphql } from '@amazeelabs/gatsby-plugin-operations'; -import { HomePageQuery, registerExecutor, useLocalized } from '@custom/schema'; +import { HomePageQuery, OperationExecutor, useLocalized } from '@custom/schema'; import { HomePage } from '@custom/ui/routes/HomePage'; import { HeadProps, PageProps } from 'gatsby'; import React from 'react'; @@ -36,6 +36,9 @@ export function Head({ data }: HeadProps) { } export default function Index({ data }: PageProps) { - registerExecutor(HomePageQuery, {}, data); - return ; + return ( + + + + ); } diff --git a/apps/website/src/templates/inquiry.tsx b/apps/website/src/templates/inquiry.tsx new file mode 100644 index 000000000..eb90f5fa2 --- /dev/null +++ b/apps/website/src/templates/inquiry.tsx @@ -0,0 +1,6 @@ +import { Inquiry } from '@custom/ui/routes/Inquiry'; +import React from 'react'; + +export default function InquiryPage() { + return ; +} diff --git a/apps/website/src/templates/page.tsx b/apps/website/src/templates/page.tsx index 115061198..f707acbb5 100644 --- a/apps/website/src/templates/page.tsx +++ b/apps/website/src/templates/page.tsx @@ -1,5 +1,5 @@ import { graphql } from '@amazeelabs/gatsby-plugin-operations'; -import { registerExecutor, useLocation, ViewPageQuery } from '@custom/schema'; +import { OperationExecutor, useLocation, ViewPageQuery } from '@custom/schema'; import { Page } from '@custom/ui/routes/Page'; import { HeadProps, PageProps } from 'gatsby'; import React from 'react'; @@ -41,6 +41,13 @@ export default function PageTemplate({ data }: PageProps) { // That makes shure the `useOperation(ViewPageQuery, ...)` with this // path immediately returns this data. const [location] = useLocation(); - registerExecutor(ViewPageQuery, { pathname: location.pathname }, data); - return ; + return ( + + + + ); } diff --git a/apps/website/src/utils/drupal-executor.ts b/apps/website/src/utils/drupal-executor.ts index a892e7a6e..f38a98fc5 100644 --- a/apps/website/src/utils/drupal-executor.ts +++ b/apps/website/src/utils/drupal-executor.ts @@ -9,23 +9,51 @@ export function drupalExecutor(endpoint: string, forward: boolean = true) { variables?: OperationVariables, ) { const url = new URL(endpoint, window.location.origin); - url.searchParams.set('queryId', id); - url.searchParams.set('variables', JSON.stringify(variables)); - const { data, errors } = await ( - await fetch(url, { - credentials: 'include', - headers: forward - ? { - 'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1), - 'SLB-Forwarded-Host': window.location.hostname, - 'SLB-Forwarded-Port': window.location.port, - } - : {}, - }) - ).json(); - if (errors) { - throw errors; + const isMutation = id.includes('Mutation:'); + if (isMutation) { + const { data, errors } = await ( + await fetch(url, { + method: 'POST', + credentials: 'include', + body: JSON.stringify({ + queryId: id, + variables: variables, + }), + headers: forward + ? { + 'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1), + 'SLB-Forwarded-Host': window.location.hostname, + 'SLB-Forwarded-Port': window.location.port, + 'Content-Type': 'application/json', + } + : { + 'Content-Type': 'application/json', + }, + }) + ).json(); + if (errors) { + throw errors; + } + return data; + } else { + url.searchParams.set('queryId', id); + url.searchParams.set('variables', JSON.stringify(variables)); + const { data, errors } = await ( + await fetch(url, { + credentials: 'include', + headers: forward + ? { + 'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1), + 'SLB-Forwarded-Host': window.location.hostname, + 'SLB-Forwarded-Port': window.location.port, + } + : {}, + }) + ).json(); + if (errors) { + throw errors; + } + return data; } - return data; }; } diff --git a/apps/website/turbo.json b/apps/website/turbo.json index e7a93c28a..fb8be6347 100644 --- a/apps/website/turbo.json +++ b/apps/website/turbo.json @@ -12,8 +12,7 @@ "inputs": ["src/**", "!src/gatsby-fragments.js"] }, "build": { - "env": ["DRUPAL_INTERNAL_URL", "DRUPAL_EXTERNAL_URL"], - "dependsOn": ["@custom/cms#prep", "prep", "^prep"], + "dependsOn": ["prep", "^prep"], "inputs": [ "netlify/**", "src/**", diff --git a/package.json b/package.json index 631a049b9..e4b531976 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,16 @@ "scripts": { "postinstall": "husky install || true", "commit": "git-cz", - "test:format": "pnpm test:format:root && pnpm test:format:workspaces", - "test:format:root": "pnpm prettier --check '**/*.{js,cjs,mjs,ts,jsx,tsx,gql,graphql,graphqls,md,mdx,json}' --ignore-path='./.prettierignore'", - "test:format:workspaces": "pnpm --workspace-concurrency=1 -r exec prettier --check '**/*.{js,cjs,mjs,ts,jsx,tsx,gql,graphql,graphqls,md,mdx,json}' --ignore-path='./.gitignore'", + "tb": "pnpm turbo --filter @custom/website", + "test:format": "pnpm test:format:root --check && pnpm test:format:workspaces --check", + "test:format:fix": "pnpm test:format:root --write && pnpm test:format:workspaces --write", + "test:format:root": "pnpm prettier '**/*.{js,cjs,mjs,ts,jsx,tsx,gql,graphql,graphqls,md,mdx,json,htm,html}' --ignore-path='./.prettierignore'", + "test:format:workspaces": "pnpm --workspace-concurrency=1 -r exec prettier '**/*.{js,cjs,mjs,ts,jsx,tsx,gql,graphql,graphqls,md,mdx,json,htm,html}' --ignore-path='./.gitignore'", "turbo:local": "if [ -z $CI ]; then echo $(date)$RANDOM > apps/cms/turbo-seed.txt; fi", - "turbo:test": "pnpm turbo:local && turbo test:unit --no-daemon --go-fallback --output-logs=new-only && turbo test:integration --no-daemon --go-fallback --output-logs=new-only --concurrency=1", - "turbo:test:quick": "pnpm turbo:local && turbo test:unit --no-daemon --go-fallback --output-logs=new-only", - "turbo:prep": "pnpm turbo:local && turbo prep --no-daemon --go-fallback --output-logs=new-only", - "turbo:prep:force": "rm -f apps/cms/web/sites/default/files/.sqlite && turbo prep --no-daemon --go-fallback --force", + "turbo:test": "pnpm turbo:local && pnpm tb test:unit --no-daemon --go-fallback --output-logs=new-only && pnpm tb test:integration --no-daemon --go-fallback --output-logs=new-only --concurrency=1", + "turbo:test:quick": "pnpm turbo:local && pnpm tb test:unit --no-daemon --go-fallback --output-logs=new-only", + "turbo:prep": "pnpm turbo:local && pnpm tb prep --no-daemon --go-fallback --output-logs=new-only", + "turbo:prep:force": "rm -f apps/cms/web/sites/default/files/.sqlite && pnpm tb prep --no-daemon --go-fallback --force", "gutenberg:generate": "pnpm run --filter \"@custom/gutenberg_blocks\" gutenberg:generate" }, "private": true, @@ -41,6 +43,7 @@ "vitest": "^1.1.1" }, "resolutions": { + "gatsby-plugin-sharp": "5.13.1", "sharp": "0.33.1", "eslint": "7", "graphql": "16.8.1" diff --git a/packages/drupal/custom/custom.info.yml b/packages/drupal/custom/custom.info.yml index e0d6338fd..732a2f809 100644 --- a/packages/drupal/custom/custom.info.yml +++ b/packages/drupal/custom/custom.info.yml @@ -6,3 +6,4 @@ dependencies: - silverback_gatsby:silverback_gatsby - simple_oauth:simple_oauth - consumers:consumers + - drupal:serialization diff --git a/packages/drupal/custom/custom.services.yml b/packages/drupal/custom/custom.services.yml index ddc26a4d3..fd6d0a918 100644 --- a/packages/drupal/custom/custom.services.yml +++ b/packages/drupal/custom/custom.services.yml @@ -3,10 +3,7 @@ services: class: Drupal\custom\Routing\RouteSubscriber tags: - { name: event_subscriber } - custom.content_hub: - class: Drupal\custom\ContentHub - arguments: ['@entity_type.manager'] custom.webform: class: Drupal\custom\Webform - arguments: ['@renderer', '@entity_type.manager'] + arguments: ['@renderer', '@entity_type.manager', '@serializer'] diff --git a/packages/drupal/custom/src/ContentHub.php b/packages/drupal/custom/src/ContentHub.php deleted file mode 100644 index db8afcfde..000000000 --- a/packages/drupal/custom/src/ContentHub.php +++ /dev/null @@ -1,81 +0,0 @@ -entityTypeManager = $entityTypeManager; - } - - /** - * Query a list of pages. - * - * @param \Drupal\graphql_directives\DirectiveArguments $args - * The graphql argument bag. - * - * @return array{'total': int, 'items': \Drupal\node\NodeInterface[]} - * Result of the query. - */ - public function query(DirectiveArguments $args) : array { - // @todo Switch this to views. - $offset = $args->args['pagination']['offset']; - $limit = $args->args['pagination']['limit']; - $locale = $args->args['locale']; - $nodeStorage = $this->entityTypeManager->getStorage('node'); - - // Clear this whenever nodes are changed. - $args->context->addCacheTags($nodeStorage->getEntityType()->getListCacheTags()); - - $countQuery = $nodeStorage->getQuery(); - $countQuery->condition('type', 'page'); - $countQuery->condition('status', 1); - if (!empty($args->args['query'])) { - $countQuery->condition('title', $args->args['query'], 'CONTAINS'); - } - $countQuery->condition('langcode', $locale); - $count = $countQuery->count()->accessCheck(TRUE)->execute(); - - $query = $nodeStorage->getQuery(); - $query->condition('type', 'page'); - $query->condition('status', 1); - if (!empty($args->args['query'])) { - $query->condition('title', $args->args['query'], 'CONTAINS'); - } - $query->condition('langcode', $locale); - $pageIds = $query->range($offset, $limit) - ->sort('title', 'ASC') - ->accessCheck(TRUE) - ->execute(); - - $entities = array_map(function (NodeInterface $node) use ($locale) { - return $node->getTranslation($locale); - }, $pageIds ? $nodeStorage->loadMultiple($pageIds) : []); - - return [ - 'total' => $count, - 'items' => $entities, - ]; - } - -} diff --git a/packages/drupal/custom/src/Webform.php b/packages/drupal/custom/src/Webform.php index 6e80afe5d..541f367ae 100644 --- a/packages/drupal/custom/src/Webform.php +++ b/packages/drupal/custom/src/Webform.php @@ -6,6 +6,10 @@ use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\RendererInterface; use Drupal\graphql_directives\DirectiveArguments; +use Drupal\webform\WebformSubmissionForm; +use Drupal\webform\WebformSubmissionInterface; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; /** * Helper service for managing webforms with graphql. @@ -24,15 +28,24 @@ class Webform { */ protected EntityTypeManagerInterface $entityTypeManager; + /** + * The serializer serice. + * + * @var \Symfony\Component\Serializer\SerializerInterface + */ + protected Serializer $serializer; + /** * Webform constructor. */ public function __construct( RendererInterface $renderer, - EntityTypeManagerInterface $entityTypeManager + EntityTypeManagerInterface $entityTypeManager, + SerializerInterface $serializer ) { $this->entityTypeManager = $entityTypeManager; $this->renderer = $renderer; + $this->serializer = $serializer; } /** @@ -68,4 +81,103 @@ function () use ($args, $webformId, $webFormStorage) { return $result; } + /** + * Directive to create a webform submission. + */ + public function createSubmission(DirectiveArguments $args) : array { + try { + $webformId = $args->args['webformId']; + $webform = $this->entityTypeManager->getStorage('webform') + ->load($webformId); + if (!$webform) { + throw new \InvalidArgumentException('The webform could no be loaded.'); + } + $isOpen = WebformSubmissionForm::isOpen($webform); + if ($isOpen !== TRUE) { + throw new \Exception($isOpen); + } + $submittedData = $args->args['submittedData']; + $values = [ + 'webform_id' => $webformId, + 'entity_type' => NULL, + 'entity_id' => NULL, + 'data' => $this->serializer->decode($submittedData, 'json'), + ]; + // The WebformSubmissionForm::submitFormValues() will return an array with + // errors, if there are validation errors, otherwise it will return a + // webform submission entity. + $webformSubmission = WebformSubmissionForm::submitFormValues($values); + + // If we get an array from the createSubmission call, then it means there + // were errors during the insert / validate operation, so we just return + // them. + if (is_array($webformSubmission)) { + return [ + 'errors' => $this->formatErrors($webformSubmission), + 'submission' => NULL, + ]; + } + + // We successfully submitted the data. + if (is_object($webformSubmission) && $webformSubmission instanceof WebformSubmissionInterface) { + $webformSubmissionData = $webformSubmission->getData(); + return [ + 'errors' => NULL, + 'submission' => $this->serializer->encode(array_merge([ + 'submissionId' => $webformSubmission->id(), + ], $webformSubmissionData), 'json'), + ]; + } + } catch (\InvalidArgumentException $e) { + return [ + 'submission' => NULL, + 'errors' => [ + [ + 'message' => $e->getMessage(), + 'key' => 'invalid_webform' + ] + ] + ]; + } catch (\Exception $e) { + return [ + 'submission' => NULL, + 'errors' => [ + [ + 'message' => $e->getMessage(), + 'key' => 'invalid_input' + ] + ] + ]; + } + + // We should actually never get here... if we do, we don't know what + // happened. + return [ + 'submission' => NULL, + 'errors' => [ + [ + 'message' => 'Unknown error', + 'key' => 'unknown_error', + ] + ] + ]; + + } + + /** + * Helper method to arrange a set of webform submission errors in a way that + * can be used by the MutationError graphl type. + */ + protected function formatErrors(array $webformSubmissionErrors) { + $formattedErrors = []; + foreach ($webformSubmissionErrors as $fieldName => $error) { + $formattedErrors[] = [ + 'message' => $error->__toString(), + 'key' => 'invalid_field_' . $fieldName, + 'field' => $fieldName, + ]; + } + return $formattedErrors; + } + } diff --git a/packages/drupal/gutenberg_blocks/css/edit.css b/packages/drupal/gutenberg_blocks/css/edit.css index f4dd2c543..f4a49c277 100644 --- a/packages/drupal/gutenberg_blocks/css/edit.css +++ b/packages/drupal/gutenberg_blocks/css/edit.css @@ -10,4 +10,28 @@ .gutenberg__editor blockquote::before, .gutenberg__editor blockquote::after { content: ''; +} + +.gutenberg__editor .container-wrapper { + display: block; + position: relative; + margin: 40px 0; + border-left: 34px solid #666666; + padding-left: 10px; + min-height: 250px; +} + +.gutenberg__editor .container-wrapper .container-label { + font: bold 12px Sans-Serif; + letter-spacing: 2px; + text-transform: uppercase; + color: #fff; + padding: 5px 10px; + margin: 0 0 10px 0; + line-height: 24px; + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + transform: rotate(90deg); } \ No newline at end of file diff --git a/packages/drupal/gutenberg_blocks/src/blocks/cta.tsx b/packages/drupal/gutenberg_blocks/src/blocks/cta.tsx new file mode 100644 index 000000000..c0bbaacdc --- /dev/null +++ b/packages/drupal/gutenberg_blocks/src/blocks/cta.tsx @@ -0,0 +1,128 @@ +import { + // @ts-ignore + __experimentalLinkControl as LinkControl, + InspectorControls, + RichText, +} from 'wordpress__block-editor'; +import { registerBlockType } from 'wordpress__blocks'; +import { PanelBody, ToggleControl } from 'wordpress__components'; +import { compose, withState } from 'wordpress__compose'; + +// @ts-ignore +const { t: __ } = Drupal; + +// @ts-ignore +const { setPlainTextAttribute } = silverbackGutenbergUtils; + +// @ts-ignore +registerBlockType('custom/cta', { + title: 'CTA', + icon: 'admin-links', + category: 'common', + attributes: { + url: { + type: 'string', + }, + text: { + type: 'string', + }, + // To have an easier integration with entity usage, we also retrieve and + // store the uuid (data-id) and the entity type of internal links. + 'data-id': { + type: 'string', + }, + 'data-entity-type': { + type: 'string', + }, + openInNewTab: { + type: 'boolean', + }, + }, + // @ts-ignore + edit: compose(withState({}))((props) => { + return ( +
+ { + setPlainTextAttribute(props, 'text', text); + }} + /> + + + { + props.setAttributes({ + url: link.url, + 'data-id': link.id, + 'data-entity-type': + // At the moment, the silverback_gutenberg link autocomplete + // controller does not return the machine name of the entity + // type. Instead, it returns the human readable, translated, + // entity type label. We should refactor the LinkAutocomplete + // controller to return the machine name of the entity type, and + // then we can set the data-entity-type value more accurate. + // Right now, we just make a "guess" based on the the human + // readable label for English and German. + link.type.startsWith('Media') || + link.type.startsWith('Medien') + ? 'media' + : link.type !== 'URL' + ? 'node' + : '', + }); + }} + /> + { + props.setAttributes({ + openInNewTab, + }); + }} + /> + + +
+ ); + }), + + save: () => { + return null; + }, +}); diff --git a/packages/drupal/gutenberg_blocks/src/blocks/hero.tsx b/packages/drupal/gutenberg_blocks/src/blocks/hero.tsx index e552a27c1..e87401af8 100644 --- a/packages/drupal/gutenberg_blocks/src/blocks/hero.tsx +++ b/packages/drupal/gutenberg_blocks/src/blocks/hero.tsx @@ -6,12 +6,22 @@ import { RichText, } from 'wordpress__block-editor'; import { registerBlockType } from 'wordpress__blocks'; -import { PanelBody } from 'wordpress__components'; +import { PanelBody, SelectControl } from 'wordpress__components'; import { compose, withState } from 'wordpress__compose'; import { dispatch } from 'wordpress__data'; import { DrupalMediaEntity } from '../utils/drupal-media'; +declare const drupalSettings: { + customGutenbergBlocks: { + forms: Array<{ + id: string; + url: string; + label: string; + }>; + }; +}; + // @ts-ignore const { t: __ } = Drupal; // @ts-ignore @@ -44,6 +54,9 @@ registerBlockType('custom/hero', { type: 'boolean', default: true, }, + formId: { + type: 'string', + }, }, supports: { inserter: false, @@ -93,6 +106,23 @@ registerBlockType('custom/hero', { )} + + ({ + label: form.label, + value: form.id, + })), + ]} + onChange={(formId: string) => { + props.setAttributes({ + formId, + }); + }} + /> +
@@ -152,7 +182,8 @@ registerBlockType('custom/hero', {
)} + {props.attributes.formId ? ( +