diff --git a/.github/actions/node_env_setup/action.yml b/.github/actions/node_env_setup/action.yml index 5913c66ca08..94d780775fa 100644 --- a/.github/actions/node_env_setup/action.yml +++ b/.github/actions/node_env_setup/action.yml @@ -1,4 +1,5 @@ name: Set up Node.js environment +description: 'Set up Node.js environment' runs: using: "composite" @@ -25,6 +26,10 @@ runs: restore-keys: | ${{ runner.os }}-pnpm-store- + - name: Install dependencies + shell: bash + run: make install + inputs: node-version: description: 'Node.js version' diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index f84d9458832..3e87530e50a 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -11,7 +11,7 @@ jobs: matrix: node-version: [24.x] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js environment uses: ./.github/actions/acceptance_tests_node_env_setup @@ -19,7 +19,7 @@ jobs: node-version: ${{ matrix.node-version }} - uses: JarvusInnovations/background-action@v1 - name: Start Servers - Volto and Plone + name: Start Servers - Plone 7 frontend and backend with: run: | make ci-acceptance-backend-start & diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index a9ab7bc1b84..43ff4d61c1a 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -12,7 +12,7 @@ jobs: towncrier: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: # Fetch all history fetch-depth: '0' @@ -38,8 +38,6 @@ jobs: - 'packages/helpers/**' types: - 'packages/types/**' - providers: - - 'packages/providers/**' publicui: - 'packages/publicui/**' plate: @@ -111,14 +109,6 @@ jobs: env: BASE_BRANCH: ${{ github.base_ref }} - - name: Providers changelog check - if: steps.filter.outputs.providers == 'true' - run: | - git fetch --no-tags origin seven - towncrier check --compare-with origin/seven --dir packages/providers - env: - BASE_BRANCH: ${{ github.base_ref }} - - name: Public UI changelog check if: steps.filter.outputs.publicui == 'true' run: | diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml index 4b0d06e2fa1..6a45834c0da 100644 --- a/.github/workflows/code-analysis.yml +++ b/.github/workflows/code-analysis.yml @@ -5,19 +5,43 @@ env: node-version: 24.x jobs: + typescript: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + name: TypeScript + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Node.js environment + uses: ./.github/actions/node_env_setup + with: + node-version: ${{ env.node-version }} + + - name: Seven app TypeScript check + run: pnpm --filter seven exec init-loaders && pnpm --filter seven run typecheck + + - name: Package TypeScript checks + run: | + pnpm --filter @plone/types run check:ts + pnpm --filter @plone/client run check:ts + pnpm --filter @plone/components run check:ts + pnpm --filter @plone/registry run check:ts + pnpm --filter @plone/blocks run check:ts + pnpm --filter @plone/cmsui run check:ts + pnpm --filter @plone/layout run check:ts + pnpm --filter @plone/plate run check:ts + pnpm --filter @plone/publicui run check:ts + prettier: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name name: Prettier runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js environment - uses: ./.github/actions/acceptance_tests_node_env_setup + uses: ./.github/actions/node_env_setup with: node-version: ${{ env.node-version }} - - run: pnpm i - - name: Prettier check run: pnpm prettier @@ -26,14 +50,12 @@ jobs: name: ESlint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js environment - uses: ./.github/actions/acceptance_tests_node_env_setup + uses: ./.github/actions/node_env_setup with: node-version: ${{ env.node-version }} - - run: pnpm i - - name: Main ESlint check run: pnpm lint @@ -44,15 +66,13 @@ jobs: strategy: fail-fast: false matrix: - name: ["@plone/components", "@plone/theming", "@plone/layout"] + name: ['@plone/components', '@plone/theming', '@plone/layout'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js environment - uses: ./.github/actions/acceptance_tests_node_env_setup + uses: ./.github/actions/node_env_setup with: node-version: ${{ env.node-version }} - - run: pnpm i - - name: Stylelint check ${{ matrix.name }} run: pnpm --filter ${{ matrix.name }} stylelint diff --git a/.github/workflows/cookieplone.yml b/.github/workflows/cookieplone.yml index 7f17846ec0e..a75323534bf 100644 --- a/.github/workflows/cookieplone.yml +++ b/.github/workflows/cookieplone.yml @@ -16,9 +16,9 @@ jobs: matrix: node-version: [24.x] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js environment - uses: ./.github/actions/acceptance_tests_node_env_setup + uses: ./.github/actions/node_env_setup with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0c868b5779a..5bc3b2fc4b6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,7 +22,7 @@ jobs: matrix: python-version: ['3.12'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index e760b2af211..00a268b6699 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -22,16 +22,14 @@ jobs: - '@plone/registry' node-version: [24.x] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js environment - uses: ./.github/actions/acceptance_tests_node_env_setup + uses: ./.github/actions/node_env_setup with: node-version: ${{ matrix.node-version }} - - run: pnpm i - - name: Run unit tests ${{ matrix.name }} - run: pnpm --filter ${{ matrix.name }} test + run: pnpm --filter seven exec init-loaders && pnpm --filter ${{ matrix.name }} test client: name: '@plone/client' @@ -41,7 +39,7 @@ jobs: matrix: node-version: [24.x] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js environment uses: ./.github/actions/acceptance_tests_node_env_setup with: @@ -76,14 +74,12 @@ jobs: matrix: node-version: [24.x] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js environment - uses: ./.github/actions/acceptance_tests_node_env_setup + uses: ./.github/actions/node_env_setup with: node-version: ${{ matrix.node-version }} - - run: pnpm i - - run: make build-deps - run: pnpm --filter seven test diff --git a/.gitignore b/.gitignore index ea87008d875..ebb7f82537d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ build # Other .vscode +.codex /cache .yarn/ .DS_Store @@ -65,6 +66,7 @@ public/critical.css packages/volto/data storybook-static/ /playwright +/racTailwind # Documentation _build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..1996f1e7e88 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# AGENTS.md + +This file applies to the entire monorepo root and all of its packages. +Each package also has its own `AGENTS.md` with more specific guidance — always prefer the package-level file when working inside a specific package. + +## Repository Overview + +This is a **pnpm monorepo** (`pnpm-workspace.yaml`) containing: + +- `apps/seven` — the main Seven (Plone 7) application +- `packages/*` — shared libraries that make up the Seven stack and tooling + +The package manager is **pnpm**. Do not use `npm` or `yarn`. + +## Two Stacks in This Repo + +### Seven (Plone 7) — the active stack + +Every package except `volto` and `volto-slate` belongs to the **Seven** stack, an API-first, React-based frontend for Plone 7. +Seven is composed of focused packages that are assembled into `apps/seven`: + +| Layer | Packages | +| -------------------------- | ----------------------------------------------------------------------------------- | +| App shell | `apps/seven` | +| Public UI (visitor-facing) | `@plone/publicui`, `@plone/layout`, `@plone/blocks` | +| CMS UI (editor-facing) | `@plone/cmsui`, `@plone/contents`, `@plone/plate`, `@plone/blocks`, `@plone/layout` | +| Shared infrastructure | `@plone/client`, `@plone/registry`, `@plone/react-router` | +| Utilities and types | `@plone/helpers`, `@plone/types` | +| Theming | `@plone/theming`, `@plone/agave`, `@plone/components` | +| Tooling | `@plone/tooling`, `@plone/scripts`, `tsconfig` | + +### Volto (Plone 6) — reference only + +`packages/volto` and `packages/volto-slate` are the stable Plone 6 frontend. +They are present **for reference only** during Seven development and will be deleted once they are no longer useful. +Do not make significant changes to these packages. + +## Working in a Package + +Always scope your commands to the package you are working in using pnpm's `--filter` flag: + +```sh +pnpm --filter @plone/ test --run +pnpm --filter @plone/ build +pnpm --filter @plone/ check:ts +``` + +Each package has its own `AGENTS.md` describing its purpose, architecture, and the exact validation commands to run. + +## Global Commands + +Run these from the repo root when working across multiple packages or on the full app: + +```sh +# Install dependencies +pnpm install + +# Build all publishable packages (registry, client, components, react-router, helpers) +pnpm build:deps + +# Lint the entire repo +pnpm lint + +# Run all package tests +pnpm test:ci + +# Format all TypeScript/JavaScript files +pnpm prettier:fix + +# Lint and auto-fix CSS +pnpm stylelint:fix + +# Run Playwright acceptance tests +pnpm acceptance-test + +# Check typings +pnpm check:ts +``` + +## General Conventions + +- **TypeScript** is the default for all new code. Avoid adding plain `.js` files to Seven packages. +- **Vitest** is the test runner across all Seven packages (not Jest). +- **Vite** is the bundler for application code; **tsup** is used for library packages. +- Keep changes **package-local** unless there is a clear cross-cutting reason. +- When adding a new package dependency, check whether it already exists in a sibling package or the workspace root before adding it. +- Do not commit secrets, credentials, or environment-specific values. + +## Editing Rules + +- Read the package-level `AGENTS.md` before touching code in any package. +- Prefer targeted, package-scoped validation over global runs when iterating. +- Do not modify `volto` or `volto-slate` unless explicitly instructed — they are reference code. diff --git a/Makefile b/Makefile index 2c4b7f83b21..f5396f9a746 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,3 @@ -### Defensive settings for make: -# https://tech.davis-hansson.com/p/make/ -SHELL:=bash -.ONESHELL: -.SHELLFLAGS:=-eu -o pipefail -c -.SILENT: -.DELETE_ON_ERROR: -MAKEFLAGS+=--warn-undefined-variables -MAKEFLAGS+=--no-builtin-rules - -CURRENT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) - # Project settings include variables.mk @@ -35,13 +23,12 @@ CHECKOUT_BRANCH=$(shell git branch --show-current) CHECKOUT_TMP=../$(CHECKOUT_BASENAME).tmp CHECKOUT_TMP_ABS="$(shell realpath $(CHECKOUT_TMP))" -# We like colors -# From: https://coderwall.com/p/izxssa/colored-makefile-for-golang-projects -RED=`tput setaf 1` -GREEN=`tput setaf 2` -RESET=`tput sgr0` -YELLOW=`tput setaf 3` - +.PHONY: test-colors +test-colors: + @echo "$(RED)This is red$(RESET)" + @echo "$(GREEN)This is green$(RESET)" + @echo "$(YELLOW)This is yellow$(RESET)" + @echo "$(CYAN)This is cyan$(RESET)" # Top-level targets @@ -53,7 +40,7 @@ all: help # to return a pretty list of targets and their descriptions. .PHONY: help help: ## This help message - @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/\\x1b[36m\1\\x1b[m:\2/' | column -c2 -t -s :)" + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/$(CYAN)\1$(RESET):\2/' | column -c2 -t -s :)" .PHONY: start start: ## Starts Seven in development mode @@ -161,9 +148,6 @@ packages/components/dist: $(shell find packages/components/src -type f) packages/client/dist: $(shell find packages/client/src -type f) pnpm build:client -# packages/providers/dist: $(shell find packages/providers/src -type f) -# pnpm build:providers - packages/helpers/dist: $(shell find packages/helpers/src -type f) pnpm build:helpers @@ -173,16 +157,6 @@ packages/react-router/dist: $(shell find packages/react-router/src -type f) .PHONY: build-deps build-deps: packages/registry/dist packages/components/dist packages/client/dist packages/react-router/dist packages/helpers/dist ## Build dependencies -## Storybook - -.PHONY: storybook-start -storybook-start: ## Start Storybook server on port 6006 - $(MAKE) -C "./packages/volto/" storybook-start - -.PHONY: storybook-build -storybook-build: ## Build Storybook - $(MAKE) -C "./packages/volto/" storybook-build - ##### Release .PHONY: release-notes-copy-to-docs diff --git a/README.md b/README.md index 11ce3126c82..c098fc6c2fe 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ This allows the code to be shared effectively, and unifies tracking of changes a | Package | Location | |---|---| -| [`@plone/client`](https://www.npmjs.com/package/@plone/client) | [`packages/client`](https://github.com/plone/volto/tree/main/packages/client#readme) | +| [`@plone/client`](https://www.npmjs.com/package/@plone/client) | [`packages/client`](https://github.com/plone/volto/tree/seven/packages/client#readme) | | [`@plone/components`](https://www.npmjs.com/package/@plone/components) | [`packages/components`](https://github.com/plone/volto/tree/main/packages/components#readme) | | [`@plone/registry`](https://www.npmjs.com/package/@plone/registry) | [`packages/registry`](https://github.com/plone/volto/tree/main/packages/registry#readme) | | [`@plone/scripts`](https://www.npmjs.com/package/@plone/scripts) | [`packages/scripts`](https://github.com/plone/volto/tree/main/packages/scripts#readme) | diff --git a/apps/seven/.gitignore b/apps/seven/.gitignore index 48ba1f51e81..7eddebebc92 100644 --- a/apps/seven/.gitignore +++ b/apps/seven/.gitignore @@ -8,3 +8,4 @@ node_modules /packages/ public/locales/ /stats* +var/ diff --git a/apps/seven/CHANGELOG.md b/apps/seven/CHANGELOG.md index 3e099ab6cc0..c97bf1ba65a 100644 --- a/apps/seven/CHANGELOG.md +++ b/apps/seven/CHANGELOG.md @@ -8,6 +8,48 @@ +## 1.0.0-alpha.4 (2026-05-13) + +## 1.0.0-alpha.3 (2026-05-07) + +### Internal + +- Added AGENTS.md file. @pnicolli +- Aligned the Seven app TypeScript configuration and root loader typing with the monorepo-wide typecheck cleanup. + +## 1.0.0-alpha.2 (2026-04-16) + +### Breaking + +- Removed Cypress support. + Added Playwright support. Move all existing Cypress tests to Playwright. @sneridagh [#7827](https://github.com/plone/volto/issues/7827) + +### Feature + +- Listing block @ebrehault [#7603](https://github.com/plone/volto/issues/7603) +- Somersault editor support. @sneridagh [#7921](https://github.com/plone/volto/issues/7921) +- Create video block view @tedw87 [#8004](https://github.com/plone/volto/issues/8004) +- Refactored runtime migrations to match the somersault editor, reorganize server config files. Fixed tests. @sneridagh [#8021](https://github.com/plone/volto/issues/8021) +- Added runtime migration for default blockWidths. @sneridagh [#8071](https://github.com/plone/volto/issues/8071) +- Update to Vite 8 and RR7 7.14.0. @sneridagh [#8106](https://github.com/plone/volto/issues/8106) +- Moved the initialize client to the middleware from the config. @sneridagh [#8108](https://github.com/plone/volto/issues/8108) +- Added user data in the context for authenticated users @pnicolli +- Apply add-on-provided Vite extension loaders in the Seven app configuration so installed add-ons can extend the app build setup. @sneridagh +- Moved basic data fetching to a middleware to allow all loaders and actions to use it @pnicolli + +### Bugfix + +- Added safeguard when checking for a contents blocks data @arybakov05 [#8001](https://github.com/plone/volto/issues/8001) +- Fixed SOMERSAULT_KEY constant, it is centralized now. @sneridagh [#8078](https://github.com/plone/volto/issues/8078) +- Added auth token to the requests in the root loader @pnicolli + +### Internal + +- Upgraded to use RR 7.12.0. @sneridagh [#7787](https://github.com/plone/volto/issues/7787) +- Adapt Seven middleware to the updated `@plone/client` user lookup argument names. @sneridagh +- Updated app test and eslint config. @pnicolli +- Use Plone 6.2.0rc1 for development. @davisagli + ## 1.0.0-alpha.1 (2025-12-23) ### Feature diff --git a/apps/seven/Makefile b/apps/seven/Makefile index 856f37a3717..19949aad9c0 100644 --- a/apps/seven/Makefile +++ b/apps/seven/Makefile @@ -1,15 +1,5 @@ # Volto development -### Defensive settings for make: -# https://tech.davis-hansson.com/p/make/ -SHELL:=bash -.ONESHELL: -.SHELLFLAGS:=-eu -o pipefail -c -.SILENT: -.DELETE_ON_ERROR: -MAKEFLAGS+=--warn-undefined-variables -MAKEFLAGS+=--no-builtin-rules - # Project settings (read from repo root) include ../../variables.mk @@ -25,14 +15,6 @@ CHECKOUT_TMP_ABS="$(shell realpath $(CHECKOUT_TMP))" CURRENT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) -# We like colors -# From: https://coderwall.com/p/izxssa/colored-makefile-for-golang-projects -RED=`tput setaf 1` -GREEN=`tput setaf 2` -RESET=`tput sgr0` -YELLOW=`tput setaf 3` - - # Top-level targets .PHONY: all @@ -43,7 +25,7 @@ all: help # to return a pretty list of targets and their descriptions. .PHONY: help help: ## This help message - @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/\\x1b[36m\1\\x1b[m:\2/' | column -c2 -t -s :)" + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/$(CYAN)\1$(RESET):\2/' | column -c2 -t -s :)" .PHONY: start start: ## Starts Plone 7 in development mode @@ -66,7 +48,7 @@ clean: ## Clean development environment ../../packages/registry/dist: $(shell find ../../packages/registry/src -type f) (cd ../../ && pnpm build:registry) -../../packages/components/dist: $(shell find .../../packages/components/src -type f) +../../packages/components/dist: $(shell find ../../packages/components/src -type f) (cd ../../ && pnpm build:components) ../../packages/client/dist: $(shell find ../../packages/client/src -type f) @@ -75,7 +57,7 @@ clean: ## Clean development environment ../../packages/helpers/dist: $(shell find ../../packages/helpers/src -type f) (cd ../../ && pnpm build:helpers) -../../packages/react-router/dist: $(shell find .../../packages/react-router/src -type f) +../../packages/react-router/dist: $(shell find ../../packages/react-router/src -type f) (cd ../../ && pnpm build:react-router) .PHONY: build-deps diff --git a/apps/seven/app/config.types.ts b/apps/seven/app/config.types.ts deleted file mode 100644 index d8e6faff055..00000000000 --- a/apps/seven/app/config.types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Content } from '@plone/types'; -import type PloneClient from '@plone/client'; -import type { Params } from 'react-router'; - -declare module '@plone/types' { - interface UtilityTypeMap { - rootContentSubRequest: (args: LoaderUtilityArgs) => Promise; - rootLoaderData: ( - args: LoaderUtilityArgs, - ) => Promise<{ status: number; data: unknown }>; - } -} - -export interface LoaderUtilityArgs { - cli: PloneClient; - content: Content; - request: Request; - path: string; - params: Params; - locale: string; -} diff --git a/apps/seven/app/config.test.ts b/apps/seven/app/config/index.test.ts similarity index 92% rename from apps/seven/app/config.test.ts rename to apps/seven/app/config/index.test.ts index b7aa7e64db7..87e92c98b3d 100644 --- a/apps/seven/app/config.test.ts +++ b/apps/seven/app/config/index.test.ts @@ -1,6 +1,6 @@ import { expect, describe, it, afterEach } from 'vitest'; import config from '@plone/registry'; -import install from './config'; +import install from './index'; describe('config', () => { afterEach(() => { diff --git a/apps/seven/app/config.ts b/apps/seven/app/config/index.ts similarity index 82% rename from apps/seven/app/config.ts rename to apps/seven/app/config/index.ts index 6ad7fe75008..73ee74cd63d 100644 --- a/apps/seven/app/config.ts +++ b/apps/seven/app/config/index.ts @@ -3,7 +3,7 @@ */ import config from '@plone/registry'; // eslint-disable-next-line import/no-unresolved -import applyAddonConfiguration from '../.plone/registry.loader'; +import applyAddonConfiguration from '../../.plone/registry.loader'; export default function install() { config.settings.defaultLanguage = 'en'; diff --git a/apps/seven/app/config.server.test.ts b/apps/seven/app/config/server.server.test.ts similarity index 59% rename from apps/seven/app/config.server.test.ts rename to apps/seven/app/config/server.server.test.ts index 2c82d4d65c5..21ed199a2ef 100644 --- a/apps/seven/app/config.server.test.ts +++ b/apps/seven/app/config/server.server.test.ts @@ -1,11 +1,15 @@ import { expect, describe, it, afterEach } from 'vitest'; import config from '@plone/registry'; -import installServer from './config.server'; +import installServer from './server.server'; -describe('config.server', () => { +describe('config/server', () => { afterEach(() => { config.settings = {}; - delete config.utilities['client']; + const utilities = config.utilities as Partial>; + delete utilities.client; + delete utilities.somersaultBlockMigration; + delete utilities.somersaultMigration; + delete utilities.rootContentSubRequest; }); it('should set the default config', () => { @@ -23,7 +27,7 @@ describe('config.server', () => { name: 'ploneClient', type: 'client', }) - .method().config.apiPath, - ).toEqual('http://localhost:8080/Plone'); + .method(), + ).toHaveProperty('initialize'); }); }); diff --git a/apps/seven/app/config.server.ts b/apps/seven/app/config/server.server.ts similarity index 77% rename from apps/seven/app/config.server.ts rename to apps/seven/app/config/server.server.ts index dd508399ea3..b2b836631bf 100644 --- a/apps/seven/app/config.server.ts +++ b/apps/seven/app/config/server.server.ts @@ -4,25 +4,22 @@ import config from '@plone/registry'; import PloneClient from '@plone/client'; // eslint-disable-next-line import/no-unresolved -import applyAddonConfiguration from '../.plone/registry.loader'; +import applyAddonConfiguration from '../../.plone/registry.loader'; // eslint-disable-next-line import/no-unresolved -import applyServerAddonConfiguration from '../.plone/registry.loader.server'; +import applyServerAddonConfiguration from '../../.plone/registry.loader.server'; import type { ListingBlockFormData } from '@plone/types'; -import type { LoaderUtilityArgs } from './config.types'; +import type { LoaderUtilityArgs } from './types'; +import installMigrations from './server/migrations.server'; export default function install() { config.settings.apiPath = process.env.PLONE_API_PATH || 'http://localhost:8080/Plone'; - const cli = PloneClient.initialize({ - apiPath: config.settings.apiPath, - }); - config.registerUtility({ name: 'ploneClient', type: 'client', - method: () => cli, + method: () => PloneClient, }); config.registerUtility({ @@ -43,14 +40,13 @@ export default function install() { }, }); + installMigrations(); + config.settings.defaultLanguage = 'en'; config.settings.supportedLanguages = ['en']; applyAddonConfiguration(config); applyServerAddonConfiguration(config); - // eslint-disable-next-line no-console - // console.log('API_PATH is:', config.settings.apiPath); - return config; } diff --git a/apps/seven/app/config/server/content-migrations.server.ts b/apps/seven/app/config/server/content-migrations.server.ts new file mode 100644 index 00000000000..07fd1d17745 --- /dev/null +++ b/apps/seven/app/config/server/content-migrations.server.ts @@ -0,0 +1,60 @@ +import config from '@plone/registry'; +import type { Value } from '@plone/plate/components/editor'; +import type { Content } from '@plone/types'; +import '../types'; +import { SOMERSAULT_KEY } from '@plone/plate/constants'; + +type SomersaultValue = Value; + +const createSomersaultValue = (content: Content): SomersaultValue => { + const orderedBlockIds = Array.isArray(content.blocks_layout?.items) + ? content.blocks_layout.items + : []; + const somersaultBlockMigrations = config.getUtilities({ + type: 'somersaultBlockMigration', + }); + + return orderedBlockIds.flatMap((blockId) => { + const block = content.blocks?.[blockId] as + | Record + | undefined; + if (!block) return []; + + return somersaultBlockMigrations.flatMap( + (utility) => + utility.method({ + block, + blockId, + content, + }) as SomersaultValue, + ); + }); +}; + +export const migrateContent = (content: Content) => { + if (content.blocks?.[SOMERSAULT_KEY]) return content; + if (!content.blocks || !Array.isArray(content.blocks_layout?.items)) { + return content; + } + + const somersaultMigrations = config.getUtilities({ + type: 'somersaultMigration', + }); + + const initialValue = createSomersaultValue(content); + const migratedValue = somersaultMigrations.reduce( + (value, utility) => + utility.method({ + content, + value, + }) as SomersaultValue, + initialValue, + ); + + content.blocks[SOMERSAULT_KEY] = { + '@type': SOMERSAULT_KEY, + value: migratedValue, + } as (typeof content.blocks)[string]; + + return content; +}; diff --git a/apps/seven/app/config/server/migrations.server.ts b/apps/seven/app/config/server/migrations.server.ts new file mode 100644 index 00000000000..44c3e45009f --- /dev/null +++ b/apps/seven/app/config/server/migrations.server.ts @@ -0,0 +1,82 @@ +import config from '@plone/registry'; +import { + migrateLegacyBoldInValue, + migrateLegacyBlockWidthsInValue, + migrateLegacyItalicInValue, + migrateLegacyLinksInValueStatic, + migrateLegacyListsInValue, + migrateLegacyStrikethroughInValue, +} from '@plone/plate/migrations'; +import type { + SomersaultBlockMigrationArgs, + SomersaultMigrationArgs, +} from '../types'; + +export default function install() { + config.registerUtility({ + name: 'somersaultBlockMigrationTitle', + type: 'somersaultBlockMigration', + method: ({ block, content }: SomersaultBlockMigrationArgs) => + block['@type'] === 'title' + ? [ + { + type: 'title', + children: [ + { + text: typeof content.title === 'string' ? content.title : '', + }, + ], + }, + ] + : [], + }); + + config.registerUtility({ + name: 'somersaultBlockMigrationLegacyValue', + type: 'somersaultBlockMigration', + method: ({ block }: SomersaultBlockMigrationArgs) => + Array.isArray(block.value) ? block.value : [], + }); + + config.registerUtility({ + name: 'somersaultMigrationLegacyBold', + type: 'somersaultMigration', + method: ({ value }: SomersaultMigrationArgs) => + migrateLegacyBoldInValue(value), + }); + + config.registerUtility({ + name: 'somersaultMigrationLegacyItalic', + type: 'somersaultMigration', + method: ({ value }: SomersaultMigrationArgs) => + migrateLegacyItalicInValue(value), + }); + + config.registerUtility({ + name: 'somersaultMigrationLegacyStrikethrough', + type: 'somersaultMigration', + method: ({ value }: SomersaultMigrationArgs) => + migrateLegacyStrikethroughInValue(value), + }); + + config.registerUtility({ + name: 'somersaultMigrationLegacyLinks', + type: 'somersaultMigration', + method: ({ value }: SomersaultMigrationArgs) => + migrateLegacyLinksInValueStatic(value), + }); + + config.registerUtility({ + name: 'somersaultMigrationLegacyLists', + type: 'somersaultMigration', + method: ({ value }: SomersaultMigrationArgs) => + migrateLegacyListsInValue(value), + }); + + config.registerUtility({ + name: 'somersaultMigrationBlockWidths', + type: 'somersaultMigration', + method: ({ value }: SomersaultMigrationArgs) => + migrateLegacyBlockWidthsInValue(value), + }); +} diff --git a/apps/seven/app/config/types.ts b/apps/seven/app/config/types.ts new file mode 100644 index 00000000000..0eabea55b7b --- /dev/null +++ b/apps/seven/app/config/types.ts @@ -0,0 +1,42 @@ +import type { Content } from '@plone/types'; +import type PloneClient from '@plone/client'; +import type { Value } from '@plone/plate/components/editor'; +import type { Params } from 'react-router'; + +export type PloneClientUtility = typeof PloneClient; + +declare module '@plone/types' { + interface UtilityTypeMap { + client: () => PloneClientUtility; + rootContentSubRequest: (args: LoaderUtilityArgs) => Promise; + rootLoaderData: ( + args: LoaderUtilityArgs, + ) => Promise<{ status: number; data: unknown }>; + somersaultBlockMigration: ( + args: SomersaultBlockMigrationArgs, + ) => SomersaultMigrationArgs['value']; + somersaultMigration: ( + args: SomersaultMigrationArgs, + ) => SomersaultMigrationArgs['value']; + } +} + +export interface LoaderUtilityArgs { + cli: PloneClient; + content: Content; + request: Request; + path: string; + params: Params; + locale: string; +} + +export interface SomersaultMigrationArgs { + content: Content; + value: Value; +} + +export interface SomersaultBlockMigrationArgs { + block: Record; + blockId: string; + content: Content; +} diff --git a/apps/seven/app/entry.server.tsx b/apps/seven/app/entry.server.tsx index ada5e8b3acc..59838dbfb20 100644 --- a/apps/seven/app/entry.server.tsx +++ b/apps/seven/app/entry.server.tsx @@ -1,6 +1,10 @@ import { PassThrough } from 'node:stream'; -import type { AppLoadContext, EntryContext } from 'react-router'; +import type { + // AppLoadContext, + EntryContext, + RouterContextProvider, +} from 'react-router'; import { createReadableStreamFromReadable } from '@react-router/node'; import { ServerRouter } from 'react-router'; import { isbot } from 'isbot'; @@ -20,9 +24,9 @@ export default async function handleRequest( responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + // loadContext: AppLoadContext, // If you have middleware enabled: - // loadContext: unstable_RouterContextProvider + loadContext: RouterContextProvider, ) { // Ensure requests from bots and SPA Mode renders wait for all content to load before responding // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation diff --git a/apps/seven/app/i18n.ts b/apps/seven/app/i18n.ts index 3a81645093d..e738231af42 100644 --- a/apps/seven/app/i18n.ts +++ b/apps/seven/app/i18n.ts @@ -5,7 +5,7 @@ export default { supportedLngs: config.settings.supportedLanguages ?? ['en'], // This is the language you want to use in case // if the user language is not in the supportedLngs - fallbackLng: config.settings.defaultLanguage ?? 'en', + fallbackLng: (config.settings.defaultLanguage as string | undefined) ?? 'en', // The default namespace of i18next is "translation", but you can customize it here defaultNS: 'common', }; diff --git a/apps/seven/app/i18next.server.ts b/apps/seven/app/i18next.server.ts index c156acaae0b..5b73dade709 100644 --- a/apps/seven/app/i18next.server.ts +++ b/apps/seven/app/i18next.server.ts @@ -6,12 +6,13 @@ import i18n from './i18n'; // your i18n configuration file const i18next = new RemixI18Next({ detection: { supportedLanguages: i18n.supportedLngs, - fallbackLanguage: i18n.fallbackLng, + fallbackLanguage: i18n.fallbackLng as string, }, // This is the configuration for i18next used // when translating messages server-side only i18next: { ...i18n, + fallbackLng: i18n.fallbackLng as string, backend: { loadPath: resolve('../locales/{{lng}}/{{ns}}.json'), }, diff --git a/apps/seven/app/middleware.server.test.ts b/apps/seven/app/middleware.server.test.ts index 5349ece5510..981e50dedeb 100644 --- a/apps/seven/app/middleware.server.test.ts +++ b/apps/seven/app/middleware.server.test.ts @@ -1,14 +1,62 @@ import { expect, describe, it, vi, afterEach } from 'vitest'; import config from '@plone/registry'; import { RouterContextProvider } from 'react-router'; +import { jwtDecode } from 'jwt-decode'; +import { getAuthFromRequest } from '@plone/react-router'; import { + fetchPloneContent, getAPIResourceWithAuth, installServerMiddleware, + linkMiddleware, + ploneClearAuthCookieContext, + PloneClientMiddleware, otherResources, + ploneClientContext, + ploneContentContext, + ploneSiteContext, + ploneUserContext, } from './middleware.server'; +vi.mock('jwt-decode'); +vi.mock('@plone/react-router', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAuthFromRequest: vi.fn(), + }; +}); + describe('middleware', () => { + const initializePloneClientContext = async ( + request: Request, + context: RouterContextProvider, + ) => { + await PloneClientMiddleware( + { + request, + context, + params: {}, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + vi.fn(), + ); + }; + + const registerPloneClientFactory = (ploneClient: Record) => { + config.registerUtility({ + name: 'ploneClient', + type: 'client', + method: () => + ({ + prototype: {}, + initialize: vi.fn().mockReturnValue(ploneClient), + }) as any, + }); + }; + afterEach(() => { + vi.resetAllMocks(); vi.restoreAllMocks(); }); @@ -18,7 +66,16 @@ describe('middleware', () => { const context = new RouterContextProvider(); const nextMock = vi.fn(); - await installServerMiddleware({ request, params: {}, context }, nextMock); + await installServerMiddleware( + { + request, + context, + params: {}, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); expect(config.settings).toStrictEqual( expect.objectContaining({ @@ -33,8 +90,78 @@ describe('middleware', () => { name: 'ploneClient', type: 'client', }) - .method().config.apiPath, - ).toEqual('http://localhost:8080/Plone'); + .method(), + ).toHaveProperty('initialize'); + }); + }); + + describe('PloneClientMiddleware', () => { + it('initializes the PloneClient and sets it in context', async () => { + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + vi.mocked(getAuthFromRequest).mockResolvedValue(undefined); + config.settings.apiPath = 'http://localhost:8080/Plone'; + config.registerUtility({ + name: 'ploneClient', + type: 'client', + method: () => + ({ + prototype: {}, + initialize: vi.fn().mockReturnValue({ + config: { apiPath: 'http://localhost:8080/Plone' }, + }), + }) as any, + }); + + await PloneClientMiddleware( + { + request, + context, + params: {}, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(context.get(ploneClientContext)).toBeDefined(); + }); + + it('initializes PloneClient with token when available', async () => { + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + vi.mocked(getAuthFromRequest).mockResolvedValue('valid.jwt.token'); + config.settings.apiPath = 'http://localhost:8080/Plone'; + const initializeMock = vi.fn().mockReturnValue({}); + config.registerUtility({ + name: 'ploneClient', + type: 'client', + method: () => + ({ + prototype: {}, + initialize: initializeMock, + }) as any, + }); + + await PloneClientMiddleware( + { + request, + context, + params: {}, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(initializeMock).toHaveBeenCalledWith({ + apiPath: 'http://localhost:8080/Plone', + token: 'valid.jwt.token', + }); }); }); @@ -45,7 +172,16 @@ describe('middleware', () => { const params = { '*': '' }; const nextMock = vi.fn(); - await otherResources({ request, params, context }, nextMock); + await otherResources( + { + request, + params, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); }); it('blocks requests to special urls: css', async () => { @@ -55,7 +191,16 @@ describe('middleware', () => { const nextMock = vi.fn(); try { - await otherResources({ request, params, context }, nextMock); + await otherResources( + { + request, + params, + context, + unstable_pattern: '/style.css', + unstable_url: new URL(request.url), + }, + nextMock, + ); } catch (err: any) { expect(err.init.status).toEqual(404); } @@ -68,7 +213,16 @@ describe('middleware', () => { const nextMock = vi.fn(); try { - await otherResources({ request, params, context }, nextMock); + await otherResources( + { + request, + params, + context, + unstable_pattern: '/style.css.map', + unstable_url: new URL(request.url), + }, + nextMock, + ); } catch (err: any) { expect(err.init.status).toEqual(404); } @@ -115,7 +269,16 @@ describe('middleware', () => { const nextMock = vi.fn(); try { - await otherResources({ request, params, context }, nextMock); + await otherResources( + { + request, + params, + context, + unstable_pattern: '/?expand=breadcrumbs', + unstable_url: new URL(request.url), + }, + nextMock, + ); } catch (err: any) { expect(err.init.status).toEqual(404); } @@ -128,11 +291,46 @@ describe('middleware', () => { const nextMock = vi.fn(); try { - await otherResources({ request, params, context }, nextMock); + await otherResources( + { + request, + params, + context, + unstable_pattern: '/assets/image.png', + unstable_url: new URL(request.url), + }, + nextMock, + ); } catch (err: any) { expect(err.init.status).toEqual(404); } }); + + it('blocks requests to .well-known paths', async () => { + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const params = { + '*': '.well-known/appspecific/com.chrome.devtools.json', + }; + const nextMock = vi.fn(); + + try { + await otherResources( + { + request, + params, + context, + unstable_pattern: + '/.well-known/appspecific/com.chrome.devtools.json', + unstable_url: new URL(request.url), + }, + nextMock, + ); + } catch (err: any) { + expect(err).toBeInstanceOf(Response); + expect(err.status).toEqual(200); + } + }); }); describe('getAPIResourceWithAuth', () => { @@ -142,7 +340,16 @@ describe('middleware', () => { const params = { '*': '' }; const nextMock = vi.fn(); - await getAPIResourceWithAuth({ request, params, context }, nextMock); + await getAPIResourceWithAuth( + { + request, + params, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); }); it('intercepts requests to special urls: @@images', async () => { @@ -156,19 +363,29 @@ describe('middleware', () => { }); global.fetch = fetchMock; - try { - await getAPIResourceWithAuth({ request, params, context }, nextMock); - } catch { - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:8080/Plone/image.png/@@images/image', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer undefined', - }), - }), - ); - } + await getAPIResourceWithAuth( + { + request, + params, + context, + unstable_pattern: '/image.png/@@images/image', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/Plone/image.png/@@images/image', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + expect( + (fetchMock.mock.calls[0]?.[1] as { headers: Headers }).headers.get( + 'Authorization', + ), + ).toBeNull(); }); it('intercepts requests to special urls: @@download', async () => { @@ -182,19 +399,29 @@ describe('middleware', () => { }); global.fetch = fetchMock; - try { - await getAPIResourceWithAuth({ request, params, context }, nextMock); - } catch { - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:8080/Plone/file.txt/@@download/file', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer undefined', - }), - }), - ); - } + await getAPIResourceWithAuth( + { + request, + params, + context, + unstable_pattern: '/file.txt/@@download/file', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/Plone/file.txt/@@download/file', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + expect( + (fetchMock.mock.calls[0]?.[1] as { headers: Headers }).headers.get( + 'Authorization', + ), + ).toBeNull(); }); it('intercepts requests to special urls: @@site-logo', async () => { @@ -208,19 +435,29 @@ describe('middleware', () => { }); global.fetch = fetchMock; - try { - await getAPIResourceWithAuth({ request, params, context }, nextMock); - } catch { - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:8080/Plone/@@site-logo/image', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer undefined', - }), - }), - ); - } + await getAPIResourceWithAuth( + { + request, + params, + context, + unstable_pattern: '/@@site-logo/image', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/Plone/@@site-logo/image', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + expect( + (fetchMock.mock.calls[0]?.[1] as { headers: Headers }).headers.get( + 'Authorization', + ), + ).toBeNull(); }); it('intercepts requests to special urls: @portrait', async () => { @@ -234,19 +471,619 @@ describe('middleware', () => { }); global.fetch = fetchMock; - try { - await getAPIResourceWithAuth({ request, params, context }, nextMock); - } catch { - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:8080/Plone/@portrait/username', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer undefined', - }), + await getAPIResourceWithAuth( + { + request, + params, + context, + unstable_pattern: '/@portrait/username', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/Plone/@portrait/username', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + expect( + (fetchMock.mock.calls[0]?.[1] as { headers: Headers }).headers.get( + 'Authorization', + ), + ).toBeNull(); + }); + + it('retries resource requests anonymously after a 401 and clears the cookie', async () => { + const request = new Request('http://example.com', { + headers: { + Cookie: 'auth_seven=token', + }, + }); + const context = new RouterContextProvider(); + const params = { '*': 'image.png/@@images/image' }; + const nextMock = vi.fn(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response('unauthorized', { + status: 401, + statusText: 'Unauthorized', + }), + ) + .mockResolvedValueOnce( + new Response('image', { + status: 200, + headers: { 'Content-Type': 'image/png' }, }), ); + global.fetch = fetchMock; + vi.mocked(getAuthFromRequest).mockResolvedValue('expired.jwt.token'); + + const response = (await getAPIResourceWithAuth( + { + request, + params, + context, + unstable_pattern: '/image.png/@@images/image', + unstable_url: new URL(request.url), + }, + nextMock, + )) as Response; + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'http://localhost:8080/Plone/image.png/@@images/image', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'http://localhost:8080/Plone/image.png/@@images/image', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + expect( + (fetchMock.mock.calls[0]?.[1] as { headers: Headers }).headers.get( + 'Authorization', + ), + ).toBe('Bearer expired.jwt.token'); + expect( + (fetchMock.mock.calls[1]?.[1] as { headers: Headers }).headers.get( + 'Authorization', + ), + ).toBeNull(); + expect(response.status).toBe(200); + expect(response.headers.get('Set-Cookie')).toContain('auth_seven='); + }); + }); + + describe('fetchPloneContent', () => { + afterEach(() => { + delete config.utilities['ploneClient']; + }); + + it('fetches content and site and sets them in context', async () => { + const mockContent = { + data: { '@id': 'http://example.com/', title: 'Home' }, + }; + const mockSite = { data: { '@id': 'http://example.com/' } }; + const getContentMock = vi.fn().mockResolvedValue(mockContent); + const getSiteMock = vi.fn().mockResolvedValue(mockSite); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getContentMock).toHaveBeenCalledWith({ + path: '/', + expand: ['navroot', 'breadcrumbs', 'navigation', 'actions'], + }); + expect(getSiteMock).toHaveBeenCalled(); + expect(context.get(ploneContentContext)).toEqual({ + '@id': '/', + title: 'Home', + }); + expect(context.get(ploneSiteContext)).toEqual({ '@id': '/' }); + expect(context.get(ploneClientContext)).toBeDefined(); + }); + + it('fetches content for a specific path', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + }); + const request = new Request('http://example.com/test-content'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: { '*': 'test-content' }, + context, + unstable_pattern: '/test-content', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getContentMock).toHaveBeenCalledWith({ + path: '/test-content', + expand: ['navroot', 'breadcrumbs', 'navigation', 'actions'], + }); + }); + + it('throws when content is not found', async () => { + const getContentMock = vi + .fn() + .mockRejectedValue({ data: undefined, status: 500 }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + try { + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + } catch (err: any) { + expect(err.init.status).toEqual(500); } }); + + it('throws when site is not found', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi + .fn() + .mockRejectedValue({ data: undefined, status: 500 }); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + try { + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + } catch (err: any) { + expect(err.init.status).toEqual(500); + } + }); + + it('sets ploneUserContext to null when no token is provided', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + const getUserMock = vi.fn(); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + getUser: getUserMock, + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getUserMock).not.toHaveBeenCalled(); + expect(context.get(ploneUserContext)).toBeNull(); + }); + + it('fetches user and sets ploneUserContext when token is valid', async () => { + const mockUser = { data: { id: 'testuser', fullname: 'Test User' } }; + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + const getUserMock = vi.fn().mockResolvedValue(mockUser); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + getUser: getUserMock, + }); + vi.mocked(getAuthFromRequest).mockResolvedValue('valid.jwt.token'); + vi.mocked(jwtDecode).mockReturnValue({ + sub: 'testuser', + exp: 9999999999, + fullname: 'Test User', + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getUserMock).toHaveBeenCalledWith({ id: 'testuser' }); + expect(context.get(ploneUserContext)).toEqual(mockUser.data); + expect(getContentMock).toHaveBeenCalledWith({ + path: '/', + expand: ['navroot', 'breadcrumbs', 'navigation', 'actions', 'types'], + }); + }); + + it('keeps content and site loading successful when user fetch fails', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + const getUserMock = vi.fn().mockRejectedValue(new Error('User failed')); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + getUser: getUserMock, + }); + vi.mocked(getAuthFromRequest).mockResolvedValue('valid.jwt.token'); + vi.mocked(jwtDecode).mockReturnValue({ + sub: 'testuser', + exp: 9999999999, + fullname: 'Test User', + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getContentMock).toHaveBeenCalledWith({ + path: '/', + expand: ['navroot', 'breadcrumbs', 'navigation', 'actions', 'types'], + }); + expect(getSiteMock).toHaveBeenCalled(); + expect(getUserMock).toHaveBeenCalledWith({ id: 'testuser' }); + expect(context.get(ploneUserContext)).toBeNull(); + }); + + it('does not fetch user when token has no sub field', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + const getUserMock = vi.fn(); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + getUser: getUserMock, + }); + vi.mocked(getAuthFromRequest).mockResolvedValue('token.without.sub'); + vi.mocked(jwtDecode).mockReturnValue({ exp: 9999999999 }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getUserMock).not.toHaveBeenCalled(); + expect(context.get(ploneUserContext)).toBeNull(); + expect(getContentMock).toHaveBeenCalledWith({ + path: '/', + expand: ['navroot', 'breadcrumbs', 'navigation', 'actions'], + }); + }); + + it('handles JWT decode errors gracefully and proceeds without user', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + const getUserMock = vi.fn(); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + getUser: getUserMock, + }); + vi.mocked(getAuthFromRequest).mockResolvedValue('malformed.token'); + vi.mocked(jwtDecode).mockImplementation(() => { + throw new Error('Invalid token'); + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getUserMock).not.toHaveBeenCalled(); + expect(context.get(ploneUserContext)).toBeNull(); + }); + + it('retries anonymously after a 401 and clears the auth cookie context', async () => { + const authContent = vi + .fn() + .mockRejectedValueOnce({ data: undefined, status: 401 }); + const authSite = vi.fn().mockResolvedValue({ data: {} }); + const anonymousContent = vi.fn().mockResolvedValueOnce({ + data: { '@id': 'http://example.com/', title: 'Home' }, + }); + const anonymousSite = vi + .fn() + .mockResolvedValueOnce({ data: { '@id': 'http://example.com/' } }); + config.settings.apiPath = 'http://example.com'; + const initializeMock = vi + .fn() + .mockReturnValueOnce({ + getContent: authContent, + getSite: authSite, + getUser: vi.fn().mockResolvedValue(null), + }) + .mockReturnValueOnce({ + getContent: anonymousContent, + getSite: anonymousSite, + getUser: vi.fn().mockResolvedValue(null), + }); + config.registerUtility({ + name: 'ploneClient', + type: 'client', + method: () => + ({ + prototype: {}, + initialize: initializeMock, + }) as any, + }); + vi.mocked(getAuthFromRequest).mockResolvedValue('expired.jwt.token'); + vi.mocked(jwtDecode).mockReturnValue({ + sub: 'testuser', + exp: 9999999999, + fullname: 'Test User', + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(anonymousContent).toHaveBeenCalledWith({ + path: '/', + expand: ['navroot', 'breadcrumbs', 'navigation', 'actions'], + }); + expect(context.get(ploneContentContext)).toEqual({ + '@id': '/', + title: 'Home', + }); + expect(context.get(ploneUserContext)).toBeNull(); + expect(context.get(ploneClearAuthCookieContext)).toBe(true); + }); + + it('redirects when getContent throws a 3xx error with location', async () => { + const getContentMock = vi + .fn() + .mockRejectedValue({ status: 301, location: '/new-path' }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + const result = await fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(301); + expect((result as Response).headers.get('Location')).toBe('/new-path'); + }); + + it('throws content error when error has 3xx status but no location', async () => { + const getContentMock = vi.fn().mockRejectedValue({ status: 301 }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + }); + const request = new Request('http://example.com'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await expect(() => + fetchPloneContent( + { + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }, + nextMock, + ), + ).rejects.toMatchObject({ init: { status: 301 } }); + }); + }); + + describe('linkMiddleware', () => { + const makeArgs = (context: RouterContextProvider) => ({ + request: new Request('http://example.com/link'), + context, + params: {}, + unstable_pattern: '/link', + unstable_url: new URL('http://example.com/link'), + }); + + it('redirects to remoteUrl when content is a Link without edit permission', async () => { + const context = new RouterContextProvider(); + context.set(ploneContentContext, { + '@type': 'Link', + remoteUrl: 'https://external.example.com', + '@components': { + actions: { + // @ts-expect-error + object: [{ id: 'view' }, { id: 'history' }], + }, + }, + }); + + const result = await linkMiddleware(makeArgs(context), vi.fn()); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(302); + expect((result as Response).headers.get('Location')).toBe( + 'https://external.example.com', + ); + }); + + it('does not redirect when content is a Link with edit permission', async () => { + const context = new RouterContextProvider(); + context.set(ploneContentContext, { + '@type': 'Link', + remoteUrl: 'https://external.example.com', + '@components': { + actions: { + // @ts-expect-error + object: [{ id: 'view' }, { id: 'edit' }], + }, + }, + }); + + const result = await linkMiddleware(makeArgs(context), vi.fn()); + + expect(result).toBeUndefined(); + }); + + it('does not redirect when content is not a Link type', async () => { + const context = new RouterContextProvider(); + context.set(ploneContentContext, { + '@type': 'Document', + '@components': { + actions: { + // @ts-expect-error + object: [{ id: 'view' }], + }, + }, + }); + + const result = await linkMiddleware(makeArgs(context), vi.fn()); + + expect(result).toBeUndefined(); + }); }); }); diff --git a/apps/seven/app/middleware.server.ts b/apps/seven/app/middleware.server.ts index 9eee910eae9..6c679c84a13 100644 --- a/apps/seven/app/middleware.server.ts +++ b/apps/seven/app/middleware.server.ts @@ -1,16 +1,64 @@ -import { data } from 'react-router'; -import { getAuthFromRequest } from '@plone/react-router'; +import { jwtDecode } from 'jwt-decode'; +import { data, createContext, redirect } from 'react-router'; +import { flattenToAppURL } from '@plone/helpers'; +import { clearAuthOnResponse, getAuthFromRequest } from '@plone/react-router'; import config from '@plone/registry'; +import type PloneClient from '@plone/client'; import type { Route } from './+types/root'; -import installServer from './config.server'; +import installServer from './config/server.server'; +import { migrateContent } from './config/server/content-migrations.server'; + +export const ploneClientContext = createContext(); +export const ploneContentContext = + createContext>['data']>(); +export const ploneSiteContext = + createContext>['data']>(); +export const ploneUserContext = createContext< + Awaited>['data'] | null +>(null); +export const ploneClearAuthCookieContext = createContext(false); + +function getAuthorizedResourceHeaders( + request: Request, + token?: string, +): HeadersInit { + const headers = new Headers(request.headers); + + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } else { + headers.delete('Authorization'); + } + + return headers; +} export const installServerMiddleware: Route.MiddlewareFunction = async ( { request, context }, next, ) => { installServer(); - // const locale = await i18next.getLocale(request); - // context.setData({ locale }); +}; + +export const PloneClientMiddleware: Route.MiddlewareFunction = async ( + { request, context }, + next, +) => { + const token = await getAuthFromRequest(request); + + const PloneClient = config + .getUtility({ + name: 'ploneClient', + type: 'client', + }) + .method(); + + const cli = PloneClient.initialize({ + apiPath: config.settings.apiPath, + token, + }); + + context.set(ploneClientContext, cli); }; export const otherResources: Route.MiddlewareFunction = async ( @@ -24,9 +72,6 @@ export const otherResources: Route.MiddlewareFunction = async ( throw Response.json({}); } - // eslint-disable-next-line no-console - // console.log(path); - if ( /^https?:\/\//.test(path) || /^favicon.ico\/\//.test(path) || @@ -53,12 +98,144 @@ export const getAPIResourceWithAuth: Route.MiddlewareFunction = async ( /\/@portrait\//.test(path) ) { const token = await getAuthFromRequest(request); - return await fetch(`${config.settings.apiPath}${path}`, { + const url = `${config.settings.apiPath}${path}`; + const response = await fetch(url, { method: 'GET', - headers: { - ...request.headers, - Authorization: `Bearer ${token}`, - }, + headers: getAuthorizedResourceHeaders(request, token), + }); + + if (token && response.status === 401) { + const anonymousResponse = await fetch(url, { + method: 'GET', + headers: getAuthorizedResourceHeaders(request), + }); + + if (anonymousResponse.ok) { + return clearAuthOnResponse( + new Response(anonymousResponse.body, { + status: anonymousResponse.status, + statusText: anonymousResponse.statusText, + headers: anonymousResponse.headers, + }), + ); + } + + return clearAuthOnResponse( + new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }), + ); + } + + return response; + } +}; + +export const fetchPloneContent: Route.MiddlewareFunction = async ( + { request, params, context }, + next, +) => { + const expand = ['navroot', 'breadcrumbs', 'navigation', 'actions']; + const token = await getAuthFromRequest(request); + + let cli = context.get(ploneClientContext); + + const path = `/${params['*'] || ''}`; + + let userId = ''; + if (token) { + try { + const decodedToken = jwtDecode<{ + sub: string; + exp: number; + fullname: string | null; + }>(token); + userId = decodedToken.sub || ''; + } catch {} + } + + if (userId) expand.push('types'); + + const setPloneContext = ( + content: Awaited>, + site: Awaited>, + user: Awaited>['data'] | null, + ) => { + migrateContent(content.data); + + context.set(ploneContentContext, flattenToAppURL(content.data)); + context.set(ploneSiteContext, flattenToAppURL(site.data)); + context.set(ploneUserContext, user); + }; + + try { + const [content, site, user] = await Promise.all([ + cli.getContent({ path, expand }), + cli.getSite(), + userId ? cli.getUser({ id: userId }).catch(() => null) : null, + ]); + + setPloneContext(content, site, user?.data ?? null); + } catch (error: any) { + if (error.status >= 300 && error.status < 400 && error.location) { + return redirect(error.location, { + status: error.status, + }); + } + if (token && error?.status === 401) { + const PloneClient = config + .getUtility({ + name: 'ploneClient', + type: 'client', + }) + .method(); + cli = PloneClient.initialize({ + apiPath: config.settings.apiPath, + }); + context.set(ploneClientContext, cli); + + try { + const [content, site] = await Promise.all([ + cli.getContent({ + path, + expand: expand.filter((item) => item !== 'types'), + }), + cli.getSite(), + ]); + + setPloneContext(content, site, null); + context.set(ploneClearAuthCookieContext, true); + return; + } catch (anonymousError: any) { + throw data('Content Not Found', { + status: + typeof anonymousError.status === 'number' + ? anonymousError.status + : 500, + }); + } + } + + throw data('Content Not Found', { + status: typeof error.status === 'number' ? error.status : 500, }); } }; + +export const linkMiddleware: Route.MiddlewareFunction = async ( + { context }, + next, +) => { + const content = context.get(ploneContentContext); + + if ( + content['@type'] === 'Link' && + !content['@components'].actions.object.find( + (action) => action.id === 'edit', + ) + ) { + return redirect(content.remoteUrl); + } +}; diff --git a/apps/seven/app/root.test.tsx b/apps/seven/app/root.test.tsx index cada409e087..e61a40f09d3 100644 --- a/apps/seven/app/root.test.tsx +++ b/apps/seven/app/root.test.tsx @@ -3,6 +3,13 @@ import { render, screen } from '@testing-library/react'; import { createRoutesStub, RouterContextProvider } from 'react-router'; import config from '@plone/registry'; import { Layout, ErrorBoundary, loader } from './root'; +import { + ploneClientContext, + ploneClearAuthCookieContext, + ploneContentContext, + ploneSiteContext, +} from './middleware.server'; +import { migrateContent } from './config/server/content-migrations.server'; import { renderWithI18n } from '../tests/testHelpers'; async function renderStub() { @@ -12,7 +19,12 @@ async function renderStub() { Component: () => (

Root Layout

@@ -32,133 +44,117 @@ async function renderStub() { await renderWithI18n(); } -describe('loader', () => { - afterEach(() => { - vi.restoreAllMocks(); - config.settings = {}; - delete config.utilities['client']; +const registerSomersaultBlockMigrations = () => { + config.registerUtility({ + name: 'testSomersaultBlockMigrationTitle', + type: 'somersaultBlockMigration', + method: ({ block, content }) => + block['@type'] === 'title' + ? [ + { + type: 'title', + children: [ + { + text: typeof content.title === 'string' ? content.title : '', + }, + ], + }, + ] + : [], + }); + + config.registerUtility({ + name: 'testSomersaultBlockMigrationLegacyValue', + type: 'somersaultBlockMigration', + method: ({ block }) => (Array.isArray(block.value) ? block.value : []), }); +}; - it('should fetch the current content', async () => { - const getContentMock = vi.fn().mockResolvedValue({ data: {} }); - const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); - config.settings.apiPath = 'http://example.com'; +afterEach(() => { + vi.restoreAllMocks(); + config.settings = {}; + const utilities = config.utilities as Partial>; + delete utilities.client; + delete utilities.somersaultBlockMigration; + delete utilities.somersaultMigration; + delete utilities.rootContentSubRequest; + delete utilities.rootLoaderData; +}); + +describe('loader', () => { + it('should return content and site from context', async () => { config.settings.defaultLanguage = 'en'; config.settings.supportedLanguages = ['en']; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { - token: undefined, - }, - getContent: getContentMock, - getSite: getSiteMock, - }), - }); + const mockContent = { '@id': 'http://example.com/', title: 'Home' }; + const mockSite = { '@id': 'http://example.com/' }; const request = new Request('http://example.com'); const context = new RouterContextProvider(); + context.set(ploneClientContext, {} as any); + context.set(ploneContentContext, mockContent as any); + context.set(ploneSiteContext, mockSite as any); - const data = await loader({ request, params: {}, context }); - - expect(getContentMock).toHaveBeenCalledWith({ - path: '/', - expand: ['navroot', 'breadcrumbs', 'navigation', 'actions'], + const result = await loader({ + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), }); - expect(getSiteMock).toHaveBeenCalled(); - expect(data.locale).toBe('en'); + + expect(result.data.locale).toBe('en'); + expect(result.data.content).toBeDefined(); + expect(result.data.site).toBeDefined(); }); - it("should fetch the current content when it's not the root", async () => { - const getContentMock = vi.fn().mockResolvedValue({ data: {} }); - const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); - config.settings.apiPath = 'http://example.com'; + it('should return content for a non-root path', async () => { config.settings.defaultLanguage = 'en'; config.settings.supportedLanguages = ['en']; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { - token: undefined, - }, - getContent: getContentMock, - getSite: getSiteMock, - }), - }); + const mockContent = { + '@id': 'http://example.com/test-content', + title: 'Test', + }; + const mockSite = { '@id': 'http://example.com/' }; const request = new Request('http://example.com/test-content'); const context = new RouterContextProvider(); + context.set(ploneClientContext, {} as any); + context.set(ploneContentContext, mockContent as any); + context.set(ploneSiteContext, mockSite as any); - const data = await loader({ + const result = await loader({ request, params: { '*': 'test-content' }, context, + unstable_pattern: '/test-content', + unstable_url: new URL(request.url), }); - expect(getContentMock).toHaveBeenCalledWith({ - path: '/test-content', - expand: ['navroot', 'breadcrumbs', 'navigation', 'actions'], - }); - expect(getSiteMock).toHaveBeenCalled(); - expect(data.locale).toBe('en'); + expect(result.data.locale).toBe('en'); + expect(result.data.content).toBeDefined(); + expect(result.data.site).toBeDefined(); }); - it('should throw when the current content is not loaded', async () => { - const getContentMock = vi - .fn() - .mockRejectedValue({ data: undefined, status: 500 }); - const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); - config.settings.apiPath = 'http://example.com'; + it('should clear the auth cookie when middleware requests it', async () => { config.settings.defaultLanguage = 'en'; config.settings.supportedLanguages = ['en']; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { - token: undefined, - }, - getContent: getContentMock, - getSite: getSiteMock, - }), - }); + const mockContent = { '@id': 'http://example.com/', title: 'Home' }; + const mockSite = { '@id': 'http://example.com/' }; const request = new Request('http://example.com'); const context = new RouterContextProvider(); + context.set(ploneClientContext, {} as any); + context.set(ploneContentContext, mockContent as any); + context.set(ploneSiteContext, mockSite as any); + context.set(ploneClearAuthCookieContext, true); - try { - await loader({ request, params: {}, context }); - } catch (err: any) { - expect(err.init.status).toEqual(500); - } - }); - - it('should throw when the site is not loaded', async () => { - const getContentMock = vi.fn().mockResolvedValue({ data: {} }); - const getSiteMock = vi - .fn() - .mockRejectedValue({ data: undefined, status: 500 }); - config.settings.apiPath = 'http://example.com'; - config.settings.defaultLanguage = 'en'; - config.settings.supportedLanguages = ['en']; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { - token: undefined, - }, - getContent: getContentMock, - getSite: getSiteMock, - }), - }); - const request = new Request('http://example.com'); - const context = new RouterContextProvider(); + const result = (await loader({ + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + })) as any; - try { - await loader({ request, params: {}, context }); - } catch (err: any) { - expect(err.init.status).toEqual(500); - } + expect(result.data.content).toEqual(mockContent); + expect(result.init.headers['Set-Cookie']).toContain('auth_seven='); }); }); @@ -176,3 +172,124 @@ describe('ErrorBoundary', () => { expect(screen.getByText('Test error')).toBeInTheDocument(); }); }); + +it('should place the migrated title block in the legacy block order', async () => { + const mockContent = { + '@id': 'http://example.com/', + title: 'Page title', + blocks: { + a: { + '@type': 'text', + value: [{ type: 'p', children: [{ text: 'First block' }] }], + }, + titleBlock: { + '@type': 'title', + }, + b: { + '@type': 'text', + value: [{ type: 'p', children: [{ text: 'Second block' }] }], + }, + }, + blocks_layout: { + items: ['a', 'titleBlock', 'b'], + }, + }; + const mockSite = { '@id': 'http://example.com/' }; + const somersaultMigration = vi.fn(({ value }) => value); + config.settings.defaultLanguage = 'en'; + config.settings.supportedLanguages = ['en']; + registerSomersaultBlockMigrations(); + config.registerUtility({ + name: 'testSomersaultMigration', + type: 'somersaultMigration', + method: somersaultMigration, + }); + const request = new Request('http://example.com'); + migrateContent(mockContent as any); + const context = new RouterContextProvider(); + context.set(ploneClientContext, {} as any); + context.set(ploneContentContext, mockContent as any); + context.set(ploneSiteContext, mockSite as any); + + const result = await loader({ + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }); + + expect(somersaultMigration).toHaveBeenCalledTimes(1); + expect(result.data.content.blocks.__somersault__).toEqual({ + '@type': '__somersault__', + value: [ + { + type: 'p', + children: [{ text: 'First block' }], + }, + { + type: 'title', + children: [{ text: 'Page title' }], + }, + { + type: 'p', + children: [{ text: 'Second block' }], + }, + ], + }); +}); + +it('should skip somersault migration when the somersault block already exists', async () => { + const existingSomersaultValue = [ + { + type: 'title', + children: [{ text: 'Already migrated' }], + }, + ]; + const mockContent = { + '@id': 'http://example.com/', + title: 'Page title', + blocks: { + __somersault__: { + '@type': '__somersault__', + value: existingSomersaultValue, + }, + a: { + '@type': 'text', + value: [{ type: 'p', children: [{ text: 'Legacy block' }] }], + }, + }, + blocks_layout: { + items: ['a'], + }, + }; + const mockSite = { '@id': 'http://example.com/' }; + const somersaultMigration = vi.fn(({ value }) => value); + config.settings.defaultLanguage = 'en'; + config.settings.supportedLanguages = ['en']; + registerSomersaultBlockMigrations(); + config.registerUtility({ + name: 'testSomersaultMigration', + type: 'somersaultMigration', + method: somersaultMigration, + }); + const request = new Request('http://example.com'); + migrateContent(mockContent as any); + const context = new RouterContextProvider(); + context.set(ploneClientContext, {} as any); + context.set(ploneContentContext, mockContent as any); + context.set(ploneSiteContext, mockSite as any); + const result = await loader({ + request, + params: {}, + context, + unstable_pattern: '/', + unstable_url: new URL(request.url), + }); + + expect(somersaultMigration).not.toHaveBeenCalled(); + expect(result.data.content.blocks.__somersault__).toEqual({ + '@type': '__somersault__', + value: existingSomersaultValue, + }); +}); diff --git a/apps/seven/app/root.tsx b/apps/seven/app/root.tsx index 5c354c38060..4a99bf91241 100644 --- a/apps/seven/app/root.tsx +++ b/apps/seven/app/root.tsx @@ -1,38 +1,40 @@ -import { PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; import { data, isRouteErrorResponse } from 'react-router'; import { useChangeLanguage } from 'remix-i18next/react'; import i18next from './i18next.server'; import type { Route } from './+types/root'; -import { flattenToAppURL } from '@plone/helpers'; -import type PloneClient from '@plone/client'; -import { getAuthFromRequest } from '@plone/react-router'; import config from '@plone/registry'; import { + ploneClearAuthCookieContext, + fetchPloneContent, getAPIResourceWithAuth, installServerMiddleware, + PloneClientMiddleware, otherResources, + ploneClientContext, + ploneContentContext, + ploneSiteContext, + ploneUserContext, + linkMiddleware, } from './middleware.server'; +import { getClearAuthCookieHeader } from '@plone/react-router'; export const middleware = [ installServerMiddleware, + PloneClientMiddleware, otherResources, getAPIResourceWithAuth, + fetchPloneContent, + linkMiddleware, ]; -export async function loader({ params, request }: Route.LoaderArgs) { +export async function loader({ params, request, context }: Route.LoaderArgs) { const locale = await i18next.getLocale(request); - const token = await getAuthFromRequest(request); - const expand = ['navroot', 'breadcrumbs', 'navigation', 'actions']; - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; + const cli = context.get(ploneClientContext); + const content = context.get(ploneContentContext); + const site = context.get(ploneSiteContext); + const user = context.get(ploneUserContext); const path = `/${params['*'] || ''}`; @@ -45,15 +47,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { }); try { - const [content, site] = await Promise.all([ - cli.getContent({ path, expand }), - cli.getSite(), - ]); - for (const utility of rootContentSubRequests) { await utility.method({ cli, - content: content.data, + content, request, path, params, @@ -65,7 +62,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { ...rootLoaderDataUtilities.map((utility) => utility.method({ cli, - content: content.data, + content, request, path, params, @@ -74,14 +71,23 @@ export async function loader({ params, request }: Route.LoaderArgs) { ), ]); - return { - content: flattenToAppURL(content.data), - site: flattenToAppURL(site.data), + const loaderData = { + content, + site, locale, + isAuthenticated: user !== null, ...rootLoaderDataUtilitiesData .filter((item) => item) .reduce((acc, item) => ({ ...acc, ...item }), {}), }; + + return data(loaderData, { + headers: context.get(ploneClearAuthCookieContext) + ? { + 'Set-Cookie': await getClearAuthCookieHeader(), + } + : undefined, + }); } catch (error: any) { throw data('Content Not Found', { status: typeof error.status === 'number' ? error.status : 500, @@ -120,19 +126,24 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { let message = 'Oops!'; let details = 'An unexpected error occurred.'; let stack: string | undefined; + if (isRouteErrorResponse(error)) { switch (error.status) { case 404: message = '404'; details = 'The requested page could not be found.'; break; + case 401: + message = '401'; + details = 'You are not authorized to view this page.'; + break; case 500: message = '500'; details = 'The server encountered an internal error. Did you start the backend?'; break; default: - message = 'Error'; + message = 'Error ' + error.status; details = error.statusText || details; break; } diff --git a/apps/seven/news/+babel-core-dev-dependency.internal b/apps/seven/news/+babel-core-dev-dependency.internal new file mode 100644 index 00000000000..3454ec05dca --- /dev/null +++ b/apps/seven/news/+babel-core-dev-dependency.internal @@ -0,0 +1 @@ +Add the missing `@babel/core` dev dependency and ignore the local `var/` runtime directory for app development. @sneridagh diff --git a/apps/seven/news/+contents-addon.feature b/apps/seven/news/+contents-addon.feature new file mode 100644 index 00000000000..e04a3821e03 --- /dev/null +++ b/apps/seven/news/+contents-addon.feature @@ -0,0 +1 @@ +Registered the new `@plone/contents` add-on in the Seven app. @pnicolli @giuliaghisini @sneridagh diff --git a/apps/seven/news/+contenttypes.breaking b/apps/seven/news/+contenttypes.breaking new file mode 100644 index 00000000000..dd2769fe3af --- /dev/null +++ b/apps/seven/news/+contenttypes.breaking @@ -0,0 +1 @@ +Refactored the `Content` type to properly match the basic Plone types and allow TypeScript to narrow this type automatically. @pnicolli \ No newline at end of file diff --git a/apps/seven/news/+handleredirects.feature b/apps/seven/news/+handleredirects.feature new file mode 100644 index 00000000000..a2257a89008 --- /dev/null +++ b/apps/seven/news/+handleredirects.feature @@ -0,0 +1 @@ +Handled redirect responses from the backend when fetching content objects. @pnicolli \ No newline at end of file diff --git a/apps/seven/news/+linkmiddleware.feature b/apps/seven/news/+linkmiddleware.feature new file mode 100644 index 00000000000..8049a02f827 --- /dev/null +++ b/apps/seven/news/+linkmiddleware.feature @@ -0,0 +1 @@ +Added a middleware to handle Link Content Type View redirecting users that don't have Edit permissions. @pnicolli \ No newline at end of file diff --git a/apps/seven/news/+recurrencewidget.feature b/apps/seven/news/+recurrencewidget.feature new file mode 100644 index 00000000000..1cefd605d44 --- /dev/null +++ b/apps/seven/news/+recurrencewidget.feature @@ -0,0 +1 @@ +Added recurrence widget. @sabrina-bongiovanni \ No newline at end of file diff --git a/apps/seven/news/+rootauth.bugfix b/apps/seven/news/+rootauth.bugfix deleted file mode 100644 index 87967aa1871..00000000000 --- a/apps/seven/news/+rootauth.bugfix +++ /dev/null @@ -1 +0,0 @@ -Added auth token to the requests in the root loader @pnicolli diff --git a/apps/seven/news/+stale-auth-cookie-public-pages.bugfix b/apps/seven/news/+stale-auth-cookie-public-pages.bugfix new file mode 100644 index 00000000000..29be6fd4643 --- /dev/null +++ b/apps/seven/news/+stale-auth-cookie-public-pages.bugfix @@ -0,0 +1 @@ +Gracefully clear stale `auth_seven` cookies and retry public page and asset requests anonymously instead of surfacing a `401` error boundary. @sneridagh diff --git a/apps/seven/news/+unify-makefiles.internal b/apps/seven/news/+unify-makefiles.internal new file mode 100644 index 00000000000..5da674df4e4 --- /dev/null +++ b/apps/seven/news/+unify-makefiles.internal @@ -0,0 +1 @@ +Unify Makefile files across the packages. @ionlizarazu diff --git a/apps/seven/news/6710.feature b/apps/seven/news/6710.feature new file mode 100644 index 00000000000..0a9671fcc0c --- /dev/null +++ b/apps/seven/news/6710.feature @@ -0,0 +1 @@ +Added `isAuthenticated` boolean to root loader data. @arybakov05 \ No newline at end of file diff --git a/apps/seven/news/7603.feature b/apps/seven/news/7603.feature deleted file mode 100644 index a52a5bedcde..00000000000 --- a/apps/seven/news/7603.feature +++ /dev/null @@ -1 +0,0 @@ -Listing block @ebrehault \ No newline at end of file diff --git a/apps/seven/news/7787.internal b/apps/seven/news/7787.internal deleted file mode 100644 index ab04404dcca..00000000000 --- a/apps/seven/news/7787.internal +++ /dev/null @@ -1 +0,0 @@ -Upgraded to use RR 7.12.0. @sneridagh diff --git a/apps/seven/news/7827.breaking b/apps/seven/news/7827.breaking deleted file mode 100644 index 7df674fb248..00000000000 --- a/apps/seven/news/7827.breaking +++ /dev/null @@ -1,2 +0,0 @@ -Removed Cypress support. -Added Playwright support. Move all existing Cypress tests to Playwright. @sneridagh diff --git a/apps/seven/news/7921.feature b/apps/seven/news/7921.feature deleted file mode 100644 index bed35ce169d..00000000000 --- a/apps/seven/news/7921.feature +++ /dev/null @@ -1 +0,0 @@ -Somersault editor support. @sneridagh diff --git a/apps/seven/news/8001.bugfix b/apps/seven/news/8001.bugfix deleted file mode 100644 index a20f7649427..00000000000 --- a/apps/seven/news/8001.bugfix +++ /dev/null @@ -1 +0,0 @@ -Added safeguard when checking for a contents blocks data @arybakov05 \ No newline at end of file diff --git a/apps/seven/package.json b/apps/seven/package.json index 2f480560492..64782982c77 100644 --- a/apps/seven/package.json +++ b/apps/seven/package.json @@ -1,6 +1,6 @@ { "name": "seven", - "version": "1.0.0-alpha.1", + "version": "1.0.0-alpha.4", "private": true, "sideEffects": false, "type": "module", @@ -15,7 +15,11 @@ "typecheck": "react-router typegen && tsc", "typegen": "react-router typegen", "release": "release-it", - "release-alpha": "release-it --preRelease=alpha" + "release-alpha": "release-it --preRelease=alpha", + "prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", + "lint:fix": "eslint --max-warnings=0 './**/*.{js,jsx,ts,tsx}' --fix --no-error-on-unmatched-pattern", + "stylelint:fix": "sh -c 'if [ -f .stylelintrc ] || [ -f .stylelintrc.json ] || [ -f .stylelintrc.js ] || [ -f .stylelintrc.cjs ] || [ -f stylelint.config.js ] || [ -f stylelint.config.cjs ] || [ -f stylelint.config.mjs ]; then stylelint '''./**/*.{css,scss,less}''' --fix --allow-empty-input; else echo \"No local stylelint config, skipping\"; fi'", + "format": "pnpm prettier:fix && pnpm lint:fix && pnpm stylelint:fix" }, "dependencies": { "@plone/agave": "workspace:*", @@ -23,6 +27,7 @@ "@plone/client": "workspace:*", "@plone/cmsui": "workspace:*", "@plone/components": "workspace:*", + "@plone/contents": "workspace:*", "@plone/helpers": "workspace:*", "@plone/layout": "workspace:*", "@plone/plate": "workspace:*", @@ -37,6 +42,7 @@ "i18next-fs-backend": "^2.6.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.27", + "jwt-decode": "^4.0.0", "react": "catalog:", "react-dom": "catalog:", "react-i18next": "catalog:", @@ -44,6 +50,7 @@ "remix-i18next": "^7.1.0" }, "devDependencies": { + "@babel/core": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@plone/types": "workspace:*", "@react-router/dev": "catalog:", @@ -54,14 +61,13 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitest/coverage-v8": "catalog:", - "babel-plugin-react-compiler": "19.1.0-rc.3", - "react-router-devtools": "^1.1.6", + "babel-plugin-react-compiler": "1.0.0", "release-it": "catalog:", "rollup-plugin-visualizer": "^7.0.1", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-babel": "^1.3.2", - "vite-tsconfig-paths": "^5.1.4", + "vite-plugin-devtools-json": "^1.0.0", "vitest": "catalog:" }, "engines": { diff --git a/apps/seven/registry.config.ts b/apps/seven/registry.config.ts index 997238d6805..384d6d7aa48 100644 --- a/apps/seven/registry.config.ts +++ b/apps/seven/registry.config.ts @@ -5,6 +5,7 @@ const addons = [ '@plone/layout', '@plone/cmsui', '@plone/publicui', + '@plone/contents', '@plone/agave', ]; const theme = ''; diff --git a/apps/seven/tsconfig.json b/apps/seven/tsconfig.json index 2733700a8c6..b1530d87622 100644 --- a/apps/seven/tsconfig.json +++ b/apps/seven/tsconfig.json @@ -8,6 +8,7 @@ "**/.client/**/*.tsx", ".react-router/types/**/*" ], + "exclude": ["packages/*"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": ["@react-router/node", "vite/client", "@plone/components/icons"], @@ -22,9 +23,9 @@ "allowJs": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "baseUrl": ".", "paths": { "~/*": ["./app/*"], + "seven/*": ["./*"], "@plone/plate/*": ["../../packages/plate/src/*"] }, "noEmit": true, diff --git a/apps/seven/vite.config.ts b/apps/seven/vite.config.ts index d0bcd853168..a19068de447 100644 --- a/apps/seven/vite.config.ts +++ b/apps/seven/vite.config.ts @@ -1,25 +1,24 @@ import { reactRouter } from '@react-router/dev/vite'; import path from 'node:path'; -import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig, PluginOption } from 'vite'; -import { reactRouterDevTools } from 'react-router-devtools'; import { PloneRegistryVitePlugin } from '@plone/registry/vite-plugin'; import { PloneSVGRVitePlugin } from '@plone/components/vite-plugin-svgr'; +import applyAddonViteConfiguration from './.plone/vite.loader'; import babel from 'vite-plugin-babel'; import tailwindcss from '@tailwindcss/vite'; import { visualizer } from 'rollup-plugin-visualizer'; +import devtoolsJson from 'vite-plugin-devtools-json'; -export default defineConfig(({ isSsrBuild }) => { +export default defineConfig(({ command, mode, isSsrBuild }) => { const analyze = process.env.ANALYZE === 'true'; const target = isSsrBuild ? 'server' : 'client'; const statsDir = path.resolve(__dirname, 'build', 'stats'); - return { + const baseConfig = { plugins: [ PloneSVGRVitePlugin(), PloneRegistryVitePlugin(), tailwindcss(), - reactRouterDevTools(), reactRouter(), babel({ filter: /app\/.*\.tsx?$/, @@ -28,7 +27,7 @@ export default defineConfig(({ isSsrBuild }) => { plugins: ['babel-plugin-react-compiler'], }, }), - tsconfigPaths(), + devtoolsJson(), ...(analyze ? [ visualizer({ @@ -46,6 +45,9 @@ export default defineConfig(({ isSsrBuild }) => { ] : []), ] as PluginOption[], + resolve: { + tsconfigPaths: true, + }, server: { port: 3000, fs: { @@ -55,4 +57,10 @@ export default defineConfig(({ isSsrBuild }) => { }, }, }; + + return applyAddonViteConfiguration(baseConfig, { + command, + mode, + isSsrBuild, + }); }); diff --git a/apps/seven/vitest.config.ts b/apps/seven/vitest.config.ts index 17696470fa8..6eb6f6c4bb3 100644 --- a/apps/seven/vitest.config.ts +++ b/apps/seven/vitest.config.ts @@ -1,9 +1,8 @@ import { coverageConfigDefaults, defineConfig } from 'vitest/config'; -import tsconfigPaths from 'vite-tsconfig-paths'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [tsconfigPaths()], + plugins: [], test: { globals: true, environment: 'jsdom', @@ -18,7 +17,7 @@ export default defineConfig({ 'packages/**', 'build/**', '*.config.ts', - 'registry.loader.js', + '.plone/**', 'app/entry.server.tsx', 'app/entry.client.tsx', 'app/i18next.server.ts', @@ -27,4 +26,7 @@ export default defineConfig({ ], }, }, + resolve: { + tsconfigPaths: true, + }, }); diff --git a/catalog.json b/catalog.json index c970c69f98d..f41784aa6ea 100644 --- a/catalog.json +++ b/catalog.json @@ -4,31 +4,32 @@ "@types/node": "^24", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "react-aria": "^3.45.0", - "react-aria-components": "^1.16.0", - "@react-aria/utils": "^3.33.1", - "@react-spectrum/utils": "^3.12.12", - "@internationalized/date": "^3.12.0", - "react-stately": "^3.45.0", + "react-aria": "^3.48.0", + "react-aria-components": "^1.17.0", + "@react-aria/utils": "^3.34.0", + "@react-spectrum/utils": "^3.13.0", + "@internationalized/date": "^3.12.1", + "react-stately": "^3.46.0", "react-i18next": "^15.4.1", - "react-router": "7.12.0", - "@react-router/dev": "7.12.0", - "@react-router/node": "7.12.0", - "@react-router/serve": "7.12.0", + "react-router": "7.14.0", + "@react-router/dev": "7.14.0", + "@react-router/node": "7.14.0", + "@react-router/serve": "7.14.0", "@tanstack/react-query": "^5.59.0", - "playwright": "^1.58.0", - "@playwright/test": "^1.58.0", + "playwright": "^1.60.0", + "@playwright/test": "^1.60.0", "@platejs/playwright": "^52.0.11", - "tailwindcss": "^4.1.12", + "tailwindcss": "^4.2.2", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2", "release-it": "^19.0.5", "tsup": "^8.5.0", "typescript": "^5.9.2", - "vitest": "^4.0.0", + "vitest": "^4.1.0", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", - "@tailwindcss/vite": "^4.1.4", - "@vitejs/plugin-react": "^5.0.2", - "@vitest/coverage-v8": "^4.0.0", - "vite": "^6.3.3" + "@tailwindcss/vite": "^4.2.2", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.4", + "vite": "^8.0.8" } diff --git a/docs/conceptual-guides/modular-arch-packages.md b/docs/conceptual-guides/modular-arch-packages.md index 14b74e09f8f..d9f99cb5e45 100644 --- a/docs/conceptual-guides/modular-arch-packages.md +++ b/docs/conceptual-guides/modular-arch-packages.md @@ -74,9 +74,6 @@ They are also published as traditional bundles that work in CommonJS and ESM env The utility packages are: -`@plone/providers` -: Data flow providers. - `@plone/helpers` : Utility functions and helpers. diff --git a/docs/contributing/developing-core.md b/docs/contributing/developing-core.md index b78fdb67858..b95e001b301 100644 --- a/docs/contributing/developing-core.md +++ b/docs/contributing/developing-core.md @@ -54,9 +54,7 @@ The package `volto` is the core code of Volto. │ ├─ blocks │ ├─ client │ ├─ components -│ ├─ coresandbox │ ├─ helpers -│ ├─ providers │ ├─ registry │ ├─ scripts │ ├─ layout @@ -404,14 +402,3 @@ You can try it out using the following command. ```shell pnpm --filter plone-rr7 dev ``` - - -## Support libraries - -Volto uses several libraries to support development. - -### `volto-coresandbox` - -`@plone/volto-coresandbox` is a support library used mainly for testing purposes. -It provides fixtures to bootstrap projects with configurations different than the default one. -It is used by the acceptance tests to set up different test fixtures, such as `multilingual` or `workingcopy`. diff --git a/docs/development/configure-editor-block-widths.md b/docs/development/configure-editor-block-widths.md new file mode 100644 index 00000000000..048af154858 --- /dev/null +++ b/docs/development/configure-editor-block-widths.md @@ -0,0 +1,217 @@ +--- +myst: + html_meta: + "description": "Configure block widths for Plate and Plone blocks" + "property=og:description": "Configure block widths for Plate and Plone blocks" + "property=og:title": "Configure Plate block widths" + "keywords": "Seven, Plate, block width, editor" +--- + +# Configure editor block widths + +This guide explains the current block width model in the Plate editor, including how shared widths are defined, how width policies are configured for Plate blocks and Plone blocks, and how the selected width is injected into rendered block styles. + +## How it works + +The block width system is implemented by `BlockWidthPlugin` in `packages/plate/components/editor/plugins/block-width-plugin.ts`. + +The current shape is: + +- Widths are stored on block nodes as semantic ids such as `narrow`, `default`, `layout`, and `full`. +- The available width definitions come from `config.blocks.widths`. +- Each width definition is a `StyleDefinition`, so it can inject a full style object. +- The selected width is resolved to a style object and merged into the Plate element `style` prop. +- The toolbar uses the active block policy to show only the widths allowed for that block. +- Normalization ensures a block always has a valid `blockWidth` value. +- If a block does not define its own `defaultWidth`, the plugin resolves it from `config.blocks.widths`. + +The important consequence is that the node stores a width id, not a CSS value. + +### Shared width definitions + +All available widths are defined in `config.blocks.widths`. + +The default definitions are installed in `packages/blocks/index.ts`: + +```ts +config.blocks.widths = [ + { + style: { + '--block-width': 'var(--narrow-container-width)', + }, + name: 'narrow', + label: 'Narrow', + }, + { + style: { + '--block-width': 'var(--default-container-width)', + }, + name: 'default', + label: 'Default', + }, + { + style: { + '--block-width': 'var(--layout-container-width)', + }, + name: 'layout', + label: 'Layout', + }, + { + style: { + '--block-width': '100%', + }, + name: 'full', + label: 'Full Width', + }, +]; +``` + +Each item is a `StyleDefinition`: + +- `name`: the value stored in `blockWidth` +- `label`: the label shown in the toolbar +- `style`: the inline style object injected into the rendered block + +If the registry contains a width named `default`, it is used as the shared default width. +Otherwise, the first item in `config.blocks.widths` becomes the shared default. + +### How styles are injected + +The plugin resolves the current `blockWidth` id against `config.blocks.widths`, then injects the matching `style` object into the Plate element. + +That means this width: + +```ts +{ + name: 'layout', + label: 'Layout', + style: { + '--block-width': 'var(--layout-container-width)', + }, +} +``` + +results in an inline CSS custom property on the block element. + +The layout CSS consumes that variable in `packages/layout/styles/content-area.css`: + +```css +.block .block-inner-container { + max-width: var(--block-width, var(--default-container-width)); +} +``` + +So the flow is: + +1. The node stores `blockWidth: 'layout'`. +2. The plugin resolves `layout` in `config.blocks.widths`. +3. The plugin injects `style={{ '--block-width': 'var(--layout-container-width)' }}`. +4. CSS uses `var(--block-width)` to compute the final `max-width`. + +## Configure widths for Plate blocks + +Plate-native blocks are configured through `config.blocks.plateBlocksConfig`. + +The default setup lives in `packages/blocks/index.ts`: + +```ts +config.blocks.plateBlocksConfig = { + p: { + blockWidth: { + defaultWidth: 'narrow', + widths: ['narrow'], + }, + }, + title: { + blockWidth: { + defaultWidth: 'default', + widths: ['default'], + }, + }, + toc: { + blockWidth: { + defaultWidth: 'default', + widths: ['layout', 'default', 'narrow'], + }, + }, +}; +``` + +The key is the Plate element type, for example: + +- `p` for paragraphs +- `title` for the title block +- `toc` for the table of contents block + +To configure a new Plate block: + +```ts +config.blocks.plateBlocksConfig.myBlock = { + blockWidth: { + defaultWidth: 'default', + widths: ['layout', 'default'], + }, +}; +``` + +The `blockWidth` policy supports: + +- `defaultWidth`: the width applied when the block has no explicit width +- `widths`: the allowed width ids shown in the toolbar + +## Configure widths for Plone blocks + +Plone blocks are configured in their block info object under `packages/blocks//index.ts`. + +Example from `packages/blocks/Image/index.ts`: + +```ts +const ImageBlockInfo = { + id: 'image', + title: 'Image', + // ... + blockWidth: { + defaultWidth: 'default', + widths: ['layout', 'default', 'narrow', 'full'], + }, +}; +``` + +This value is registered through `config.blocks.blocksConfig`, so the width plugin can resolve it for adapted Plone blocks. + +To configure another Plone block, add a `blockWidth` section to its block info object: + +```ts +const MyBlockInfo = { + id: 'myBlock', + title: 'My block', + // ... + blockWidth: { + defaultWidth: 'default', + widths: ['default', 'narrow'], + }, +}; +``` + +## Resolution order + +The width plugin resolves the active block policy from the registry: + +- For Plate blocks, it reads `config.blocks.plateBlocksConfig[element.type]`. +- For adapted Plone blocks, it reads `config.blocks.blocksConfig[element['@type']]`. +- If no registry config is found, it falls back to plugin options for backward compatibility. + +The toolbar uses the resolved policy and the shared width definitions together: + +- the policy determines which width ids are allowed +- `config.blocks.widths` determines the labels and injected styles for those ids + + +```{note} +Widths are stored in the node as `blockWidth`. +Width values should be semantic ids such as `narrow` or `layout`, not raw CSS values. +The `BlockWidthPlugin` normalizes blocks to ensure `blockWidth` is set and valid for the current block. +The toolbar options are sourced from `config.blocks.widths`. +The actual visual width is controlled by CSS through `--block-width`. +Registry-based configuration is now the preferred approach for both Plate and Plone blocks. +``` diff --git a/docs/development/editor-slash-menu.md b/docs/development/editor-slash-menu.md new file mode 100644 index 00000000000..56101913ce7 --- /dev/null +++ b/docs/development/editor-slash-menu.md @@ -0,0 +1,470 @@ +--- +myst: + html_meta: + "description": "Configure and extend the Editor slash menu per editor in Seven" + "property=og:description": "Configure and extend the Editor slash menu per editor in Seven" + "property=og:title": "Configure Editor slash menus" + "keywords": "Seven, Editor, Plate, slash menu, editor, extensibility, shadowing" +--- + +# Editor slash menus + +This guide explains how the Plate Editor slash menu is wired in Seven, how to extend it without bloating the registry or the bundle, and how to configure different slash menus for different editors. + +The key design choice is that the slash menu is configured at editor composition time, not by globally shadowing the UI component. + +## Current architecture + +The slash menu is split into three parts: + +- {file}`packages/plate/components/editor/plugins/slash-kit.tsx` +- {file}`packages/plate/components/editor/plugins/slash-menu.tsx` +- {file}`packages/plate/components/ui/slash-node.tsx` + +Their responsibilities are: + +- `slash-kit.tsx`: registers the Plate slash plugins and exposes `createSlashKit(...)` +- `slash-menu.tsx`: defines the menu types, the default menu builders, and the extension API +- `slash-node.tsx`: renders the menu UI from the plugin-provided configuration + +This split is important because it keeps the renderer generic and moves customization into configuration. + +### Why this model exists + +Shadowing `SlashInputElement` is a poor fit when a project has more than one editor. + +If the menu is customized by shadowing the component: + +- the customization becomes global +- every editor gets the same slash menu +- projects cannot easily provide one menu for one editor and another menu for a different editor +- add-ons are pushed toward component forks instead of configuration + +The current model avoids that by letting each editor build its own `SlashKit`. + +### Default behavior + +The default export is still: + +```ts +export const SlashKit = createSlashKit(); +``` + +This preserves the current behavior for existing editor kits. + +By default, the menu includes: + +- static Plate items such as paragraphs, headings, lists, tables, and callouts +- the title block insertion entry when the current document does not already contain one +- registry-backed Plone blocks from `config.blocks.blocksConfig` + +### The extension API + +The slash menu is configured with `createSlashKit(...)`. + +The supported shape is: + +```ts +type SlashKitOptions = { + menu?: SlashMenuConfig; +}; + +type SlashMenuConfig = { + groups?: SlashMenuGroup[]; + getGroups?: ( + editor: PlateEditor, + context: SlashMenuContext, + ) => SlashMenuGroup[]; + extendGroups?: ( + groups: SlashMenuGroup[], + editor: PlateEditor, + context: SlashMenuContext, + ) => SlashMenuGroup[]; +}; +``` + +The extension points serve different use cases: + +- `groups`: provide a fully static menu +- `getGroups`: build a complete menu dynamically from the editor state +- `extendGroups`: start from the default menu and modify it + +In most cases, `extendGroups` is the best entry point. + +### How groups are resolved + +The renderer resolves groups in this order: + +1. `menu.getGroups(editor, context)` +2. `menu.groups` +3. `getDefaultSlashMenuGroups(editor, context)` + +Then, if `menu.extendGroups` exists, it receives the resolved groups and returns the final menu. + +That means: + +- `getGroups` replaces the full base menu +- `groups` is a static replacement +- `extendGroups` can be used either with the defaults or with a replacement menu + +### The context object + +Slash builders receive a `context` object: + +```ts +type SlashMenuContext = { + hasTitleBlock: boolean; + translate?: (id: string) => string; +}; +``` + +This is useful when the menu depends on editor state. For example: + +- adding a title command only when no title exists +- translating labels from registry-backed blocks +- showing or hiding commands depending on the current editor + +The default renderer adapts the current editor i18n implementation to this narrower function shape, so slash menu builders do not need to know about `intl` objects. + +## Examples of slash menu customization + +Following are some common use cases of slash menu customization and how to implement them with the provided API. + +### Use case: keep the defaults and add one item + +This is the most common customization. + +```ts +import { SparklesIcon } from 'lucide-react'; + +import { + createSlashKit, +} from '@plone/plate/components/editor/plugins/slash-kit'; + +const CustomSlashKit = createSlashKit({ + menu: { + extendGroups: (groups) => + groups.map((group) => { + if (group.group === 'Actions') { + return { + ...group, + items: [ + ...group.items, + { + icon: , + label: 'My action', + value: 'my_action', + onSelect: (editor) => { + // custom command + }, + }, + ], + } + }; + } + return group; + }), + }, +}); +``` + +Use this when: + +- you want the built-in items to remain +- you only need to append or prepend a few commands +- you want the smallest maintenance surface + +### Use case: remove items or groups from the defaults + +You can filter the default groups with `extendGroups`. + +Example removing the `Actions` group: + +```ts +const CustomSlashKit = createSlashKit({ + menu: { + extendGroups: (groups) => + groups.filter((group) => group.group !== 'Actions'), + }, +}); +``` + +Example removing just one item: + +```ts +const CustomSlashKit = createSlashKit({ + menu: { + extendGroups: (groups) => + groups.map((group) => + group.group === 'Advanced blocks' + ? { + ...group, + items: group.items.filter((item) => item.value !== 'action_three_columns'), + } + : group, + ), + }, +}); +``` + +Use this when: + +- your project wants to curate the default menu +- some commands should not be available in a specific editor + +### Use case: reorder groups or items + +`extendGroups` can also reorder the resolved menu. + +Example moving `Blocks` to the top: + +```ts +const CustomSlashKit = createSlashKit({ + menu: { + extendGroups: (groups) => { + const blocks = groups.find((group) => group.group === 'Blocks'); + const rest = groups.filter((group) => group.group !== 'Blocks'); + + return blocks ? [blocks, ...rest] : groups; + }, + }, +}); +``` + +Use this when: + +- one editor should emphasize project blocks over core text commands +- you need an editorial workflow-specific ordering + +### Use case: replace the full menu with a static list + +Use `groups` when the menu is fixed and does not depend on runtime editor state. + +```ts +import { Heading1Icon, PilcrowIcon } from 'lucide-react'; +import { KEYS } from 'platejs'; + +const MinimalSlashKit = createSlashKit({ + menu: { + groups: [ + { + group: 'Text', + items: [ + { + icon: , + label: 'Paragraph', + value: KEYS.p, + onSelect: (editor, value) => { + editor.tf.setNodes({ type: value }); + }, + }, + { + icon: , + label: 'Heading 1', + value: KEYS.h1, + onSelect: (editor, value) => { + editor.tf.setNodes({ type: value }); + }, + }, + ], + }, + ], + }, +}); +``` + +Use this when: + +- an editor must expose a very narrow authoring surface +- the menu is intentionally independent from the default menu + +### Use case: build the full menu dynamically + +Use `getGroups` when the menu depends on editor state or external configuration. + +```ts +const ConditionalSlashKit = createSlashKit({ + menu: { + getGroups: (editor, context) => { + const groups = []; + + if (!context.hasTitleBlock) { + groups.push({ + group: 'Structure', + items: [ + { + icon: , + label: 'Title', + value: 'title', + onSelect: (nextEditor, value) => { + // insert title block + }, + }, + ], + }); + } + + if (editor.selection) { + groups.push({ + group: 'Text', + items: [ + // build commands from editor state + ], + }); + } + + return groups; + }, + }, +}); +``` + +Use this when: + +- the set of available commands depends on the editor state +- commands differ based on selection, schema, or document content +- you need more control than `extendGroups` provides + +### Use case: different slash menus for different editors + +This is the main reason to use `createSlashKit(...)`. + +You can compose different editor kits with different slash configurations: + +```ts +import { createSlashKit } from './plugins/slash-kit'; + +export const NewsEditorKit = [ + // ... + ...createSlashKit({ + menu: { + extendGroups: (groups) => + groups.filter((group) => group.group !== 'Advanced blocks'), + }, + }), +]; + +export const LandingPageEditorKit = [ + // ... + ...createSlashKit({ + menu: { + extendGroups: (groups) => [ + ...groups, + { + group: 'Landing page', + items: [ + // landing-specific commands + ], + }, + ], + }, + }), +]; +``` + +This approach is preferable to shadowing because each editor keeps its own slash menu definition. + +### Use case: project-wide defaults in an add-on + +If a project wants to change the default slash menu everywhere, there are two reasonable approaches. + +#### Option 1: shadow the editor kit + +Shadow the editor kit that your project uses and replace: + +```ts +...SlashKit +``` + +with: + +```ts +...createSlashKit({ + menu: { + extendGroups: (groups) => { + // project defaults + return groups; + }, + }, +}) +``` + +This is the preferred approach when you control the editor composition. + +#### Option 2: shadow `slash-menu.tsx` + +This is acceptable only if your goal is to redefine the global defaults used by `createSlashKit()` with no explicit `menu` argument. + +This is useful when: + +- your project intentionally wants a different global default +- you still want per-editor overrides to remain possible + +It is less desirable than composing a custom editor kit because it changes the default behavior for all consumers of the default slash menu. + +## When not to shadow `SlashInputElement` + +Do not shadow `packages/plate/components/ui/slash-node.tsx` just to change groups or items. + +That should now be treated as a rendering component, not as the main extension point. + +Shadow it only when you need to change: + +- the combobox layout +- visual styling +- custom markup for items or groups +- accessibility behavior of the rendered menu + +If the change is about menu contents, prefer `createSlashKit(...)`. + +## Registry-backed Plone blocks + +The default slash menu still reads Plone blocks from: + +- `config.blocks.blocksConfig` + +The current default builder: + +- filters malformed or restricted blocks +- localizes titles when needed +- creates slash entries that insert an adapted Plone block into the editor + +That means projects can still register blocks through the registry as usual, while customizing the slash menu composition per editor. + +## Performance and bundle-size notes + +This model is designed to avoid unnecessary registry or bundle growth. + +Prefer this pattern: + +- keep reusable slash builders in small modules +- import only the builders needed by a given editor kit +- compose the menu at editor build time + +Avoid this pattern: + +- a central registry of every possible slash command for every editor + +The editor-specific factory approach is better because: + +- editors only import the commands they actually need +- customization stays local to the editor composition +- add-ons do not need to fork the UI renderer to change menu data + +## Recommended extension strategy + +Use this order of preference: + +1. `extendGroups` for small additive or subtractive changes +2. `getGroups` when the menu is dynamic +3. `groups` for a fully static replacement +4. shadow an editor kit when a project wants different defaults everywhere +5. shadow `slash-node.tsx` only for rendering changes + +## Related files + +- {file}`packages/plate/components/editor/plugins/slash-kit.tsx` +- {file}`packages/plate/components/editor/plugins/slash-menu.tsx` +- {file}`packages/plate/components/ui/slash-node.tsx` +- {file}`packages/plate/components/editor/editor-kit.tsx` +- {file}`packages/plate/components/editor/block-editor-kit.tsx` diff --git a/docs/development/index.md b/docs/development/index.md index 2081a18252a..5d184c8b0ff 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -17,4 +17,6 @@ This part of the documentation describes how to develop projects using Seven. images i18n +editor-slash-menu +configure-editor-block-widths ``` diff --git a/docs/how-to-guides/bind-metadata-fields-to-plate-text-blocks.md b/docs/how-to-guides/bind-metadata-fields-to-plate-text-blocks.md new file mode 100644 index 00000000000..7390224ab0f --- /dev/null +++ b/docs/how-to-guides/bind-metadata-fields-to-plate-text-blocks.md @@ -0,0 +1,97 @@ +--- +myst: + html_meta: + "description": "How to bind a metadata field to a text-like Plate block in Seven." + "property=og:description": "How to bind a metadata field to a text-like Plate block in Seven." + "property=og:title": "Bind metadata fields to Plate text blocks" + "keywords": "Seven, Plate, metadata, title block, how-to" +--- + +# Bind Metadata Fields To Plate Text Blocks + +Use this guide when you want a Plate block to stay synchronized with a single metadata field such as `title` or `description`. + +Reference: + +- [Metadata Text Binding](../reference/metadata-text-binding.md) + +Implementation sources: + +- {file}`packages/plate/components/editor/plugins/metadata-text-binding.tsx` +- {file}`packages/plate/components/editor/plugins/title.tsx` + +## Before You Start + +This pattern fits when: + +- one metadata field maps to one editor value +- the block can expose that value as a string +- the plugin can detect whether the block is currently active + +If your block is structured or maps multiple fields at once, stop here and use a richer abstraction instead. + +## Steps + +1. Extract the current value from the plugin node. +2. Detect whether the current selection is inside that node. +3. Call `useMetadataTextBinding` from an `afterEditable` component. +4. Implement `writeToEditor` inside the plugin so metadata changes can be applied safely. +5. Add unit and end-to-end tests for both sync directions. + +## Example + +```ts +function ExampleMetadataSync() { + useMetadataTextBinding({ + field: 'description', + getState: (editor) => { + const entry = getMyNodeEntry(editor.children as unknown[]); + + if (!entry) { + return { isActive: false, value: null }; + } + + const path = [entry.index]; + + return { + isActive: isSelectionInside(editor.selection, path), + value: getNodeText(entry.node), + }; + }, + writeToEditor: (editor, value) => { + const entry = getMyNodeEntry(editor.children as unknown[]); + if (!entry) return; + + editor.tf.replaceNodes( + { + ...(entry.node as object), + children: [{ text: value }], + } as any, + { at: [entry.index] }, + ); + }, + }); + + return null; +} +``` + +## Practical Advice + +- Keep `writeToEditor` plugin-local, because only the plugin knows the node shape it owns. +- If a replace is needed, make sure it only runs while the block is inactive. +- Return `null` from `getState` when the block does not exist yet. +- Treat the editor as the source of truth while the bound block is active. + +## Testing + +At minimum, cover: + +- metadata to editor sync +- editor to metadata sync +- fast typing while the block is active + +Current examples: + +- {file}`packages/plate/components/editor/plugins/metadata-text-binding.test.ts` +- {file}`packages/cmsui/acceptance/tests/title-block-sync.test.ts` diff --git a/docs/how-to-guides/configure-plate-block-widths.md b/docs/how-to-guides/configure-plate-block-widths.md deleted file mode 100644 index ed04df8ad53..00000000000 --- a/docs/how-to-guides/configure-plate-block-widths.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -myst: - html_meta: - "description": "Configure width defaults and options for Plate blocks" - "property=og:description": "Configure width defaults and options for Plate blocks" - "property=og:title": "Configure Plate block widths" - "keywords": "Seven, Plate, block width, editor" ---- - -# Configure Plate block widths - -This guide explains how block widths are configured in the Plate editor, how to adjust defaults for existing blocks, and how to set widths for new block plugins. - -## How it works - -The block width system is implemented by `BlockWidthPlugin`: - -- It injects a `blockWidth` property on block elements. -- It maps that value to `style.maxWidth` in the rendered element. -- It uses the configured defaults per block to decide which width should be used. -- The width chooser in the toolbar uses the active block's config to show only allowed widths. - -The plugin lives in: - -- `packages/plate/components/editor/plugins/block-width-plugin.ts` -- `packages/plate/components/editor/plugins/block-width-kit.tsx` -- `packages/plate/components/editor/plugins/block-width-base-kit.tsx` - -The available width values are: - -- `BLOCK_WIDTH_VALUES.layout` -- `BLOCK_WIDTH_VALUES.default` -- `BLOCK_WIDTH_VALUES.narrow` - -These values are CSS custom properties, so the actual sizes come from CSS: - -- `--layout-container-width` -- `--default-container-width` -- `--narrow-container-width` - -## Configure defaults for existing blocks - -Each block can define its own width defaults via `options.blockWidth` in its plugin configuration. The two keys you can set are: - -- `defaultWidth`: the width applied when the block has no explicit width set -- `widths`: the allowed widths that the toolbar will show - -### Example: Paragraphs default to narrow - -This is already configured in the basic blocks kit: - -```ts -import { BLOCK_WIDTH_VALUES } from './block-width-plugin'; - -ParagraphPlugin.configure({ - node: { component: ParagraphElement }, - options: { - blockWidth: { - defaultWidth: BLOCK_WIDTH_VALUES.narrow, - }, - }, -}); -``` - -File: -- `packages/plate/components/editor/plugins/basic-blocks-kit.tsx` -- `packages/plate/components/editor/plugins/basic-blocks-base-kit.tsx` - -### Example: Table of contents default - -```ts -import { BLOCK_WIDTH_VALUES } from './block-width-plugin'; - -TocPlugin.configure({ - options: { - blockWidth: { - defaultWidth: BLOCK_WIDTH_VALUES.default, - }, - }, -}).withComponent(TocElement); -``` - -File: -- `packages/plate/components/editor/plugins/toc-kit.tsx` -- `packages/plate/components/editor/plugins/toc-base-kit.tsx` - -### Example: Restrict widths for a block - -If a block should only allow a subset of widths, specify `widths`: - -```ts -import { BLOCK_WIDTH_VALUES } from './block-width-plugin'; - -SomeBlockPlugin.configure({ - options: { - blockWidth: { - defaultWidth: BLOCK_WIDTH_VALUES.default, - widths: [ - BLOCK_WIDTH_VALUES.default, - BLOCK_WIDTH_VALUES.narrow, - ], - }, - }, -}); -``` - -The toolbar will only show `default` and `narrow` for that block. - -## Configure widths for new block plugins - -When creating a new block plugin, add `options.blockWidth` in the plugin's configuration: - -```ts -import { createPlatePlugin } from 'platejs/react'; -import { BLOCK_WIDTH_VALUES } from '../plugins/block-width-plugin'; - -export const MyBlockPlugin = createPlatePlugin({ - key: 'myBlock', - node: { - isElement: true, - }, - options: { - blockWidth: { - defaultWidth: BLOCK_WIDTH_VALUES.default, - widths: [ - BLOCK_WIDTH_VALUES.layout, - BLOCK_WIDTH_VALUES.default, - ], - }, - }, -}); -``` - -If you don't specify `blockWidth`, the plugin will use: - -- `defaultWidth`: `BLOCK_WIDTH_VALUES.default` -- `widths`: all available widths - -## Notes - -- Widths are stored in the node as `blockWidth`. -- The `BlockWidthPlugin` normalizes blocks to ensure `blockWidth` is set and valid. -- Centering is handled by CSS in the editor container so blocks with max widths are centered. - -If you need to override widths in a specific editor instance, you can configure the block plugin in that editor kit with different `options.blockWidth` values. diff --git a/docs/how-to-guides/configure-style-fields.md b/docs/how-to-guides/configure-style-fields.md new file mode 100644 index 00000000000..d0f6b8a685c --- /dev/null +++ b/docs/how-to-guides/configure-style-fields.md @@ -0,0 +1,320 @@ +--- +myst: + html_meta: + "description": "Configure schema-driven style fields in Seven" + "property=og:description": "Configure schema-driven style fields in Seven" + "property=og:title": "Configure style fields" + "keywords": "Seven, style fields, theme, blocks, Plate" +--- + +# Configure style fields + +This guide explains how to configure block styles based on fields in the block schema in Seven. +It focuses on a `theme` field, because that is the common case for the new style field system. + +Use this model when you want a block to store a semantic ID, such as `default` or `sand`, and resolve that ID to a runtime style object later. + +## How it works + +The style field system has three parts: + +1. The block schema marks a field as a style-backed field. +2. A registry utility returns the `StyleDefinition[]` for that field. +3. Runtime resolves the stored ID to a `style` object, and injects it into the rendered block. + +The important consequence is that the block stores a semantic ID, not raw CSS. + +For example, a block can store `sand` as an identifier: + +```ts +{ + '@type': 'teaser', + theme: 'sand', +} +``` + +And Seven can later resolve `sand` to a CSS variable: + +```ts +{ + '--theme-color': 'wheat', +} +``` + +## Mark the schema field + +Configure generic style fields in the block schema. +Add the field as usual, then mark it with `styleField: true`. + +```ts +export function TeaserSchema(): JSONSchema { + return { + title: 'Teaser', + fieldsets: [ + { + id: 'default', + title: 'Default', + fields: ['theme'], + }, + ], + properties: { + theme: { + title: 'Theme', + widget: 'choices', + default: 'default', + choices: [ + ['default', 'Default'], + ['sand', 'Sand'], + ['night', 'Night'], + ], + styleField: true, + }, + }, + required: [], + }; +} +``` + +The schema is now the source of truth for: + +- the field name +- the widget +- the default value +- the available values exposed by the widget + +Seven reads this metadata from the schema at runtime. + +## Register the style definitions + +Next, register a `styleFieldDefinition` utility for the field name. +The following code example shows how to register a `theme` field. + +```ts +config.registerUtility({ + type: 'styleFieldDefinition', + name: 'theme', + method: ({ blockType }) => { + const blockConfig = blockType + ? config.blocks.blocksConfig?.[blockType] + : undefined; + + return blockConfig?.themes ?? config.blocks.themes ?? []; + }, +}); +``` + +This utility must return an array of `StyleDefinition` items: + +```ts +config.blocks.themes = [ + { + name: 'default', + label: 'Default', + style: { + '--theme-color': 'white', + }, + }, + { + name: 'sand', + label: 'Sand', + style: { + '--theme-color': 'wheat', + }, + }, + { + name: 'night', + label: 'Night', + style: { + '--theme-color': '#111', + }, + }, +]; +``` + +Each item has: + +`name` +: the literal stored in the block data + +`label` +: the human-readable label + +`style` +: the inline style object injected at runtime + +## Use the field in block data + +Once the schema field and utility are registered, the block only stores the selected ID: + +```ts +{ + '@type': 'teaser', + theme: 'night', +} +``` + +At render time, Seven: + +1. inspects the schema +2. finds fields marked with `styleField` +3. looks up a `styleFieldDefinition` utility with the same field name +4. resolves the stored value against the returned `StyleDefinition[]` +5. injects the matching `style` object into the block wrapper + +This works in both: + +- Plate and Somersault rendering +- public block rendering in `@plone/layout` + +## Nested storage + +If the value must be stored under a nested key, use an object marker instead of `true`. + +```ts +theme: { + title: 'Theme', + widget: 'choices', + default: 'default', + choices: [ + ['default', 'Default'], + ['sand', 'Sand'], + ], + styleField: { + path: 'styles.theme', + }, +} +``` + +This stores the selected value under: + +```ts +{ + styles: { + theme: 'sand', + }, +} +``` + +Use this only when you need compatibility with an existing data shape. +For new Seven code, flat fields such as `theme` are the preferred default. + +## Why `blockWidth` is different + +`blockWidth` remains special. +It is intrinsic to the block wrapper and width policy of each block, so it still uses `blockWidth` configuration in `blocksConfig` and `plateBlocksConfig`. + +That means: + +- generic style fields such as `theme` are schema-driven +- `blockWidth` remains block configuration-driven + +The global width definitions themselves have not changed. +They are still defined in `config.blocks.widths` and resolved through the `blockWidth` utility. + +## Theme example + +Take the foregoing parts and put them together to form a complete theme example. + +```ts +config.blocks.themes = [ + { + name: 'default', + label: 'Default', + style: { + '--theme-color': 'white', + }, + }, + { + name: 'sand', + label: 'Sand', + style: { + '--theme-color': 'wheat', + }, + }, +]; + +config.registerUtility({ + type: 'styleFieldDefinition', + name: 'theme', + method: () => config.blocks.themes, +}); +``` + +This is the simplest useful version. +The utility just returns the shared theme definitions from `config.blocks.themes`. + +You could make the `method` more elaborate, as shown above, for example: + +```ts +config.registerUtility({ + type: 'styleFieldDefinition', + name: 'theme', + method: ({ blockType }) => { + const blockConfig = blockType + ? config.blocks.blocksConfig?.[blockType] + : undefined; + + return blockConfig?.themes ?? config.blocks.themes ?? []; + }, +}); +``` + +That version is more flexible, because it allows block-specific theme definitions with a global fallback. +However, it is also more complex. + +If you do not need block-specific theme sets, prefer the simpler version that returns `config.blocks.themes` directly. + +```ts +export function TeaserSchema(): JSONSchema { + return { + title: 'Teaser', + fieldsets: [ + { + id: 'default', + title: 'Default', + fields: ['theme'], + }, + ], + properties: { + theme: { + title: 'Theme', + widget: 'choices', + default: 'default', + choices: [ + ['default', 'Default'], + ['sand', 'Sand'], + ], + styleField: true, + }, + }, + required: [], + }; +} +``` + +```ts +{ + '@type': 'teaser', + theme: 'sand', +} +``` + +That `sand` value is resolved at runtime to: + +```ts +{ + '--theme-color': 'wheat', +} +``` + +## Summary + +For generic style-backed fields: + +- define the field in the schema +- mark it with `styleField` +- set its `default` in the schema +- expose its values through `choices` or `actions` (or other widget configuration) +- register a `styleFieldDefinition` utility with the same field name + +For `blockWidth`, keep using the existing `blockWidth` block configuration. diff --git a/docs/how-to-guides/custom-content-types.md b/docs/how-to-guides/custom-content-types.md new file mode 100644 index 00000000000..081a90f17b8 --- /dev/null +++ b/docs/how-to-guides/custom-content-types.md @@ -0,0 +1,165 @@ +--- +myst: + html_meta: + "description": "How to define TypeScript types for custom Plone content types" + "property=og:description": "How to define TypeScript types for custom Plone content types" + "property=og:title": "Custom content types" + "keywords": "Seven, TypeScript, custom, content types, @plone/types, ContentTypeMap, type narrowing" +--- + +# Custom content types + +In most Plone projects, developers add content types beyond the standard ones that ship with the {term}`CMS`. +These types carry fields that are not present on the base content object, and TypeScript needs to know about them to give you accurate autocompletion and type safety. + +`@plone/types` provides an augmentable registry called `ContentTypeMap`. +When you add your type to it, TypeScript automatically narrows the `Content` union when you check the `@type` field, removing the need to cast with `as`. + + +## Define the content type interface + +Create a {file}`.d.ts` file for your type and extend `ContentBase` from `@plone/types`. +Set the `@type` field to the _exact string_ that Plone uses for that content type, which you can verify from the `@type` key in any REST API response for that content type. +This is, for example, `Event` for the event content type or `Document` for the page content type. + +```{code-block} ts +:caption: {file}`packages//types/content.d.ts` + +import type { ContentBase } from '@plone/types'; + +export interface BlogPostContent extends ContentBase { + '@type': 'BlogPost'; + text: { + 'content-type': string; + data: string; + encoding: string; + } | null; + reading_time: number; +} +``` + +```{note} +Use `ContentBase`, not `Content`, as the base of your interface. +`Content` is a discriminated union of all registered types. +Extending a union type is not valid TypeScript. +`ContentBase` is the plain interface that holds all fields common to every Plone content object. +``` + + +## Register the type in `ContentTypeMap` + +In the same {file}`.d.ts` file, add a `declare module` block to merge your interface into the `ContentTypeMap` registry. + +```{code-block} ts +:caption: {file}`packages//types/content.d.ts` + +import type { ContentBase } from '@plone/types'; + +export interface BlogPostContent extends ContentBase { + '@type': 'BlogPost'; + text: { + 'content-type': string; + data: string; + encoding: string; + } | null; + reading_time: number; +} + +declare module '@plone/types' { + interface ContentTypeMap { + 'BlogPost': BlogPostContent; + } +} +``` + +Once registered, `BlogPostContent` becomes part of the `Content` union exported by `@plone/types`. +You do not need to import or reference `ContentTypeMap` anywhere else. +TypeScript picks up the augmentation automatically whenever the {file}`.d.ts` file is included in the compilation. + + +## Use automatic type narrowing + +You can now narrow a `Content` value by checking its `@type` field. +TypeScript infers the specific type in the narrowed branch without any cast. + +```ts +import type { Content } from '@plone/types'; + +function renderContent(content: Content) { + if (content['@type'] === 'BlogPost') { + // content is now BlogPostContent + console.log(content.reading_time); // ✅ number + } +} +``` + +This also works in React components. + +```{code-block} tsx +:caption: Example component that renders a blog post + +import type { Content } from '@plone/types'; + +interface Props { + content: Content; +} + +export default function BlogPostView({ content }: Props) { + if (content['@type'] !== 'BlogPost') return null; + + return ( +
+

{content.reading_time} min read

+
+ ); +} +``` + + +## Where to put the type definitions + +Place your type definitions in a `.d.ts` file inside your add-on package. +A common convention is to use a `types/` folder. + +```text +packages// +└── types/ + └── content.d.ts ← your content type definitions +``` + +The file must be a _declaration file_ ({file}`.d.ts`), not a plain TypeScript file ({file}`.ts`). +A `declare module` augmentation inside a {file}`.ts` file is treated as a local module augmentation and will not apply globally. + +Make sure the file is included in your TypeScript compilation. +If you use a {file}`tsconfig.json` with an explicit `include` list, add `types/**/*.d.ts` to it. + +```json +{ + "include": ["src/**/*", "types/**/*.d.ts"] +} +``` + + +## Ship types with an add-on package + +If you build an add-on package that others will install, include the type definitions in your published package so that consumers get narrowing automatically. + +Export the {file}`.d.ts` file from your package by listing it in your {file}`package.json` `exports` or `types` field, or by placing it in a location covered by your package's `files` list. + +```json +{ + "name": "my-seven-addon", + "types": "dist/index.d.ts", + "files": ["dist"] +} +``` + +Consumers of your add-on will get the `ContentTypeMap` augmentation applied as soon as they import anything from your package. + +Alternatively, if the augmentation lives in a side-effect-only {file}`.d.ts` file, add a triple-slash reference. + +```ts +/// +``` + +This ensures that anyone installing your add-on gets full TypeScript support for its content types without any manual setup. diff --git a/docs/how-to-guides/customize-login-screen.md b/docs/how-to-guides/customize-login-screen.md new file mode 100644 index 00000000000..fca9a164c12 --- /dev/null +++ b/docs/how-to-guides/customize-login-screen.md @@ -0,0 +1,176 @@ +--- +myst: + html_meta: + "description": "How to customize the Seven login screen with slots." + "property=og:description": "How to customize the Seven login screen with slots." + "property=og:title": "Customize the login screen" + "keywords": "Seven, Plone, login, authentication, slots, customization" +--- + +# Customize the login screen + +The Seven login screen is rendered by the `/login` route in `@plone/cmsui`. +The route provides the login form, error handling, authentication action, and redirect behavior. +Its visual extension points are exposed as slots, so add-ons and projects can change the branding and supporting actions without shadowing the whole route. + +`@plone/cmsui` registers three default login slot components. + +`loginLogo` +: Renders above the login heading. + The default component displays the Plone logo inside a colored circle. + Customize this slot to replace the logo, add product branding, or remove the logo area. + +`loginActions` +: Renders inside the login form after the username and password fields. + The default component displays the registration link and the submit button. + Customize this slot when you need a different submit button, extra links, SSO buttons, or no registration link. + If you replace this slot, remember that the login form still needs a submit control. + +`loginHero` +: Renders in the right-hand side of the login screen on large viewports. + The default component displays the Volto hero illustration. + Customize this slot to use project-specific artwork, campaign imagery, institutional branding, or remove the right-hand visual column. + +The `/login` route passes the current `content` and React Router `location` to each slot renderer. +This means custom slot predicates can use the same values as other Seven slots. + +## Override a login slot + +Register your replacement slot component from an add-on or project configuration that runs after `@plone/cmsui`. +Use the same `slot` and `name` as the default component you want to replace. + +```tsx +import type { ConfigType } from '@plone/registry'; + +function MyLoginLogo() { + return My site; +} + +export default function applyConfig(config: ConfigType) { + config.registerSlotComponent({ + slot: 'loginLogo', + name: 'LoginLogo', + component: MyLoginLogo, + }); + + return config; +} +``` + +Slot registrations are evaluated in reverse registration order for the same slot component name. +Because your add-on runs after `@plone/cmsui`, the component above becomes the active `LoginLogo` registration. + +## Replace the login actions + +The `loginActions` slot is inside the existing `
`. +Your component can render any controls that belong in the form. +The most important requirement is to include a submit button, unless you intentionally provide another login mechanism. + +```tsx +import type { ConfigType } from '@plone/registry'; +import { Button, Link } from '@plone/components/quanta'; + +function MyLoginActions() { + return ( +
+ Forgot password? + +
+ ); +} + +export default function applyConfig(config: ConfigType) { + config.registerSlotComponent({ + slot: 'loginActions', + name: 'LoginActions', + component: MyLoginActions, + }); + + return config; +} +``` + +## Replace the login hero + +The `loginHero` slot is wrapped by the login route in a container that is hidden below the large breakpoint. +Your component only needs to render the visual content for that column. + +```tsx +import type { ConfigType } from '@plone/registry'; + +function MyLoginHero() { + return ( + + ); +} + +export default function applyConfig(config: ConfigType) { + config.registerSlotComponent({ + slot: 'loginHero', + name: 'LoginHero', + component: MyLoginHero, + }); + + return config; +} +``` + +## Remove a default login slot + +You can remove one of the default login slot components from a later add-on configuration. +Each default login slot is registered once, so its registration position is `0`. + +```ts +import type { ConfigType } from '@plone/registry'; + +export default function applyConfig(config: ConfigType) { + config.unRegisterSlotComponent('loginHero', 'LoginHero', 0); + + return config; +} +``` + +Use the same pattern for the other default login slots. + +```ts +config.unRegisterSlotComponent('loginLogo', 'LoginLogo', 0); +config.unRegisterSlotComponent('loginActions', 'LoginActions', 0); +``` + +Removing `loginHero` removes the right-hand visual content. +The login route still owns the wrapper around that slot, but with no rendered hero component there is no project artwork or illustration in that area. +Removing `loginLogo` leaves the heading and form in place. +Removing `loginActions` removes the default sign-up link and submit button, so only do this if another submit control or authentication flow is provided. + +## Default slot registrations + +The default registrations in `@plone/cmsui` are equivalent to the following. + +```ts +config.registerSlotComponent({ + name: 'LoginLogo', + slot: 'loginLogo', + component: LoginLogo, +}); + +config.registerSlotComponent({ + name: 'LoginHero', + slot: 'loginHero', + component: LoginHero, +}); + +config.registerSlotComponent({ + name: 'LoginActions', + slot: 'loginActions', + component: LoginActions, +}); +``` + +For more details about slot registration, ordering, predicates, and unregistering, see {doc}`register-slots`. diff --git a/docs/how-to-guides/extend-vite-configuration.md b/docs/how-to-guides/extend-vite-configuration.md new file mode 100644 index 00000000000..4521b1e7406 --- /dev/null +++ b/docs/how-to-guides/extend-vite-configuration.md @@ -0,0 +1,103 @@ +--- +myst: + html_meta: + "description": "How to extend Seven Vite configuration from an add-on" + "property=og:description": "How to extend Seven Vite configuration from an add-on" + "property=og:title": "Extend Vite config" + "keywords": "Seven, Vite, add-on, vite.extend, configuration" +--- + +# Extend Vite configuration + +This guide shows how to extend a Seven app's Vite configuration from an add-on. + +## Create a `vite.extend` file in your add-on + +Create a file named either {file}`vite.extend.js` or {file}`vite.extend.ts` in the root of your add-on package. + +- {file}`packages/my-addon/vite.extend.js` +- {file}`packages/my-addon/vite.extend.ts` + +## Export a default function + +The file must export a default function. +Seven calls this function with the current Vite configuration and a context object, and expects the function to return a Vite `config` object. + +```ts +export default function extendViteConfig(config, context) { + return config; +} +``` + +If your function does not return a `config` object, Seven raises an error during loader generation. + +## Update the Vite configuration + +Use `extendViteConfig()` to return a new `config` object or mutate the existing one before returning it. + +The following example adds an alias and externalizes a package during SSR builds. + +```ts +export default function extendViteConfig(config, context) { + return { + ...config, + resolve: { + ...(config.resolve || {}), + alias: [ + ...(config.resolve?.alias || []), + { + find: '@acme/example', + replacement: '/absolute/path/to/example', + }, + ], + }, + ssr: { + ...(config.ssr || {}), + external: [ + ...(config.ssr?.external || []), + 'some-server-only-package', + ], + }, + }; +} +``` + +## Use the context object + +The second argument to `extendViteConfig(), `context`, contains information about the current Vite run. +Use it when you need different behavior for development, production, or SSR. + +```ts +export default function extendViteConfig(config, context) { + const { command, mode, isSsrBuild } = context; + + if (command === 'build' && isSsrBuild) { + return { + ...config, + define: { + ...(config.define || {}), + __SSR_BUILD__: true, + }, + }; + } + + return config; +} +``` + +## Register the add-on + +Make sure your add-on is registered in the app, for example, through {file}`registry.config.ts` or the `addons` key in {file}`package.json`. + +Seven discovers {file}`vite.extend.js` and {file}`vite.extend.ts` from registered add-ons only. + +## Run the build + +Run the app build as usual. +Seven generates a Vite loader from the registered add-ons and applies the extenders in add-on order. + +```shell +make build +``` + +If multiple add-ons provide a {file}`vite.extend.*s` file, they are applied in the same order as the registered add-ons. diff --git a/docs/how-to-guides/index.md b/docs/how-to-guides/index.md index 170ca006386..a1e07d64325 100644 --- a/docs/how-to-guides/index.md +++ b/docs/how-to-guides/index.md @@ -16,14 +16,18 @@ This section of the documentation contains how-to guides for developing with Sev routes register-an-add-on +extend-vite-configuration access-registry register-and-retrieve-components register-and-retrieve-utilities +configure-style-fields register-slots +customize-login-screen shadow-a-component fetch-additional-data-root-loader add-tailwind icons -configure-plate-block-widths configure-plate-code-block-languages +bind-metadata-fields-to-plate-text-blocks +custom-content-types ``` diff --git a/docs/reference/index.md b/docs/reference/index.md index d9343198c30..e1ffc9aec78 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -19,4 +19,5 @@ react-19 utilities slate-to-plate-converters plate-playwright +metadata-text-binding ``` diff --git a/docs/reference/metadata-text-binding.md b/docs/reference/metadata-text-binding.md new file mode 100644 index 00000000000..31d7f0adabb --- /dev/null +++ b/docs/reference/metadata-text-binding.md @@ -0,0 +1,87 @@ +--- +myst: + html_meta: + "description": "Reference for metadata-backed text bindings in Plate plugins." + "property=og:description": "Reference for metadata-backed text bindings in Plate plugins." + "property=og:title": "Metadata text binding reference for Seven" + "keywords": "Seven, Plate, metadata, title block, editor binding" +--- + +# Metadata Text Binding + +`useMetadataTextBinding` is a small synchronization helper for Plate plugins that mirror a metadata field into a text-like block in the editor. + +Current implementation: + +- Source: {file}`packages/plate/components/editor/plugins/metadata-text-binding.tsx` +- Example usage: {file}`packages/plate/components/editor/plugins/title.tsx` + +How to apply it in a plugin: + +- See [Bind Metadata Fields To Plate Text Blocks](../how-to-guides/bind-metadata-fields-to-plate-text-blocks.md) + +## What It Is For + +Use it when all of the following are true: + +- One metadata field maps to one editor value. +- The editor value is plain text or can be treated as plain text. +- The plugin can tell whether its block is currently active. +- Metadata-to-editor writes can be implemented by a plugin-specific `writeToEditor` function. + +Typical fits: + +- Title block bound to `title` +- Subtitle block bound to `description` +- Simple summary or teaser text block bound to a single field + +## What It Guarantees + +The helper enforces these rules: + +- While the bound block is active, editor changes win and are pushed into the metadata field. +- While the bound block is inactive, metadata changes win and are pushed into the editor. +- Self-inflicted echoes are suppressed with explicit tracking of the last editor-originated and field-originated values. + +This is the important difference from the earlier `previous value` sync approach: it avoids blindly writing a stale metadata value back into the block while the user is typing. + +## API + +```ts +useMetadataTextBinding({ + field: 'title', + getState: (editor) => ({ + isActive: true, + value: 'Current text', + }), + writeToEditor: (editor, value) => { + // plugin-specific editor update + }, +}); +``` + +Binding shape: + +- `field`: metadata field name in `formAtom` +- `getState(editor)`: returns: + - `value`: current plain-text value represented by the block, or `null` if the block is absent + - `isActive`: whether the user is currently editing that block +- `writeToEditor(editor, value)`: applies an external metadata update into the block + +## When Not To Use It + +This helper is intentionally narrow. Do not use it for: + +- Structured metadata that maps to multiple nodes or multiple fields +- Rich text metadata where plain-text extraction is lossy +- Blocks whose editor state cannot be represented as a single string +- Cases where metadata and editor must merge concurrently instead of following active/inactive ownership + +For those cases, create a richer binding abstraction with explicit serialization and conflict handling. + +## Notes + +- Prefer minimal metadata-to-editor writes, but correctness matters more than micro-optimizing transforms. +- If a structural replace is required, only allow it while the block is inactive. +- Return `null` from `getState` when the block is absent so the helper stays idle. +- Add both unit coverage for sync decisions and end-to-end coverage for the real editor behavior. diff --git a/eslint.config.mjs b/eslint.config.mjs index dc07002c680..3a01bf2ffe4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,7 +31,6 @@ const nonAddons = [ 'packages/components', 'packages/registry', 'packages/helpers', - 'packages/providers', 'packages/react-router', 'packages/scripts', 'packages/tooling', @@ -83,6 +82,10 @@ export default tseslint.config( typescript: { project: ['packages/*/tsconfig.json', 'apps/seven/tsconfig.json'], alwaysTryTypes: true, + noWarnOnMultipleProjects: true, + }, + alias: { + map: [['seven', './apps/seven']], }, node: true, }, @@ -171,7 +174,6 @@ export default tseslint.config( '**/storybook-static/*', '**/.storybook/*', 'packages/volto/*', - 'packages/coresandbox/*', 'packages/volto-slate', '!**/.*', '**/dist', @@ -181,8 +183,7 @@ export default tseslint.config( 'packages/registry/docs', '**/.react-router/*', '**/+types/*', - '**/registry.loader.js', - '**/registry.loader.server.js', + '**/.plone/*', ], }, ); diff --git a/package.json b/package.json index 71f5892aa2b..3ba0e08f680 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "husky:uninstall": "husky uninstall", "prereleaser": "node packages/scripts/preleaser.js", "acceptance-test": "pnpm exec playwright test --config=playwright.config.ts", - "acceptance-test:open": "pnpm exec playwright test --ui --config=playwright.config.ts" + "acceptance-test:open": "pnpm exec playwright test --ui --config=playwright.config.ts", + "update-baseline-browser-mapping": "pnpm update baseline-browser-mapping browserslist -r" }, "devDependencies": { "concurrently": "^8.2.2", diff --git a/packages/agave/AGENTS.md b/packages/agave/AGENTS.md new file mode 100644 index 00000000000..d6f4079c349 --- /dev/null +++ b/packages/agave/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md + +This file applies only to `packages/agave` and its subdirectories. + +## What This Package Is + +- `@plone/agave` is the **example base theme for Seven** (Plone 7). +- It is a CSS-only theme that consumes design tokens and primitives from `@plone/theming`. +- It is intended as a starting point that end users will most likely **remove and replace** with their own custom theme. +- TypeScript or React code may be added in the future if needed, but this is not planned — keep it CSS-first. + +## Package Model + +- CSS lives under `styles/`. +- Import from `@plone/theming` for foundational tokens (colors, typography, spacing). Do not redefine what `@plone/theming` already provides. +- Keep styles scoped and avoid leaking global resets or overrides that other packages would not expect. +- Do not add app-specific logic (routing, data fetching, state management) here. + +## Editing Rules + +- Keep changes minimal and theme-local. +- When restyling an element, check if `@plone/theming` already provides a token or utility for it before adding new CSS variables. +- If TypeScript or React code ever becomes necessary, discuss it explicitly before adding it — the CSS-only constraint is intentional. + +## Validation + +This package has no dedicated test or lint script. + +For repo-wide CSS linting: + +```sh +pnpm stylelint +``` diff --git a/packages/agave/CHANGELOG.md b/packages/agave/CHANGELOG.md index 00bc4106d9a..0fb44213dda 100644 --- a/packages/agave/CHANGELOG.md +++ b/packages/agave/CHANGELOG.md @@ -8,6 +8,19 @@ +## 1.0.0-alpha.5 (2026-05-07) + +### Internal + +- Added AGENTS.md file. @pnicolli +- Aligned Agave's local formatting and typecheck scripts with the monorepo-wide package script cleanup. + +## 1.0.0-alpha.4 (2026-04-16) + +### Feature + +- Listing block @ebrehault [#7603](https://github.com/plone/volto/issues/7603) + ## 1.0.0-alpha.3 (2025-12-23) ### Feature diff --git a/packages/agave/Makefile b/packages/agave/Makefile new file mode 100644 index 00000000000..6dffbcd8592 --- /dev/null +++ b/packages/agave/Makefile @@ -0,0 +1,25 @@ +# Project settings +include ../../variables.mk + +.PHONY: all +all: help + +.PHONY: help +help: ## This help message + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/$(CYAN)\1$(RESET):\2/' | column -c2 -t -s :)" + +.PHONY: install +install: ## Install dependencies + pnpm install + +.PHONY: build +build: ## Build the package + pnpm run --if-present build + +# .PHONY: storybook-start +# storybook-start: ## Start Storybook +# pnpm run storybook + +# .PHONY: storybook-build +# storybook-build: ## Build Storybook +# pnpm run build-storybook diff --git a/packages/agave/news/+unify-makefiles.internal b/packages/agave/news/+unify-makefiles.internal new file mode 100644 index 00000000000..5da674df4e4 --- /dev/null +++ b/packages/agave/news/+unify-makefiles.internal @@ -0,0 +1 @@ +Unify Makefile files across the packages. @ionlizarazu diff --git a/packages/agave/news/7603.feature b/packages/agave/news/7603.feature deleted file mode 100644 index a52a5bedcde..00000000000 --- a/packages/agave/news/7603.feature +++ /dev/null @@ -1 +0,0 @@ -Listing block @ebrehault \ No newline at end of file diff --git a/packages/agave/package.json b/packages/agave/package.json index 11b80d1fd88..1563190012d 100644 --- a/packages/agave/package.json +++ b/packages/agave/package.json @@ -9,7 +9,7 @@ ], "funding": "https://github.com/sponsors/plone", "license": "MIT", - "version": "1.0.0-alpha.3", + "version": "1.0.0-alpha.5", "repository": { "type": "git", "url": "https://github.com/plone/volto.git" @@ -35,7 +35,11 @@ "release-major-alpha": "release-it major --preRelease=alpha", "release-alpha": "release-it --preRelease=alpha", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", + "lint:fix": "eslint --max-warnings=0 './**/*.{js,jsx,ts,tsx}' --fix --no-error-on-unmatched-pattern", + "stylelint:fix": "sh -c 'if [ -f .stylelintrc ] || [ -f .stylelintrc.json ] || [ -f .stylelintrc.js ] || [ -f .stylelintrc.cjs ] || [ -f stylelint.config.js ] || [ -f stylelint.config.cjs ] || [ -f stylelint.config.mjs ]; then stylelint '''./**/*.{css,scss,less}''' --fix --allow-empty-input; else echo \"No local stylelint config, skipping\"; fi'", + "format": "pnpm prettier:fix && pnpm lint:fix && pnpm stylelint:fix" }, "peerDependencies": { "react": "^19.1.0", diff --git a/packages/blocks/.stylelintrc b/packages/blocks/.stylelintrc new file mode 100644 index 00000000000..4393c74c68b --- /dev/null +++ b/packages/blocks/.stylelintrc @@ -0,0 +1,8 @@ +{ + "extends": ["stylelint-config-idiomatic-order"], + "plugins": ["stylelint-prettier"], + "rules": { + "prettier/prettier": true, + "order/properties-alphabetical-order": null + } +} diff --git a/packages/blocks/AGENTS.md b/packages/blocks/AGENTS.md new file mode 100644 index 00000000000..1c41465e88f --- /dev/null +++ b/packages/blocks/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md + +This file applies only to `packages/blocks` and its subdirectories. + +## What This Package Is + +- `@plone/blocks` provides the **core content blocks for Seven** (Plone 7). +- It is **not part of Volto** and not used by it. +- Each block serves two consumers: + - **Edit components** → consumed by `@plone/plate` (the Seven block editor) + - **View components** → consumed by `@plone/publicui` (the public-facing renderer) + +> [!WARNING] +> This package is experimental. Breaking changes may occur without notice. + +## Block Structure + +Each block lives in its own folder at the package root (e.g., `Video/`, `Image/`, `Teaser/`, `Listing/`): + +``` +/ + BlockView.tsx # View variant — used by publicui + BlockEdit.tsx # Edit variant — used by plate + schema.tsx # Block schema definition + index.ts # Re-exports +``` + +- View and Edit components are co-located in the same folder. +- The `index.ts` should export both variants so consumers can import what they need. + +## Package Model + +- Keep blocks **self-contained**. Avoid cross-block dependencies. +- Blocks receive their data via props; they do not fetch data independently. +- The schema file defines the block's configuration fields for the editor UI. +- Do not add routing, global state, or provider dependencies directly inside block components. + +## Editing Rules + +- When adding a new block, create the full folder structure: View, Edit, schema, and index. +- Make sure both Edit and View variants are exported from the block's `index.ts`. +- Write tests for non-trivial rendering logic. +- Keep CSS colocated with the component that uses it. + +## Validation + +```sh +pnpm --filter @plone/blocks test --run +pnpm --filter @plone/blocks check:ts +``` diff --git a/packages/blocks/CHANGELOG.md b/packages/blocks/CHANGELOG.md index 6fd8c62fd89..75158511d81 100644 --- a/packages/blocks/CHANGELOG.md +++ b/packages/blocks/CHANGELOG.md @@ -8,6 +8,45 @@ +## 1.0.0-alpha.10 (2026-05-13) + +### Internal + +- Added first-class generic style field support while preserving `blockWidth` fallback for Plone blocks and explicit width handling for Plate-native blocks. @sneridagh + +## 1.0.0-alpha.9 (2026-05-08) + +### Internal + +- Registered the `blockWidth` style field definition utility to support the new generic style field runtime. @sneridagh + +## 1.0.0-alpha.8 (2026-05-07) + +### Internal + +- Added AGENTS.md file. @pnicolli +- Aligned Blocks' local registration and TypeScript project setup with the monorepo-wide typecheck cleanup. +- Switched Blocks' local `@testing-library/jest-dom` dev dependency to the shared catalog entry to keep test tooling aligned with the monorepo dependency refresh. + +## 1.0.0-alpha.7 (2026-04-16) + +### Breaking + +- Remove Text block from @plone/blocks, remove dependency on @plone/plate @sneridagh [#8015](https://github.com/plone/volto/pull/8015) +- Refactored and re-thinked blockWidth feature. + Added widths to the existing block configs. @sneridagh [#8053](https://github.com/plone/volto/pull/8053) + +### Feature + +- Listing block @ebrehault [#7603](https://github.com/plone/volto/pull/7603) +- Somersault editor support. @sneridagh [#7921](https://github.com/plone/volto/pull/7921) +- Create video block view @tedw87 [#8004](https://github.com/plone/volto/pull/8004) + +### Bugfix + +- Use Image component in ImageBlockView instead of manually constructing image scale URLs. @jmevissen [#8008](https://github.com/plone/volto/pull/8008) +- Added default widths for plate headings. @sneridagh [#8076](https://github.com/plone/volto/pull/8076) + ## 1.0.0-alpha.6 (2025-12-23) ### Feature diff --git a/packages/blocks/Image/ImageBlockEdit.tsx b/packages/blocks/Image/ImageBlockEdit.tsx index 6e882f800ee..557c537c633 100644 --- a/packages/blocks/Image/ImageBlockEdit.tsx +++ b/packages/blocks/Image/ImageBlockEdit.tsx @@ -1,5 +1,11 @@ import { useCallback } from 'react'; -import type { BlockEditProps } from '@plone/types'; +import type { + BlockEditProps, + Brain, + ContainedItem, + Content, + RelatedItem, +} from '@plone/types'; import Image from '@plone/layout/components/Image/Image'; import clsx from 'clsx'; import config from '@plone/registry'; @@ -29,7 +35,15 @@ const ImageBlockEdit = (props: BlockEditProps) => { | undefined; const handleChange = useCallback( - (image: string | null, { title, image_field, image_scales } = {}) => { + ( + image: string | null, + item: { + title?: string; + image_field?: string; + image_scales?: Record; + } = {}, + ) => { + const { title, image_field, image_scales } = item; const url = image ? flattenToAppUrl(image) : ''; setBlock({ @@ -43,6 +57,14 @@ const ImageBlockEdit = (props: BlockEditProps) => { [data, setBlock], ); + const imageItem = data.image_scales + ? ({ + '@id': data.url, + image_field: data.image_field, + image_scales: data.image_scales, + } as unknown as Content | Brain | ContainedItem | RelatedItem) + : undefined; + return (
{ medium: data.size === 'm', small: data.size === 's', })} - item={ - data.image_scales - ? { - '@id': data.url, - image_field: data.image_field, - image_scales: data.image_scales, - } - : undefined - } + item={imageItem} src={ data.image_scales ? undefined diff --git a/packages/blocks/Image/index.ts b/packages/blocks/Image/index.ts index 6826d2714ad..965d05ee09b 100644 --- a/packages/blocks/Image/index.ts +++ b/packages/blocks/Image/index.ts @@ -1,4 +1,5 @@ import React from 'react'; +import type { BlockConfigBase } from '@plone/types'; import { ImageSchema } from './schema'; import { ImageIcon } from '@plone/components/Icons'; @@ -14,6 +15,6 @@ const ImageBlockInfo = { category: 'media', blockSchema: ImageSchema, icon: ImageIcon, -}; +} satisfies Partial; export default ImageBlockInfo; diff --git a/packages/blocks/Image/schema.tsx b/packages/blocks/Image/schema.tsx index 50d4e254abf..b7d5d85dc17 100644 --- a/packages/blocks/Image/schema.tsx +++ b/packages/blocks/Image/schema.tsx @@ -4,15 +4,9 @@ import type { SchemaEnhancerArgs, } from '@plone/types'; -type ImageBlockFormData = BlocksFormData & { - url?: string; -}; - -type ImageSchemaArgs = { - formData?: ImageBlockFormData; -}; - -export function ImageSchema({ formData = {} }: ImageSchemaArgs): JSONSchema { +export function ImageSchema({ + formData = {} as BlocksFormData, +}: { formData?: BlocksFormData } = {}): JSONSchema { return { title: 'Image', fieldsets: [ @@ -60,6 +54,7 @@ export function ImageSchema({ formData = {} }: ImageSchemaArgs): JSONSchema { title: 'Block width', widget: 'width', default: 'default', + styleField: true, }, align: { title: 'Alignment', diff --git a/packages/blocks/Listing/index.tsx b/packages/blocks/Listing/index.tsx index 82b8fb2ed8a..6d4af2dbc30 100644 --- a/packages/blocks/Listing/index.tsx +++ b/packages/blocks/Listing/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { BlockConfigBase } from '@plone/types'; const ListingBlockInfo = { id: 'listing', @@ -7,6 +8,6 @@ const ListingBlockInfo = { () => import(/* webpackChunkName: "plone-blocks" */ './ListingBlockView'), ), category: 'common', -}; +} satisfies Partial; export default ListingBlockInfo; diff --git a/packages/blocks/Makefile b/packages/blocks/Makefile new file mode 100644 index 00000000000..6dffbcd8592 --- /dev/null +++ b/packages/blocks/Makefile @@ -0,0 +1,25 @@ +# Project settings +include ../../variables.mk + +.PHONY: all +all: help + +.PHONY: help +help: ## This help message + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/$(CYAN)\1$(RESET):\2/' | column -c2 -t -s :)" + +.PHONY: install +install: ## Install dependencies + pnpm install + +.PHONY: build +build: ## Build the package + pnpm run --if-present build + +# .PHONY: storybook-start +# storybook-start: ## Start Storybook +# pnpm run storybook + +# .PHONY: storybook-build +# storybook-build: ## Build Storybook +# pnpm run build-storybook diff --git a/packages/blocks/Maps/MapsBlockEdit.test.tsx b/packages/blocks/Maps/MapsBlockEdit.test.tsx new file mode 100644 index 00000000000..c3b32d0567e --- /dev/null +++ b/packages/blocks/Maps/MapsBlockEdit.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { BlockEditProps } from '@plone/types'; +import MapsBlockEdit from './MapsBlockEdit'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'blocks.maps.maps-block-input-placeholder': 'Enter map Embed Code', + 'blocks.maps.google-maps-embedded-block': 'Google Maps Embedded Block', + 'blocks.maps.instructions': + 'Please enter the Embed Code provided by Google Maps', + 'blocks.maps.code-error': + 'Embed code error, please follow the instructions and try again.', + }; + return translations[key] || key; + }, + }), +})); + +vi.mock('@plone/components/Icons', () => ({ + ArrowrightIcon: (props: Record) => ( + + ), + CloseIcon: (props: Record) => ( + + ), +})); + +const makeProps = (overrides: Partial = {}) => + ({ + block: 'test-block-id', + data: {}, + selected: true, + onChangeBlock: vi.fn(), + ...overrides, + }) as BlockEditProps; + +describe('MapsBlockEdit', () => { + it('renders iframe when url is already set', () => { + render( + { + render(); + + expect( + screen.getByPlaceholderText('Enter map Embed Code'), + ).toBeInTheDocument(); + expect( + screen.getByText('Please enter the Embed Code provided by Google Maps'), + ).toBeInTheDocument(); + }); + + it('shows validation error for invalid embed code', () => { + const onChangeBlock = vi.fn(); + const props = makeProps({ onChangeBlock }); + const { container } = render(); + const input = container.querySelector('input') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'not-an-iframe' } }); + const buttons = container.querySelectorAll('button'); + fireEvent.click(buttons[buttons.length - 1]); + + expect( + screen.getByText( + 'Embed code error, please follow the instructions and try again.', + ), + ).toBeInTheDocument(); + expect(onChangeBlock).toHaveBeenCalledWith( + 'test-block-id', + expect.objectContaining({ + url: '', + }), + ); + }); + + it('uses clear button to reset input', () => { + const { container } = render(); + const input = container.querySelector('input') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '' } }); + + const buttonsAfterTyping = container.querySelectorAll('button'); + expect(buttonsAfterTyping).toHaveLength(2); + fireEvent.click(buttonsAfterTyping[0]); + expect(input.value).toBe(''); + }); + + it('renders map overlay only when block is not selected', () => { + const { container, rerender } = render( + , + ); + + expect(container.querySelector('.map-overlay')).toBeInTheDocument(); + + rerender(); + expect(container.querySelector('.map-overlay')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/blocks/Maps/MapsBlockEdit.tsx b/packages/blocks/Maps/MapsBlockEdit.tsx new file mode 100644 index 00000000000..dd3784e5284 --- /dev/null +++ b/packages/blocks/Maps/MapsBlockEdit.tsx @@ -0,0 +1,156 @@ +import type { BlockEditProps } from '@plone/types'; +import { + useState, + useCallback, + useMemo, + type ChangeEvent, + type KeyboardEvent, +} from 'react'; +import clsx from 'clsx'; +import mapsBlockSVG from './block-maps.svg'; +import { useTranslation } from 'react-i18next'; +import { ArrowrightIcon, CloseIcon } from '@plone/components/Icons'; + +const MapsBlockEdit = (props: BlockEditProps) => { + const { t } = useTranslation(); + + const [url, setUrl] = useState(''); + const [error, setError] = useState(null); + + const { onChangeBlock, data, block, selected } = props; + const onChangeUrl = ({ target }: ChangeEvent) => { + setUrl(target.value); + }; + + const onSubmitUrl = useCallback(() => { + onChangeBlock(block, { + ...data, + url: getSrc(url), + }); + }, [onChangeBlock, block, data, url]); + + const onKeyDownVariantMenuForm = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + onSubmitUrl(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + // TODO: Do something on ESC key + } + }, + [onSubmitUrl], + ); + + const getSrc = (embed: string) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(embed, 'text/html'); + const iframe = doc.getElementsByTagName('iframe'); + if (iframe.length === 0) { + setError(true); + return ''; + } + setError(false); + return iframe[0].src; + }; + + const placeholder = useMemo( + () => data.placeholder || t('blocks.maps.maps-block-input-placeholder'), + [data, t], + ); + + return ( +
+ {data.url ? ( +
+ `; +const MAP_TITLE = 'Bucharest map'; + +async function createMapsEditPage( + page: Parameters[0]['page'], + mapsBlockData: Record = {}, +) { + await createContent(page, { + contentType: 'Document', + contentId: EMPTY_PAGE_ID, + contentTitle: 'Maps block edit page', + transition: 'publish', + bodyModifier: (body) => ({ + ...body, + blocks: { + __somersault__: { + '@type': '__somersault__', + value: [ + { + type: 'title', + children: [{ text: 'Maps block edit page' }], + }, + { + type: 'p', + children: [{ text: 'Text before maps block' }], + }, + { + type: 'unknown', + '@type': 'maps', + children: [{ text: '' }], + ...mapsBlockData, + }, + { + type: 'p', + children: [{ text: 'Text after maps block' }], + }, + ], + }, + }, + blocks_layout: { + items: ['__somersault__'], + }, + }), + }); + + await page.goto(`/@@edit/${EMPTY_PAGE_ID}`); + await page.locator('[data-slate-editor]').waitFor({ state: 'visible' }); + await waitForPlateEditorReady(page); +} + +async function createMapsViewPage(page: Parameters[0]['page']) { + await createContent(page, { + contentType: 'Document', + contentId: VIEW_PAGE_ID, + contentTitle: 'Maps block view page', + transition: 'publish', + bodyModifier: (body) => ({ + ...body, + blocks: { + __somersault__: { + '@type': '__somersault__', + value: [ + { + type: 'title', + children: [{ text: 'Maps block view page' }], + }, + { + type: 'p', + children: [{ text: 'Text before maps block' }], + }, + { + type: 'unknown', + '@type': 'maps', + title: MAP_TITLE, + url: MAP_IFRAME_SRC, + align: 'full', + children: [{ text: '' }], + }, + { + type: 'p', + children: [{ text: 'Text after maps block' }], + }, + ], + }, + }, + blocks_layout: { + items: ['__somersault__'], + }, + }), + }); +} + +test('Maps block shows placeholder and embed instructions while empty', async ({ + page, +}) => { + await login(page); + await createMapsEditPage(page); + + await expect(page.getByPlaceholder('Enter map Embed Code')).toBeVisible(); + await expect( + page.getByText(/Please enter the Embed Code provided by Google Maps/i), + ).toBeVisible(); + await expect(page.locator('.maps iframe.google-map')).toHaveCount(0); +}); + +test('Maps block shows an error for invalid embed code', async ({ page }) => { + await login(page); + await createMapsEditPage(page); + + const input = page.getByPlaceholder('Enter map Embed Code'); + await input.fill('https://example.com/not-an-iframe'); + await input.press('Enter'); + + await expect( + page.getByText( + 'Embed code error, please follow the instructions and try again.', + ), + ).toBeVisible(); + await expect(page.locator('.maps iframe.google-map')).toHaveCount(0); +}); + +test('Maps block extracts iframe src and switches to iframe edit mode', async ({ + page, +}) => { + await login(page); + await createMapsEditPage(page); + const editorHandle = await getEditorHandle(page); + + const input = page.getByPlaceholder('Enter map Embed Code'); + await input.fill(MAP_EMBED_CODE); + await input.press('Enter'); + + const iframe = page.locator('.maps iframe.google-map'); + await expect(iframe).toBeAttached(); + await expect(iframe).toHaveAttribute('src', MAP_IFRAME_SRC); + await expect(input).toHaveCount(0); + + const mapsNodeHandle = await getNodeByPath(page, editorHandle, [2]); + const mapsNode = (await mapsNodeHandle.jsonValue()) as Record< + string, + unknown + >; + expect(mapsNode.type).toBe('unknown'); + expect(mapsNode['@type']).toBe('maps'); + expect(mapsNode.url).toBe(MAP_IFRAME_SRC); +}); + +test('Maps block renders iframe in published view mode', async ({ page }) => { + await createMapsViewPage(page); + const response = await page.goto(`/${VIEW_PAGE_ID}`); + expect(response?.ok()).toBeTruthy(); + + await expect(page.getByText('Text before maps block')).toBeVisible(); + await expect(page.getByText('Text after maps block')).toBeVisible(); + + const iframe = page.locator('.maps iframe.google-map'); + await expect(iframe).toBeAttached(); + await expect(iframe).toHaveAttribute('src', MAP_IFRAME_SRC); + await expect(iframe).toHaveAttribute('title', MAP_TITLE); + await expect(page.locator('.maps .maps-inner.w-full')).toHaveCount(1); +}); diff --git a/packages/cmsui/acceptance/tests/title-block-sync.test.ts b/packages/cmsui/acceptance/tests/title-block-sync.test.ts index c31e67f4303..0af1ea6b19f 100644 --- a/packages/cmsui/acceptance/tests/title-block-sync.test.ts +++ b/packages/cmsui/acceptance/tests/title-block-sync.test.ts @@ -6,27 +6,53 @@ import { clickAtPath, getEditorHandle, getNodeByPath, + getSelection, setSelection, } from '@platejs/playwright'; +async function expectTitleNodeText( + page: any, + editorHandle: any, + expectedText: string, +) { + await expect + .poll(async () => { + const titleNodeHandle = await getNodeByPath(page, editorHandle, [0]); + const titleNode = (await titleNodeHandle.jsonValue()) as Record< + string, + unknown + >; + + return JSON.stringify(titleNode.children); + }) + .toBe(JSON.stringify([{ text: expectedText }])); +} + test('Title block and metadata title stay in sync', async ({ page }) => { + const initialTitle = 'Original title'; await login(page); await createContent(page, { contentType: 'Document', contentId: 'title-sync-page', - contentTitle: 'Original title', + contentTitle: initialTitle, transition: 'publish', bodyModifier: (body) => ({ ...body, blocks: { - '1a2b3c4d5e': { - '@type': 'slate', - value: [{ type: 'p', children: [{ text: '' }] }], + __somersault__: { + '@type': '__somersault__', + value: [ + { + type: 'title', + children: [{ text: initialTitle }], + }, + { + type: 'p', + children: [{ text: '' }], + }, + ], }, }, - blocks_layout: { - items: ['1a2b3c4d5e'], - }, }), }); @@ -38,9 +64,17 @@ test('Title block and metadata title stay in sync', async ({ page }) => { await metadataTitleInput.fill('Metadata updated title'); await page.getByRole('tab', { name: 'Blocks' }).click(); - const editorTitle = page.locator('[data-slate-editor] h1').first(); - await expect(editorTitle).toHaveText('Metadata updated title'); - await editorTitle.fill('Editor updated title'); + const editorHandle = await getEditorHandle(page); + await expectTitleNodeText(page, editorHandle, 'Metadata updated title'); + + await clickAtPath(page, editorHandle, [0]); + await setSelection(page, editorHandle, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 'Metadata updated title'.length }, + }); + await page.keyboard.type('Editor updated title', { delay: 0 }); + + await expectTitleNodeText(page, editorHandle, 'Editor updated title'); await page.getByRole('tab', { name: 'Content' }).click(); await expect(metadataTitleInput).toHaveValue('Editor updated title'); @@ -49,43 +83,51 @@ test('Title block and metadata title stay in sync', async ({ page }) => { test('Newly created title block is initialized from metadata title', async ({ page, }) => { + const initialTitle = 'Initial title'; await login(page); await createContent(page, { contentType: 'Document', contentId: 'title-sync-no-title-block', - contentTitle: 'Initial title', + contentTitle: initialTitle, transition: 'publish', bodyModifier: (body) => ({ ...body, blocks: { - '1a2b3c4d5e': { - '@type': 'slate', - value: [{ type: 'p', children: [{ text: '' }] }], + __somersault__: { + '@type': '__somersault__', + value: [ + { + type: 'title', + children: [{ text: initialTitle }], + }, + { + type: 'p', + children: [{ text: '' }], + }, + ], }, }, - blocks_layout: { - items: ['1a2b3c4d5e'], - }, }), }); await page.goto('/@@edit/title-sync-no-title-block'); await waitForPlateEditorReady(page); - - const editorTitle = page.locator('[data-slate-editor] h1').first(); - await expect(editorTitle).toHaveText('Initial title'); + let editorHandle = await getEditorHandle(page); + await expectTitleNodeText(page, editorHandle, 'Initial title'); await page.getByRole('tab', { name: 'Content' }).click(); const metadataTitleInput = page.locator('input[name="title"]').first(); await metadataTitleInput.fill('Seeded metadata title'); await page.getByRole('tab', { name: 'Blocks' }).click(); - await expect(editorTitle).toHaveText('Seeded metadata title'); + editorHandle = await getEditorHandle(page); + await expectTitleNodeText(page, editorHandle, 'Seeded metadata title'); }); test('Reloading edit view with no stored title block does not trigger hydration mismatch', async ({ page, }) => { + const initialTitle = 'Reload title'; const pageErrors: string[] = []; const consoleErrors: string[] = []; @@ -103,19 +145,25 @@ test('Reloading edit view with no stored title block does not trigger hydration await createContent(page, { contentType: 'Document', contentId: 'title-sync-reload-no-title-block', - contentTitle: 'Reload title', + contentTitle: initialTitle, transition: 'publish', bodyModifier: (body) => ({ ...body, blocks: { - '1a2b3c4d5e': { - '@type': 'slate', - value: [{ type: 'p', children: [{ text: '' }] }], + __somersault__: { + '@type': '__somersault__', + value: [ + { + type: 'title', + children: [{ text: initialTitle }], + }, + { + type: 'p', + children: [{ text: '' }], + }, + ], }, }, - blocks_layout: { - items: ['1a2b3c4d5e'], - }, }), }); @@ -123,9 +171,8 @@ test('Reloading edit view with no stored title block does not trigger hydration await waitForPlateEditorReady(page); await page.reload(); await waitForPlateEditorReady(page); - - const editorTitle = page.locator('[data-slate-editor] h1').first(); - await expect(editorTitle).toHaveText('Reload title'); + const editorHandle = await getEditorHandle(page); + await expectTitleNodeText(page, editorHandle, 'Reload title'); expect( pageErrors.filter((message) => message.includes('Hydration failed')), @@ -165,9 +212,6 @@ test('Enter on title inserts a new empty paragraph before existing next block', ], }, }, - blocks_layout: { - items: ['__somersault__'], - }, }), }); @@ -198,3 +242,134 @@ test('Enter on title inserts a new empty paragraph before existing next block', expect(nextNode.type).toBe('p'); expect(nextNode.children).toEqual([{ text: existingNextText }]); }); + +test('Empty title block keeps showing its placeholder when another block is selected', async ({ + page, +}) => { + const contentId = 'title-placeholder-visibility'; + const titleText = 'Placeholder seed title'; + + await login(page); + await createContent(page, { + contentType: 'Document', + contentId, + contentTitle: titleText, + transition: 'publish', + bodyModifier: (body) => ({ + ...body, + blocks: { + __somersault__: { + '@type': '__somersault__', + value: [ + { + type: 'title', + children: [{ text: titleText }], + }, + { + type: 'p', + children: [{ text: 'Paragraph after title' }], + }, + ], + }, + }, + }), + }); + + await page.goto(`/@@edit/${contentId}`); + await waitForPlateEditorReady(page); + const editorHandle = await getEditorHandle(page); + await clickAtPath(page, editorHandle, [0]); + await setSelection(page, editorHandle, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: titleText.length }, + }); + await page.keyboard.press('Backspace'); + + await expect + .poll(async () => { + const titleNodeHandle = await getNodeByPath(page, editorHandle, [0]); + const titleNode = (await titleNodeHandle.jsonValue()) as Record< + string, + unknown + >; + + return JSON.stringify(titleNode.children); + }) + .toBe(JSON.stringify([{ text: '' }])); + + const titlePlaceholder = page + .locator('[data-slate-editor] h1') + .getByText('Type the title...'); + await expect(titlePlaceholder).toBeVisible(); + + await clickAtPath(page, editorHandle, [1]); + + await expect(titlePlaceholder).toBeVisible(); +}); + +test('Add view title placeholder is aligned to the constrained title container', async ({ + page, +}) => { + await login(page); + await page.goto('/@@add?type=Document'); + await waitForPlateEditorReady(page); + + const editorTitle = page.locator('[data-slate-editor] h1').first(); + const titlePlaceholder = editorTitle.getByText('Type the title...'); + const innerContainer = editorTitle.locator('.block-inner-container').first(); + + await expect(titlePlaceholder).toBeVisible(); + + const placeholderBox = await titlePlaceholder.boundingBox(); + const innerContainerBox = await innerContainer.boundingBox(); + + expect(placeholderBox).not.toBeNull(); + expect(innerContainerBox).not.toBeNull(); + + expect( + Math.abs((placeholderBox?.x ?? 0) - (innerContainerBox?.x ?? 0)), + ).toBeLessThanOrEqual(1); + expect( + Math.abs((placeholderBox?.width ?? 0) - (innerContainerBox?.width ?? 0)), + ).toBeLessThanOrEqual(1); +}); + +test('Fast typing in add view keeps the caret in the title block', async ({ + page, +}) => { + const typedTitle = 'Quick typing should stay in the title block'; + + await login(page); + await page.goto('/@@add?type=Document'); + await waitForPlateEditorReady(page); + + const editorHandle = await getEditorHandle(page); + await clickAtPath(page, editorHandle, [0]); + await setSelection(page, editorHandle, { + path: [0, 0], + offset: 0, + }); + + await page.keyboard.type(typedTitle, { delay: 0 }); + + const selection = await getSelection(page, editorHandle); + expect(selection).not.toBeNull(); + expect(selection?.anchor.path).toEqual([0, 0]); + expect(selection?.focus.path).toEqual([0, 0]); + + const titleNodeHandle = await getNodeByPath(page, editorHandle, [0]); + const titleNode = (await titleNodeHandle.jsonValue()) as Record< + string, + unknown + >; + expect(titleNode.type).toBe('title'); + expect(titleNode.children).toEqual([{ text: typedTitle }]); + + const nextNodeHandle = await getNodeByPath(page, editorHandle, [1]); + const nextNode = (await nextNodeHandle.jsonValue()) as Record< + string, + unknown + >; + expect(nextNode.type).toBe('p'); + expect(nextNode.children).toEqual([{ text: '' }]); +}); diff --git a/packages/cmsui/components/BlockEditor/BlockSettingsForm.test.tsx b/packages/cmsui/components/BlockEditor/BlockSettingsForm.test.tsx index 8ad546eda55..86a1fa3f3b0 100644 --- a/packages/cmsui/components/BlockEditor/BlockSettingsForm.test.tsx +++ b/packages/cmsui/components/BlockEditor/BlockSettingsForm.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { Content } from '@plone/types'; +import type { JSX } from 'react'; import BlockSettingsForm from './BlockSettingsForm'; const { useAppFormSpy, getLastForm } = vi.hoisted(() => { diff --git a/packages/cmsui/components/BlockEditor/BlocksEditor.tsx b/packages/cmsui/components/BlockEditor/BlocksEditor.tsx index bca70c48fc8..e0333a17815 100644 --- a/packages/cmsui/components/BlockEditor/BlocksEditor.tsx +++ b/packages/cmsui/components/BlockEditor/BlocksEditor.tsx @@ -3,11 +3,11 @@ import * as React from 'react'; import { PlateEditor, type Value } from '@plone/plate/components/editor'; import plateBlockSomersaultConfig from '@plone/plate/config/presets/somersault-editor'; import { TITLE_BLOCK_TYPE } from '@plone/plate/components/editor/plugins/title'; +import { SOMERSAULT_KEY } from '@plone/plate/constants'; +import { LinkKit } from './plugins/link-kit'; import { SidebarPlugin } from './plugins/SidebarPlugin'; import { blockAtomFamily, formAtom } from '../../routes/atoms'; -const SOMERSAULT_KEY = '__somersault__'; - const getDefaultSomersaultValue = (title = ''): Value => [ { type: TITLE_BLOCK_TYPE, @@ -40,7 +40,11 @@ const BlocksEditor = () => { const editorConfig = React.useMemo( () => ({ ...plateBlockSomersaultConfig, - plugins: [...(plateBlockSomersaultConfig.plugins ?? []), SidebarPlugin], + plugins: [ + ...(plateBlockSomersaultConfig.plugins ?? []), + SidebarPlugin, + ...LinkKit, + ], }), [], ); diff --git a/packages/cmsui/components/BlockEditor/plugins/link-kit.tsx b/packages/cmsui/components/BlockEditor/plugins/link-kit.tsx new file mode 100644 index 00000000000..23750a8a959 --- /dev/null +++ b/packages/cmsui/components/BlockEditor/plugins/link-kit.tsx @@ -0,0 +1,602 @@ +import * as React from 'react'; + +import type { Brain } from '@plone/types'; +import type { TLinkElement } from 'platejs'; + +import { + type UseVirtualFloatingOptions, + flip, + offset, +} from '@platejs/floating'; +import { + LinkPlugin as PlateLinkPlugin, + submitFloatingLink, + useFloatingLinkEdit, + useFloatingLinkEditState, + useFloatingLinkEscape, + useFloatingLinkInsert, + useFloatingLinkInsertState, +} from '@platejs/link/react'; +import { cva } from 'class-variance-authority'; +import { + Check, + ExternalLink, + FolderOpen, + Link, + LoaderCircle, + Search, + Unlink, +} from 'lucide-react'; +import { useAtomValue } from 'jotai'; +import { KEYS, RangeApi } from 'platejs'; +import { + useEditorPlugin, + useEditorRef, + useEditorSelection, + useFormInputProps, + usePluginOption, +} from 'platejs/react'; +import { useFetcher } from 'react-router'; +import { flattenToAppURL, isInternalURL } from '@plone/helpers'; + +import { buttonVariants } from '@plone/plate/components/ui/button'; +import { LinkElement } from '@plone/plate/components/ui/link-node'; +import { Separator } from '@plone/plate/components/ui/separator'; +import { LegacyLinkPlugin } from '@plone/plate/components/editor/plugins/legacy-link-plugin'; +import { ObjectBrowserProvider } from '../../ObjectBrowserWidget/ObjectBrowserContext'; +import { ObjectBrowserModal } from '../../ObjectBrowserWidget/ObjectBrowserModal'; +import { buildObjectBrowserUrl } from '../../ObjectBrowserWidget/utils'; +import { formAtom } from '../../../routes/atoms'; + +const popoverVariants = cva( + 'z-50 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden', +); + +const inputVariants = cva( + ` + flex h-[28px] w-full rounded-md border-none bg-transparent px-1.5 py-1 text-base + placeholder:text-muted-foreground + focus-visible:ring-transparent focus-visible:outline-none + md:text-sm + `, +); + +const resultsListVariants = cva( + 'mt-1 max-h-56 overflow-y-auto rounded-md border bg-background', +); + +const resultButtonVariants = cva( + ` + flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left text-sm + hover:bg-muted + focus:bg-muted focus:outline-none + `, +); + +type SearchItem = Pick; + +const isDirectLinkInput = (value: string) => { + const trimmed = value.trim(); + + return ( + trimmed.startsWith('/') || + trimmed.startsWith('#') || + /^[a-z][a-z\d+.-]*:/i.test(trimmed) || + false + ); +}; + +const shouldSearchForInput = (value: string) => { + const trimmed = value.trim(); + + if (trimmed.length < 2) return false; + if (isDirectLinkInput(trimmed)) return false; + + return true; +}; + +const normalizeLinkUrl = (value: string) => { + const trimmed = value.trim(); + + if (!trimmed) return trimmed; + if (isInternalURL(trimmed)) { + return flattenToAppURL(trimmed); + } + + return trimmed; +}; + +const getInternalLinkTarget = (value?: string | null) => { + if (!value) return null; + + const normalizedUrl = normalizeLinkUrl(value); + + return normalizedUrl.startsWith('/') ? normalizedUrl : null; +}; + +const getParentPath = (path?: string | null) => { + if (!path || path === '/') return '/'; + + const segments = path.split('/').filter(Boolean); + if (segments.length <= 1) return '/'; + + return `/${segments.slice(0, -1).join('/')}`; +}; + +function LinkOpenButton() { + const editor = useEditorRef(); + useEditorSelection(); + const entry = editor.api.node({ + match: { type: editor.getType(KEYS.link) }, + }); + const attributes = entry + ? editor.getApi(PlateLinkPlugin).link.getAttributes(entry[0]) + : {}; + + return ( + { + event.stopPropagation(); + }} + onFocus={(event) => { + event.stopPropagation(); + }} + aria-label="Open link in a new tab" + target="_blank" + > + + + ); +} + +function CmsuiLinkInput({ + url, + onChange, + onSubmit, + onOpenBrowser, + searching, + inputProps, +}: { + url: string; + onChange: (value: string) => void; + onSubmit: () => void; + onOpenBrowser: () => void; + searching: boolean; + inputProps: ReturnType; +}) { + return ( +
+
+ +
+ + onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Enter') return; + + event.preventDefault(); + onSubmit(); + }} + /> + + + + +
+ ); +} + +function LinkObjectBrowser({ + open, + initialPath, + selectedUrl, + onClose, + onSelect, +}: { + open: boolean; + initialPath?: string; + selectedUrl?: string; + onClose: () => void; + onSelect: (item: SearchItem) => void; +}) { + const handleChange = React.useCallback( + (selected: Partial[]) => { + const item = selected[0]; + if (!item?.['@id']) return; + + onSelect(item as SearchItem); + onClose(); + }, + [onClose, onSelect], + ); + + if (!open) return null; + + return ( + + { + if (!nextOpen) onClose(); + }} + /> + + ); +} + +function LinkFloatingToolbar({ + state, +}: { + state?: { floatingOptions?: UseVirtualFloatingOptions }; +}) { + const editor = useEditorRef(); + const selection = useEditorSelection(); + const content = useAtomValue(formAtom); + const currentContentPath = content?.['@id']; + const { setOption } = useEditorPlugin(PlateLinkPlugin); + const searchFetcher = useFetcher<{ results?: { items?: SearchItem[] } }>(); + const inputProps = useFormInputProps({ + preventDefaultOnEnterKeydown: true, + }); + const activeCommentId = usePluginOption({ key: KEYS.comment }, 'activeId'); + const activeSuggestionId = usePluginOption( + { key: KEYS.suggestion }, + 'activeId', + ); + const mode = usePluginOption(PlateLinkPlugin, 'mode'); + const isEditing = usePluginOption(PlateLinkPlugin, 'isEditing'); + const url = usePluginOption(PlateLinkPlugin, 'url') ?? ''; + const [isObjectBrowserOpen, setObjectBrowserOpen] = React.useState(false); + const lastSelectionRef = React.useRef(selection); + const pendingSelectionRef = React.useRef(null); + const debouncedUrl = React.useDeferredValue(url); + const [objectBrowserPath, setObjectBrowserPath] = React.useState< + string | undefined + >(undefined); + const activeLinkEntry = editor.api.node({ + match: { type: editor.getType(KEYS.link) }, + }); + + React.useEffect(() => { + if (selection) { + lastSelectionRef.current = selection; + } + }, [selection]); + + const floatingOptions: UseVirtualFloatingOptions = React.useMemo(() => { + return { + middleware: [ + offset(8), + flip({ + fallbackPlacements: ['bottom-end', 'top-start', 'top-end'], + padding: 12, + }), + ], + placement: + activeSuggestionId || activeCommentId ? 'top-start' : 'bottom-start', + }; + }, [activeCommentId, activeSuggestionId]); + + const insertState = useFloatingLinkInsertState({ + ...state, + floatingOptions: { + ...floatingOptions, + ...state?.floatingOptions, + }, + }); + const { + hidden, + props: insertProps, + ref: insertRef, + } = useFloatingLinkInsert(insertState); + + const editState = useFloatingLinkEditState({ + ...state, + floatingOptions: { + ...floatingOptions, + ...state?.floatingOptions, + }, + }); + const { + editButtonProps, + props: editProps, + ref: editRef, + unlinkButtonProps, + } = useFloatingLinkEdit(editState); + + useFloatingLinkEscape(); + + React.useEffect(() => { + if (!mode) return; + if (!shouldSearchForInput(debouncedUrl)) return; + + const timeout = window.setTimeout(() => { + const searchUrl = buildObjectBrowserUrl(undefined, debouncedUrl.trim()); + if (searchUrl) { + searchFetcher.load(searchUrl); + } + }, 250); + + return () => window.clearTimeout(timeout); + }, [debouncedUrl, mode, searchFetcher]); + + const restoreSelection = React.useCallback(() => { + const selectionToRestore = + pendingSelectionRef.current ?? lastSelectionRef.current; + + if (!selectionToRestore) return; + + editor.tf.select(selectionToRestore); + }, [editor]); + + const openObjectBrowser = React.useCallback(() => { + const currentSelection = editor.selection ?? lastSelectionRef.current; + const activeLinkUrl = + activeLinkEntry && typeof activeLinkEntry[0]?.url === 'string' + ? activeLinkEntry[0].url + : ''; + const nextTarget = + getInternalLinkTarget(url) ?? getInternalLinkTarget(activeLinkUrl); + + pendingSelectionRef.current = currentSelection; + setObjectBrowserPath(getParentPath(nextTarget ?? currentContentPath)); + setObjectBrowserOpen(true); + }, [activeLinkEntry, currentContentPath, editor, url]); + + const applyLink = React.useCallback( + (nextUrl: string, fallbackText?: string) => { + const normalizedUrl = normalizeLinkUrl(nextUrl); + + restoreSelection(); + setOption('url', normalizedUrl); + + const selectionToRestore = + pendingSelectionRef.current ?? lastSelectionRef.current; + const selectedText = selectionToRestore + ? editor.api.string(selectionToRestore).trim() + : ''; + const shouldUseFallbackText = + !selectedText && + selectionToRestore && + RangeApi.isCollapsed(selectionToRestore); + + if (selectedText) { + setOption('text', selectedText); + } else if (shouldUseFallbackText && fallbackText) { + setOption('text', fallbackText); + } + + submitFloatingLink(editor); + pendingSelectionRef.current = null; + }, + [editor, restoreSelection, setOption], + ); + + const handleResultSelect = React.useCallback( + (item: SearchItem) => { + applyLink(flattenToAppURL(item['@id']), item.title); + }, + [applyLink], + ); + + const results = React.useMemo( + () => + shouldSearchForInput(url) + ? ((searchFetcher.data?.results?.items as SearchItem[] | undefined) ?? + []) + : [], + [searchFetcher.data?.results?.items, url], + ); + const searching = + shouldSearchForInput(url) && searchFetcher.state === 'loading'; + + const handleSubmit = React.useCallback(() => { + const nextUrl = normalizeLinkUrl(url); + if (!nextUrl) return; + if (shouldSearchForInput(nextUrl) && results.length > 0) { + handleResultSelect(results[0]); + return; + } + if (!shouldSearchForInput(nextUrl)) { + applyLink(nextUrl); + } + }, [applyLink, handleResultSelect, results, url]); + + const input = ( +
event.stopPropagation()} + > + setOption('url', value)} + onSubmit={handleSubmit} + onOpenBrowser={openObjectBrowser} + searching={searching} + inputProps={inputProps} + /> + + {shouldSearchForInput(url) ? ( + <> + +
+ Search results +
+
+ {results.length > 0 ? ( + results.map((item) => ( + + )) + ) : ( +
+ {searching ? 'Searching…' : 'No matching content found'} +
+ )} +
+ + ) : null} +
+ ); + + const editContent = isEditing ? ( + input + ) : ( +
+ + + + + + + + + + + + + +
+ ); + + if (hidden && !isObjectBrowserOpen) return null; + + return ( + <> + {!hidden ? ( + <> +
+ {input} +
+ +
+ {editContent} +
+ + ) : null} + + setObjectBrowserOpen(false)} + onSelect={(item) => { + applyLink(flattenToAppURL(item['@id']), item.title); + }} + /> + + ); +} + +export const LinkKit = [ + ...LegacyLinkPlugin, + PlateLinkPlugin.configure({ + options: { + transformInput: normalizeLinkUrl, + }, + render: { + node: LinkElement, + afterEditable: () => , + }, + }), +]; diff --git a/packages/cmsui/components/ContentForm/ContentForm.tsx b/packages/cmsui/components/ContentForm/ContentForm.tsx new file mode 100644 index 00000000000..7de5f8a3c1e --- /dev/null +++ b/packages/cmsui/components/ContentForm/ContentForm.tsx @@ -0,0 +1,180 @@ +import Checkbox from '@plone/components/icons/checkbox.svg?react'; +import Close from '@plone/components/icons/close.svg?react'; +import Settings from '@plone/components/icons/settings.svg?react'; +import { + Accordion, + AccordionItem, + AccordionItemTrigger, + AccordionPanel, + Tabs, +} from '@plone/components/quanta'; +import { InitAtoms } from '@plone/helpers'; +import { Plug } from '@plone/layout/components/Pluggable'; +import type { Content } from '@plone/types'; +import type { DeepKeys } from '@tanstack/react-form'; +import clsx from 'clsx'; +import { createStore, Provider, useAtom } from 'jotai'; +import type { ReactNode } from 'react'; +import { useRef } from 'react'; +import { Link } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; +import { useFetcher, type SubmitTarget } from 'react-router'; +import { useAppForm } from '../Form/Form'; +import Sidebar, { sidebarAtom } from '../Sidebar/Sidebar'; +import { formAtom } from '../../routes/atoms'; +import BlocksEditor from '../BlockEditor/BlocksEditor'; + +interface Schema { + title: string; + fieldsets: Array<{ + id: string; + title: string; + fields: string[]; + }>; + properties: Record; + required: string[]; +} + +interface ContentFormProps { + content: Content; + schema: Schema; + heading: ReactNode; + submitMethod: 'post' | 'patch'; +} + +export default function ContentForm({ + content, + schema, + heading, + submitMethod, +}: ContentFormProps) { + const { t } = useTranslation(); + const fetcher = useFetcher(); + const storeRef = useRef(createStore()); + const store = storeRef.current; + const [collapsed, setCollapsed] = useAtom(sidebarAtom); + + const form = useAppForm({ + defaultValues: content, + onSubmit: async () => { + fetcher.submit(store.get(formAtom) as unknown as SubmitTarget, { + method: submitMethod, + encType: 'application/json', + }); + }, + }); + + return ( + + +
+
+ , + }, + { + id: 'content', + title: t('cmsui.blocksEditor.contentTab'), + content: ( +
+

{heading}

+ + {schema.fieldsets.map((fieldset) => ( + + + + {fieldset.title} + + + {(fieldset.fields as DeepKeys[]).map( + (schemaField, index) => ( + ( + + )} + /> + ), + )} + + + + ))} + +
+ ), + }, + ]} + /> + + + + + + + + + + + +
+ +
+
+
+ ); +} diff --git a/packages/cmsui/components/Form/Field.tsx b/packages/cmsui/components/Form/Field.tsx index 52b3e8f33bd..7c5e7b0ddba 100644 --- a/packages/cmsui/components/Form/Field.tsx +++ b/packages/cmsui/components/Form/Field.tsx @@ -57,7 +57,7 @@ const getWidgetDefault = (): React.ComponentType => * Get widget by field's `id` attribute */ const getWidgetByFieldId = ( - id: FieldProps['id'], + id: FieldProps['name'], ): React.ComponentType | null => typeof id === 'string' ? (config.getWidget(id) ?? null) : null; @@ -167,7 +167,9 @@ const renderFieldWidget = ({ onFieldChange: (value: any) => void; }) => { const Widget = - getWidgetByFieldId(fieldProps.id) || + getWidgetByFieldId( + (fieldProps.id ?? fieldProps.name) as FieldProps['name'], + ) || getWidgetFromTaggedValues(fieldProps.widgetOptions) || getWidgetByName(fieldProps.widget) || getWidgetByChoices(fieldProps) || diff --git a/packages/cmsui/components/Form/Form.tsx b/packages/cmsui/components/Form/Form.tsx index 0db4e6b634f..83c3fdc771c 100644 --- a/packages/cmsui/components/Form/Form.tsx +++ b/packages/cmsui/components/Form/Form.tsx @@ -2,7 +2,7 @@ import { createFormHookContexts, createFormHook } from '@tanstack/react-form'; import Quanta from './Field'; // export useFieldContext for use in your custom components -export const { fieldContext, formContext, useFieldContext } = +export const { fieldContext, formContext, useFieldContext, useFormContext } = createFormHookContexts(); export const { useAppForm } = createFormHook({ diff --git a/packages/cmsui/components/ImageWidget/ImageWidget.test.tsx b/packages/cmsui/components/ImageWidget/ImageWidget.test.tsx index 378b05683b9..5968d579a3f 100644 --- a/packages/cmsui/components/ImageWidget/ImageWidget.test.tsx +++ b/packages/cmsui/components/ImageWidget/ImageWidget.test.tsx @@ -14,6 +14,7 @@ vi.mock('@plone/components/quanta', () => ({ {children} ), + DialogTrigger: ({ children }: any) => <>{children}, Input: ({ ...props }: any) => , })); @@ -31,10 +32,6 @@ vi.mock('../Field/Field', () => ({ Label: ({ children }: any) => , })); -vi.mock('react-aria-components', () => ({ - DialogTrigger: ({ children }: any) => <>{children}, -})); - vi.mock('../ObjectBrowserWidget/ObjectBrowserModal', () => ({ ObjectBrowserModal: () =>
, })); @@ -163,16 +160,19 @@ describe('ImageWidget', () => { } vi.stubGlobal('FileReader', MockFileReader); - mockFetcher = { - state: 'submitting', - data: undefined, - submit: vi.fn(), - }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: { + '@id': '/uploaded-image', + title: 'Uploaded image', + }, + }), + }); + vi.stubGlobal('fetch', fetchMock); const onChange = vi.fn(); - const { rerender } = render( - , - ); + render(); const fileInput = document.querySelector( 'input[type="file"]', @@ -183,23 +183,10 @@ describe('ImageWidget', () => { fireEvent.change(fileInput, { target: { files: [file] } }); await waitFor(() => { - expect(mockFetcher.submit).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); }); expect(screen.queryByText('Image upload failed')).not.toBeInTheDocument(); - mockFetcher = { - ...mockFetcher, - state: 'idle', - data: { - data: { - '@id': '/uploaded-image', - title: 'Uploaded image', - }, - }, - }; - - rerender(); - await waitFor(() => { expect(onChange).toHaveBeenCalledWith('/uploaded-image', { title: 'Uploaded image', diff --git a/packages/cmsui/components/ImageWidget/ImageWidget.tsx b/packages/cmsui/components/ImageWidget/ImageWidget.tsx index 2bdefe47a5e..f27e14eee17 100644 --- a/packages/cmsui/components/ImageWidget/ImageWidget.tsx +++ b/packages/cmsui/components/ImageWidget/ImageWidget.tsx @@ -1,14 +1,5 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type DragEvent, -} from 'react'; -import { useFetcher } from 'react-router'; -import { DialogTrigger } from 'react-aria-components'; -import { Button, Input } from '@plone/components/quanta'; +import { useCallback, useMemo, useRef, useState, type DragEvent } from 'react'; +import { Button, DialogTrigger, Input } from '@plone/components/quanta'; import type { TextFieldProps as QuantaTextFieldProps } from '@plone/components/quanta'; import { BinIcon, @@ -102,6 +93,14 @@ function parseCreateContentResponse(value: unknown): CreateContentResponse { return {}; } +async function parseJsonSafe(response: Response) { + try { + return await response.json(); + } catch { + return null; + } +} + function normalizeImageValue(value: unknown): string { if (typeof value === 'string') return value; @@ -208,17 +207,11 @@ function ImageInputBase({ const resolvedValue = value !== undefined ? value : defaultValue; const imageValue = normalizeImageValue(resolvedValue); const fileInputRef = useRef(null); - const uploadFetcher = useFetcher(); - const previousUploadFetcherState = useRef(uploadFetcher.state); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(''); const [linkValue, setLinkValue] = useState(imageValue); - useEffect(() => { - setLinkValue(imageValue); - }, [imageValue]); - const resolvedCurrentPath = useMemo(() => { const fallbackPath = typeof window !== 'undefined' @@ -231,31 +224,16 @@ function ImageInputBase({ () => uploadPath || getBasePath(resolvedCurrentPath), [resolvedCurrentPath, uploadPath], ); + const uploadAction = useMemo( + () => + resolvedUploadPath === '/' + ? '/@createContent' + : `/@createContent${resolvedUploadPath}`, + [resolvedUploadPath], + ); const objectBrowserMode = objectBrowserPickerType === 'multiple' ? 'multiple' : 'single'; - useEffect(() => { - const previousState = previousUploadFetcherState.current; - previousUploadFetcherState.current = uploadFetcher.state; - - const finishedRequest = - previousState !== 'idle' && uploadFetcher.state === 'idle'; - if (!isUploading || !finishedRequest) { - return; - } - - const result = parseCreateContentResponse(uploadFetcher.data); - if (result && typeof result?.['@id'] === 'string') { - setUploadError(''); - onValueChange(result['@id'], { - title: result.title, - }); - } else { - setUploadError(result?.message || 'Image upload failed'); - } - setIsUploading(false); - }, [uploadFetcher.state, uploadFetcher.data, isUploading, onValueChange]); - const submitUpload = useCallback( async (file: File) => { if (!file || restrictFileUpload) return; @@ -277,8 +255,17 @@ function ImageInputBase({ return; } - uploadFetcher.submit( - { + // Keep this as a direct fetch: this widget treats `@createContent` + // like an API endpoint and needs its raw JSON payload to update local + // state. `useFetcher.submit` routes the action response through React + // Router's data transport instead of exposing that API-ish shape. + const response = await fetch(uploadAction, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ path: resolvedUploadPath, data: { '@type': 'Image', @@ -290,19 +277,27 @@ function ImageInputBase({ filename: file.name, }, }, - }, - { - method: 'post', - encType: 'application/json', - action: '/@createContent', - }, - ); + }), + }); + + const responseData = await parseJsonSafe(response); + const result = parseCreateContentResponse(responseData); + + if (response.ok && typeof result?.['@id'] === 'string') { + setUploadError(''); + onValueChange(result['@id'], { + title: result.title, + }); + } else { + setUploadError(result?.message || 'Image upload failed'); + } + setIsUploading(false); } catch { setUploadError('Could not read the selected file'); setIsUploading(false); } }, - [restrictFileUpload, uploadFetcher, resolvedUploadPath], + [restrictFileUpload, resolvedUploadPath, uploadAction, onValueChange], ); const onDrop = useCallback( @@ -393,7 +388,7 @@ function ImageInputBase({ { + const { t } = useTranslation(); + + return ( +
+ {t('cmsui.auth.signUp')} + +
+ ); +}; + +export default LoginActions; diff --git a/packages/cmsui/components/Login/LoginHero.tsx b/packages/cmsui/components/Login/LoginHero.tsx new file mode 100644 index 00000000000..df1b9651d02 --- /dev/null +++ b/packages/cmsui/components/Login/LoginHero.tsx @@ -0,0 +1,14 @@ +import voltoHeroSvg from '../../static/volto-hero.svg'; + +const LoginHero = () => { + return ( + + ); +}; + +export default LoginHero; diff --git a/packages/cmsui/components/Login/LoginLogo.tsx b/packages/cmsui/components/Login/LoginLogo.tsx new file mode 100644 index 00000000000..214f5092078 --- /dev/null +++ b/packages/cmsui/components/Login/LoginLogo.tsx @@ -0,0 +1,15 @@ +import ploneWhiteSVG from '../../static/plone.svg'; + +const LoginLogo = () => { + return ( +
+ +
+ ); +}; + +export default LoginLogo; diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserContext.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserContext.tsx index c147b3a9e7f..a01eea2532d 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserContext.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserContext.tsx @@ -27,7 +27,7 @@ import { type PatternOptions = { maximumSelectionSize?: number; - selectableTypes?: string[]; + selectableTypes?: Brain['@type'][]; basePath?: string; currentPath?: string; } & Record; @@ -37,7 +37,7 @@ export interface UseObjectBrowserConfig { widgetOptions?: { pattern_options?: PatternOptions; }; - selectedAttrs?: Array; + selectedItemAttrs?: Array; // TODO: Also add blockchange/slate signature compat onChange?: (selected: Partial[]) => void; defaultValue?: Brain[]; @@ -50,7 +50,7 @@ const useObjectBrowserInternal = (config: UseObjectBrowserConfig = {}) => { const { mode = 'multiple', widgetOptions = {}, - selectedAttrs = ['@id', 'title', 'description', '@type', 'UID'], + selectedItemAttrs = ['@id', 'title', 'description', '@type', 'UID'], onChange, defaultValue = [], title, @@ -72,13 +72,13 @@ const useObjectBrowserInternal = (config: UseObjectBrowserConfig = {}) => { return brains.map( (item) => Object.fromEntries( - selectedAttrs + selectedItemAttrs .filter((attr) => attr in item) .map((attr) => [attr, item[attr]]), ) as Partial, ); }, - [selectedAttrs], + [selectedItemAttrs], ); const handleSelectionChange = useCallback( @@ -184,7 +184,7 @@ const useObjectBrowserInternal = (config: UseObjectBrowserConfig = {}) => { // Config mode, - selectedAttrs, + selectedItemAttrs, title, widgetOptions, }; diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.test.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.test.tsx index ffeeef9efae..9ab9449b949 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.test.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.test.tsx @@ -1,5 +1,6 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ComponentProps } from 'react'; import { ObjectBrowserModal } from './ObjectBrowserModal'; // Mock react-i18next @@ -117,9 +118,12 @@ vi.mock('./ObjectBrowserWidgetBody', () => ({ ), })); -const renderWithContext = (contextValue: any) => { +const renderWithContext = ( + contextValue: any, + modalProps?: ComponentProps, +) => { mockContextValue = contextValue; - return render(); + return render(); }; describe('ObjectBrowserModal', () => { @@ -153,6 +157,20 @@ describe('ObjectBrowserModal', () => { expect(screen.getByTestId('modal')).toHaveAttribute('data-open', 'false'); }); + it('should prefer controlled isOpen when provided', () => { + renderWithContext( + { + ...defaultContextValue, + open: false, + }, + { + isOpen: true, + }, + ); + + expect(screen.getByTestId('modal')).toHaveAttribute('data-open', 'true'); + }); + it('should render dialog and widget body', () => { renderWithContext(defaultContextValue); @@ -283,6 +301,15 @@ describe('ObjectBrowserModal', () => { expect(setOpen).toHaveBeenCalledWith(false); }); + + it('should call external onOpenChange when modal backdrop is clicked', () => { + const onOpenChange = vi.fn(); + renderWithContext(defaultContextValue, { onOpenChange }); + + fireEvent.click(screen.getByTestId('modal-backdrop')); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); }); describe('Button Variants and Slots', () => { diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.tsx index 3db4da87fdb..c2d4a01f9cf 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.tsx @@ -6,7 +6,15 @@ import { ObjectBrowserWidgetBody } from './ObjectBrowserWidgetBody'; import { useObjectBrowserContext } from './ObjectBrowserContext'; import { useTranslation } from 'react-i18next'; -export const ObjectBrowserModal = () => { +type ObjectBrowserModalProps = { + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; +}; + +export const ObjectBrowserModal = ({ + isOpen, + onOpenChange, +}: ObjectBrowserModalProps = {}) => { const { t } = useTranslation(); const { open, @@ -18,6 +26,12 @@ export const ObjectBrowserModal = () => { ariaControlsId, title, } = useObjectBrowserContext(); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + onOpenChange?.(nextOpen); + }; + return ( { fixed top-0 right-0 bottom-0 w-[360px] border-l border-quanta-azure bg-quanta-air px-6 py-8 text-black shadow-[rgba(0,0,0,0.1)_-8px_0px_20px] outline-none `} - isOpen={open} - onOpenChange={(isOpen) => setOpen(isOpen)} + isOpen={isOpen ?? open} + onOpenChange={handleOpenChange} > {!searchMode ? ( @@ -54,7 +68,7 @@ export const ObjectBrowserModal = () => { variant="icon" type="button" aria-label={t('cmsui.objectbrowserwidget.closeDialog')} - onPress={() => setOpen(false)} + onPress={() => handleOpenChange(false)} > diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserTrigger.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserTrigger.tsx index 40763d4c2bf..02255ea7382 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserTrigger.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserTrigger.tsx @@ -1,9 +1,8 @@ // Componente Button estratto dal widget originale import { type PropsWithChildren } from 'react'; import { Icon } from '@plone/components'; -import { Button } from '@plone/components/quanta'; +import { Button, DialogTrigger } from '@plone/components/quanta'; import { useObjectBrowserContext } from './ObjectBrowserContext'; -import { DialogTrigger } from 'react-aria-components'; import { useTranslation } from 'react-i18next'; export const ObjectBrowserTrigger = ({ children }: PropsWithChildren) => { diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.stories.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.stories.tsx index f53146d6993..563efe4f213 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.stories.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidget.stories.tsx @@ -144,15 +144,6 @@ const MOCK_NODES: MockNode[] = [ UID: 'folder-1', review_state: 'published', }, - { - '@id': `${STORY_ROOT_ID}/media/launch-video`, - '@type': 'Video', - title: 'Launch Video', - description: 'Teaser for the new release', - parent: `${STORY_ROOT_ID}/media`, - UID: 'video-1', - review_state: 'pending', - }, { '@id': `${STORY_ROOT_ID}/media/product-image`, '@type': 'Image', @@ -316,11 +307,6 @@ export const WithInitialSelection: Story = { title: 'Welcome Document', '@type': 'Document', }, - { - '@id': `${STORY_ROOT_ID}/media/launch-video`, - title: 'Launch Video', - '@type': 'Video', - }, ] as any, }, }; diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidgetBody.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidgetBody.tsx index 5ad08ed962c..fedd22b6871 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidgetBody.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserWidgetBody.tsx @@ -54,6 +54,7 @@ export function ObjectBrowserWidgetBody() { }, [currentPath, loading, items]); const handleNavigation = (item: Brain) => { + if (!item.is_folderish) return; navigateTo(item['@id']); setSearchMode(false); }; @@ -142,10 +143,11 @@ export function ObjectBrowserWidgetBody() { count: items?.length ?? 0, })}`} key={`${viewMode}-${currentPath}`} // Force re-render on viewMode or path change - selectionMode={'multiple'} + selectionMode={mode === 'single' ? 'single' : 'multiple'} disabledBehavior="selection" escapeKeyBehavior="none" - selectionBehavior={'toggle'} + selectionBehavior={mode === 'single' ? 'replace' : 'toggle'} + dependencies={[selectedItems]} items={items ?? []} layout={viewMode ? 'grid' : 'stack'} // Todo: better styling @@ -181,15 +183,10 @@ export function ObjectBrowserWidgetBody() { } > {(item) => { - // Convert selectedItems IDs to actual Brain objects for isSelectable - const selectedItemObjects = selectedItems - .map((id) => items?.find((item) => item['@id'] === id)) - .filter(Boolean) as Brain[]; - const disabled = !isSelectable(item, { ...widgetOptions, mode, - items: selectedItemObjects, + selectedItemIds: selectedItems, }); const isSelected = selectedItems.includes(item['@id']); const reviewState = item.review_state || undefined; @@ -200,7 +197,9 @@ export function ObjectBrowserWidgetBody() { textValue={getItemLabel(t, item, isSelected, disabled)} aria-label={getItemLabel(t, item, isSelected, disabled)} data-selectable={!disabled} - onAction={() => handleNavigation(item)} + onAction={ + mode !== 'single' ? () => handleNavigation(item) : undefined + } isDisabled={disabled} className={itemVariants({ viewMode: viewMode ? 'grid' : 'list', diff --git a/packages/cmsui/components/ObjectBrowserWidget/utils.ts b/packages/cmsui/components/ObjectBrowserWidget/utils.ts index 1be494b01f3..ffa4f1e5063 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/utils.ts +++ b/packages/cmsui/components/ObjectBrowserWidget/utils.ts @@ -22,7 +22,7 @@ export interface ContentIconMap { export type ObjectBrowserWidgetMode = 'multiple' | 'single'; export type PartialBrainWithRequired = Partial & { '@id': string; - '@type': string; + '@type': Content['@type']; title: string; }; export type WidgetPatternOptions = { @@ -31,7 +31,7 @@ export type WidgetPatternOptions = { selectableTypes?: Content['@type'][]; [key: string]: any; }; - items: PartialBrainWithRequired[]; + selectedItemIds: string[]; mode: ObjectBrowserWidgetMode; }; @@ -96,7 +96,7 @@ const isSelectable = ( item: PartialBrainWithRequired, options: WidgetPatternOptions, ) => { - const { pattern_options, items } = options; + const { pattern_options, selectedItemIds, mode } = options; const { maximumSelectionSize, selectableTypes } = pattern_options || { maximumSelectionSize: undefined, selectableTypes: [], @@ -111,10 +111,14 @@ const isSelectable = ( return false; } - // Second check: respect maximum selection limit - if (maximumSelectionSize && items && maximumSelectionSize <= items.length) { + // Second check: respect maximum selection limit (multiple mode only) + if ( + isMultipleMode(mode) && + maximumSelectionSize && + maximumSelectionSize <= selectedItemIds.length + ) { // At limit: only already selected items are selectable (for deselection) - return items.some((i) => i['@id'] === item['@id']); + return selectedItemIds.includes(item['@id']); } // All checks passed diff --git a/packages/cmsui/components/RecurrenceWidget/Components/ByDayField.tsx b/packages/cmsui/components/RecurrenceWidget/Components/ByDayField.tsx new file mode 100644 index 00000000000..3c0378c76ea --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/Components/ByDayField.tsx @@ -0,0 +1,48 @@ +import { CheckboxGroup, Checkbox, Label } from '@plone/components/quanta'; +import { Days, getLocalizedWeekday, widgetTailwindClasses } from '../utils'; +import type { Updater } from '@tanstack/react-form'; +import { useTranslation } from 'react-i18next'; +import { twMerge } from 'tailwind-merge'; + +interface ByDayFieldProps { + onChange: (updater: Updater) => void; + label: string; + defaultValue: string; +} + +const ByDayField = ({ label, onChange, defaultValue }: ByDayFieldProps) => { + const { i18n } = useTranslation(); + const currentLocale = i18n.language; + return ( + onChange(value)} + defaultValue={[defaultValue]} + > + + {(Object.keys(Days) as Array).map((d, i) => { + return ( + + {getLocalizedWeekday(Days[d].weekday, currentLocale, 'short')} + + ); + })} + + ); +}; + +export default ByDayField; diff --git a/packages/cmsui/components/RecurrenceWidget/Components/ByMonthDayField.tsx b/packages/cmsui/components/RecurrenceWidget/Components/ByMonthDayField.tsx new file mode 100644 index 00000000000..334ec736c1f --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/Components/ByMonthDayField.tsx @@ -0,0 +1,37 @@ +import { Input, TextField } from '@plone/components/quanta'; +import { type Updater } from '@tanstack/react-form'; +import { useTranslation } from 'react-i18next'; + +interface ByMonthDayFieldProps { + onChange: (updater: Updater) => void; + defaultValue: number; +} + +const ByMonthDayField = ({ onChange, defaultValue }: ByMonthDayFieldProps) => { + const { t } = useTranslation(); + return ( +
+
+ {t('cmsui.recurrence.day_label')} + { + const inputValue = Number(e); + onChange(inputValue); + }} + validate={(value) => + Number(value) > 31 ? t('cmsui.recurrence.monthdayError') : null + } + minValue={1} + defaultValue={defaultValue?.toString()} + > + + + {t('cmsui.recurrence.ofmonth_label')} +
+
+ ); +}; + +export default ByMonthDayField; diff --git a/packages/cmsui/components/RecurrenceWidget/Components/ByWeekdayOfTheMonth.tsx b/packages/cmsui/components/RecurrenceWidget/Components/ByWeekdayOfTheMonth.tsx new file mode 100644 index 00000000000..6ca04ebffcf --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/Components/ByWeekdayOfTheMonth.tsx @@ -0,0 +1,29 @@ +import { Select } from '@plone/components/quanta'; +import { getDaysOptions } from '../utils'; +import { useTranslation } from 'react-i18next'; +import type { Updater } from '@tanstack/react-form'; + +interface ByWeekdayOfTheMonth { + onChange: (updater: Updater) => void; + defaultValue: number; +} + +const ByWeekdayOfTheMonth = ({ + onChange, + defaultValue, +}: ByWeekdayOfTheMonth) => { + const { i18n } = useTranslation(); + const currentLocale = i18n.language; + const daysOptions = getDaysOptions(currentLocale); + return ( + { + const indexValue = Number(value); + onChange(indexValue); + }} + defaultValue={Number(defaultValue)} + items={getOrdinalNumbersOptions(t)} + /> + ); +}; + +export default ByWeekdayOfTheMonthIndex; diff --git a/packages/cmsui/components/RecurrenceWidget/Components/CountEndField.tsx b/packages/cmsui/components/RecurrenceWidget/Components/CountEndField.tsx new file mode 100644 index 00000000000..d376c6de518 --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/Components/CountEndField.tsx @@ -0,0 +1,35 @@ +import type { Updater } from '@tanstack/react-form'; +import { useTranslation } from 'react-i18next'; +import { TextField, Label, Input } from '@plone/components/quanta'; +// import { Input } from '../../Field/Field'; + +interface CountEndFieldProps { + onChange: (updater: Updater) => void; +} + +const CountEndField = ({ onChange }: CountEndFieldProps) => { + const { t } = useTranslation(); + return ( +
+ + { + const inputValue = Number(e); + onChange(inputValue); + }} + > + + + +
+ {t('cmsui.recurrence.infinite_occurrences')} +
+
+ ); +}; + +export default CountEndField; diff --git a/packages/cmsui/components/RecurrenceWidget/Components/IntervalField.tsx b/packages/cmsui/components/RecurrenceWidget/Components/IntervalField.tsx new file mode 100644 index 00000000000..936246673c9 --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/Components/IntervalField.tsx @@ -0,0 +1,35 @@ +import { Group } from 'react-aria-components'; +import { TextField, Input, Label } from '@plone/components/quanta'; +import type { Updater } from '@tanstack/react-form'; +import { widgetTailwindClasses } from '../utils'; + +interface IntervalFieldProps { + labelAfter?: string; + label: string; + onChange: (updater: Updater) => void; +} + +const IntervalField = ({ labelAfter, label, onChange }: IntervalFieldProps) => { + return ( +
+ + + { + const inputValue = Number(e); + onChange(inputValue); + }} + minValue={1} + defaultValue="1" + > + + + {labelAfter &&
{labelAfter}
} +
+
+ ); +}; + +export default IntervalField; diff --git a/packages/cmsui/components/RecurrenceWidget/Components/MonthOfTheYearField.tsx b/packages/cmsui/components/RecurrenceWidget/Components/MonthOfTheYearField.tsx new file mode 100644 index 00000000000..f65b8276497 --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/Components/MonthOfTheYearField.tsx @@ -0,0 +1,29 @@ +import type { Updater } from '@tanstack/react-form'; +import { useTranslation } from 'react-i18next'; +import { Select } from '@plone/components/quanta'; +import { getMonthOptions } from '../utils'; + +interface MonthOfTheYearFieldProps { + onChange: (updater: Updater) => void; + defaultValue: number; +} + +const MonthOfTheYearField = ({ + onChange, + defaultValue, +}: MonthOfTheYearFieldProps) => { + const { i18n } = useTranslation(); + const currentLocale = i18n.language; + const months = getMonthOptions(currentLocale); + return ( + { + if (formValues !== defaultValues) resetForm(); + if (value && isFrequency(value)) + field.handleChange(value); + }} + className={widgetTailwindClasses.fieldComponent} + defaultValue={Object.keys(OPTIONS.frequences).find( + (el) => el === formValues.freq, + )} + label={t('cmsui.recurrence.repeat')} + labelClassnames={widgetTailwindClasses.labelComponent} + items={selectOptions} + /> + + )} + /> + + {/* Sets how often the recurrence repeats. */} + {/*Eg. every x week, every y days, every z months */} + {OPTIONS.frequences[formValues.freq]?.interval && ( + ( + + )} + /> + )} + + {/* Only appears if recurrence is weekly */} + {/* i.e. if repeat value is byday (weekday) */} + {/*Eg. event repeats on each monday, each tuesday and thursday, etc. */} + {OPTIONS.frequences[formValues.freq]?.byday && ( + ( + + )} + /> + )} + + {/* Only appears if recurrence is monthly */} + {/* selection between: + - by month day (e.g. day 22 of the month) + - by week day (e.g. every third wednesday of the month) */} + {OPTIONS.frequences[formValues.freq]?.bymonth && ( + ( + + )} + key={formValues.freq} + /> + )} + + store.values} + children={(values) => { + if ( + values.freq === 'monthly' && + values.monthly === 'bymonthday' + ) { + return ( + ( + + + + )} + /> + ); + } else if ( + values.freq === 'monthly' && + values.monthly === 'byweekday' + ) + return ( + + +
The
+ ( + + )} + /> + ( + + )} + /> +
+
+ ); + }} + /> + + {/* Only appears if recurrence is yearly */} + {/* selection between: + - by month day (e.g. on january 3rd) + - by week day (e.g. on first monday of january) */} + {OPTIONS.frequences[formValues.freq]?.byyear && ( + ( + + )} + /> + )} + + store.values} + children={(values) => { + if ( + values.freq === 'yearly' && + values.yearly === 'bymonthday' + ) { + return ( + + + ( + + )} + /> + ( + + )} + /> + + + ); + } else if ( + values.freq === 'yearly' && + values.yearly === 'byday' + ) + return ( + + +
{t('cmsui.recurrence.on_the_label')}
+ ( + + )} + /> + ( + + )} + /> + {t('cmsui.recurrence.ofmonth_label')} + ( + + )} + /> +
+
+ ); + }} + /> + + ( + + )} + /> + + store.values.recurrenceEnd} + children={(recurrenceEnd) => { + if (recurrenceEnd === 'count') { + return ( + ( + + + + )} + /> + ); + } else if (recurrenceEnd === 'until') { + return ( + ( + + + + )} + /> + ); + } + }} + /> + +
+ {rruleText &&
{rruleText}
} +
+ +
+
+ +
+ +
+ + + + ); +}; + +export default RecurrenceWidgetModal; diff --git a/packages/cmsui/components/RecurrenceWidget/Components/SelectedDates.tsx b/packages/cmsui/components/RecurrenceWidget/Components/SelectedDates.tsx new file mode 100644 index 00000000000..7ea849e0b94 --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/Components/SelectedDates.tsx @@ -0,0 +1,131 @@ +import { useState, type PropsWithChildren } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getLocalizedMonth, getLocalizedWeekday } from '../utils'; +import { Heading } from 'react-aria-components'; +import DeleteIcon from '@plone/components/icons/bin.svg?react'; +import AddIcon from '@plone/components/icons/add.svg?react'; +import { Button } from '@plone/components/quanta'; + +interface SelectedDatesProps { + rruleDates: Date[]; + exdates?: Date[]; + editMode?: boolean; + excludeDate?: (d: Date) => void; + onToggleDate?: (d: Date) => void; +} + +const SelectedDateListItem = ({ children }: PropsWithChildren) => { + return ( +
  • + {children} +
  • + ); +}; + +const PAGE_SIZE = 20; +const INITIAL_COUNT = 20; + +const SelectedDates = ({ + rruleDates, + exdates = [], + editMode = false, + excludeDate, + onToggleDate, +}: SelectedDatesProps) => { + const { t, i18n } = useTranslation(); + const currentLocale = i18n.language; + + const [visibleCount, setVisibleCount] = useState(INITIAL_COUNT); + + const shownOccurrences = rruleDates.slice(0, visibleCount); + const remainingCount = rruleDates.length - visibleCount; + + return ( +
    + + {t('cmsui.recurrence.selected_dates')} + + {shownOccurrences.length > 0 ? ( +
      + {shownOccurrences.map((d, index) => { + const isExcluded = exdates.some((e) => e.getTime() === d.getTime()); + const date = `${getLocalizedWeekday(d.getDay() - 1, currentLocale, 'long')}, + ${getLocalizedMonth(d.getMonth() + 1, currentLocale, 'long')} + ${d.getDate()}, ${d.getFullYear()}`; + const handleToggle = onToggleDate + ? () => onToggleDate(d) + : excludeDate + ? () => excludeDate(d) + : undefined; + + return ( + +
      + {date} +
      + {editMode && ( +
      + {index === 0 && t('cmsui.recurrence.start_recurrence')} +
      + )} + {editMode && handleToggle && ( +
      + +
      + )} +
      + ); + })} + {remainingCount > 0 && ( + +
      + +
      +
      + )} +
    + ) : ( +
    {t('cmsui.recurrence.no_occurrences')}
    + )} +
    + ); +}; + +export default SelectedDates; diff --git a/packages/cmsui/components/RecurrenceWidget/Components/UntilEndField.tsx b/packages/cmsui/components/RecurrenceWidget/Components/UntilEndField.tsx new file mode 100644 index 00000000000..266d651c572 --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/Components/UntilEndField.tsx @@ -0,0 +1,40 @@ +import { DatePicker } from '@plone/components/quanta'; +import type { Updater } from '@tanstack/react-form'; +import { useAtomValue } from 'jotai'; +import { formAtom } from '../../../routes/atoms'; + +interface UntilEndFieldProps { + onChange: (updater: Updater) => void; +} + +const UntilEndField = ({ onChange }: UntilEndFieldProps) => { + const formContext = useAtomValue(formAtom); + + // @ts-ignore + const endDate = new Date(formContext.end); + const defaultYear = endDate.getFullYear(); + const defaultMonth = endDate.getMonth() + 1; + const defaultDay = endDate.getDate(); + + const today = new Date(); + + const month = defaultMonth < 10 ? `0${defaultMonth}` : defaultMonth; + + const defaultDate = endDate + ? `${defaultYear}-${month}-${defaultDay}` + : `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`; + + return ( + { + if (value && new Date(value).getFullYear().toString().length === 4) { + onChange(value); + } + }} + defaultValue={defaultDate} + className="**:dark:text-foreground" + resettable={false} + /> + ); +}; +export default UntilEndField; diff --git a/packages/cmsui/components/RecurrenceWidget/RecurrenceWidget.stories.tsx b/packages/cmsui/components/RecurrenceWidget/RecurrenceWidget.stories.tsx new file mode 100644 index 00000000000..4620b4d0f97 --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/RecurrenceWidget.stories.tsx @@ -0,0 +1,244 @@ +import { useMemo } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { I18nextProvider } from 'react-i18next'; +import { Provider, createStore } from 'jotai'; +import { RecurrenceWidget } from './RecurrenceWidget'; +import { formAtom } from '../../routes/atoms'; +import type { EventContent } from '@plone/types'; + +const translations = { + 'cmsui.recurrence.editRecurrence': 'Edit recurrence', + 'cmsui.recurrence.repeat': 'Repeat', + 'cmsui.recurrence.interval_label': 'Every', + 'cmsui.recurrence.repeaton_label': 'Repeat on', + 'cmsui.recurrence.ends_label': 'Ends', + 'cmsui.recurrence.on_the_label': 'The', + 'cmsui.recurrence.ofmonth_label': 'of month', + 'cmsui.recurrence.day_label': 'Day', + 'cmsui.recurrence.first_label': 'First', + 'cmsui.recurrence.second_label': 'Second', + 'cmsui.recurrence.third_label': 'Third', + 'cmsui.recurrence.fourth_label': 'Fourth', + 'cmsui.recurrence.last_label': 'Last', + 'cmsui.recurrence.monthdayError': 'Please select a valid day of the month', + 'cmsui.recurrence.bymonthday': 'By month day', + 'cmsui.recurrence.bymonthday_description': 'ex. Day 22 of the month', + 'cmsui.recurrence.byweekday': 'By week day', + 'cmsui.recurrence.byweekday_description': + 'ex. On the third tuesday of the month', + 'cmsui.recurrence.byday': 'By week day of a specific month', + 'cmsui.recurrence.byday_description': 'ex. On the first monday of february', + 'cmsui.recurrence.count': 'After a set number of occurrences', + 'cmsui.recurrence.count_description': 'ex. After 5 occurrences', + 'cmsui.recurrence.count_after': 'After', + 'cmsui.recurrence.count_occurrences': 'occurrence(s)', + 'cmsui.recurrence.infinite_occurences': + 'Set to 0 or leave empty for infinite occurrences', + 'cmsui.recurrence.until': 'On a set date', + 'cmsui.recurrence.until_description': 'on June 15th, 2050', + 'cmsui.recurrence.selected_dates': 'Selected Dates', + 'cmsui.recurrence.start_recurrence': 'Start of recurrence', + 'cmsui.recurrence.no_occurrences': 'No occurrences available', + 'cmsui.recurrence.other_dates': 'more dates', + 'cmsui.recurrence.show_more_dates': 'Show {{count}} more dates', + 'cmsui.recurrence.options.daily': 'Daily', + 'cmsui.recurrence.options.mondayfriday': 'Monday - Friday', + 'cmsui.recurrence.options.weekly': 'Weekly', + 'cmsui.recurrence.options.weekdays': 'Weekdays', + 'cmsui.recurrence.options.monthly': 'Monthly', + 'cmsui.recurrence.options.yearly': 'Yearly', + 'cmsui.recurrence.intervals.interval_daily': 'day(s)', + 'cmsui.recurrence.intervals.interval_weekly': 'week(s)', + 'cmsui.recurrence.intervals.interval_monthly': 'month(s)', + 'cmsui.recurrence.intervals.interval_yearly': 'year(s)', +} as const; + +const formatTranslation = (value: string, options?: Record) => + options + ? value.replace(/{{(.*?)}}/g, (_, match) => { + const key = String(match).trim(); + const replacement = options[key]; + return replacement === undefined ? '' : String(replacement); + }) + : value; + +const translate = (key: string, options?: Record) => { + const template = translations[key as keyof typeof translations]; + if (!template) return key; + return formatTranslation(template, options); +}; + +const storyI18n = { + language: 'en', + languages: ['en'], + isInitialized: true, + initializedStoreOnce: true, + options: { + ns: ['translation'], + defaultNS: 'translation', + fallbackLng: 'en', + react: { + useSuspense: false, + bindI18n: 'languageChanged', + bindI18nStore: '', + }, + }, + reportNamespaces: { addUsedNamespaces: () => {} }, + services: { backendConnector: {} }, + store: { on: () => {}, off: () => {} }, + t: translate, + getFixedT: () => translate, + hasLoadedNamespace: () => true, + changeLanguage: async () => 'en', + loadNamespaces: (_ns: string | string[], callback?: () => void) => { + callback?.(); + return Promise.resolve(); + }, + loadLanguages: ( + _lng: string, + _ns: string | string[], + callback?: () => void, + ) => { + callback?.(); + return Promise.resolve(); + }, + on: () => {}, + off: () => {}, + emit: () => {}, +} as const satisfies Record; + +interface StoryFormData { + start: string; + end: string; + recurrence?: string; + [key: string]: unknown; +} + +type RecurrenceWidgetStoryProps = { + label?: string; + name?: string; + value?: any; + onChange?: (value: string | null) => void; + formData: StoryFormData; +}; + +function StoryRecurrenceWidget({ + formData, + onChange, + ...widgetProps +}: RecurrenceWidgetStoryProps) { + const store = useMemo(() => { + const s = createStore(); + s.set(formAtom, formData as any); + return s; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handleChange = (rrule: string | null) => { + store.set(formAtom, { + ...(store.get(formAtom) as EventContent), + recurrence: rrule ?? '', + }); + onChange?.(rrule); + }; + + return ( + + +
    +
    + +
    +
    +
    +
    + ); +} + +const DEFAULT_FORM_DATA: StoryFormData = { + start: '2025-06-01T10:00:00', + end: '2025-12-31T10:00:00', +}; + +const meta = { + component: + RecurrenceWidget as React.ComponentType, + parameters: { + layout: 'fullscreen', + backgrounds: { disable: true }, + }, + argTypes: { + onChange: { action: 'onChange' }, + }, + tags: ['autodocs'], + args: { + label: 'Recurrence', + formData: DEFAULT_FORM_DATA, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; + +export const WithoutLabel: Story = { + render: (args) => , + args: { + label: undefined, + }, +}; + +export const WithDailyRecurrence: Story = { + render: (args) => , + args: { + formData: { + ...DEFAULT_FORM_DATA, + recurrence: 'RRULE:FREQ=DAILY', + }, + }, +}; + +export const WithWeeklyRecurrence: Story = { + render: (args) => , + args: { + formData: { + ...DEFAULT_FORM_DATA, + recurrence: 'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR', + }, + }, +}; + +export const WithWeekdaysRecurrence: Story = { + render: (args) => , + args: { + formData: { + ...DEFAULT_FORM_DATA, + recurrence: 'RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', + }, + }, +}; + +export const WithMonthlyRecurrence: Story = { + render: (args) => , + args: { + formData: { + ...DEFAULT_FORM_DATA, + recurrence: 'RRULE:FREQ=MONTHLY;BYMONTHDAY=15', + }, + }, +}; + +export const WithYearlyRecurrence: Story = { + render: (args) => , + args: { + formData: { + ...DEFAULT_FORM_DATA, + recurrence: 'RRULE:FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=1', + }, + }, +}; diff --git a/packages/cmsui/components/RecurrenceWidget/RecurrenceWidget.test.tsx b/packages/cmsui/components/RecurrenceWidget/RecurrenceWidget.test.tsx new file mode 100644 index 00000000000..e5d7d314588 --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/RecurrenceWidget.test.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RecurrenceWidget } from './RecurrenceWidget'; + +const Widget = RecurrenceWidget as React.ComponentType; + +const { mockRruleStr, mockGetRruleText } = vi.hoisted(() => ({ + mockRruleStr: vi.fn(), + mockGetRruleText: vi.fn(), +})); + +let mockFormContext: any = { recurrence: null }; + +vi.mock('jotai', () => ({ + atom: vi.fn(), + useAtomValue: () => mockFormContext, +})); + +vi.mock('../../routes/atoms', () => ({ + formAtom: Symbol('formAtom'), +})); + +vi.mock('./rrule', () => ({ + rrulestr: mockRruleStr, +})); + +vi.mock('./utils', () => ({ + getRruleText: mockGetRruleText, +})); + +vi.mock('react-aria-components', () => ({ + DialogTrigger: ({ children, isOpen }: any) => { + const childrenArray = Array.isArray(children) ? children : [children]; + return ( +
    + {childrenArray[0]} + {isOpen && childrenArray[1]} +
    + ); + }, + Group: ({ children }: any) =>
    {children}
    , +})); + +vi.mock('../Field/Field', () => ({ + Label: ({ children }: any) => , +})); + +vi.mock('@plone/components/icons/edit.svg?react', () => ({ + default: () => , +})); + +vi.mock('@plone/components/icons/bin.svg?react', () => ({ + default: () => , +})); + +vi.mock('@plone/components/quanta', () => ({ + Button: ({ children, onClick, onPress }: any) => ( + + ), +})); + +vi.mock('./Components/RecurrenceWidgetModal', () => ({ + default: ({ onSave, setIsModalOpen }: any) => ( +
    + + +
    + ), +})); + +vi.mock('./Components/SelectedDates', () => ({ + default: ({ rruleDates }: any) => ( +
    {rruleDates.length} dates
    + ), +})); + +const RRULE_STRING = 'RRULE:FREQ=WEEKLY;BYDAY=MO'; + +describe('RecurrenceWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFormContext = { recurrence: null }; + mockRruleStr.mockReturnValue({ all: vi.fn(() => []) }); + mockGetRruleText.mockReturnValue(undefined); + }); + + describe('rendering', () => { + it('renders without crashing when there is no recurrence', () => { + expect(() => render()).not.toThrow(); + }); + + it('renders label when prop is provided', () => { + render(); + expect(screen.getByText('Recurrence')).toBeInTheDocument(); + }); + + it('does not render label when prop is absent', () => { + const { container } = render(); + expect(container.querySelector('label')).toBeNull(); + }); + + it('always renders edit and delete buttons', () => { + render(); + expect(screen.getAllByRole('button')).toHaveLength(2); + }); + }); + + describe('delete button', () => { + it('calls onChange with null when clicked', () => { + const onChange = vi.fn(); + render(); + const [, deleteButton] = screen.getAllByRole('button'); + fireEvent.click(deleteButton); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('does not throw when clicked without onChange', () => { + render(); + const [, deleteButton] = screen.getAllByRole('button'); + expect(() => fireEvent.click(deleteButton)).not.toThrow(); + }); + + it('does not call onChange when edit button is clicked', () => { + const onChange = vi.fn(); + render(); + const [editButton] = screen.getAllByRole('button'); + fireEvent.click(editButton); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('modal', () => { + it('is closed on initial render', () => { + render(); + expect(screen.getByTestId('dialog-trigger')).toHaveAttribute( + 'data-open', + 'false', + ); + expect(screen.queryByTestId('recurrence-modal')).not.toBeInTheDocument(); + }); + + it('opens when edit button is clicked', () => { + render(); + const [editButton] = screen.getAllByRole('button'); + fireEvent.click(editButton); + expect(screen.getByTestId('dialog-trigger')).toHaveAttribute( + 'data-open', + 'true', + ); + expect(screen.getByTestId('recurrence-modal')).toBeInTheDocument(); + }); + + it('calls onChange with the rrule string when modal saves', () => { + const onChange = vi.fn(); + render(); + const [editButton] = screen.getAllByRole('button'); + fireEvent.click(editButton); + fireEvent.click(screen.getByTestId('modal-save')); + expect(onChange).toHaveBeenCalledWith('RRULE:FREQ=WEEKLY'); + }); + + it('does not throw on modal save when onChange is not provided', () => { + render(); + const [editButton] = screen.getAllByRole('button'); + fireEvent.click(editButton); + expect(() => + fireEvent.click(screen.getByTestId('modal-save')), + ).not.toThrow(); + }); + + it('closes when setIsModalOpen(false) is called from modal', () => { + render(); + const [editButton] = screen.getAllByRole('button'); + fireEvent.click(editButton); + expect(screen.getByTestId('recurrence-modal')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(screen.queryByTestId('recurrence-modal')).not.toBeInTheDocument(); + expect(screen.getByTestId('dialog-trigger')).toHaveAttribute( + 'data-open', + 'false', + ); + }); + }); + + describe('recurrence display', () => { + it('does not show rrule text when recurrence is null', () => { + mockGetRruleText.mockReturnValue('every week'); + render(); + expect(screen.queryByText('every week')).not.toBeInTheDocument(); + }); + + it('does not show SelectedDates when recurrence is null', () => { + render(); + expect(screen.queryByTestId('selected-dates')).not.toBeInTheDocument(); + }); + + it('shows rrule text when recurrence is set', () => { + mockFormContext = { recurrence: RRULE_STRING }; + mockGetRruleText.mockReturnValue('every week on Monday'); + render(); + expect(screen.getByText('every week on Monday')).toBeInTheDocument(); + }); + + it('shows SelectedDates when recurrence produces dates', () => { + const dates = [new Date('2025-01-06'), new Date('2025-01-13')]; + mockFormContext = { recurrence: RRULE_STRING }; + mockRruleStr.mockReturnValue({ all: vi.fn(() => dates) }); + render(); + expect(screen.getByTestId('selected-dates')).toBeInTheDocument(); + expect(screen.getByText('2 dates')).toBeInTheDocument(); + }); + + it('does not show SelectedDates when recurrence produces no dates', () => { + mockFormContext = { recurrence: RRULE_STRING }; + mockRruleStr.mockReturnValue({ all: vi.fn(() => []) }); + render(); + expect(screen.queryByTestId('selected-dates')).not.toBeInTheDocument(); + }); + + it('passes the recurrence string to rrulestr', () => { + mockFormContext = { recurrence: RRULE_STRING }; + render(); + expect(mockRruleStr).toHaveBeenCalledWith(RRULE_STRING); + }); + + it('passes the parsed rrule object to getRruleText', () => { + const mockRrule = { all: vi.fn(() => []) }; + mockFormContext = { recurrence: RRULE_STRING }; + mockRruleStr.mockReturnValue(mockRrule); + render(); + expect(mockGetRruleText).toHaveBeenCalledWith(mockRrule); + }); + + it('does not call rrulestr when recurrence is null', () => { + render(); + expect(mockRruleStr).not.toHaveBeenCalled(); + }); + + it('passes null to getRruleText when recurrence is null', () => { + render(); + expect(mockGetRruleText).toHaveBeenCalledWith(null); + }); + }); +}); diff --git a/packages/cmsui/components/RecurrenceWidget/RecurrenceWidget.tsx b/packages/cmsui/components/RecurrenceWidget/RecurrenceWidget.tsx new file mode 100644 index 00000000000..d538b766736 --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/RecurrenceWidget.tsx @@ -0,0 +1,98 @@ +import { DialogTrigger, Group } from 'react-aria-components'; + +import { Label } from '../Field/Field'; +import EditIcon from '@plone/components/icons/edit.svg?react'; +import DeleteIcon from '@plone/components/icons/bin.svg?react'; + +import { Button } from '@plone/components/quanta'; + +import { lazy, Suspense, useMemo, useState } from 'react'; + +const RecurrenceWidgetModal = lazy( + () => import('./Components/RecurrenceWidgetModal'), +); +import { useAtomValue } from 'jotai'; +import { formAtom } from '../../routes/atoms'; +import type { FieldProps } from '../Form/Field'; + +import { rrulestr } from './rrule'; +import SelectedDates from './Components/SelectedDates'; +import { getRruleText } from './utils'; +import type { EventContent } from '@plone/types'; + +type RecurrenceWidgetProps = FieldProps; + +export function RecurrenceWidget({ label, onChange }: RecurrenceWidgetProps) { + const eventFormContext = useAtomValue(formAtom) as EventContent; + + // @ts-ignore + const recurrence = eventFormContext?.recurrence ?? null; + + const rrule = recurrence ? rrulestr(recurrence) : null; + const rruleText = getRruleText(rrule); + + const dates = useMemo(() => rrule?.all() ?? [], [rrule]); + + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + + {label && } +
    + + + {isModalOpen && ( + + { + if (onChange) onChange(rrule); + }} + setIsModalOpen={setIsModalOpen} + /> + + )} + + + +
    + + {recurrence && ( +
    +
    + {rruleText &&
    {rruleText}
    } +
    +
    + {dates.length > 0 && } +
    +
    + )} +
    + ); +} diff --git a/packages/cmsui/components/RecurrenceWidget/rrule.ts b/packages/cmsui/components/RecurrenceWidget/rrule.ts new file mode 100644 index 00000000000..b1a14f3b713 --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/rrule.ts @@ -0,0 +1,12 @@ +import * as RRuleLib from 'rrule'; +import type * as RRuleTypes from 'rrule'; + +type Options = RRuleTypes.Options; +type WeekdayStr = RRuleTypes.WeekdayStr; +type RRuleType = RRuleTypes.RRule; + +const { RRule, rrulestr, RRuleSet } = ((RRuleLib as any).default || + RRuleLib) as typeof RRuleTypes; + +export { rrulestr, RRule, RRuleSet }; +export type { Options, WeekdayStr, RRuleType }; diff --git a/packages/cmsui/components/RecurrenceWidget/utils.ts b/packages/cmsui/components/RecurrenceWidget/utils.ts new file mode 100644 index 00000000000..6ff58fbee7a --- /dev/null +++ b/packages/cmsui/components/RecurrenceWidget/utils.ts @@ -0,0 +1,205 @@ +import { RRule, RRuleSet, type RRuleType } from './rrule'; + +export const FREQUENCES = { + DAILY: 'daily', + MONDAYFRIDAY: 'mondayfriday', + WEEKDAYS: 'weekdays', + WEEKLY: 'weekly', + MONTHLY: 'monthly', + YEARLY: 'yearly', +} as const; + +export type Frequency = (typeof FREQUENCES)[keyof typeof FREQUENCES]; + +export function isFrequency(value: any): value is Frequency { + return Object.values(FREQUENCES).includes(value as Frequency); +} + +type FrequencyOption = { + rrule: any; + interval?: boolean; + byday?: boolean; + bymonth?: boolean; + byyear?: boolean; +}; + +export const OPTIONS: { frequences: Record } = { + frequences: { + [FREQUENCES.DAILY]: { rrule: RRule.DAILY, interval: true }, + [FREQUENCES.MONDAYFRIDAY]: { rrule: RRule.WEEKLY }, + [FREQUENCES.WEEKDAYS]: { rrule: RRule.WEEKLY }, + [FREQUENCES.WEEKLY]: { rrule: RRule.WEEKLY, interval: true, byday: true }, + [FREQUENCES.MONTHLY]: { + rrule: RRule.MONTHLY, + interval: true, + bymonth: true, + }, + [FREQUENCES.YEARLY]: { rrule: RRule.YEARLY, interval: true, byyear: true }, + }, +}; + +export function getSelectOptions(t: (key: string) => string) { + return Object.entries(OPTIONS.frequences).map(([key]) => ({ + value: key as Frequency, + label: t(`cmsui.recurrence.options.${key as Frequency}`), + })); +} + +export const Days = { + MO: RRule.MO, + TU: RRule.TU, + WE: RRule.WE, + TH: RRule.TH, + FR: RRule.FR, + SA: RRule.SA, + SU: RRule.SU, +}; + +export const getDaysOptions = ( + currentLocale: string, +): { value: number; label: string }[] => { + return (Object.keys(Days) as Array).map((d) => ({ + value: Days[d].weekday, + label: getLocalizedWeekday(Days[d].weekday, currentLocale, 'long'), + })); +}; + +export const WEEKLY_DAYS = [Days.MO, Days.TU, Days.WE, Days.TH, Days.FR]; +export const MONDAYFRIDAY_DAYS = [Days.MO, Days.FR]; + +export const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + +export const getMonthOptions = ( + currentLocale: string, +): { value: number; label: string }[] => { + return months.map((m) => ({ + value: m, + label: getLocalizedMonth(m, currentLocale, 'long'), + })); +}; + +export const ORDINAL_NUMBERS = { + 1: 'first', + 2: 'second', + 3: 'third', + 4: 'fourth', + '-1': 'last', +}; + +export const getOrdinalNumbersOptions = ( + t: (key: string) => string, +): { value: number; label: string }[] => { + return ( + Object.keys(ORDINAL_NUMBERS) as Array + ).map((numb) => ({ + value: Number(numb), + label: getLocalizedOrdinalNumber(ORDINAL_NUMBERS[numb], t), + })); +}; + +export function getRruleText(rrule: RRuleType | null) { + if (rrule) { + const mainRule = rrule instanceof RRuleSet ? rrule.rrules()[0] : rrule; + const rruleText = mainRule?.toText(); + return rruleText; + } + return undefined; +} + +export function getLocalizedOrdinalNumber(string: string, t: any) { + return t(`cmsui.recurrence.${string}_label`); +} + +export function getLocalizedWeekday( + dayIndex: number, + locale: string = 'en-US', + format: 'long' | 'short' | 'narrow' | undefined, +) { + const baseDate = new Date(2024, 0, 1 + dayIndex); + return new Intl.DateTimeFormat(locale, { weekday: format }).format(baseDate); +} + +export function getLocalizedMonth( + monthIndex: number, + locale: string = 'en-US', + format: 'long' | 'short' | 'narrow' | 'numeric' | '2-digit' | undefined, +) { + const month = new Date(2000, monthIndex - 1).toLocaleString(locale, { + month: format, + }); + return month; +} + +export const getWeekday = (number: number) => { + const n = number === -1 ? 6 : number; //because sunday has index 0, but for rrule has index 6 + + const entry = Object.entries(Days).find(([, value]) => value.weekday === n); + + return entry ? entry[1] : null; +}; + +export type RecurrenceEndOption = 'until' | 'count'; +export type MonthlyOption = 'bymonthday' | 'byweekday'; +export type YearlyOption = 'bymonthday' | 'byday'; + +interface RadioGroupOptionsProps { + id: T; + title: string; + description: string; +} + +export function byMonthOptions( + t: any, +): RadioGroupOptionsProps[] { + return [ + { + id: 'bymonthday', + title: t('cmsui.recurrence.bymonthday'), + description: t('cmsui.recurrence.bymonthday_description'), + }, + { + id: 'byweekday', + title: t('cmsui.recurrence.byweekday'), + description: t('cmsui.recurrence.byweekday_description'), + }, + ]; +} + +export function byYearOptions(t: any): RadioGroupOptionsProps[] { + return [ + { + id: 'bymonthday', + title: t('cmsui.recurrence.bymonthday'), + description: t('cmsui.recurrence.bymonthday_description'), + }, + { + id: 'byday', + title: t('cmsui.recurrence.byday'), + description: t('cmsui.recurrence.byday_description'), + }, + ]; +} + +export function recurrenceEndOptions( + t: any, +): RadioGroupOptionsProps[] { + return [ + { + id: 'count', + title: t('cmsui.recurrence.count'), + description: t('cmsui.recurrence.count_description'), + }, + { + id: 'until', + title: t('cmsui.recurrence.until'), + description: t('cmsui.recurrence.until_description'), + }, + ]; +} + +export const widgetTailwindClasses = { + fieldComponent: 'flex items-center flex-row gap-0', + fieldGroupComponent: + 'flex items-center border-0 h-auto gap-2 px-4 py-2 hover:bg-quanta-snow', + labelComponent: 'basis-1/5 text-base', +}; diff --git a/packages/cmsui/components/Sidebar/Sidebar.tsx b/packages/cmsui/components/Sidebar/Sidebar.tsx index 60049f48f78..5a03641a6b2 100644 --- a/packages/cmsui/components/Sidebar/Sidebar.tsx +++ b/packages/cmsui/components/Sidebar/Sidebar.tsx @@ -7,6 +7,7 @@ export const sidebarAtom = atom(false); const sidebar = tv({ base: ` + fixed top-0 right-0 h-screen overflow-y-auto p-8 shadow-[0_12px_24px_0_var(--color-quanta-smoke)] transition-[width] duration-200 ease-linear `, variants: { diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts new file mode 100644 index 00000000000..9032a55a241 --- /dev/null +++ b/packages/cmsui/config/routes.ts @@ -0,0 +1,130 @@ +import type { ConfigType } from '@plone/registry'; + +export default function install(config: ConfigType) { + config.registerRoute({ + type: 'layout', + file: '@plone/cmsui/routes/layout.tsx', + children: [ + { + type: 'prefix', + path: 'login', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/auth/login.tsx', + }, + ], + }, + { + type: 'prefix', + path: 'logout', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/auth/logout.tsx', + }, + ], + }, + { + type: 'prefix', + path: '@@add', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/add.tsx', + }, + ], + }, + { + type: 'prefix', + path: '@@edit', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/edit.tsx', + }, + ], + }, + { + type: 'prefix', + path: 'controlpanel', + children: [ + { + type: 'index', + file: '@plone/cmsui/routes/controlpanels.tsx', + options: { + id: 'index-controlpanel', + }, + }, + { + type: 'route', + path: ':id', + file: '@plone/cmsui/routes/controlpanel.tsx', + }, + ], + }, + { + type: 'prefix', + path: 'test-layout', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/test.tsx', + }, + ], + }, + ], + }); + + config.registerRoute({ + type: 'prefix', + path: '@search', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/search.tsx', + }, + ], + }); + config.registerRoute({ + type: 'prefix', + path: '@breadcrumbs', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/breadcrumbs.tsx', + }, + ], + }); + config.registerRoute({ + type: 'prefix', + path: '@objectBrowserWidget', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/objectBrowserWidget.tsx', + }, + ], + }); + config.registerRoute({ + type: 'prefix', + path: '@createContent', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/api/createContent.tsx', + }, + ], + }); + + return config; +} diff --git a/packages/cmsui/config/slots.ts b/packages/cmsui/config/slots.ts new file mode 100644 index 00000000000..aa4071960cb --- /dev/null +++ b/packages/cmsui/config/slots.ts @@ -0,0 +1,24 @@ +import type { ConfigType } from '@plone/registry'; +import LoginLogo from '../components/Login/LoginLogo'; +import LoginHero from '../components/Login/LoginHero'; +import LoginActions from '../components/Login/LoginActions'; + +export default function installSlots(config: ConfigType) { + config.registerSlotComponent({ + name: 'LoginLogo', + slot: 'loginLogo', + component: LoginLogo, + }); + + config.registerSlotComponent({ + name: 'LoginHero', + slot: 'loginHero', + component: LoginHero, + }); + + config.registerSlotComponent({ + name: 'LoginActions', + slot: 'loginActions', + component: LoginActions, + }); +} diff --git a/packages/cmsui/config/widgets.ts b/packages/cmsui/config/widgets.ts index df49918545b..e88f79472d7 100644 --- a/packages/cmsui/config/widgets.ts +++ b/packages/cmsui/config/widgets.ts @@ -8,11 +8,17 @@ import { TextField, } from '@plone/components/quanta'; import { DateField } from '@plone/components'; +import { RecurrenceWidget } from '../components/RecurrenceWidget/RecurrenceWidget'; import { ObjectBrowserWidget } from '../components/ObjectBrowserWidget/ObjectBrowserWidget'; import ImageWidget from '../components/ImageWidget/ImageWidget'; export default function install(config: ConfigType) { config.registerDefaultWidget(TextField); + + config.registerWidget({ + key: 'id', + definition: { recurrence: RecurrenceWidget }, + }); config.registerWidget({ key: 'widget', definition: { date: DateField } }); config.registerWidget({ key: 'widget', diff --git a/packages/cmsui/index.ts b/packages/cmsui/index.ts index ec46f872559..ae2d59ce7fd 100644 --- a/packages/cmsui/index.ts +++ b/packages/cmsui/index.ts @@ -1,6 +1,8 @@ import type { ConfigType } from '@plone/registry'; import installWidgets from './config/widgets'; import installControlpanels from './config/controlpanels'; +import installRoutes from './config/routes'; +import installSlots from './config/slots'; import { formAtom } from './routes/atoms'; import type { BlockConfigBase } from '@plone/types'; @@ -24,120 +26,8 @@ export default function install(config: ConfigType) { installWidgets(config); installControlpanels(config); - - config.registerRoute({ - type: 'layout', - file: '@plone/cmsui/routes/layout.tsx', - children: [ - { - type: 'prefix', - path: 'login', - children: [ - { - type: 'route', - path: '*', - file: '@plone/cmsui/routes/auth/login.tsx', - }, - ], - }, - { - type: 'prefix', - path: 'logout', - children: [ - { - type: 'route', - path: '*', - file: '@plone/cmsui/routes/auth/logout.tsx', - }, - ], - }, - { - type: 'prefix', - path: '@@edit', - children: [ - { - type: 'route', - path: '*', - file: '@plone/cmsui/routes/edit.tsx', - }, - ], - }, - { - type: 'prefix', - path: 'controlpanel', - children: [ - { - type: 'index', - file: '@plone/cmsui/routes/controlpanels.tsx', - options: { - id: 'index-controlpanel', - }, - }, - { - type: 'route', - path: ':id', - file: '@plone/cmsui/routes/controlpanel.tsx', - }, - ], - }, - { - type: 'prefix', - path: 'test-layout', - children: [ - { - type: 'route', - path: '*', - file: '@plone/cmsui/routes/test.tsx', - }, - ], - }, - ], - }); - - config.registerRoute({ - type: 'prefix', - path: '@search', - children: [ - { - type: 'route', - path: '*', - file: '@plone/cmsui/routes/search.tsx', - }, - ], - }); - config.registerRoute({ - type: 'prefix', - path: '@breadcrumbs', - children: [ - { - type: 'route', - path: '*', - file: '@plone/cmsui/routes/breadcrumbs.tsx', - }, - ], - }); - config.registerRoute({ - type: 'prefix', - path: '@objectBrowserWidget', - children: [ - { - type: 'route', - path: '*', - file: '@plone/cmsui/routes/objectBrowserWidget.tsx', - }, - ], - }); - config.registerRoute({ - type: 'prefix', - path: '@createContent', - children: [ - { - type: 'route', - path: '*', - file: '@plone/cmsui/routes/api/createContent.tsx', - }, - ], - }); + installRoutes(config); + installSlots(config); return config; } diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json new file mode 100644 index 00000000000..fcd14d91200 --- /dev/null +++ b/packages/cmsui/locales/de/common.json @@ -0,0 +1,76 @@ +{ + "cmsui": { + "add": "Hinzufügen", + "edit": "Bearbeiten", + "save": "Speichern", + "controlpanel": "Kontrollzentrum", + "objectbrowserwidget": { + "openDialog": "Inhalt auswählen", + "dialogTitle": "Inhalt auswählen", + "closeDialog": "Auswahl schließen", + "openSearch": "Inhalt suchen", + "closeSearch": "Suche schließen", + "searchPlaceholder": "Suche Inhalte...", + "loading": "Lade...", + "routeannouncer": "Aktueller Pfad: {{route}}", + "noResults": "Keine Inhalte gefunden", + "searchResults": "Suchergebnisse: {{count}} Inhalte", + "searchResultsFor": "Suchergebnisse für \"{{searchTerm}}\": {{count}} ", + "goback": "Zurück", + "changeViewMode": "Zur {{mode}} umschalten", + "currentItems": "Aktuelle Inhalte", + "home": "Startseite", + "item": "{{title}}", + "itemSelected": "ausgewählt", + "itemNotSelectable": "nicht auswählbar", + "itemNavigateTo": "Zu {{title}} navigieren", + "canNavigateTo": "Ordnerähnlicher Inhalt", + "workflowStates": { + "private": "Privat", + "pending": "Ausstehend", + "published": "Veröffentlicht" + }, + "viewModes": { + "list": "Listenansicht", + "grid": "Kachelansicht" + } + }, + "panelgroups": { + "general": "Allgemein", + "content": "Inhalt", + "security": "Sicherheit", + "users": "Benutzer", + "site": "Seitenkonfiguration", + "addons": "Erweiterungen", + "maintenance": "Wartung" + }, + "paneltitles": { + "addons": "Erweiterungen", + "database": "Datenbank", + "contentRules": "Inhaltsregeln", + "undo": "Rückgängig machen", + "urlmanagement": "URL Verwaltung", + "relations": "Relationen", + "moderatecomments": "Kommentare moderieren", + "users": "Benutzer", + "groupMembership": "Gruppenmitgliedschaft", + "groups": "Gruppen" + }, + "blocksEditor": { + "blocksTab": "Blöcke", + "contentTab": "Inhalt" + }, + "sidebar": { + "label": "Seitenleiste" + }, + "auth": { + "username": "Benutzername", + "password": "Passwort", + "signIn": "Anmelden", + "signUp": "Registrieren", + "signInTo": "Bei {{site}} anmelden", + "forgotPassword": "Passwort vergessen? Neues Passwort anfordern", + "returnToHome": "Zur Startseite zurückkehren" + } + } +} diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index 71fcae06efd..46fd15f4285 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -1,7 +1,56 @@ { "cmsui": { + "add": "Add", "edit": "Edit", "save": "Save", + "recurrence": { + "editRecurrence": "Edit recurrence", + "repeat": "Repeat", + "interval_label": "Every", + "options": { + "daily": "Daily", + "mondayfriday": "Monday - Friday", + "weekly": "Weekly", + "weekdays": "Weekdays", + "monthly": "Monthly", + "yearly": "Yearly" + }, + "intervals": { + "interval_daily": "day(s)", + "interval_weekly": "week(s)", + "interval_monthly": "month(s)", + "interval_yearly": "year(s)" + }, + "repeaton_label": "Repeat every", + "bymonthday": "By month day", + "bymonthday_description": "ex. Day 22 of the month", + "byweekday": "By week day", + "byweekday_description": "ex. On the third tuesday of the month", + "byday": "By week day of a specific month", + "byday_description": "ex. On the first monday of february", + "on_the_label": "The", + "day_label": "Day", + "first_label": "First", + "second_label": "Second", + "third_label": "Third", + "fourth_label": "Fourth", + "last_label": "Last", + "ofmonth_label": "of month", + "monthdayError": "Please select a valid day of the month", + "ends_label": "Ends", + "count": "After a set number of occurrences", + "count_description": "ex. After 5 occurrences", + "until": "On a set date", + "until_description": "on June 15th, 2050", + "count_after": "After", + "count_occurrences": "occurrence(s)", + "infinite_occurrences": "Set to 0 or leave empty for infinite occurrences", + "selected_dates": "Selected Dates", + "no_occurrences": "No occurences available", + "start_recurrence": "Start of recurrence", + "other_dates": "more dates", + "show_more_dates": "Show more dates" + }, "controlpanel": "Control Panel", "objectbrowserwidget": { "openDialog": "Select content", @@ -61,6 +110,15 @@ }, "sidebar": { "label": "Sidebar" + }, + "auth": { + "username": "username", + "password": "password", + "signIn": "Sign in", + "signUp": "Sign up", + "signInTo": "Sign in to {{site}}", + "forgotPassword": "Forgot password? Request a new password", + "returnToHome": "Return to home page" } } } diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 480b330317e..7cd7fb030b2 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -1,7 +1,56 @@ { "cmsui": { + "add": "Aggiungi", "edit": "Modifica", "save": "Salva", + "recurrence": { + "editRecurrence": "Cambia la ricorrenza", + "repeat": "Ricorrenza", + "interval_label": "Ogni", + "options": { + "daily": "Giornaliera", + "mondayfriday": "Lunedi - Venerdi", + "weekly": "Settimanale", + "weekdays": "Giorni feriali", + "monthly": "Mensile", + "yearly": "Annuale" + }, + "intervals": { + "interval_daily": "giorni", + "interval_weekly": "settimana/e", + "interval_monthly": "mese/i", + "interval_yearly": "anno/i" + }, + "repeaton_label": "Ripeti ogni", + "bymonthday": "Per giorno del mese", + "bymonthday_description": "es. Giorno 22 del mese", + "byweekday": "Per giorno della settimana", + "byweekday_description": "es. Il terzo martedì del mese", + "byday": "Per giorno della settimana di un mese preciso", + "byday_description": "es. Il primo lunedì del mese di febbraio", + "on_the_label": "Il", + "day_label": "Giorno", + "first_label": "Primo", + "second_label": "Second", + "third_label": "Terzo", + "fourth_label": "Quarto", + "last_label": "Ultimo", + "ofmonth_label": "del mese", + "monthdayError": "Selezionare un giorno del mese valido", + "ends_label": "Termina", + "count": "Dopo un numero di occorrenze", + "count_description": "es. Dopo 5 occorrenze", + "until": "In una data precisa", + "until_description": "il 15 giugno 2050", + "count_after": "Dopo", + "count_occurrences": "occorrenza/e", + "infinite_occurrences": "Impostare a 0 o lasciare vuoto per ricorrenze infinite", + "selected_dates": "Date selezionate", + "no_occurrences": "Nessuna ricorrenza disponibile", + "start_recurrence": "Inizio ricorrenza", + "other_dates": "altre date", + "show_more_dates": "Mostra più date" + }, "controlpanel": "Pannello di controllo", "objectbrowserwidget": { "openDialog": "Seleziona contenuti", @@ -51,6 +100,15 @@ }, "sidebar": { "label": "Barra laterale destra" + }, + "auth": { + "username": "nome utente", + "password": "password", + "signIn": "Accedi", + "signUp": "Registrati", + "signInTo": "Accedi a {{site}}", + "forgotPassword": "Password dimenticata? Richiedi una nuova password", + "returnToHome": "Torna alla home page" } } } diff --git a/packages/cmsui/news/+contenttypes.breaking b/packages/cmsui/news/+contenttypes.breaking new file mode 100644 index 00000000000..dd2769fe3af --- /dev/null +++ b/packages/cmsui/news/+contenttypes.breaking @@ -0,0 +1 @@ +Refactored the `Content` type to properly match the basic Plone types and allow TypeScript to narrow this type automatically. @pnicolli \ No newline at end of file diff --git a/packages/cmsui/news/+fix-cancel-button-href.bugfix b/packages/cmsui/news/+fix-cancel-button-href.bugfix new file mode 100644 index 00000000000..7f8372a8b75 --- /dev/null +++ b/packages/cmsui/news/+fix-cancel-button-href.bugfix @@ -0,0 +1 @@ +Fix toolbar cancel button navigating to site root instead of current content page. @iFlameing diff --git a/packages/cmsui/news/+fix-sidebar-vertical-height.bugfix b/packages/cmsui/news/+fix-sidebar-vertical-height.bugfix new file mode 100644 index 00000000000..32401303bfa --- /dev/null +++ b/packages/cmsui/news/+fix-sidebar-vertical-height.bugfix @@ -0,0 +1 @@ +Fix the sidebar to take 100% vertical space. @frapell diff --git a/packages/cmsui/news/+fixobjectbrowser.bugfix b/packages/cmsui/news/+fixobjectbrowser.bugfix new file mode 100644 index 00000000000..1c16c22853a --- /dev/null +++ b/packages/cmsui/news/+fixobjectbrowser.bugfix @@ -0,0 +1 @@ +Fix several issues in the Object Browser widget: single selection mode now behaves correctly, clicking a non-folder item no longer attempts navigation, the maximum selection limit is ignored in single mode, and the API for specifying which item attributes to return has been renamed for consistency across the codebase. @iFlameing diff --git a/packages/cmsui/news/+layout-context-loader.internal b/packages/cmsui/news/+layout-context-loader.internal new file mode 100644 index 00000000000..92dc00a6204 --- /dev/null +++ b/packages/cmsui/news/+layout-context-loader.internal @@ -0,0 +1 @@ +Updated the CMS UI layout loader to read content and locale from route context. @pnicolli diff --git a/packages/cmsui/news/+recurrencewidget.feature b/packages/cmsui/news/+recurrencewidget.feature new file mode 100644 index 00000000000..1cefd605d44 --- /dev/null +++ b/packages/cmsui/news/+recurrencewidget.feature @@ -0,0 +1 @@ +Added recurrence widget. @sabrina-bongiovanni \ No newline at end of file diff --git a/packages/cmsui/news/+storybook.internal b/packages/cmsui/news/+storybook.internal new file mode 100644 index 00000000000..cc424aade8a --- /dev/null +++ b/packages/cmsui/news/+storybook.internal @@ -0,0 +1 @@ +Update to storybook 10. @sneridagh diff --git a/packages/cmsui/news/+unify-makefiles.internal b/packages/cmsui/news/+unify-makefiles.internal new file mode 100644 index 00000000000..5da674df4e4 --- /dev/null +++ b/packages/cmsui/news/+unify-makefiles.internal @@ -0,0 +1 @@ +Unify Makefile files across the packages. @ionlizarazu diff --git a/packages/cmsui/news/6649.feature b/packages/cmsui/news/6649.feature deleted file mode 100644 index f37df625361..00000000000 --- a/packages/cmsui/news/6649.feature +++ /dev/null @@ -1 +0,0 @@ -Added the left toolbar @pnicolli diff --git a/packages/cmsui/news/6656.bugfix b/packages/cmsui/news/6656.bugfix new file mode 100644 index 00000000000..fe850aaeada --- /dev/null +++ b/packages/cmsui/news/6656.bugfix @@ -0,0 +1 @@ +Renamed `quanta-lemmon` color to `quanta-lemon`. @arybakov05 \ No newline at end of file diff --git a/packages/cmsui/news/6656.feature b/packages/cmsui/news/6656.feature new file mode 100644 index 00000000000..2366575a995 --- /dev/null +++ b/packages/cmsui/news/6656.feature @@ -0,0 +1 @@ +Implement login view according to Volto Quanta UI. @arybakov05 \ No newline at end of file diff --git a/packages/cmsui/news/6718.internal b/packages/cmsui/news/6718.internal new file mode 100644 index 00000000000..776624c3fc3 --- /dev/null +++ b/packages/cmsui/news/6718.internal @@ -0,0 +1 @@ +Added acceptance test coverage for Maps block. diff --git a/packages/cmsui/news/7355.feature b/packages/cmsui/news/7355.feature deleted file mode 100644 index bdbf4b5ede5..00000000000 --- a/packages/cmsui/news/7355.feature +++ /dev/null @@ -1 +0,0 @@ -Added blocks editor layout with tabs @pnicolli \ No newline at end of file diff --git a/packages/cmsui/news/7827.breaking b/packages/cmsui/news/7827.breaking deleted file mode 100644 index 7df674fb248..00000000000 --- a/packages/cmsui/news/7827.breaking +++ /dev/null @@ -1,2 +0,0 @@ -Removed Cypress support. -Added Playwright support. Move all existing Cypress tests to Playwright. @sneridagh diff --git a/packages/cmsui/news/7921.feature b/packages/cmsui/news/7921.feature deleted file mode 100644 index bed35ce169d..00000000000 --- a/packages/cmsui/news/7921.feature +++ /dev/null @@ -1 +0,0 @@ -Somersault editor support. @sneridagh diff --git a/packages/cmsui/news/8015.breaking b/packages/cmsui/news/8015.breaking deleted file mode 100644 index 9ee31725de8..00000000000 --- a/packages/cmsui/news/8015.breaking +++ /dev/null @@ -1 +0,0 @@ -Remove resident TextField in CMSUI (former quanta one), use @plone/components one instead. @sneridagh diff --git a/packages/cmsui/news/8018.bugfix b/packages/cmsui/news/8018.bugfix deleted file mode 100644 index b0ef2691e24..00000000000 --- a/packages/cmsui/news/8018.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fixed hydration problems with the `@@edit` view. @sneridagh diff --git a/packages/cmsui/news/8246.feature b/packages/cmsui/news/8246.feature new file mode 100644 index 00000000000..6e29be5534a --- /dev/null +++ b/packages/cmsui/news/8246.feature @@ -0,0 +1 @@ +Integrate links with ObjectBrowser. @sneridagh diff --git a/packages/cmsui/package.json b/packages/cmsui/package.json index ce3ae2e7fb8..7fdd05033b7 100644 --- a/packages/cmsui/package.json +++ b/packages/cmsui/package.json @@ -9,7 +9,7 @@ ], "funding": "https://github.com/sponsors/plone", "license": "MIT", - "version": "1.0.0-alpha.1", + "version": "1.0.0-alpha.3", "repository": { "type": "git", "url": "https://github.com/plone/volto.git", @@ -24,7 +24,7 @@ "plone", "plone6", "react", - "helpers" + "cmsui" ], "publishConfig": { "access": "public" @@ -33,13 +33,17 @@ "main": "index.ts", "scripts": { "test": "vitest --coverage", - "check-ts": "tsc --project tsconfig.json", + "check:ts": "pnpm --filter seven run typegen && tsc --project tsconfig.json", "dry-release": "release-it --dry-run", "release": "release-it", "release-major-alpha": "release-it major --preRelease=alpha", "release-alpha": "release-it --preRelease=alpha", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", + "lint:fix": "eslint --max-warnings=0 './**/*.{js,jsx,ts,tsx}' --fix --no-error-on-unmatched-pattern", + "stylelint:fix": "sh -c 'if [ -f .stylelintrc ] || [ -f .stylelintrc.json ] || [ -f .stylelintrc.js ] || [ -f .stylelintrc.cjs ] || [ -f stylelint.config.js ] || [ -f stylelint.config.cjs ] || [ -f stylelint.config.mjs ]; then stylelint '''./**/*.{css,scss,less}''' --fix --allow-empty-input; else echo \"No local stylelint config, skipping\"; fi'", + "format": "pnpm prettier:fix && pnpm lint:fix && pnpm stylelint:fix" }, "peerDependencies": { "react": "^19.1.0", @@ -51,6 +55,8 @@ } }, "dependencies": { + "@platejs/floating": "^49.0.0", + "@platejs/link": "^49.1.1", "@plone/blocks": "workspace:*", "@plone/client": "workspace:*", "@plone/components": "workspace:*", @@ -60,26 +66,32 @@ "@plone/react-router": "workspace:*", "@plone/registry": "workspace:*", "@tanstack/react-form": "^1.3.3", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jotai": "^2.12.3", "jotai-optics": "^0.4.0", + "jwt-decode": "^4.0.0", + "lucide-react": "^0.544.0", "optics-ts": "^2.4.1", + "platejs": "^49.2.21", "react-aria": "catalog:", "react-aria-components": "catalog:", "react-i18next": "catalog:", "react-router": "catalog:", + "rrule": "^2.8.1", "tailwind-merge": "catalog:", "tailwind-variants": "catalog:", "tailwindcss": "catalog:", + "tailwindcss-react-aria-components": "^2.0.0", "usehooks-ts": "^3.1.1" }, "devDependencies": { "@plone/types": "workspace:*", - "@storybook/addon-docs": "^9.1.7", - "@storybook/addon-links": "^9.1.7", - "@storybook/react-vite": "^9.1.7", + "@storybook/addon-docs": "^10.4.0", + "@storybook/addon-links": "^10.4.0", + "@storybook/react-vite": "^10.4.0", "@tailwindcss/vite": "catalog:", - "@testing-library/jest-dom": "6.4.2", + "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/jest-axe": "^3.5.7", "@types/node": "catalog:", @@ -87,16 +99,14 @@ "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "catalog:", - "eslint-plugin-storybook": "^9.1.7", + "eslint-plugin-storybook": "^10.4.0", "jest-axe": "^8.0.0", "release-it": "catalog:", - "storybook": "^9.1.7", + "storybook": "^10.4.0", "tailwindcss-animate": "^1.0.7", - "tailwindcss-react-aria-components": "^2.0.0", "tsconfig": "workspace:*", "typescript": "catalog:", "vite": "catalog:", - "vite-tsconfig-paths": "^5.1.4", "vitest": "catalog:", "vitest-axe": "^0.1.0" } diff --git a/packages/cmsui/routes/add.test.tsx b/packages/cmsui/routes/add.test.tsx new file mode 100644 index 00000000000..fbb29d63b92 --- /dev/null +++ b/packages/cmsui/routes/add.test.tsx @@ -0,0 +1,258 @@ +import { expect, describe, it, vi, afterEach } from 'vitest'; +import config from '@plone/registry'; +import { loader, action } from './add'; +import { RouterContextProvider } from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; + +vi.mock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), +})); + +const mockSchema = { + title: 'Page', + fieldsets: [ + { id: 'default', title: 'Default', fields: ['title', 'description'] }, + ], + properties: { + title: { title: 'Title', type: 'string' }, + description: { title: 'Description', type: 'string' }, + }, + required: ['title'], +}; + +describe('Add route', () => { + afterEach(() => { + vi.restoreAllMocks(); + config.settings = {}; + }); + + describe('loader', () => { + it('should call getType with the type query parameter', async () => { + const getTypeMock = vi.fn().mockResolvedValue({ data: mockSchema }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { getType: getTypeMock } as any); + + const request = new Request( + 'http://example.com/my-folder/add?type=Document', + ); + + await loader({ + request, + params: { '*': 'my-folder' }, + context, + unstable_pattern: '/my-folder/add', + unstable_url: new URL(request.url), + }); + + expect(getTypeMock).toHaveBeenCalledWith({ type: 'Document' }); + }); + + it('should return schema and type from loader data', async () => { + const getTypeMock = vi.fn().mockResolvedValue({ data: mockSchema }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { getType: getTypeMock } as any); + + const request = new Request( + 'http://example.com/my-folder/add?type=Document', + ); + + const result = await loader({ + request, + params: { '*': 'my-folder' }, + context, + unstable_pattern: '/my-folder/add', + unstable_url: new URL(request.url), + }); + + expect((result as any).data).toEqual({ + schema: mockSchema, + type: 'Document', + }); + }); + + it('should throw redirect when type query param is missing', async () => { + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, {} as any); + + const request = new Request('http://example.com/my-folder/add'); + + await expect( + loader({ + request, + params: { '*': 'my-folder' }, + context, + unstable_pattern: '/my-folder/add', + unstable_url: new URL(request.url), + }), + ).rejects.toEqual( + expect.objectContaining({ + status: 302, + headers: expect.objectContaining( + new Headers({ Location: '/my-folder' }), + ), + }), + ); + }); + + it('should derive path from empty params as root', async () => { + const getTypeMock = vi.fn().mockResolvedValue({ data: mockSchema }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { getType: getTypeMock } as any); + + const request = new Request('http://example.com/add?type=Document'); + + // Should not throw; loader succeeds with empty params + const result = await loader({ + request, + params: {}, + context, + unstable_pattern: '/add', + unstable_url: new URL(request.url), + }); + + expect((result as any).data.type).toBe('Document'); + }); + }); + + describe('action', () => { + it('should call createContent with the correct path and body', async () => { + const createContentMock = vi.fn().mockResolvedValue({ + data: { id: 'new-page' }, + }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + createContent: createContentMock, + } as any); + + const body = { '@type': 'Document', title: 'New Page' }; + const request = new Request('http://example.com/my-folder/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + await action({ + request, + params: { '*': 'my-folder' }, + context, + unstable_pattern: '/my-folder/add', + unstable_url: new URL(request.url), + }); + + expect(createContentMock).toHaveBeenCalledWith({ + path: '/my-folder', + data: body, + }); + }); + + it('should redirect to the new content URL for non-root path', async () => { + const createContentMock = vi.fn().mockResolvedValue({ + data: { id: 'new-page' }, + }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + createContent: createContentMock, + } as any); + + const request = new Request('http://example.com/my-folder/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ '@type': 'Document', title: 'New Page' }), + }); + + const result = await action({ + request, + params: { '*': 'my-folder' }, + context, + unstable_pattern: '/my-folder/add', + unstable_url: new URL(request.url), + }); + + expect((result as Response).status).toBe(302); + expect((result as Response).headers.get('Location')).toBe( + '/my-folder/new-page', + ); + }); + + it('should redirect correctly for root path', async () => { + const createContentMock = vi.fn().mockResolvedValue({ + data: { id: 'new-page' }, + }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + createContent: createContentMock, + } as any); + + const request = new Request('http://example.com/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ '@type': 'Document', title: 'New Page' }), + }); + + const result = await action({ + request, + params: {}, + context, + unstable_pattern: '/add', + unstable_url: new URL(request.url), + }); + + expect((result as Response).status).toBe(302); + expect((result as Response).headers.get('Location')).toBe('/new-page'); + }); + }); + + describe('Add component', () => { + it('should render ContentForm with correct props', async () => { + // Reset module registry so doMock takes effect on fresh imports + vi.resetModules(); + + vi.doMock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), + })); + + vi.doMock('react-router', async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { + ...actual, + useLoaderData: () => ({ + schema: mockSchema, + type: 'Document', + }), + }; + }); + + vi.doMock('../components/ContentForm/ContentForm', () => ({ + default: (props: any) => ( +
    + {props.heading} +
    + ), + })); + + // Dynamic imports to pick up the mocks + const { render, screen } = await import('@testing-library/react'); + const { default: AddMocked } = await import('./add'); + + render(); + + const form = screen.getByTestId('content-form'); + expect(form).toHaveAttribute('data-submit-method', 'post'); + expect(form).toHaveAttribute('data-content-type', 'Document'); + expect(form).toHaveAttribute('data-content-title', ''); + expect(form.textContent).toContain('Page'); + }); + }); +}); diff --git a/packages/cmsui/routes/add.tsx b/packages/cmsui/routes/add.tsx new file mode 100644 index 00000000000..a75c72d0fb9 --- /dev/null +++ b/packages/cmsui/routes/add.tsx @@ -0,0 +1,78 @@ +import { flattenToAppURL } from '@plone/helpers'; +import { requireAuthCookie } from '@plone/react-router'; +import type { Content } from '@plone/types'; +import { + data, + redirect, + useLoaderData, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type RouterContextProvider, +} from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import ContentForm from '../components/ContentForm/ContentForm'; + +export async function loader({ + params, + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + + const path = `/${params['*'] || ''}`; + const cli = context.get(ploneClientContext); + const query = Object.fromEntries(new URL(request.url).searchParams.entries()); + + if (!query.type) { + // TODO warn about the problem? Or maybe return error 400? + throw redirect(path); + } + + const { data: schema } = await cli.getType({ type: query.type }); + + return data(flattenToAppURL({ schema, type: query.type })); +} + +export async function action({ + params, + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + + const path = `/${params['*'] || ''}`; + const formData = await request.json(); + + const createdContent = await cli.createContent({ + path, + data: formData, + }); + + const isRoot = /\/$/.test(path); + const redir = `${isRoot ? path : `${path}/`}${createdContent.data.id}`; + return redirect(redir); +} + +export default function Add() { + const { schema, type } = useLoaderData(); + const { t } = useTranslation(); + + const emptyContent = { + '@type': type, + title: '', + blocks: {}, + blocks_layout: { items: [] }, + } as unknown as Content; + + return ( + + ); +} diff --git a/packages/cmsui/routes/api/createContent.test.tsx b/packages/cmsui/routes/api/createContent.test.tsx index 01c7e5dd6e1..993c8b97fdd 100644 --- a/packages/cmsui/routes/api/createContent.test.tsx +++ b/packages/cmsui/routes/api/createContent.test.tsx @@ -1,12 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import config from '@plone/registry'; import { action } from './createContent'; +import { RouterContextProvider } from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; describe('createContent API route action', () => { afterEach(() => { vi.restoreAllMocks(); config.settings = {}; - delete config.utilities['ploneClient']; }); it('calls createContent with wildcard path and returns response data', async () => { @@ -18,16 +19,10 @@ describe('createContent API route action', () => { }); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - config: { - token: undefined, - }, - createContent: createContentMock, - }), - }); + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + createContent: createContentMock, + } as any); const request = new Request('http://example.com/@createContent/folder', { method: 'POST', @@ -51,8 +46,10 @@ describe('createContent API route action', () => { const response = await action({ request, params: { '*': 'folder' }, - context: {}, - } as any); + context, + unstable_pattern: '/@createContent/folder', + unstable_url: new URL(request.url), + }); expect(createContentMock).toHaveBeenCalledWith({ path: '/folder', diff --git a/packages/cmsui/routes/api/createContent.tsx b/packages/cmsui/routes/api/createContent.tsx index e3f019c1e29..3ee51e76f12 100644 --- a/packages/cmsui/routes/api/createContent.tsx +++ b/packages/cmsui/routes/api/createContent.tsx @@ -1,25 +1,22 @@ -import { data, type ActionFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { + data, + RouterContextProvider, + type ActionFunctionArgs, +} from 'react-router'; import { flattenToAppURL } from '@plone/helpers'; -import { getAuthFromRequest } from '@plone/react-router'; -import config from '@plone/registry'; +import { ploneClientContext } from 'seven/app/middleware.server'; type CreateContentRequest = { path?: string; data?: Record; }; -export async function action({ params, request }: ActionFunctionArgs) { - const token = await getAuthFromRequest(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; +export async function action({ + params, + request, + context, +}: ActionFunctionArgs) { + const cli = context.get(ploneClientContext); const body = (await request.json()) as CreateContentRequest; const pathFromParams = `/${params['*'] || ''}`; diff --git a/packages/cmsui/routes/atoms.ts b/packages/cmsui/routes/atoms.ts index 3fd5c84e450..9cc66b98713 100644 --- a/packages/cmsui/routes/atoms.ts +++ b/packages/cmsui/routes/atoms.ts @@ -8,3 +8,5 @@ export const formAtom = atom({} as Content); export const blockAtomFamily = atomFamily((id: string) => focusAtom(formAtom, (optic) => optic.prop('blocks').prop(id)), ); + +export const recurrenceAtom = atom(null); diff --git a/packages/cmsui/routes/auth/login.tsx b/packages/cmsui/routes/auth/login.tsx index 2c91780731c..85bcf75287f 100644 --- a/packages/cmsui/routes/auth/login.tsx +++ b/packages/cmsui/routes/auth/login.tsx @@ -1,25 +1,47 @@ import { Form, useActionData, + useLoaderData, redirect, type ActionFunctionArgs, + type LoaderFunctionArgs, + RouterContextProvider, + useLocation, + type MetaFunction, } from 'react-router'; - +import { jwtDecode } from 'jwt-decode'; import { - redirectIfLoggedInLoader, - setAuthOnResponse, -} from '@plone/react-router'; -import { Button, TextField } from '@plone/components/quanta'; -import ploneSvg from '../../static/plone-white.svg'; -import ArrowRightSVG from '@plone/components/icons/arrow-right.svg?react'; + ploneClientContext, + ploneContentContext, + ploneSiteContext, +} from 'seven/app/middleware.server'; +import { getAuthFromRequest, setAuthOnResponse } from '@plone/react-router'; +import { TextField, Link } from '@plone/components/quanta'; +import CloseSVG from '@plone/components/icons/close.svg?react'; +import SlotRenderer from '@plone/layout/slots/SlotRenderer'; +import { Trans, useTranslation } from 'react-i18next'; +import type { RootLoader } from 'seven/app/root'; + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + const token = await getAuthFromRequest(request); + if (token) throw redirect('/'); + + const content = context.get(ploneContentContext); + const site = context.get(ploneSiteContext); + return { content, siteTitle: site['plone.site_title'] }; +} -import type PloneClient from '@plone/client'; -import config from '@plone/registry'; +export const meta: MetaFunction = ({ + matches, +}) => { + const rootData = matches.find((match) => match.id === 'root')?.data; -export const loader = redirectIfLoggedInLoader; + const siteTitle = rootData?.site?.['plone.site_title']; -export const meta = () => { - return [{ title: 'Plone Login' }]; + return [{ title: siteTitle || 'Login' }]; }; type LoginErrorResponse = { @@ -31,22 +53,26 @@ type LoginErrorResponse = { }; }; -export async function action({ request }: ActionFunctionArgs) { +export async function action({ + request, + context, +}: ActionFunctionArgs) { const formData = await request.formData(); const username = String(formData.get('username') || ''); const password = String(formData.get('password') || ''); - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; + const cli = context.get(ploneClientContext); try { - const { data } = await cli.login({ username, password }); + const { data } = await cli.login({ data: { login: username, password } }); + const decodedToken = jwtDecode<{ + sub: string; + exp: number; + fullname: string | null; + }>(data.token); + const expires = new Date(decodedToken.exp * 1000); const response = redirect('/'); - return await setAuthOnResponse(response, data.token); + return await setAuthOnResponse(response, data.token, { expires }); } catch (error: any) { return { status: Number(error?.status) || 500, @@ -60,59 +86,97 @@ export async function action({ request }: ActionFunctionArgs) { } export default function Login() { + const { content, siteTitle } = useLoaderData(); const actionResult = useActionData() as | LoginErrorResponse | undefined; + const location = useLocation(); + const { t } = useTranslation(); return ( -
    +
    *:nth-child(2)]:grid-cols-[minmax(50%,1fr)_auto] + `} + > +
    +
    + + + +
    + +

    + {t('cmsui.auth.signInTo', { site: siteTitle || 'Volto' })} +

    +
    +
    +
    +
    + +
    + + + }} + /> + +
    + + +
    +
    +
    +
    - -
    -
    -
    - - - - - -
    +
    ); diff --git a/packages/cmsui/routes/auth/logout.tsx b/packages/cmsui/routes/auth/logout.tsx index 996a97d3297..5a372c657cd 100644 --- a/packages/cmsui/routes/auth/logout.tsx +++ b/packages/cmsui/routes/auth/logout.tsx @@ -1,28 +1,25 @@ -import { type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { RouterContextProvider, type LoaderFunctionArgs } from 'react-router'; import { getAuthFromRequest, redirectWithClearedCookie, } from '@plone/react-router'; -import config from '@plone/registry'; +// import { ploneClientContext } from 'seven/app/middleware.server'; -export async function loader({ request }: LoaderFunctionArgs) { - const token = await getAuthFromRequest(request); +export async function loader({ + request, + // context, +}: LoaderFunctionArgs) { + await getAuthFromRequest(request); + // const token = await getAuthFromRequest(request); - if (token) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; + // if (token) { + // const cli = context.get(ploneClientContext); - // this does not exist yet in @plone/client - // but it's also not needed by default - // see https://6.docs.plone.org/plone.restapi/docs/source/usage/authentication.html - // await cli.logout(); - } + // this does not exist yet in @plone/client + // but it's also not needed by default + // see https://6.docs.plone.org/plone.restapi/docs/source/usage/authentication.html + // await cli.logout(); + // } return redirectWithClearedCookie(); } diff --git a/packages/cmsui/routes/breadcrumbs.tsx b/packages/cmsui/routes/breadcrumbs.tsx index 67d2d75174b..cf972595d32 100644 --- a/packages/cmsui/routes/breadcrumbs.tsx +++ b/packages/cmsui/routes/breadcrumbs.tsx @@ -1,20 +1,16 @@ -import { data, type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; import { flattenToAppURL } from '@plone/helpers'; -import { getAuthFromRequest } from '@plone/react-router'; -import config from '@plone/registry'; -export async function loader({ params, request }: LoaderFunctionArgs) { - const token = await getAuthFromRequest(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; +export async function loader({ + params, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; diff --git a/packages/cmsui/routes/controlpanel.tsx b/packages/cmsui/routes/controlpanel.tsx index cac78dd49bb..a4710b54b28 100644 --- a/packages/cmsui/routes/controlpanel.tsx +++ b/packages/cmsui/routes/controlpanel.tsx @@ -1,5 +1,6 @@ import { redirect, + RouterContextProvider, useFetcher, useLoaderData, useNavigate, @@ -8,8 +9,8 @@ import { } from 'react-router'; import { useTranslation } from 'react-i18next'; import { atom } from 'jotai'; +import { ploneClientContext } from 'seven/app/middleware.server'; import type { DeepKeys } from '@tanstack/react-form'; -import type PloneClient from '@plone/client'; import { requireAuthCookie } from '@plone/react-router'; import { InitAtoms } from '@plone/helpers'; import type { @@ -31,35 +32,29 @@ import Back from '@plone/components/icons/arrow-left.svg?react'; import Checkbox from '@plone/components/icons/checkbox.svg?react'; import config from '@plone/registry'; -export async function loader({ params, request }: LoaderFunctionArgs) { - const token = await requireAuthCookie(request); +export async function loader({ + params, + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); const panel_id = params.id || 'navigation'; - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; + const cli = context.get(ploneClientContext); - cli.config.token = token; - - const { data: controlpanel } = await cli.getControlpanel({ path: panel_id }); + const { data: controlpanel } = await cli.getControlpanel({ id: panel_id }); return { controlpanel }; } -export async function action({ params, request }: ActionFunctionArgs) { - const token = await requireAuthCookie(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; +export async function action({ + params, + request, + // context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); - cli.config.token = token; + // const cli = context.get(ploneClientContext); // const path = `/${params['*'] || ''}`; diff --git a/packages/cmsui/routes/controlpanels.tsx b/packages/cmsui/routes/controlpanels.tsx index 5efd050a6e7..b0be3450189 100644 --- a/packages/cmsui/routes/controlpanels.tsx +++ b/packages/cmsui/routes/controlpanels.tsx @@ -1,33 +1,34 @@ import { + RouterContextProvider, useLoaderData, useNavigate, type LoaderFunctionArgs, } from 'react-router'; import { useTranslation } from 'react-i18next'; -import type PloneClient from '@plone/client'; +import { ploneClientContext } from 'seven/app/middleware.server'; import { requireAuthCookie } from '@plone/react-router'; import { Button, Container } from '@plone/components/quanta'; import { Plug } from '@plone/layout/components/Pluggable'; import ControlPanelsList from '../components/ControlPanel/ControlPanelsList'; import VersionOverview from '../components/VersionOverview/VersionOverview'; import Back from '@plone/components/icons/arrow-left.svg?react'; -import config from '@plone/registry'; -export async function loader({ params, request }: LoaderFunctionArgs) { - const token = await requireAuthCookie(request); +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; + const cli = context.get(ploneClientContext); - cli.config.token = token; - - const { data: controlpanels } = await cli.getControlpanels(); - const { data: systemInformation } = await cli.getSystem(); - return { controlpanels, systemInformation }; + const [controlpanelsRes, sysInfoRes] = await Promise.all([ + cli.getControlpanels(), + cli.getSystem(), + ]); + return { + controlpanels: controlpanelsRes.data, + systemInformation: sysInfoRes.data, + }; } export default function ControlPanels() { diff --git a/packages/cmsui/routes/edit.test.tsx b/packages/cmsui/routes/edit.test.tsx new file mode 100644 index 00000000000..7837256b1e5 --- /dev/null +++ b/packages/cmsui/routes/edit.test.tsx @@ -0,0 +1,216 @@ +import { expect, describe, it, vi, afterEach } from 'vitest'; +import config from '@plone/registry'; +import { loader, action } from './edit'; +import { RouterContextProvider } from 'react-router'; +import { + ploneClientContext, + ploneContentContext, +} from 'seven/app/middleware.server'; + +vi.mock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), +})); + +const mockSchema = { + title: 'Page', + fieldsets: [ + { id: 'default', title: 'Default', fields: ['title', 'description'] }, + ], + properties: { + title: { title: 'Title', type: 'string' }, + description: { title: 'Description', type: 'string' }, + }, + required: ['title'], +}; + +const mockContent = { + '@id': 'http://example.com/++api++/my-page', + '@type': 'Document', + title: 'My Page', + description: 'A test page', + blocks: {}, + blocks_layout: { items: [] }, +}; + +describe('Edit route', () => { + afterEach(() => { + vi.restoreAllMocks(); + config.settings = {}; + }); + + describe('loader', () => { + it('should call getType with the content @type', async () => { + const getTypeMock = vi.fn().mockResolvedValue({ data: mockSchema }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { getType: getTypeMock } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request('http://example.com/my-page/edit'); + + await loader({ + request, + params: { '*': 'my-page' }, + context, + unstable_pattern: '/my-page/edit', + unstable_url: new URL(request.url), + }); + + expect(getTypeMock).toHaveBeenCalledWith({ type: 'Document' }); + }); + + it('should return content and schema from loader data', async () => { + const getTypeMock = vi.fn().mockResolvedValue({ data: mockSchema }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { getType: getTypeMock } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request('http://example.com/my-page/edit'); + + const result = await loader({ + request, + params: { '*': 'my-page' }, + context, + unstable_pattern: '/my-page/edit', + unstable_url: new URL(request.url), + }); + + const resultData = (result as any).data; + expect(resultData.schema).toEqual(mockSchema); + // flattenToAppURL strips the API path prefix from @id + expect(resultData.content['@id']).toBe('/++api++/my-page'); + expect(resultData.content.title).toBe('My Page'); + }); + }); + + describe('action', () => { + it('should call updateContent with the correct path and body', async () => { + const updateContentMock = vi.fn().mockResolvedValue({}); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + updateContent: updateContentMock, + } as any); + + const body = { title: 'Updated Title' }; + const request = new Request('http://example.com/my-page/edit', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + await action({ + request, + params: { '*': 'my-page' }, + context, + unstable_pattern: '/my-page/edit', + unstable_url: new URL(request.url), + }); + + expect(updateContentMock).toHaveBeenCalledWith({ + path: '/my-page', + data: body, + }); + }); + + it('should redirect back to the same path', async () => { + const updateContentMock = vi.fn().mockResolvedValue({}); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + updateContent: updateContentMock, + } as any); + + const request = new Request('http://example.com/my-page/edit', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Updated' }), + }); + + const result = await action({ + request, + params: { '*': 'my-page' }, + context, + unstable_pattern: '/my-page/edit', + unstable_url: new URL(request.url), + }); + + expect((result as Response).status).toBe(302); + expect((result as Response).headers.get('Location')).toBe('/my-page'); + }); + + it('should handle root path', async () => { + const updateContentMock = vi.fn().mockResolvedValue({}); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + updateContent: updateContentMock, + } as any); + + const request = new Request('http://example.com/edit', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Updated' }), + }); + + const result = await action({ + request, + params: {}, + context, + unstable_pattern: '/edit', + unstable_url: new URL(request.url), + }); + + expect((result as Response).status).toBe(302); + expect((result as Response).headers.get('Location')).toBe('/'); + }); + }); + + describe('Edit component', () => { + it('should render ContentForm with correct props', async () => { + // Reset module registry so doMock takes effect on fresh imports + vi.resetModules(); + + vi.doMock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), + })); + + vi.doMock('react-router', async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { + ...actual, + useLoaderData: () => ({ + content: mockContent, + schema: mockSchema, + }), + }; + }); + + vi.doMock('../components/ContentForm/ContentForm', () => ({ + default: (props: any) => ( +
    + {props.heading} +
    + ), + })); + + // Dynamic imports to pick up the mocks + const { render, screen } = await import('@testing-library/react'); + const { default: EditMocked } = await import('./edit'); + + render(); + + const form = screen.getByTestId('content-form'); + expect(form).toHaveAttribute('data-submit-method', 'patch'); + expect(form).toHaveAttribute('data-content-type', 'Document'); + expect(form).toHaveAttribute('data-content-title', 'My Page'); + expect(form.textContent).toContain('My Page'); + }); + }); +}); diff --git a/packages/cmsui/routes/edit.tsx b/packages/cmsui/routes/edit.tsx index fc84de87b2f..36395f96eb6 100644 --- a/packages/cmsui/routes/edit.tsx +++ b/packages/cmsui/routes/edit.tsx @@ -1,73 +1,44 @@ -import { useTranslation } from 'react-i18next'; -import { useRef } from 'react'; +import { flattenToAppURL } from '@plone/helpers'; +import { requireAuthCookie } from '@plone/react-router'; import { data, redirect, - useFetcher, + RouterContextProvider, useLoaderData, type ActionFunctionArgs, type LoaderFunctionArgs, - type SubmitTarget, } from 'react-router'; -import type PloneClient from '@plone/client'; -import config from '@plone/registry'; -import { requireAuthCookie } from '@plone/react-router'; -import type { DeepKeys } from '@tanstack/react-form'; -import { flattenToAppURL, InitAtoms } from '@plone/helpers'; -import type { Content } from '@plone/types'; -import { Plug } from '@plone/layout/components/Pluggable'; -import Checkbox from '@plone/components/icons/checkbox.svg?react'; -import Close from '@plone/components/icons/close.svg?react'; - -import { useAppForm } from '../components/Form/Form'; -import { Link } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; import { - Accordion, - AccordionItem, - AccordionPanel, - AccordionItemTrigger, - Tabs, -} from '@plone/components/quanta'; -import BlocksEditor from '../components/BlockEditor/BlocksEditor'; - -// import { ConsoleLog } from '../helpers/debug'; -import { formAtom } from './atoms'; -import { createStore, Provider } from 'jotai'; - -export async function loader({ params, request }: LoaderFunctionArgs) { - const token = await requireAuthCookie(request); + ploneClientContext, + ploneContentContext, +} from 'seven/app/middleware.server'; +import ContentForm from '../components/ContentForm/ContentForm'; - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); - cli.config.token = token; + const cli = context.get(ploneClientContext); + const content = context.get(ploneContentContext); - const path = `/${params['*'] || ''}`; - - const { data: content } = await cli.getContent({ path }); - const { data: schema } = await cli.getType({ contentType: content['@type'] }); + const { data: schema } = await cli.getType({ type: content['@type'] }); return data(flattenToAppURL({ content, schema })); } -export async function action({ params, request }: ActionFunctionArgs) { - const token = await requireAuthCookie(request); +export async function action({ + params, + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; + const cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; - const formData = await request.json(); await cli.updateContent({ @@ -81,111 +52,13 @@ export async function action({ params, request }: ActionFunctionArgs) { export default function Edit() { const { content, schema } = useLoaderData(); const { t } = useTranslation(); - const fetcher = useFetcher(); - const storeRef = useRef(createStore()); - const store = storeRef.current; - - const form = useAppForm({ - defaultValues: content, - onSubmit: async () => { - fetcher.submit(store.get(formAtom) as unknown as SubmitTarget, { - method: 'post', - encType: 'application/json', - }); - - return redirect(`/${content['@id']}`); - }, - }); return ( - - -
    - , - }, - { - id: 'content', - title: t('cmsui.blocksEditor.contentTab'), - content: ( -
    -

    - {content.title} - {t('cmsui.edit')} -

    -
    - {schema.fieldsets.map((fieldset) => ( - - - - {fieldset.title} - - - {(fieldset.fields as DeepKeys[]).map( - (schemaField, index) => ( - ( - - )} - /> - ), - )} - - - - ))} -
    - {/*
    - -
    */} -
    - ), - }, - ]} - /> - - - - - - - - -
    -
    -
    + ); } diff --git a/packages/cmsui/routes/layout.tsx b/packages/cmsui/routes/layout.tsx index f7ae77dcaba..519bbd68091 100644 --- a/packages/cmsui/routes/layout.tsx +++ b/packages/cmsui/routes/layout.tsx @@ -2,26 +2,26 @@ import { Links, Meta, Outlet, + RouterContextProvider, Scripts, ScrollRestoration, + useLoaderData, useNavigate, - useRouteLoaderData, type LinksFunction, + type LoaderFunctionArgs, type MetaFunction, } from 'react-router'; import { useTranslation } from 'react-i18next'; import { RouterProvider as RACRouterProvider } from 'react-aria-components'; +import { clsx } from 'clsx'; +import i18next from 'seven/app/i18next.server'; import type { RootLoader } from 'seven/app/root'; -import { PluggablesProvider, Plug } from '@plone/layout/components/Pluggable'; +import { PluggablesProvider } from '@plone/layout/components/Pluggable'; import Toolbar from '@plone/layout/components/Toolbar/Toolbar'; import { shouldShowToolbar } from '@plone/layout/helpers'; -import Sidebar, { sidebarAtom } from '../components/Sidebar/Sidebar'; -import Settings from '@plone/components/icons/settings.svg?react'; -import { useAtom } from 'jotai'; -import { clsx } from 'clsx'; -import config from '@plone/registry'; import stylesheet from 'seven/.plone/cmsui.css?url'; +import { ploneContentContext } from 'seven/app/middleware.server'; export const meta: MetaFunction = ({ matches, @@ -63,22 +63,23 @@ export const links: LinksFunction = () => [ }, ]; -export async function loader() { - return { cssLayers: config.settings.cssLayers }; +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + const content = context.get(ploneContentContext); + const locale = await i18next.getLocale(request); + return { locale, content }; } export default function Index() { - const rootData = useRouteLoaderData('root'); + const { locale, content } = useLoaderData(); const { i18n } = useTranslation(); const navigate = useNavigate(); - const [collapsed, setCollapsed] = useAtom(sidebarAtom); - if (!rootData) { - return null; - } - const { content, locale } = rootData; const contentLanguage = (content?.language as { token?: string } | undefined) ?.token; + const showToolbar = shouldShowToolbar(content); return ( @@ -94,30 +95,13 @@ export default function Index() { - - - - + {showToolbar && }
    -
    - -
    - +
    diff --git a/packages/cmsui/routes/objectBrowserWidget.tsx b/packages/cmsui/routes/objectBrowserWidget.tsx index eb2694d432e..3fed6dbf653 100644 --- a/packages/cmsui/routes/objectBrowserWidget.tsx +++ b/packages/cmsui/routes/objectBrowserWidget.tsx @@ -1,21 +1,17 @@ -import { data, type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; import { flattenToAppURL } from '@plone/helpers'; -import { getAuthFromRequest } from '@plone/react-router'; -import config from '@plone/registry'; -// NOTE: cannot import and reuse loaders, somewhere it tries to load js process which is undefined -export async function loader({ params, request, context }: LoaderFunctionArgs) { - const token = await getAuthFromRequest(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; +export async function loader({ + params, + request, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; @@ -51,7 +47,10 @@ export async function loader({ params, request, context }: LoaderFunctionArgs) { // const strippedRequest = new Request(request.url.replace(/\?.*$/, ''), { // headers: request.headers, // }); - // Call the breadcrumbs endpoint + + // TODO replace with reading from the context expander + // const content = context.get(ploneContentContext), + // content.data['@components'].breadcrumbs.... const { data: breadcrumbs } = await cli.getBreadcrumbs({ path, }); diff --git a/packages/cmsui/routes/search.test.tsx b/packages/cmsui/routes/search.test.tsx index 43579aff327..395366aa418 100644 --- a/packages/cmsui/routes/search.test.tsx +++ b/packages/cmsui/routes/search.test.tsx @@ -1,12 +1,13 @@ import { expect, describe, it, vi, afterEach } from 'vitest'; import config from '@plone/registry'; import { loader } from './search'; +import { RouterContextProvider } from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; describe('loader', () => { afterEach(() => { vi.restoreAllMocks(); config.settings = {}; - delete config.utilities['ploneClient']; }); it('should call the search method with the correct parameters', async () => { @@ -18,21 +19,21 @@ describe('loader', () => { }, }); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - search: searchMock, - config: { - token: undefined, - }, - }), - }); + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + search: searchMock, + } as any); const request = new Request( 'http://example.com/@search?SearchableText=test&path.depth=1', ); - await loader({ request, params: {}, context: {} } as any); + await loader({ + request, + params: {}, + context, + unstable_pattern: '/@search?SearchableText=test&path.depth=1', + unstable_url: new URL(request.url), + }); expect(searchMock).toHaveBeenCalledWith({ query: { @@ -54,19 +55,19 @@ describe('loader', () => { }, }); config.settings.apiPath = 'http://example.com'; - config.registerUtility({ - name: 'ploneClient', - type: 'client', - method: () => ({ - search: searchMock, - config: { - token: undefined, - }, - }), - }); + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + search: searchMock, + } as any); const request = new Request('http://example.com/@search'); - await loader({ request, params: {}, context: {} } as any); + await loader({ + request, + params: {}, + context, + unstable_pattern: '@search', + unstable_url: new URL(request.url), + }); expect(searchMock).toHaveBeenCalledWith({ query: { diff --git a/packages/cmsui/routes/search.tsx b/packages/cmsui/routes/search.tsx index b6879780372..33911dd1d82 100644 --- a/packages/cmsui/routes/search.tsx +++ b/packages/cmsui/routes/search.tsx @@ -1,20 +1,17 @@ -import { data, type LoaderFunctionArgs } from 'react-router'; -import type PloneClient from '@plone/client'; +import { + data, + RouterContextProvider, + type LoaderFunctionArgs, +} from 'react-router'; import { flattenToAppURL } from '@plone/helpers'; -import { getAuthFromRequest } from '@plone/react-router'; -import config from '@plone/registry'; +import { ploneClientContext } from 'seven/app/middleware.server'; -export async function loader({ params, request }: LoaderFunctionArgs) { - const token = await getAuthFromRequest(request); - - const cli = config - .getUtility({ - name: 'ploneClient', - type: 'client', - }) - .method() as PloneClient; - - cli.config.token = token; +export async function loader({ + params, + request, + context, +}: LoaderFunctionArgs) { + const cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; const query = Object.fromEntries(new URL(request.url).searchParams.entries()); diff --git a/packages/cmsui/static/plone-white.svg b/packages/cmsui/static/plone.svg similarity index 80% rename from packages/cmsui/static/plone-white.svg rename to packages/cmsui/static/plone.svg index a3f33e604a2..c98e0563245 100644 --- a/packages/cmsui/static/plone-white.svg +++ b/packages/cmsui/static/plone.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/packages/cmsui/static/volto-hero.svg b/packages/cmsui/static/volto-hero.svg new file mode 100644 index 00000000000..be485abffbc --- /dev/null +++ b/packages/cmsui/static/volto-hero.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/cmsui/styles/cmsui.css b/packages/cmsui/styles/cmsui.css index db3c2d741cc..0eb7b8acc26 100644 --- a/packages/cmsui/styles/cmsui.css +++ b/packages/cmsui/styles/cmsui.css @@ -101,7 +101,7 @@ --color-quanta-cream: #fcf3cf; --color-quanta-banana: #faeaad; - --color-quanta-lemmon: #f6d355; + --color-quanta-lemon: #f6d355; --color-quanta-gold: #b48f09; --color-quanta-dijon: #917308; --color-quanta-bronze: #6b5506; diff --git a/packages/cmsui/tsconfig.json b/packages/cmsui/tsconfig.json index e8a9d912d3d..76ac67db001 100644 --- a/packages/cmsui/tsconfig.json +++ b/packages/cmsui/tsconfig.json @@ -1,17 +1,28 @@ { "extends": "tsconfig/react-library.json", - "include": ["**/*.ts", "**/*.tsx", "../components/src/icons.d.ts"], + "include": [ + "**/*.ts", + "**/*.tsx", + "../components/src/icons.d.ts", + "../../apps/seven/.react-router/types/**/*" + ], "exclude": [ "node_modules", "build", "public", "coverage", + "acceptance/tests/**/*", "src/**/*.test.{js,jsx,ts,tsx}", "src/**/*.spec.{js,jsx,ts,tsx}", "src/**/*.stories.{js,jsx,ts,tsx}" ], "compilerOptions": { "types": ["vite/client"], + "rootDirs": [ + ".", + "../../apps/seven/app", + "../../apps/seven/.react-router/types/app" + ], "paths": { "seven/*": ["../../apps/seven/*"] } diff --git a/packages/cmsui/vite.config.ts b/packages/cmsui/vite.config.ts index 5fff3098888..baee9d6bb6e 100644 --- a/packages/cmsui/vite.config.ts +++ b/packages/cmsui/vite.config.ts @@ -1,17 +1,14 @@ import { defineConfig, type PluginOption } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; import { PloneSVGRVitePlugin } from '@plone/components/vite-plugin-svgr'; export default defineConfig({ - plugins: [ - tsconfigPaths(), - tailwindcss(), - PloneSVGRVitePlugin() as PluginOption, - react(), - ], + plugins: [tailwindcss(), PloneSVGRVitePlugin() as PluginOption, react()], css: { transformer: 'lightningcss', }, + resolve: { + tsconfigPaths: true, + }, }); diff --git a/packages/cmsui/vitest.config.ts b/packages/cmsui/vitest.config.ts index 38cde0a5e15..e9eef0ea564 100644 --- a/packages/cmsui/vitest.config.ts +++ b/packages/cmsui/vitest.config.ts @@ -2,13 +2,16 @@ import { coverageConfigDefaults, defineConfig } from 'vitest/config'; // https://vitejs.dev/config/ export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, test: { globals: true, environment: 'jsdom', setupFiles: './setupTesting.ts', // you might want to disable it, if you don't have tests that rely on CSS // since parsing CSS is slow - css: true, + // css: true, exclude: ['**/node_modules/**', '**/lib/**', '**/acceptance/**'], coverage: { exclude: [ @@ -16,7 +19,7 @@ export default defineConfig({ 'packages/**', 'build/**', '*.config.ts', - 'registry.loader.js', + '.plone/**', 'app/entry.server.tsx', 'app/entry.client.tsx', 'app/i18next.server.ts', diff --git a/packages/components/.storybook/main.ts b/packages/components/.storybook/main.ts index 08b54436fc5..d63b1b01047 100644 --- a/packages/components/.storybook/main.ts +++ b/packages/components/.storybook/main.ts @@ -1,6 +1,5 @@ import type { StorybookConfig } from '@storybook/react-vite'; import { mergeConfig } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; const config: StorybookConfig = { // For some reason the property does not allow negation @@ -39,7 +38,9 @@ const config: StorybookConfig = { }, async viteFinal(config) { return mergeConfig(config, { - plugins: [tsconfigPaths()], + resolve: { + tsconfigPaths: true, + }, build: { minify: false, }, diff --git a/packages/components/.storybook/preview.ts b/packages/components/.storybook/preview.ts index 04b5f9dc1f7..20487f9633f 100644 --- a/packages/components/.storybook/preview.ts +++ b/packages/components/.storybook/preview.ts @@ -3,9 +3,7 @@ import '../../theming/styles/tailwind.css'; import '../src/styles/basic/main.css'; export const parameters = { - backgrounds: { - default: 'light', - }, + backgrounds: {}, options: { storySort: { order: [ @@ -14,6 +12,8 @@ export const parameters = { 'Tailwind', 'Basic', ['Forms', 'Quanta', '*'], + 'Quanta', + ['Introduction', 'Forms', '*'], ], }, }, @@ -25,3 +25,9 @@ export const parameters = { }, }, }; + +export const initialGlobals = { + backgrounds: { + value: 'light', + }, +}; diff --git a/packages/components/AGENTS.md b/packages/components/AGENTS.md new file mode 100644 index 00000000000..f9aea01f0f7 --- /dev/null +++ b/packages/components/AGENTS.md @@ -0,0 +1,70 @@ +# AGENTS.md + +This file applies only to `packages/components` and its subdirectories. + +## What This Package Is + +- `@plone/components` is a thin wrapper layer around `react-aria-components`. +- Components should be usable out of the box in Seven and in Volto. +- Keep components presentational and lightweight. Avoid adding app-specific behavior, data logic, or i18n machinery here. + +## Component Model + +- Prefer staying very close to the underlying React Aria Components API. +- Do not reinvent component behavior that RAC already provides. +- Add Plone value mainly through packaging, small ergonomic wrappers, and styling. +- Some components, such as `Breadcrumbs`, are intentionally adapted for Seven/Volto and REST API use cases. In those cases, the wrapper props and helpers may shape data for that environment, but the underlying RAC behavior should remain intact. +- Even adapted components should still behave like thin proxies: keep forwarding supported props through to the underlying RAC component so upstream RAC documentation and expectations continue to apply. + +## Two Flavours + +- Components may exist in two flavours: + - basic: CSS-styled components + - Quanta styles for a few CSS-styled components in `src/styles/quanta/`: CSS assets for Quanta-styled output + - Quanta components: the Tailwind-styled React components, named with the `.quanta.tsx` suffix +- Both flavours live under the same component folder in `src/components//`. +- Basic components are exported from `src/index.ts`. +- Tailwind Quanta components are exported from `src/quanta/index.ts` and are the real Quanta component implementation. +- Keep tree-shaking in mind when adding exports or shared helpers. + +## Styles + +- Built CSS lives under `src/styles`. +- Basic component styles live in `src/styles/basic/`, usually one CSS file per component, and are bundled from `src/styles/basic/main.css`. +- `src/styles/quanta/` contains CSS-based Quanta style definitions and is bundled from `src/styles/quanta/main.css`. +- Do not confuse `src/styles/quanta/` with the Tailwind Quanta component implementation. The Tailwind components are the `.quanta.tsx` files in `src/components`. +- Other `src/styles` folders are for shared assets such as static files and fonts. +- When adding or renaming a styled component, make sure the corresponding style entry is wired into the appropriate `main.css`. + +## Stories + +- Every public component should have a Storybook story. +- Keep stories colocated with the component in the same folder. +- If both basic and Quanta variants are public, prefer stories for both. + +## Icons + +- Raw SVG icons live in `src/icons`. +- Ready-to-use React icon components live in `src/components/icons`. +- When adding an SVG icon, also add its React component counterpart and export it from the relevant index when needed. +- Keep SVG and React component names aligned. + +## Editing Rules + +- Keep changes minimal and package-local. +- Prefer extending existing component folders and patterns over introducing new abstractions. +- If adding a new public component, check all relevant pieces: + - component file + - optional `.quanta.tsx` variant + - stories + - styles + - exports + - tests when behavior is non-trivial + +## Validation + +- Prefer targeted checks from this package: + - `pnpm --filter @plone/components test --run` + - `pnpm --filter @plone/components lint` + - `pnpm --filter @plone/components build` +- Run `pnpm --filter @plone/components eslint:fix` after editing component code. This package uses formatting/lint tooling that reorders Tailwind utilities, so apply it before finishing changes. diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 1dfc6a237b8..cc246cbb4ab 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -8,6 +8,75 @@ +## 5.0.0-alpha.0 (2026-05-07) + +### Bugfix + +- Added missing exports. @sneridagh [#8160](https://github.com/plone/volto/issues/8160) + +### Internal + +- Aligned Components' local test dependency setup with the monorepo refresh by moving `@testing-library/jest-dom` and `jsdom` to the current shared baseline and dropping the unused `vitest-axe` dev dependency. +- Aligned Components' shared tsconfig usage and local package scripts with the monorepo-wide typecheck cleanup. +- Moved `vite-plugin-svgr` and its SVGR plugins into Components runtime dependencies and narrowed the published plugin file entry for correct downstream installs. @sneridagh + +## 4.0.0-alpha.7 (2026-04-16) + +### Breaking + +- # Quanta Menu API cleanup + + The Quanta `Menu` component was refactored to behave as a thin wrapper around `react-aria-components` instead of exposing a custom data-driven API. + + ## Removed + + - The custom `menuItems` prop. + - The bundled trigger API based on `button`, `onPress`, and `placement` props on `Menu`. + - The internal item schema used to describe menu entries with fields such as `label`, `description`, `keyboard`, `icon`, `separator`, `section`, `header`, and nested `children`. + - Automatic rendering of sections and separators from that custom schema. + - Automatic rendering of text slots, icons, keyboard shortcuts, and similar item content from custom item objects. + - The custom `title` prop on `MenuSection`. + + ## Added + + - RAC-aligned Quanta primitives: + - `Menu` + - `MenuItem` + - `MenuTrigger` + - `SubmenuTrigger` + - `MenuSection` + - `MenuSeparator` + + ## Migration + + Consumers should now compose Quanta menus using the standard `react-aria-components` structure: + + - Wrap menus with `MenuTrigger` instead of passing trigger props to `Menu`. + - Pass `MenuItem`, `MenuSection`, and `MenuSeparator` as children instead of a `menuItems` array. + - Use `Header` inside `MenuSection`, or `aria-label` when there is no visible header, instead of a custom `title` prop. + - Render icons, descriptions, labels, keyboard shortcuts, and links explicitly in `MenuItem` children using RAC patterns and slots. + + This removes opinionated behavior from Quanta `Menu` and makes the component API match upstream RAC usage more closely. + + The same alignment was also applied to the basic `Menu` component so both basic and Quanta flavours now follow the same RAC composition model. + + @sneridagh + +### Feature + +- Icons for the Somersault editor support. @sneridagh [#7921](https://github.com/plone/volto/issues/7921) +- Update browserslist. @sneridagh [#8106](https://github.com/plone/volto/issues/8106) + +### Bugfix + +- Updated to latest RAC 1.16.0. @sneridagh [#8018](https://github.com/plone/volto/issues/8018) +- Fixed Tabs panel font size. @sneridagh [#8076](https://github.com/plone/volto/issues/8076) + +### Internal + +- Updated packages configuration for vite 8. @pnicolli +- Updated the React Aria Components dependency set to `react-aria-components` 1.17.0 and aligned the related RAC packages. @sneridagh + ## 4.0.0-alpha.6 (2026-02-20) ### Feature diff --git a/packages/components/Makefile b/packages/components/Makefile index bd414b66b71..335c4b22255 100644 --- a/packages/components/Makefile +++ b/packages/components/Makefile @@ -1,30 +1,34 @@ -SHELL := /bin/bash -CURRENT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) - +# Project settings +include ../../variables.mk -# We like colors -# From: https://coderwall.com/p/izxssa/colored-makefile-for-golang-projects -RED=`tput setaf 1` -GREEN=`tput setaf 2` -RESET=`tput sgr0` -YELLOW=`tput setaf 3` +CURRENT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) DOCKER_IMAGE=plone/plone-backend:6.0.1 TESTING_ADDONS=plone.app.robotframework==2.0.0 plone.app.testing==7.0.0 .PHONY: all -all: build +all: help -# Add the following 'help' target to your Makefile -# And add help text after each target name starting with '\#\#' .PHONY: help help: ## This help message - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/$(CYAN)\1$(RESET):\2/' | column -c2 -t -s :)" + +.PHONY: install +install: ## Install dependencies + pnpm install + +.PHONY: build +build: ## Build the package + pnpm run build + +.PHONY: storybook-start +storybook-start: ## Start Storybook + pnpm run storybook + +.PHONY: storybook-build +storybook-build: ## Build Storybook + pnpm run build-storybook .PHONY: start-test-acceptance-server start-test-acceptance-server: ## Start Test Acceptance Server Main Fixture (docker container) docker run -i --rm -d -e ZSERVER_HOST=0.0.0.0 -e ZSERVER_PORT=55001 -p 55001:55001 -e ADDONS='$(TESTING_ADDONS)' -e APPLY_PROFILES=plone.app.contenttypes:plone-content,plone.restapi:default,plone.volto:default -e CONFIGURE_PACKAGES=plone.app.contenttypes,plone.restapi,plone.volto,plone.volto.cors $(DOCKER_IMAGE) ./bin/robot-server plone.app.robotframework.testing.VOLTO_ROBOT_TESTING - -.PHONY: build-storybook -build-storybook: ## Build Storybook - yarn && yarn build-storybook diff --git a/packages/components/news/+contents-components.feature b/packages/components/news/+contents-components.feature new file mode 100644 index 00000000000..786d861457a --- /dev/null +++ b/packages/components/news/+contents-components.feature @@ -0,0 +1 @@ +Added DropZone, Pagination, Quanta Table, and Quanta Tooltip components for contents views. @pnicolli @giuliaghisini diff --git a/packages/components/news/+dialogtrigger.feature b/packages/components/news/+dialogtrigger.feature new file mode 100644 index 00000000000..cba7357732c --- /dev/null +++ b/packages/components/news/+dialogtrigger.feature @@ -0,0 +1 @@ +Add DialogTrigger component. @jnptk diff --git a/packages/components/news/+qmodal.feature b/packages/components/news/+qmodal.feature new file mode 100644 index 00000000000..56989a9e441 --- /dev/null +++ b/packages/components/news/+qmodal.feature @@ -0,0 +1 @@ +Added Modal and Dialog quanta components. @pnicolli diff --git a/packages/components/news/+radiogroup-and-components-props.feature b/packages/components/news/+radiogroup-and-components-props.feature new file mode 100644 index 00000000000..ab4f7b8a87f --- /dev/null +++ b/packages/components/news/+radiogroup-and-components-props.feature @@ -0,0 +1 @@ +Added RadioGroup quanta component, added props to form components, fixed select items. @sabrina-bongiovanni diff --git a/packages/components/news/+storybook.internal b/packages/components/news/+storybook.internal new file mode 100644 index 00000000000..cc424aade8a --- /dev/null +++ b/packages/components/news/+storybook.internal @@ -0,0 +1 @@ +Update to storybook 10. @sneridagh diff --git a/packages/components/news/+tableRenderEmptyState.feature b/packages/components/news/+tableRenderEmptyState.feature new file mode 100644 index 00000000000..b8bffc730a6 --- /dev/null +++ b/packages/components/news/+tableRenderEmptyState.feature @@ -0,0 +1 @@ +Add `renderEmptyState` to `Table` component. It provides content to display when there are no rows in the table. @jnptk diff --git a/packages/components/news/+unify-makefiles.internal b/packages/components/news/+unify-makefiles.internal new file mode 100644 index 00000000000..5da674df4e4 --- /dev/null +++ b/packages/components/news/+unify-makefiles.internal @@ -0,0 +1 @@ +Unify Makefile files across the packages. @ionlizarazu diff --git a/packages/components/news/6656.bugfix b/packages/components/news/6656.bugfix new file mode 100644 index 00000000000..148d0ea7a6f --- /dev/null +++ b/packages/components/news/6656.bugfix @@ -0,0 +1,2 @@ +Added missing `forwardRef` to the Quanta button component. +Renamed `quanta-lemmon` color to `quanta-lemon`. @arybakov05 \ No newline at end of file diff --git a/packages/components/news/7379.feature b/packages/components/news/7379.feature new file mode 100644 index 00000000000..b4d366c5cbf --- /dev/null +++ b/packages/components/news/7379.feature @@ -0,0 +1 @@ +Added link and button cross variants. @pnicolli \ No newline at end of file diff --git a/packages/components/news/7921.feature b/packages/components/news/7921.feature deleted file mode 100644 index 8b91c789a4f..00000000000 --- a/packages/components/news/7921.feature +++ /dev/null @@ -1 +0,0 @@ -Icons for the Somersault editor support. @sneridagh diff --git a/packages/components/news/8018.bugfix b/packages/components/news/8018.bugfix deleted file mode 100644 index 7867b1ca7d5..00000000000 --- a/packages/components/news/8018.bugfix +++ /dev/null @@ -1 +0,0 @@ -Updated to latest RAC 1.16.0. @sneridagh diff --git a/packages/components/package.json b/packages/components/package.json index 0aac4dc29ed..bb06ceccf7e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -8,7 +8,7 @@ } ], "license": "MIT", - "version": "4.0.0-alpha.6", + "version": "5.0.0-alpha.0", "repository": { "type": "git", "url": "http://github.com/plone/volto.git", @@ -22,7 +22,7 @@ "dist", "src", "README.md", - "vite-plugin.*" + "vite-plugin-svgr.*" ], "main": "./dist/index.js", "exports": { @@ -40,6 +40,7 @@ "types": "./vite-plugin-svgr.d.ts" }, "./dist/*.css": "./dist/*.css", + "./dist/fonts/*": "./dist/fonts/*", "./src/*": "./src/*", "./quanta": { "import": "./dist/quanta/index.js", @@ -67,25 +68,26 @@ "build:force": "tsup && pnpm build:css", "build:css": "pnpm build:basic && pnpm build:quanta", "build:basic": "lightningcss --browserslist --bundle --sourcemap src/styles/basic/main.css -o basic.css && mv basic.css* dist/.", - "build:quanta": "lightningcss --browserslist --bundle --sourcemap src/styles/quanta/main.css -o quanta.css && mv quanta.css* dist/.", + "build:quanta": "lightningcss --browserslist --bundle --sourcemap src/styles/quanta/main.css -o quanta.css && mv quanta.css* dist/. && cp -r src/styles/fonts dist/.", "check:exports": "attw --pack .", "check:ts": "tsc --project tsconfig.json", "test": "vitest --passWithNoTests", "coverage": "vitest run --coverage --no-threads", "lint": "pnpm eslint && pnpm prettier && pnpm stylelint && pnpm check:ts", - "format": "pnpm eslint:fix && pnpm prettier:fix && pnpm stylelint:fix", + "format": "pnpm prettier:fix && pnpm lint:fix && pnpm stylelint:fix", "eslint": "eslint 'src/**/*.{js,ts,tsx}' --quiet", "eslint:fix": "eslint 'src/**/*.{js,ts,tsx}' --quiet --fix", "prettier": "prettier --check 'src/**/*.{js,jsx,ts,tsx}'", - "prettier:fix": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'", + "prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", "stylelint": "stylelint 'src/**/*.{css,scss,less}'", - "stylelint:fix": "stylelint 'src/**/*.{css,scss,less}' --fix", + "stylelint:fix": "sh -c 'if [ -f .stylelintrc ] || [ -f .stylelintrc.json ] || [ -f .stylelintrc.js ] || [ -f .stylelintrc.cjs ] || [ -f stylelint.config.js ] || [ -f stylelint.config.cjs ] || [ -f stylelint.config.mjs ]; then stylelint '''./**/*.{css,scss,less}''' --fix --allow-empty-input; else echo \"No local stylelint config, skipping\"; fi'", "dry-release": "release-it --dry-run", "release": "release-it", "release-major-alpha": "release-it major --preRelease=alpha", "release-alpha": "release-it --preRelease=alpha", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "lint:fix": "pnpm eslint:fix" }, "publishConfig": { "access": "public" @@ -96,8 +98,11 @@ "not dead" ], "dependencies": { + "@internationalized/date": "catalog:", "@react-aria/utils": "catalog:", "@react-spectrum/utils": "catalog:", + "@svgr/plugin-jsx": "^8.1.0", + "@svgr/plugin-svgo": "^8.1.0", "clsx": "^2.1.1", "react-aria": "catalog:", "react-aria-components": "catalog:", @@ -106,40 +111,36 @@ "tailwind-variants": "catalog:", "tailwindcss": "catalog:", "tailwindcss-animate": "^1.0.7", - "@internationalized/date": "catalog:" + "vite-plugin-svgr": "^4.3.0" }, "devDependencies": { "@arethetypeswrong/cli": "^0.16.4", "@plone/types": "workspace: *", "@react-types/shared": "^3.32.1", - "@storybook/addon-docs": "^9.1.7", - "@storybook/addon-links": "^9.1.7", - "@storybook/react-vite": "^9.1.7", - "@svgr/plugin-svgo": "^8.1.0", + "@storybook/addon-docs": "^10.4.0", + "@storybook/addon-links": "^10.4.0", + "@storybook/react-vite": "^10.4.0", "@tailwindcss/vite": "catalog:", - "@testing-library/jest-dom": "6.4.2", + "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/jest-axe": "^3.5.7", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "catalog:", - "browserslist": "^4.23.0", - "eslint-plugin-storybook": "^9.1.7", + "browserslist": "^4.28.2", + "eslint-plugin-storybook": "^10.4.0", "jest-axe": "^8.0.0", - "jsdom": "^22.1.0", + "jsdom": "^28.1.0", "lightningcss": "^1.29.0", "lightningcss-cli": "^1.29.1", "release-it": "catalog:", - "storybook": "^9.1.7", - "tailwindcss-react-aria-components": "^2.0.0", + "storybook": "^10.4.0", "tsup": "catalog:", + "tsconfig": "workspace:*", "typescript": "catalog:", "vite": "catalog:", - "vite-plugin-svgr": "^4.3.0", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "catalog:", - "vitest-axe": "^0.1.0" + "vitest": "catalog:" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", diff --git a/packages/components/src/components/BlockToolbar/BlockToolbar.stories.tsx b/packages/components/src/components/BlockToolbar/BlockToolbar.stories.tsx index 45977f81ab2..85ef57a5bb4 100644 --- a/packages/components/src/components/BlockToolbar/BlockToolbar.stories.tsx +++ b/packages/components/src/components/BlockToolbar/BlockToolbar.stories.tsx @@ -1,7 +1,13 @@ import React from 'react'; import { BlockToolbar } from './BlockToolbar'; -import { Group, Separator, Text, ToggleButton } from 'react-aria-components'; -import { Menu, MenuItem } from '../Menu/Menu'; +import { + Button, + Group, + Separator, + Text, + ToggleButton, +} from 'react-aria-components'; +import { Menu, MenuItem, MenuTrigger } from '../Menu/Menu'; import { BoldIcon } from '../icons/BoldIcon'; import { ItalicIcon } from '../icons/ItalicIcon'; @@ -39,24 +45,29 @@ export const Example = (args: any) => ( - }> - - - Settings - - - - Insert block before - - - - Insert block after - - - - - Remove block - - + + + + + + Settings + + + + Insert block before + + + + Insert block after + + + + + Remove block + + + ); diff --git a/packages/components/src/components/Breadcrumbs/Breadcrumb.stories.tsx b/packages/components/src/components/Breadcrumbs/Breadcrumb.stories.tsx index 3b8432c83a0..6a00abda3d6 100644 --- a/packages/components/src/components/Breadcrumbs/Breadcrumb.stories.tsx +++ b/packages/components/src/components/Breadcrumbs/Breadcrumb.stories.tsx @@ -7,7 +7,8 @@ import { MoreoptionsIcon, PageIcon, } from '../../components/icons'; -import { Menu, MenuItem } from '../Menu/Menu'; +import { Button } from '../Button/Button'; +import { Menu, MenuItem, MenuTrigger } from '../Menu/Menu'; import type { Meta, StoryObj } from '@storybook/react-vite'; @@ -159,13 +160,18 @@ export const LotsOfItems: Story = { {first?.title} - } placement="bottom"> - {(item) => ( - - {item.title} - - )} - + + + + {(item) => ( + + {item.title} + + )} + + {last?.title} @@ -204,13 +210,18 @@ export const LotsOfItemsWithSeparator: Story = { {first?.title} }> - } placement="bottom"> - {(item) => ( - - {item.title} - - )} - + + + + {(item) => ( + + {item.title} + + )} + + - } - placement="bottom" - > + + + + {inner.map((item) => ( + + {item.title} + + ))} + + {last?.title} diff --git a/packages/components/src/components/Breadcrumbs/Breadcrumbs.quanta.tsx b/packages/components/src/components/Breadcrumbs/Breadcrumbs.quanta.tsx index 0aa0bf74f05..8d5b96c614a 100644 --- a/packages/components/src/components/Breadcrumbs/Breadcrumbs.quanta.tsx +++ b/packages/components/src/components/Breadcrumbs/Breadcrumbs.quanta.tsx @@ -24,11 +24,12 @@ export function Breadcrumb( value?: Breadcrumb; }, ) { + const { className, separator, value, ...otherProps } = props; return ( svg]:mx-1 [&_a>svg]:inline [&_a>svg]:align-text-top @@ -37,10 +38,10 @@ export function Breadcrumb( > {({ isCurrent }) => ( <> - {props.value?.icon && props.value?.icon} - + {value?.icon && value?.icon} + {!isCurrent && - (props.separator ?? ( + (separator ?? ( ))} diff --git a/packages/components/src/components/Button/Button.quanta.stories.tsx b/packages/components/src/components/Button/Button.quanta.stories.tsx index f4e03f973a6..429b3374455 100644 --- a/packages/components/src/components/Button/Button.quanta.stories.tsx +++ b/packages/components/src/components/Button/Button.quanta.stories.tsx @@ -14,12 +14,39 @@ const meta = { argTypes: { variant: { control: 'select', - options: ['neutral', 'primary', 'destructive'], + options: ['neutral', 'primary', 'destructive', 'secondary'], + }, + asLink: { + control: 'boolean', + description: 'Show the button as a link', + table: { + type: { + summary: 'boolean', + }, + defaultValue: { + summary: 'false', + }, + }, + }, + accent: { + table: { + defaultValue: { + summary: 'false', + }, + }, + }, + isDisabled: { + table: { + defaultValue: { + summary: 'false', + }, + }, }, }, args: { isDisabled: false, children: 'Button', + asLink: false as any, accent: false, }, } satisfies Meta; @@ -166,3 +193,19 @@ export const WithTWClassName: Story = { accent: true, }, }; + +export const AsLink: Story = { + args: { + asLink: true, + variant: 'primary', + }, + argTypes: { + variant: { + control: 'select', + options: ['primary', 'secondary'], + }, + accent: { + table: { disable: true }, + }, + }, +}; diff --git a/packages/components/src/components/Button/Button.quanta.test.tsx b/packages/components/src/components/Button/Button.quanta.test.tsx new file mode 100644 index 00000000000..f4c88bd34f9 --- /dev/null +++ b/packages/components/src/components/Button/Button.quanta.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { Button } from './Button.quanta'; + +expect.extend(toHaveNoViolations); + +it('Button basic a11y test', async () => { + const { container } = render(); + + const button = screen.getByText('The button'); + expect(button).toHaveRole('button'); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); + +it('Button with accent a11y test', async () => { + const { container } = render( + , + ); + + const button = screen.getByText('The button'); + expect(button).toHaveRole('button'); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); + +it('Button asLink a11y test', async () => { + const { container } = render( + , + ); + + const button = screen.getByText('The button as link'); + expect(button).toHaveRole('button'); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); diff --git a/packages/components/src/components/Button/Button.quanta.tsx b/packages/components/src/components/Button/Button.quanta.tsx index 228e2958a71..a8a785e445d 100644 --- a/packages/components/src/components/Button/Button.quanta.tsx +++ b/packages/components/src/components/Button/Button.quanta.tsx @@ -1,155 +1,55 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { composeRenderProps, Button as RACButton, type ButtonProps as RACButtonProps, } from 'react-aria-components'; -import { tv } from 'tailwind-variants'; -import { focusRing } from '../utils'; +import { button } from './Button.quanta.variants'; +import { link } from '../Link/Link.quanta.variants'; -export interface ButtonProps extends RACButtonProps { - variant?: 'neutral' | 'primary' | 'destructive' | 'icon'; - size?: 'S' | 'L'; - accent?: boolean; -} +type ButtonVariants = Parameters[0]; -const button = tv({ - extend: focusRing, - base: ` - cursor-default rounded-md px-3 py-1.5 text-center text-base font-medium transition - hover:shadow-sm - focus:shadow-sm - active:shadow-md - has-[svg]:rounded-full has-[svg]:p-1.5 has-[svg]:text-xs - `, - variants: { - variant: { - neutral: ` - bg-quanta-air text-quanta-iron - hover:bg-quanta-snow - focus:bg-quanta-snow - active:bg-quanta-silver - has-[svg]:text-quanta-iron - pressed:bg-quanta-silver - `, - primary: ` - focus:bg-quanta-artic focus:text-quanta-royal - bg-quanta-air text-quanta-sapphire - hover:bg-quanta-arctic hover:text-quanta-royal - active:bg-quanta-sky active:text-quanta-royal - pressed:bg-quanta-cobalt - `, - destructive: ` - bg-quanta-air text-quanta-candy - hover:bg-quanta-ballet hover:text-quanta-wine - focus:bg-quanta-ballet focus:text-quanta-wine - active:bg-quanta-flamingo active:text-quanta-wine - pressed:bg-quanta-rose - `, - icon: ` - focus:bg-quanta-artic - flex items-center justify-center border-0 bg-quanta-air p-1 text-quanta-iron - hover:bg-quanta-snow - active:bg-quanta-silver - has-[svg]:text-quanta-iron - pressed:bg-quanta-cobalt pressed:[&_svg]:text-white - `, - }, - accent: { - true: '', - }, - size: { - S: ` - text-xs - has-[svg]:p-1.5 - [&_svg]:size-5 - `, - L: ` - px-4.5 py-3 text-xl/6 - has-[svg]:p-3 has-[svg]:text-xs - `, - }, - isDisabled: { - true: ` - cursor-not-allowed bg-quanta-air text-quanta-smoke - hover:bg-quanta-air hover:text-quanta-silver - has-[svg]:text-quanta-smoke has-[svg]:hover:text-quanta-silver - `, - }, - }, - compoundVariants: [ - { - variant: 'neutral', - accent: true, - class: ` - bg-quanta-snow text-quanta-iron - hover:bg-quanta-smoke - focus:bg-quanta-smoke - active:bg-quanta-silver - pressed:bg-quanta-smoke - `, - }, - { - variant: 'primary', - accent: true, - class: ` - bg-quanta-sapphire text-quanta-air - hover:bg-quanta-royal hover:text-quanta-air - focus:bg-quanta-royal focus:text-quanta-air - active:bg-quanta-cobalt active:text-quanta-air - pressed:bg-quanta-cobalt - `, - }, - { - variant: 'icon', - accent: true, - class: ` - focus:bg-quanta-artic - flex items-center justify-center border-0 bg-quanta-air p-1 text-quanta-iron - hover:bg-quanta-snow - active:bg-quanta-silver - has-[svg]:text-quanta-iron - pressed:bg-quanta-cobalt pressed:[&_svg]:text-white - `, - }, - { - variant: 'destructive', - accent: true, - class: ` - bg-quanta-candy text-quanta-air - hover:bg-quanta-wine hover:text-quanta-air - focus:bg-quanta-wine focus:text-quanta-air - active:bg-quanta-rose active:text-quanta-air - pressed:bg-quanta-rose - `, - }, - { - isDisabled: true, - accent: true, - class: ` - bg-quanta-snow text-quanta-silver - hover:bg-quanta-smoke - `, - }, - ], - defaultVariants: { - variant: 'neutral', - }, -}); +export type ButtonProps = RACButtonProps & + ButtonVariants & { + asLink?: never; + }; + +type LinkVariants = Parameters[0]; + +type ButtonAsLinkProps = RACButtonProps & + LinkVariants & { + asLink: true; + }; + +export const Button = forwardRef(function _Button( + props: ButtonProps | ButtonAsLinkProps, + ref: React.ForwardedRef, +) { + const { asLink, variant, ...buttonProps } = props; -export function Button(props: ButtonProps) { return ( - button({ - ...renderProps, - variant: props.variant, - size: props.size, - accent: props.accent, - className, - }), + ref={ref} + {...buttonProps} + className={composeRenderProps( + props.className, + (className, renderProps) => { + if (asLink) { + return link({ + ...renderProps, + className, + variant, + }); + } + return button({ + ...renderProps, + variant, + size: props.size, + accent: props.accent, + className, + }); + }, )} /> ); -} +}); diff --git a/packages/components/src/components/Button/Button.quanta.variants.tsx b/packages/components/src/components/Button/Button.quanta.variants.tsx new file mode 100644 index 00000000000..fbc7ee597a4 --- /dev/null +++ b/packages/components/src/components/Button/Button.quanta.variants.tsx @@ -0,0 +1,126 @@ +import { tv } from 'tailwind-variants'; +import { focusRing } from '../utils'; + +export const button = tv({ + extend: focusRing, + base: ` + cursor-default rounded-md px-3 py-1.5 text-center text-base font-medium transition + hover:shadow-sm + focus:shadow-sm + active:shadow-md + has-[svg]:rounded-full has-[svg]:p-1.5 has-[svg]:text-xs + `, + variants: { + variant: { + neutral: ` + bg-quanta-air text-quanta-iron + hover:bg-quanta-snow + focus:bg-quanta-snow + active:bg-quanta-silver + has-[svg]:text-quanta-iron + pressed:bg-quanta-silver + `, + primary: ` + focus:bg-quanta-artic focus:text-quanta-royal + bg-quanta-air text-quanta-sapphire + hover:bg-quanta-arctic hover:text-quanta-royal + active:bg-quanta-sky active:text-quanta-royal + pressed:bg-quanta-cobalt + `, + destructive: ` + bg-quanta-air text-quanta-candy + hover:bg-quanta-ballet hover:text-quanta-wine + focus:bg-quanta-ballet focus:text-quanta-wine + active:bg-quanta-flamingo active:text-quanta-wine + pressed:bg-quanta-rose + `, + icon: ` + focus:bg-quanta-artic + flex items-center justify-center border-0 bg-quanta-air p-1 text-quanta-iron + hover:bg-quanta-snow + active:bg-quanta-silver + has-[svg]:text-quanta-iron + pressed:bg-quanta-cobalt pressed:[&_svg]:text-white + `, + }, + accent: { + true: '', + }, + size: { + S: ` + text-xs + has-[svg]:p-1.5 + [&_svg]:size-5 + `, + L: ` + px-4.5 py-3 text-xl/6 + has-[svg]:p-3 has-[svg]:text-xs + `, + }, + isDisabled: { + true: ` + cursor-not-allowed bg-quanta-air text-quanta-smoke + hover:bg-quanta-air hover:text-quanta-silver + has-[svg]:text-quanta-smoke has-[svg]:hover:text-quanta-silver + `, + }, + }, + compoundVariants: [ + { + variant: 'neutral', + accent: true, + class: ` + bg-quanta-snow text-quanta-iron + hover:bg-quanta-smoke + focus:bg-quanta-smoke + active:bg-quanta-silver + pressed:bg-quanta-smoke + `, + }, + { + variant: 'primary', + accent: true, + class: ` + bg-quanta-sapphire text-quanta-air + hover:bg-quanta-royal hover:text-quanta-air + focus:bg-quanta-royal focus:text-quanta-air + active:bg-quanta-cobalt active:text-quanta-air + pressed:bg-quanta-cobalt + `, + }, + { + variant: 'icon', + accent: true, + class: ` + focus:bg-quanta-artic + flex items-center justify-center border-0 bg-quanta-air p-1 text-quanta-iron + hover:bg-quanta-snow + active:bg-quanta-silver + has-[svg]:text-quanta-iron + pressed:bg-quanta-cobalt pressed:[&_svg]:text-white + `, + }, + { + variant: 'destructive', + accent: true, + class: ` + bg-quanta-candy text-quanta-air + hover:bg-quanta-wine hover:text-quanta-air + focus:bg-quanta-wine focus:text-quanta-air + active:bg-quanta-rose active:text-quanta-air + pressed:bg-quanta-rose + `, + }, + { + isDisabled: true, + accent: true, + class: ` + bg-quanta-snow text-quanta-silver + hover:bg-quanta-smoke + `, + }, + ], + defaultVariants: { + variant: 'neutral', + }, +}); diff --git a/packages/components/src/components/Button/Button.test.tsx b/packages/components/src/components/Button/Button.test.tsx new file mode 100644 index 00000000000..4ea39d544dd --- /dev/null +++ b/packages/components/src/components/Button/Button.test.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { Button } from './Button'; + +expect.extend(toHaveNoViolations); + +it('Button basic a11y test', async () => { + const { container } = render(); + + const button = screen.getByText('The button'); + expect(button).toHaveRole('button'); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); diff --git a/packages/components/src/components/ColorPicker/ColorPicker.tsx b/packages/components/src/components/ColorPicker/ColorPicker.tsx index dc1a783382b..8b21d09c10c 100644 --- a/packages/components/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/components/src/components/ColorPicker/ColorPicker.tsx @@ -12,9 +12,10 @@ import { ColorSlider } from '../ColorSlider/ColorSlider'; import { ColorArea } from '../ColorArea/ColorArea'; import { ColorField } from '../ColorField/ColorField'; -export interface ColorPickerProps extends RACColorPickerProps { +export interface ColorPickerProps + extends Omit { label?: string; - children: React.ReactNode; + children?: React.ReactNode; } export function ColorPicker({ label, children, ...props }: ColorPickerProps) { diff --git a/packages/components/src/components/Dialog/Dialog.quanta.tsx b/packages/components/src/components/Dialog/Dialog.quanta.tsx index f01d8cccc2f..7fc06163f60 100644 --- a/packages/components/src/components/Dialog/Dialog.quanta.tsx +++ b/packages/components/src/components/Dialog/Dialog.quanta.tsx @@ -16,3 +16,5 @@ export function Dialog(props: DialogProps) { /> ); } + +export { DialogTrigger } from 'react-aria-components'; diff --git a/packages/components/src/components/Dialog/Dialog.stories.tsx b/packages/components/src/components/Dialog/Dialog.stories.tsx index 0cdfa0fb105..5bbd42a5728 100644 --- a/packages/components/src/components/Dialog/Dialog.stories.tsx +++ b/packages/components/src/components/Dialog/Dialog.stories.tsx @@ -1,14 +1,7 @@ import React from 'react'; -import { Dialog } from './Dialog'; +import { Dialog, DialogTrigger } from './Dialog'; import { Button } from '../Button/Button'; -import { - DialogTrigger, - Heading, - Input, - Label, - Modal, - TextField, -} from 'react-aria-components'; +import { Heading, Input, Label, Modal, TextField } from 'react-aria-components'; import type { Meta, StoryObj } from '@storybook/react-vite'; diff --git a/packages/components/src/components/Dialog/Dialog.tsx b/packages/components/src/components/Dialog/Dialog.tsx index 503f87f9d55..2b15148e8ce 100644 --- a/packages/components/src/components/Dialog/Dialog.tsx +++ b/packages/components/src/components/Dialog/Dialog.tsx @@ -4,3 +4,5 @@ import { Dialog as RACDialog, type DialogProps } from 'react-aria-components'; export function Dialog(props: DialogProps) { return ; } + +export { DialogTrigger } from 'react-aria-components'; diff --git a/packages/components/src/components/DropZone/DropZone.quanta.stories.tsx b/packages/components/src/components/DropZone/DropZone.quanta.stories.tsx new file mode 100644 index 00000000000..535a19d0bf0 --- /dev/null +++ b/packages/components/src/components/DropZone/DropZone.quanta.stories.tsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react'; +import { DropZone, DropZoneText } from './DropZone.quanta'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta = { + title: 'Quanta/DropZone', + component: DropZone, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) =>
    {Story()}
    , + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Usage example that renders the DropZone shown below + * + * ```ts + * import { useState } from 'react'; + * import { DropZone, DropZoneText } from '@plone/components/quanta'; + * + * const [content, setContent] = useState( + * null, + * ); + * + * + * ['text/plain', 'image/jpeg', 'image/png', 'image.gif'].some((t) => + * types.has(t), + * ) + * ? 'copy' + * : 'cancel' + * } + * onDrop={async (event) => { + * // Find the first accepted item. + * const item = event.items.find( + * (item) => + * (item.kind === 'text' && item.types.has('text/plain')) || + * (item.kind === 'file' && item.type.startsWith('image/')), + * ); + * + * if (item?.kind === 'text') { + * const text = await item.getText('text/plain'); + * setContent(text); + * } else if (item?.kind === 'file') { + * const file = await item.getFile(); + * const url = URL.createObjectURL(file); + * setContent( + * {item.name}, + * ); + * } + * }} + * > + * + * {content || 'Drop or paste text or images here'} + * + * + * ``` + */ +export const Default: Story = { + render: (args: any) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [content, setContent] = useState( + null, + ); + + return ( + + ['text/plain', 'image/jpeg', 'image/png', 'image.gif'].some((t) => + types.has(t), + ) + ? 'copy' + : 'cancel' + } + onDrop={async (event) => { + // Find the first accepted item. + const item = event.items.find( + (item) => + (item.kind === 'text' && item.types.has('text/plain')) || + (item.kind === 'file' && item.type.startsWith('image/')), + ); + + if (item?.kind === 'text') { + const text = await item.getText('text/plain'); + setContent(text); + } else if (item?.kind === 'file') { + const file = await item.getFile(); + const url = URL.createObjectURL(file); + setContent( + {item.name}, + ); + } + }} + {...args} + > + + {content || 'Drop or paste text or images here'} + + + ); + }, + args: {}, +}; diff --git a/packages/components/src/components/DropZone/DropZone.quanta.tsx b/packages/components/src/components/DropZone/DropZone.quanta.tsx new file mode 100644 index 00000000000..efa01acd173 --- /dev/null +++ b/packages/components/src/components/DropZone/DropZone.quanta.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import { + type DropZoneProps, + DropZone as RACDropZone, + Text as DropZoneText, +} from 'react-aria-components/DropZone'; +import { tv } from 'tailwind-variants'; + +const dropZone = tv({ + base: ` + flex min-h-24 w-full items-center justify-center rounded-md bg-quanta-aqua p-8 text-center + font-sans text-base text-balance + read-only:bg-quanta-air + hover:bg-quanta-spa + disabled:bg-quanta-air + dark:bg-neutral-900 + `, + variants: { + isFocusVisible: { + true: ` + outline-2 -outline-offset-1 outline-quanta-cobalt + forced-colors:outline-[Highlight] + `, + }, + isDropTarget: { + true: ` + outline-2 -outline-offset-1 outline-quanta-cobalt + forced-colors:outline-[Highlight] + `, + }, + }, +}); + +export function DropZone(props: DropZoneProps) { + return ( + + dropZone({ ...renderProps, className }), + )} + /> + ); +} + +export { DropZoneText }; diff --git a/packages/components/src/components/DropZone/DropZone.stories.tsx b/packages/components/src/components/DropZone/DropZone.stories.tsx new file mode 100644 index 00000000000..6766c690e21 --- /dev/null +++ b/packages/components/src/components/DropZone/DropZone.stories.tsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react'; +import { DropZone, Text as DropZoneText } from './DropZone'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta = { + title: 'Basic/DropZone', + component: DropZone, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) =>
    {Story()}
    , + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Usage example that renders the DropZone shown below + * + * ```ts + * import { useState } from 'react'; + * import { DropZone, DropZoneText } from '@plone/components'; + * + * const [content, setContent] = useState( + * null, + * ); + * + * + * ['text/plain', 'image/jpeg', 'image/png', 'image.gif'].some((t) => + * types.has(t), + * ) + * ? 'copy' + * : 'cancel' + * } + * onDrop={async (event) => { + * // Find the first accepted item. + * const item = event.items.find( + * (item) => + * (item.kind === 'text' && item.types.has('text/plain')) || + * (item.kind === 'file' && item.type.startsWith('image/')), + * ); + * + * if (item?.kind === 'text') { + * const text = await item.getText('text/plain'); + * setContent(text); + * } else if (item?.kind === 'file') { + * const file = await item.getFile(); + * const url = URL.createObjectURL(file); + * setContent( + * {item.name}, + * ); + * } + * }} + * > + * + * {content || 'Drop or paste text or images here'} + * + * + * ``` + */ +export const Default: Story = { + render: (args: any) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [content, setContent] = useState( + null, + ); + + return ( + + ['text/plain', 'image/jpeg', 'image/png', 'image.gif'].some((t) => + types.has(t), + ) + ? 'copy' + : 'cancel' + } + onDrop={async (event) => { + // Find the first accepted item. + const item = event.items.find( + (item) => + (item.kind === 'text' && item.types.has('text/plain')) || + (item.kind === 'file' && item.type.startsWith('image/')), + ); + + if (item?.kind === 'text') { + const text = await item.getText('text/plain'); + setContent(text); + } else if (item?.kind === 'file') { + const file = await item.getFile(); + const url = URL.createObjectURL(file); + setContent( + {item.name}, + ); + } + }} + {...args} + > + + {content || 'Drop or paste text or images here'} + + + ); + }, + args: {}, +}; diff --git a/packages/components/src/components/DropZone/DropZone.tsx b/packages/components/src/components/DropZone/DropZone.tsx new file mode 100644 index 00000000000..945976287d5 --- /dev/null +++ b/packages/components/src/components/DropZone/DropZone.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { + type DropZoneProps, + DropZone as RACDropZone, + Text, +} from 'react-aria-components/DropZone'; + +export function DropZone(props: DropZoneProps) { + return ; +} + +export { Text }; diff --git a/packages/components/src/components/Form/Form.quanta.stories.tsx b/packages/components/src/components/Form/Form.quanta.stories.tsx new file mode 100644 index 00000000000..d7b910d627e --- /dev/null +++ b/packages/components/src/components/Form/Form.quanta.stories.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Form } from './Form.quanta'; +import { Button } from '../Button/Button.quanta'; +import { TextField } from '../TextField/TextField.quanta'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta = { + title: 'Quanta/Forms/Form', + component: Form, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +
    {Story()}
    + ), + ], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args: any) => ( +
    + + + + ), + args: {}, +}; diff --git a/packages/components/src/components/Form/Form.quanta.tsx b/packages/components/src/components/Form/Form.quanta.tsx new file mode 100644 index 00000000000..f261c1f1c20 --- /dev/null +++ b/packages/components/src/components/Form/Form.quanta.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { type FormProps, Form as RACForm } from 'react-aria-components/Form'; +import { twMerge } from 'tailwind-merge'; + +export function Form(props: FormProps) { + return ( + + ); +} diff --git a/packages/components/src/components/Link/Link.quanta.stories.tsx b/packages/components/src/components/Link/Link.quanta.stories.tsx index a19e1173670..7c5259b5d13 100644 --- a/packages/components/src/components/Link/Link.quanta.stories.tsx +++ b/packages/components/src/components/Link/Link.quanta.stories.tsx @@ -9,6 +9,37 @@ const meta = { layout: 'centered', }, tags: ['autodocs'], + argTypes: { + asButton: { + control: 'boolean', + description: 'Show the link as a button', + table: { + type: { + summary: 'boolean', + }, + defaultValue: { + summary: 'false', + }, + }, + }, + accent: { + description: 'Only available when `asButton` is set to `true`', + table: { + defaultValue: { + summary: 'false', + }, + }, + }, + variant: { + control: 'select', + description: 'Only available when `asButton` is set to `true`', + options: ['neutral', 'primary', 'destructive', 'secondary'], + }, + }, + args: { + accent: false, + asButton: false as any, + }, } satisfies Meta; export default meta; @@ -30,3 +61,14 @@ export const Secondary: Story = { target: '_blank', }, }; + +export const AsButton: Story = { + render: (args) => The missing link, + args: { + asButton: true, + variant: 'primary', + accent: true, + href: 'https://www.imdb.com/title/tt6348138/', + target: '_blank', + }, +}; diff --git a/packages/components/src/components/Link/Link.quanta.test.tsx b/packages/components/src/components/Link/Link.quanta.test.tsx new file mode 100644 index 00000000000..28086c9714d --- /dev/null +++ b/packages/components/src/components/Link/Link.quanta.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { Link } from './Link.quanta'; + +expect.extend(toHaveNoViolations); + +it('Link basic a11y test', async () => { + const { container } = render(The link); + + const asd = screen.getByText('The link'); + expect(asd).toHaveAttribute('href', '/'); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); + +it('Link asButton a11y test', async () => { + const { container } = render( + + The link as button + , + ); + + const asd = screen.getByText('The link as button'); + expect(asd).toHaveAttribute('href', '/'); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); diff --git a/packages/components/src/components/Link/Link.quanta.tsx b/packages/components/src/components/Link/Link.quanta.tsx index 3b227cd7ca2..cfd272c2b52 100644 --- a/packages/components/src/components/Link/Link.quanta.tsx +++ b/packages/components/src/components/Link/Link.quanta.tsx @@ -4,45 +4,43 @@ import { type LinkProps as AriaLinkProps, composeRenderProps, } from 'react-aria-components'; -import { tv } from 'tailwind-variants'; -import { focusRing } from '../utils'; +import { button } from '../Button/Button.quanta.variants'; +import { link } from './Link.quanta.variants'; -interface LinkProps extends AriaLinkProps { - variant?: 'primary' | 'secondary'; -} +type LinkVariants = Parameters[0]; + +type LinkProps = AriaLinkProps & + LinkVariants & { + asButton?: never; + }; + +type ButtonVariants = Parameters[0]; + +type LinkAsButtonProps = AriaLinkProps & + ButtonVariants & { + asButton: true; + }; -const styles = tv({ - extend: focusRing, - base: ` - rounded-xs underline transition - disabled:cursor-default disabled:no-underline - forced-colors:disabled:text-[GrayText] - `, - variants: { - variant: { - primary: ` - text-quanta-sapphire underline decoration-quanta-sapphire/40 - hover:text-quanta-royal hover:decoration-quanta-royal - focus:text-quanta-royal focus:decoration-quanta-royal - active:text-quanta-cobalt active:decoration-quanta-cobalt - `, - secondary: ` - text-gray-700 underline decoration-gray-700/50 - hover:decoration-gray-700 - `, - }, - }, - defaultVariants: { - variant: 'primary', - }, -}); +export function Link(props: LinkProps | LinkAsButtonProps) { + const { asButton, variant, ...linkProps } = props; -export function Link(props: LinkProps) { return ( - styles({ ...renderProps, className, variant: props.variant }), + {...linkProps} + className={composeRenderProps( + props.className, + (className, renderProps) => { + if (asButton) { + return button({ + ...renderProps, + className, + variant, + size: props.size, + accent: props.accent, + }); + } + return link({ ...renderProps, className, variant }); + }, )} /> ); diff --git a/packages/components/src/components/Link/Link.quanta.variants.tsx b/packages/components/src/components/Link/Link.quanta.variants.tsx new file mode 100644 index 00000000000..81ae6292f87 --- /dev/null +++ b/packages/components/src/components/Link/Link.quanta.variants.tsx @@ -0,0 +1,28 @@ +import { tv } from 'tailwind-variants'; +import { focusRing } from '../utils'; + +export const link = tv({ + extend: focusRing, + base: ` + rounded-xs underline transition + disabled:cursor-default disabled:no-underline + forced-colors:disabled:text-[GrayText] + `, + variants: { + variant: { + primary: ` + text-quanta-sapphire underline decoration-quanta-sapphire/40 + hover:text-quanta-royal hover:decoration-quanta-royal + focus:text-quanta-royal focus:decoration-quanta-royal + active:text-quanta-cobalt active:decoration-quanta-cobalt + `, + secondary: ` + text-gray-700 underline decoration-gray-700/50 + hover:decoration-gray-700 + `, + }, + }, + defaultVariants: { + variant: 'primary', + }, +}); diff --git a/packages/components/src/components/Link/Link.stories.tsx b/packages/components/src/components/Link/Link.stories.tsx index 89c863cc4ee..7b184222e0a 100644 --- a/packages/components/src/components/Link/Link.stories.tsx +++ b/packages/components/src/components/Link/Link.stories.tsx @@ -26,3 +26,11 @@ export const Default: Story = { children: 'The link', }, }; + +export const AsButton: Story = { + args: { + href: '/', + children: 'The link as button', + className: 'react-aria-Button', + }, +}; diff --git a/packages/components/src/components/Link/Link.test.tsx b/packages/components/src/components/Link/Link.test.tsx index 291956214af..6877bac24b1 100644 --- a/packages/components/src/components/Link/Link.test.tsx +++ b/packages/components/src/components/Link/Link.test.tsx @@ -16,3 +16,18 @@ it('Link basic a11y test', async () => { expect(results).toHaveNoViolations(); }); + +it('Link asButton a11y test', async () => { + const { container } = render( + + The link as button + , + ); + + const asd = screen.getByText('The link as button'); + expect(asd).toHaveAttribute('href', '/'); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); diff --git a/packages/components/src/components/Menu/Menu.quanta.stories.tsx b/packages/components/src/components/Menu/Menu.quanta.stories.tsx index 3058b6d2039..55120bd0b2e 100644 --- a/packages/components/src/components/Menu/Menu.quanta.stories.tsx +++ b/packages/components/src/components/Menu/Menu.quanta.stories.tsx @@ -2,16 +2,26 @@ /* eslint-disable react-hooks/rules-of-hooks */ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Keyboard, Text, type Selection } from 'react-aria-components'; +import { Button } from '../Button/Button.quanta'; import { BackgroundIcon, BlindIcon, DashIcon, LinkIcon, + MoreoptionsIcon, PropertiesIcon, SettingsIcon, -} from '../../components/icons'; -import { type Selection } from 'react-aria-components'; -import { Menu } from './Menu.quanta'; +} from '../icons'; +import { + Menu, + MenuItem, + MenuSection, + MenuSectionHeader, + MenuSeparator, + MenuTrigger, + SubmenuTrigger, +} from './Menu.quanta'; const meta: Meta = { title: 'Quanta/Menu', @@ -25,162 +35,174 @@ const meta: Meta = { export default meta; type Story = StoryObj; +function TriggerButton({ children }: { children: React.ReactNode }) { + return ( + + ); +} + export const Default: Story = { render: (args: any) => ( - + + + + Cut + Copy + Paste + + ), args: {}, }; export const WithTextSlots: Story = { render: (args: any) => ( - + + + + + + Cut + Cut to the clipboard + ⌘X + + + + Copy + Copy to the clipboard + ⌘C + + + + Paste + Paste from the clipboard + ⌘V + + + ), args: {}, }; export const WithIconButton: Story = { render: (args: any) => ( - } - menuItems={[ - { id: 'cut', label: 'Cut' }, - { id: 'copy', label: 'Copy' }, - { id: 'paste', label: 'Paste' }, - ]} - > + + + + + + Cut + Copy + Paste + + ), args: {}, }; export const DisabledItems: Story = { render: (args: any) => ( - } - menuItems={[ - { id: 'cut', label: 'Cut' }, - { id: 'copy', label: 'Copy' }, - { id: 'paste', label: 'Paste', disabled: true }, - ]} - > + + + + + + Cut + Copy + Paste + + ), args: {}, }; export const WithSeparators: Story = { render: (args: any) => ( - } - menuItems={[ - { id: 'cut', label: 'Cut' }, - { id: 'copy', label: 'Copy' }, - { id: 'paste', label: 'Paste' }, - { separator: true }, - { id: 'bold', label: 'Bold' }, - ]} - > + + + + + + Cut + Copy + Paste + + Bold + + ), args: {}, }; export const WithSections: Story = { render: (args: any) => ( - } - menuItems={[ - { - section: true, - header: 'Styles', - children: [ - { id: 'bold', label: 'Bold' }, - { id: 'underline', label: 'Underline' }, - ], - }, - { - section: true, - header: 'Align', - children: [ - { id: 'left', label: 'Left' }, - { id: 'middle', label: 'Middle' }, - { id: 'right', label: 'Right' }, - ], - }, - ]} - > + + + + + + + Styles + Bold + Underline + + + Align + Left + Middle + Right + + + + ), + args: {}, +}; + +export const WithCustomHeader: Story = { + render: (args: any) => ( + + + + + + + Styles + Bold + Underline + + + ), args: {}, }; export const AsLinks: Story = { render: (args: any) => ( - } - menuItems={[ - { - id: 'adobe', - label: 'Adobe', - href: 'https://adobe.com/', - target: '_blank', - }, - { - id: 'apple', - label: 'Apple', - href: 'https://apple.com/', - target: '_blank', - }, - { - id: 'google', - label: 'Google', - href: 'https://google.com/', - target: '_blank', - }, - { - id: 'microsoft', - label: 'Microsoft', - href: 'https://microsoft.com/', - target: '_blank', - }, - ]} - > + + + + + + + Adobe + + + Apple + + + Google + + + Microsoft + + + ), args: {}, }; @@ -193,18 +215,21 @@ export const SingleSelection: Story = { return ( <> - } - selectionMode="single" - selectedKeys={selected} - onSelectionChange={setSelected} - menuItems={[ - { id: 'left', label: 'Left' }, - { id: 'center', label: 'Center' }, - { id: 'right', label: 'Right' }, - ]} - > + + + + + + Left + Center + Right + +

    Current selection (controlled):{' '} {selected === 'all' ? 'all' : [...selected].join(', ')} @@ -223,19 +248,22 @@ export const MultipleSelection: Story = { return ( <> -

    } - selectionMode="multiple" - selectedKeys={selected} - onSelectionChange={setSelected} - menuItems={[ - { id: 'sidebar', label: 'Sidebar' }, - { id: 'searchbar', label: 'Searchbar' }, - { id: 'tools', label: 'Tools' }, - { id: 'console', label: 'Console' }, - ]} - > + + + + + + Sidebar + Searchbar + Tools + Console + +

    Current selection (controlled):{' '} {selected === 'all' ? 'all' : [...selected].join(', ')} @@ -246,21 +274,66 @@ export const MultipleSelection: Story = { args: {}, }; +export const ControlledState: Story = { + render: (args: any) => { + const [open, setOpen] = React.useState(false); + + return ( + + + + +

    + Cut + Copy + Paste + + + ); + }, + args: {}, +}; + export const LongPress: Story = { render: (args: any) => ( - + + + + + alert(String(id))}> + Cut + Copy + Paste + + ), - args: { - trigger: 'longPress', - onPress: () => alert('crop'), - onAction: (id: any) => alert(id), - }, + args: {}, +}; + +export const WithSubmenu: Story = { + render: (args: any) => ( + + + + + + New + + Share + + SMS + X + + Email + + Work + Personal + + + + + + + ), + args: {}, }; diff --git a/packages/components/src/components/Menu/Menu.quanta.tsx b/packages/components/src/components/Menu/Menu.quanta.tsx index a304b2cc2fd..07cbe4d798e 100644 --- a/packages/components/src/components/Menu/Menu.quanta.tsx +++ b/packages/components/src/components/Menu/Menu.quanta.tsx @@ -1,139 +1,100 @@ -import React, { Fragment, type ReactNode } from 'react'; - +import React from 'react'; import { - Menu as RACMenu, - MenuItem as RACMenuItem, - composeRenderProps, - MenuTrigger, - Keyboard, - Section, - Text, Header, - type MenuItemProps as RACMenuItemProps, + Menu as AriaMenu, + MenuItem as AriaMenuItem, + MenuSection as AriaMenuSection, + MenuTrigger as AriaMenuTrigger, + Separator as AriaSeparator, + SubmenuTrigger as AriaSubmenuTrigger, + composeRenderProps, + type MenuItemProps, + type MenuProps, + type MenuSectionProps, + type MenuTriggerProps as AriaMenuTriggerProps, type SeparatorProps, - type MenuProps as RACMenuProps, - type MenuTriggerProps, - type PressEvent, + type SubmenuTriggerProps, } from 'react-aria-components'; -import { Popover, type PopoverProps } from '../Popover/Popover.quanta'; -import { CheckboxIcon, ChevronrightIcon } from '../../components/icons'; -import { Button } from '../Button/Button.quanta'; -import { Separator } from '../Separator/Separator.quanta'; import { tv } from 'tailwind-variants'; +import { twMerge } from 'tailwind-merge'; +import { CheckboxIcon, ChevronrightIcon } from '../icons'; +import { Popover, type PopoverProps } from '../Popover/Popover.quanta'; +import { composeTailwindRenderProps, focusRing } from '../utils'; +import { getMenuTriggerChildren } from './menuTriggerChildren'; -export interface itemProps { - id: string; - label: string; - description?: string; - keyboard?: string; - icon?: ReactNode; - separator?: boolean; - disabled?: boolean; - section?: boolean; - header?: string; - href?: string; - target?: string; - children?: itemProps[]; +export function Menu(props: MenuProps) { + return ( + + ); } -export const dropdownItemStyles = tv({ +const menuItemStyles = tv({ + extend: focusRing, base: ` - group cursor-default items-center gap-x-3 gap-y-0 rounded-lg py-1 pr-1 pl-3 outline-0 - forced-color-adjust-none select-none + group relative flex cursor-default items-center gap-4 rounded-lg py-2 pr-3 pl-3 text-sm + no-underline forced-color-adjust-none select-none + [-webkit-tap-highlight-color:transparent] + selected:pr-1 + [&[href]]:cursor-pointer `, variants: { isDisabled: { false: ` - text-gray-900 - dark:text-zinc-100 + text-neutral-900 + dark:text-neutral-100 `, true: ` - text-gray-300 - dark:text-zinc-600 + text-neutral-300 + dark:text-neutral-600 forced-colors:text-[GrayText] `, }, + isPressed: { + true: ` + bg-neutral-100 + dark:bg-neutral-800 + `, + }, isFocused: { true: ` bg-blue-600 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText] `, }, - selectionMode: { - single: 'flex', - multiple: 'flex', - }, - hasDescription: { - false: '', - true: '', - }, - hasKeyboard: { - false: '', - true: '', - }, - hasIcon: { - false: '', - true: '', - }, - hasHref: { - false: '', - true: 'block', - }, }, compoundVariants: [ { isFocused: false, isOpen: true, className: ` - bg-gray-100 - dark:bg-zinc-700/60 + bg-neutral-100 + dark:bg-neutral-700/60 `, }, - { - hasDescription: true, - hasIcon: true, - hasKeyboard: true, - className: - 'grid grid-flow-col grid-cols-[10%_auto_auto] grid-rows-[auto_auto]', - }, ], }); -export interface MenuItemProps extends RACMenuItemProps { - selectionMode: 'single' | 'multiple' | undefined; - item: itemProps; -} - -export interface MenuButtonProps - extends RACMenuProps, - Omit { - button?: React.ReactNode; - onPress?: (e: PressEvent) => void; - - placement?: PopoverProps['placement']; - selectionMode?: 'single' | 'multiple'; - menuItems: itemProps[]; -} export function MenuItem(props: MenuItemProps) { const textValue = props.textValue || (typeof props.children === 'string' ? props.children : undefined); + return ( - - dropdownItemStyles({ - ...renderProps, - hasDescription: !!props.item.description, - hasIcon: !!props.item?.icon, - hasKeyboard: !!props.item?.keyboard, - isDisabled: props.item?.disabled, - selectionMode: props?.selectionMode, - hasHref: !!props.item?.href, - className, - }), + menuItemStyles({ ...renderProps, className }), )} > {composeRenderProps( @@ -145,20 +106,7 @@ export function MenuItem(props: MenuItemProps) { {isSelected && } )} - {props.item.icon && } - - {children} - - {props.item.description && ( - - {props.item.description} - - )} - {props.item.keyboard && ( - - {props.item.keyboard} - - )} + {children} {hasSubmenu && ( ), )} - + + ); +} + +export function MenuSeparator(props: SeparatorProps) { + return ( + ); } -export function Menu({ - button, - onPress, - children, - ...props -}: MenuButtonProps) { +export function MenuSection(props: MenuSectionProps) { return ( - - - - - {props?.menuItems?.map((item, key) => { - return ( - - {item.separator && !item.section && } - {!item.separator && !item.section && ( - - {item.label} - - )} - {!item.separator && item.section && ( -
    0 ? 'mt-4' : ''}> -
    - {item.header} -
    - {item.children.map((child) => ( - - {child.label} - - ))} -
    - )} -
    - ); - })} -
    -
    -
    + + {props.children} + ); } -export function MenuSeparator(props: SeparatorProps) { +export function MenuSectionHeader(props: React.ComponentProps) { return ( - ); } + +interface MenuTriggerProps extends AriaMenuTriggerProps { + placement?: PopoverProps['placement']; +} + +export function MenuTrigger(props: MenuTriggerProps) { + const [trigger, menu] = getMenuTriggerChildren(props.children, 'MenuTrigger'); + + return ( + + {trigger} + + {menu} + + + ); +} + +export function SubmenuTrigger(props: SubmenuTriggerProps) { + const [trigger, menu] = getMenuTriggerChildren( + props.children, + 'SubmenuTrigger', + ); + + return ( + + {trigger} + + {menu} + + + ); +} diff --git a/packages/components/src/components/Menu/Menu.stories.tsx b/packages/components/src/components/Menu/Menu.stories.tsx index cd3a7c64af7..44a79cc1a19 100644 --- a/packages/components/src/components/Menu/Menu.stories.tsx +++ b/packages/components/src/components/Menu/Menu.stories.tsx @@ -1,17 +1,18 @@ /* eslint-disable no-alert */ /* eslint-disable react-hooks/rules-of-hooks */ import React from 'react'; -import { Menu, MenuItem } from './Menu'; -import { - Header, - Keyboard, - Section, - type Selection, - Separator, - Text, -} from 'react-aria-components'; -import { SettingsIcon } from '../icons/SettingsIcon'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Header, Keyboard, Text, type Selection } from 'react-aria-components'; +import { Button } from '../Button/Button'; +import { SettingsIcon } from '../icons/SettingsIcon'; +import { + Menu, + MenuItem, + MenuSection, + MenuSeparator, + MenuTrigger, + SubmenuTrigger, +} from './Menu'; const meta = { title: 'Basic/Menu', @@ -25,75 +26,96 @@ const meta = { export default meta; type Story = StoryObj; +function TriggerButton({ children }: { children: React.ReactNode }) { + return ; +} + export const Default: Story = { render: (args: any) => ( - - Cut - Copy - Paste - + + Edit + + Cut + Copy + Paste + + ), args: {}, }; export const WithTextSlots: Story = { render: (args: any) => ( - - - - Cut - Cut to the clipboard - ⌘X - - - - Copy - Copy to the clipboard - ⌘C - - - - Paste - Paste from the clipboard - ⌘V - - + + Edit + + + + Cut + Cut to the clipboard + ⌘X + + + + Copy + Copy to the clipboard + ⌘C + + + + Paste + Paste from the clipboard + ⌘V + + + ), args: {}, }; export const WithIconButton: Story = { render: (args: any) => ( - }> - Cut - Copy - Paste - + + + + + + Cut + Copy + Paste + + ), args: {}, }; export const DisabledItems: Story = { render: (args: any) => ( - } disabledKeys={['paste']}> - Cut - Copy - Paste - + + + + + + Cut + Copy + Paste + + ), args: {}, }; export const AsADynamicCollection: Story = { - render: (args: any) => { - return ( - + render: (args: any) => ( + + Actions + {(item: { id: number; name: string }) => ( {item.name} )} - ); - }, + + ), args: { items: [ { id: 1, name: 'New' }, @@ -109,66 +131,88 @@ export const AsADynamicCollection: Story = { export const WithSeparators: Story = { render: (args: any) => ( - }> - Cut - Copy - Paste - - Bold - + + + + + + Cut + Copy + Paste + + Bold + + ), args: {}, }; export const WithSections: Story = { render: (args: any) => ( - }> -
    -
    Styles
    - Bold - Underline -
    -
    -
    Align
    - Left - Middle - Right -
    -
    + + + + + + +
    Styles
    + Bold + Underline +
    + +
    Align
    + Left + Middle + Right +
    +
    +
    ), args: {}, }; -export const AsLinks: Story = { +export const WithCustomHeader: Story = { render: (args: any) => ( - }> - - Adobe - - - Apple - - - Google - - - Microsoft - - + + + + + + +
    Styles
    + Bold + Underline +
    +
    +
    ), args: {}, }; -// export const OpenByDefault: Story = { -// render: (args: any) => ( -// } isOpen> -// Cut -// Copy -// Paste -// -// ), -// args: {}, -// }; +export const AsLinks: Story = { + render: (args: any) => ( + + + + + + + Adobe + + + Apple + + + Google + + + Microsoft + + + + ), + args: {}, +}; export const SingleSelection: Story = { render: (args: any) => { @@ -178,17 +222,19 @@ export const SingleSelection: Story = { return ( <> - - Left - Center - Right - + + Align + + Left + Center + Right + +

    Current selection (controlled):{' '} {selected === 'all' ? 'all' : [...selected].join(', ')} @@ -207,18 +253,20 @@ export const MultipleSelection: Story = { return ( <> -

    - Sidebar - Searchbar - Tools - Console - + + View + + Sidebar + Searchbar + Tools + Console + +

    Current selection (controlled):{' '} {selected === 'all' ? 'all' : [...selected].join(', ')} @@ -232,33 +280,56 @@ export const MultipleSelection: Story = { export const ControlledState: Story = { render: (args: any) => { const [open, setOpen] = React.useState(false); + return ( -

    } - isOpen={open} - onOpenChange={setOpen} - > + + + + + + Cut + Copy + Paste + + + ); + }, + args: {}, +}; + +export const LongPress: Story = { + render: (args: any) => ( + + + + + alert(String(id))}> Cut Copy Paste - ); - }, + + ), args: {}, }; -export const LongPress: Story = { +export const WithSubmenu: Story = { render: (args: any) => ( - }> - Cut - Copy - Paste - + + + + + + New + + Share + + SMS + Email + + + + ), - args: { - trigger: 'longPress', - onPress: () => alert('crop'), - onAction: (id) => alert(id), - }, + args: {}, }; diff --git a/packages/components/src/components/Menu/Menu.tsx b/packages/components/src/components/Menu/Menu.tsx index afa37372848..0c42c2c9318 100644 --- a/packages/components/src/components/Menu/Menu.tsx +++ b/packages/components/src/components/Menu/Menu.tsx @@ -1,42 +1,68 @@ import React from 'react'; import { + Header, Menu as RACMenu, MenuItem as RACMenuItem, + MenuSection as RACMenuSection, + MenuTrigger as RACMenuTrigger, + Popover, + Separator as RACSeparator, + SubmenuTrigger as RACSubmenuTrigger, type MenuItemProps, type MenuProps, - MenuTrigger, + type MenuSectionProps, type MenuTriggerProps, - Popover, - type PressEvent, + type SeparatorProps, + type SubmenuTriggerProps, } from 'react-aria-components'; - -import { Button } from '../Button/Button'; import type { Placement } from 'react-aria'; +import { getMenuTriggerChildren } from './menuTriggerChildren'; + +export function Menu(props: MenuProps) { + return ; +} + +export function MenuItem(props: MenuItemProps) { + return ; +} + +export function MenuSeparator(props: SeparatorProps) { + return ; +} -export interface MenuButtonProps - extends MenuProps, - Omit { - button?: React.ReactNode; - onPress?: (e: PressEvent) => void; +export function MenuSection(props: MenuSectionProps) { + return ; +} + +export function MenuSectionHeader(props: React.ComponentProps) { + return
    ; +} + +interface BasicMenuTriggerProps extends MenuTriggerProps { placement?: Placement; } -export function Menu({ - button, - onPress, - children, - ...props -}: MenuButtonProps) { +export function MenuTrigger(props: BasicMenuTriggerProps) { + const [trigger, menu] = getMenuTriggerChildren(props.children, 'MenuTrigger'); + return ( - - - - {children} - - + + {trigger} + {menu} + ); } -export function MenuItem(props: MenuItemProps) { - return ; +export function SubmenuTrigger(props: SubmenuTriggerProps) { + const [trigger, menu] = getMenuTriggerChildren( + props.children, + 'SubmenuTrigger', + ); + + return ( + + {trigger} + {menu} + + ); } diff --git a/packages/components/src/components/Menu/menuTriggerChildren.tsx b/packages/components/src/components/Menu/menuTriggerChildren.tsx new file mode 100644 index 00000000000..0e558f650e4 --- /dev/null +++ b/packages/components/src/components/Menu/menuTriggerChildren.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +type MenuTriggerChildren = [React.ReactElement, React.ReactElement]; + +function isFragmentElement(element: React.ReactElement) { + return element.type === React.Fragment; +} + +export function getMenuTriggerChildren( + children: React.ReactNode, + componentName: string, +): MenuTriggerChildren { + const nodes = React.Children.toArray(children); + + if (nodes.length !== 2) { + throw new Error( + `${componentName} expects exactly two children: a trigger element and a menu element.`, + ); + } + + const [trigger, menu] = nodes; + + if (!React.isValidElement(trigger) || !React.isValidElement(menu)) { + throw new Error( + `${componentName} expects both children to be valid React elements.`, + ); + } + + if (isFragmentElement(trigger) || isFragmentElement(menu)) { + throw new Error( + `${componentName} does not accept Fragment children. Pass the trigger element and menu element directly.`, + ); + } + + return [trigger, menu]; +} + +export type { MenuTriggerChildren }; diff --git a/packages/components/src/components/Modal/Modal.quanta.stories.tsx b/packages/components/src/components/Modal/Modal.quanta.stories.tsx new file mode 100644 index 00000000000..19c8ff1f1c4 --- /dev/null +++ b/packages/components/src/components/Modal/Modal.quanta.stories.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Modal } from './Modal.quanta'; +import { Button } from '../Button/Button.quanta'; +import { Dialog, DialogTrigger } from '../Dialog/Dialog.quanta'; +import { Form } from '../Form/Form.quanta'; +import { TextField } from '../TextField/TextField.quanta'; + +import { Heading } from 'react-aria-components'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta = { + title: 'Quanta/Modal', + component: Modal, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args: any) => ( + + + + + {({ close }) => ( +
    + Sign up + + + + + )} +
    +
    +
    + ), +}; diff --git a/packages/components/src/components/Modal/Modal.quanta.tsx b/packages/components/src/components/Modal/Modal.quanta.tsx new file mode 100644 index 00000000000..af1a5a14eca --- /dev/null +++ b/packages/components/src/components/Modal/Modal.quanta.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { + ModalOverlay, + type ModalOverlayProps, + Modal as RACModal, +} from 'react-aria-components/Modal'; +import { tv } from 'tailwind-variants'; + +const overlayStyles = tv({ + base: ` + absolute top-0 left-0 isolate z-20 h-(--page-height) w-full bg-black/50 text-center + backdrop-blur-lg + `, + variants: { + isEntering: { + true: 'duration-200 ease-out animate-in fade-in', + }, + isExiting: { + true: 'duration-200 ease-in animate-out fade-out', + }, + }, +}); + +const modalStyles = tv({ + base: ` + max-h-[calc(var(--visual-viewport-height)*.9)] w-full max-w-[90vw] rounded-md border + border-black/10 bg-white bg-clip-padding text-left align-middle text-neutral-700 shadow-2xl + dark:border-white/10 dark:bg-neutral-800/70 dark:text-neutral-300 dark:backdrop-blur-2xl + dark:backdrop-saturate-200 + forced-colors:bg-[Canvas] + `, + variants: { + isEntering: { + true: 'duration-200 ease-out animate-in zoom-in-105', + }, + isExiting: { + true: 'duration-200 ease-in animate-out zoom-out-95', + }, + }, +}); + +export function Modal(props: ModalOverlayProps) { + return ( + +
    + +
    +
    + ); +} diff --git a/packages/components/src/components/Modal/Modal.stories.tsx b/packages/components/src/components/Modal/Modal.stories.tsx index 704f2ceba6a..ee27678453c 100644 --- a/packages/components/src/components/Modal/Modal.stories.tsx +++ b/packages/components/src/components/Modal/Modal.stories.tsx @@ -1,15 +1,9 @@ import React from 'react'; import { Modal } from './Modal'; import { Button } from '../Button/Button'; +import { Dialog, DialogTrigger } from '../Dialog/Dialog'; -import { - Dialog, - DialogTrigger, - Heading, - Input, - Label, - TextField, -} from 'react-aria-components'; +import { Heading, Input, Label, TextField } from 'react-aria-components'; import type { Meta, StoryObj } from '@storybook/react-vite'; diff --git a/packages/components/src/components/MultiSelect/TokenSelect.quanta.tsx b/packages/components/src/components/MultiSelect/TokenSelect.quanta.tsx index 2527b2e3659..0bbbc6166fc 100644 --- a/packages/components/src/components/MultiSelect/TokenSelect.quanta.tsx +++ b/packages/components/src/components/MultiSelect/TokenSelect.quanta.tsx @@ -23,7 +23,7 @@ export type Option = { export interface TokenSelectProps extends Omit, 'children'> { items: Iterable; - children: React.ReactNode | ((item: T) => React.ReactNode); + children?: React.ReactNode | ((item: T) => React.ReactNode); } export function TokenSelect(props: TokenSelectProps) { @@ -73,7 +73,8 @@ export function TokenSelect(props: TokenSelectProps) { - {(state) => {state.name}} + {props.children || + ((item: T) => {item.name})} diff --git a/packages/components/src/components/Pagination/Pagination.tsx b/packages/components/src/components/Pagination/Pagination.tsx new file mode 100644 index 00000000000..4b523bbe8a1 --- /dev/null +++ b/packages/components/src/components/Pagination/Pagination.tsx @@ -0,0 +1,184 @@ +import React, { useRef } from 'react'; +import { VisuallyHidden } from 'react-aria'; +import { ArrowleftIcon, ArrowrightIcon } from '../icons'; +import { Button } from '../Button/Button'; + +export type PaginationProps = { + totalPages: number; + currentPage: number; + onPageChange: (page: number) => void; + + ariaLabel?: string; + prevLabel?: string; + nextLabel?: string; + currentLabel?: string; + pageLabel?: string; + buttonComponent?: React.ElementType; + // --- props to change page size. Not implemented yet.--- + // pageSize: number; + // pageSizes: Array; + // onChangePageSizes: (pageSize: number) => void; +}; + +export const Pagination = ({ + totalPages, + currentPage = 0, + onPageChange, + + ariaLabel = 'Pagination', + prevLabel = 'Previous page', + nextLabel = 'Next page', + currentLabel = 'Current page', + pageLabel = 'Page', + buttonComponent: ButtonComponent = Button, + // pageSize, + // pageSizes, + //onChangePageSizes, +}: PaginationProps) => { + const ref = useRef(null); + + type PageType = { + type: string; + value?: number; + text?: string; + icon?: any; + className?: string; + disabled?: boolean; + ariaLabel?: string; + current?: boolean; + }; + const pages = [] as PageType[]; + + //previous button + pages.push({ + type: 'prev-next', + value: currentPage - 1, + icon: , + className: 'previous-page', + disabled: currentPage == 0, + ariaLabel: prevLabel, + }); + + //page 1, always visible + if (currentPage > 2) { + pages.push({ + type: 'page', + value: 0, + text: '1', + }); + } + + //dots + if (currentPage > 3) { + pages.push({ type: 'other-items', disabled: true, text: '...' }); + } + + //current page -2 + if (currentPage > 1) { + pages.push({ + type: 'page', + value: currentPage - 2, + text: `${currentPage - 1}`, + }); + } + //current page -1 + if (currentPage > 0) { + pages.push({ + type: 'page', + value: currentPage - 1, + text: `${currentPage}`, + current: true, + }); + } + //current page + if (totalPages > 1) { + pages.push({ + type: 'page', + value: currentPage, + text: `${currentPage + 1}`, + current: true, + className: 'active', + ariaLabel: currentLabel, + }); + } + + //current page +1 + if (totalPages > currentPage + 1) { + pages.push({ + type: 'page', + value: currentPage + 1, + text: `${currentPage + 2}`, + }); + } + //current page +2 + if (totalPages > currentPage + 2) { + pages.push({ + type: 'page', + value: currentPage + 2, + text: `${currentPage + 3}`, + }); + } + + //dots + if (totalPages > currentPage + 4) { + pages.push({ type: 'other-items', disabled: true, text: '...' }); + } + + //last page + if (totalPages > currentPage + 3) { + pages.push({ + type: 'page', + value: totalPages - 1, + text: `${totalPages}`, + }); + } + + //next button + pages.push({ + type: 'prev-next', + value: currentPage + 1, + icon: , + className: 'next-page', + disabled: currentPage == totalPages - 1, + ariaLabel: nextLabel, + }); + + //TODO: handle change pagesize + + return totalPages > 1 ? ( + + ) : ( + <> + ); +}; diff --git a/packages/components/src/components/Popover/Popover.quanta.stories.tsx b/packages/components/src/components/Popover/Popover.quanta.stories.tsx index 291f968e276..a2d393d191a 100644 --- a/packages/components/src/components/Popover/Popover.quanta.stories.tsx +++ b/packages/components/src/components/Popover/Popover.quanta.stories.tsx @@ -1,8 +1,8 @@ import React from 'react'; import type { Meta } from '@storybook/react-vite'; -import { DialogTrigger, Heading } from 'react-aria-components'; +import { Heading } from 'react-aria-components'; import { Button } from '../Button/Button.quanta'; -import { Dialog } from '../Dialog/Dialog.quanta'; +import { Dialog, DialogTrigger } from '../Dialog/Dialog.quanta'; import { Popover } from '../Popover/Popover.quanta'; import { InfoIcon } from '../../components/icons'; diff --git a/packages/components/src/components/Popover/Popover.stories.tsx b/packages/components/src/components/Popover/Popover.stories.tsx index ed75e1a7cb3..779792a366f 100644 --- a/packages/components/src/components/Popover/Popover.stories.tsx +++ b/packages/components/src/components/Popover/Popover.stories.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { Popover } from './Popover'; import { Button } from '../Button/Button'; -import { DialogTrigger, Heading } from 'react-aria-components'; +import { DialogTrigger } from '../Dialog/Dialog'; +import { Heading } from 'react-aria-components'; import type { Meta, StoryObj } from '@storybook/react-vite'; diff --git a/packages/components/src/components/Select/Select.quanta.stories.tsx b/packages/components/src/components/Select/Select.quanta.stories.tsx new file mode 100644 index 00000000000..77598d87c5b --- /dev/null +++ b/packages/components/src/components/Select/Select.quanta.stories.tsx @@ -0,0 +1,347 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Autocomplete, + Collection, + Group, + Select as RACSelect, + SelectValue, + useFilter, +} from 'react-aria-components'; + +import { Button } from '../Button/Button.quanta'; +import { Label } from '../Field/Field.quanta'; +import { Popover } from '../Popover/Popover.quanta'; +import { SearchField } from '../SearchField/SearchField.quanta'; +import { Tag, TagGroup } from '../TagGroup/TagGroup.quanta'; +import { + Select, + SelectItem, + SelectListBox, + SelectSection, + SelectSectionHeader, + type SelectItemObject, + type SelectProps, +} from './Select.quanta'; + +const options = [ + { label: '1', value: 'Aerospace' }, + { label: '2', value: 'Mechanical' }, + { label: '3', value: 'Civil' }, + { label: '4', value: 'Biomedical' }, + { label: '5', value: 'Nuclear' }, + { label: '6', value: 'Industrial' }, + { label: '7', value: 'Chemical' }, + { label: '8', value: 'Agricultural' }, + { label: '9', value: 'Electrical' }, + { label: '10', value: 'Telco' }, +]; + +const groupedOptions = [ + { + name: 'Fruit', + children: [ + { id: 'apple', name: 'Apple' }, + { id: 'banana', name: 'Banana' }, + { id: 'orange', name: 'Orange' }, + { id: 'pear', name: 'Pear' }, + ], + }, + { + name: 'Vegetable', + children: [ + { id: 'broccoli', name: 'Broccoli' }, + { id: 'carrots', name: 'Carrots' }, + { id: 'lettuce', name: 'Lettuce' }, + { id: 'spinach', name: 'Spinach' }, + ], + }, +]; + +const categories = [ + { id: 'news', name: 'News' }, + { id: 'travel', name: 'Travel' }, + { id: 'shopping', name: 'Shopping' }, + { id: 'business', name: 'Business' }, + { id: 'entertainment', name: 'Entertainment' }, + { id: 'food', name: 'Food' }, + { id: 'technology', name: 'Technology' }, + { id: 'health', name: 'Health' }, + { id: 'science', name: 'Science' }, +]; + +const states = [ + { id: 'AL', name: 'Alabama' }, + { id: 'AK', name: 'Alaska' }, + { id: 'AZ', name: 'Arizona' }, + { id: 'CA', name: 'California' }, + { id: 'CO', name: 'Colorado' }, + { id: 'FL', name: 'Florida' }, + { id: 'MA', name: 'Massachusetts' }, + { id: 'NY', name: 'New York' }, + { id: 'TX', name: 'Texas' }, + { id: 'WA', name: 'Washington' }, +]; + +const meta: Meta = { + title: 'Quanta/Select', + component: Select, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +function ControlledValueStory(args: any) { + const [value, setValue] = React.useState('10'); + + return ( + <> + +
    +        Current selection: {JSON.stringify(value)}
    +      
    + + ); +} + +function MultipleValueStory(args: any) { + const [value, setValue] = React.useState(['2', '9']); + + return ( + <> + +
    +        Current selection: {JSON.stringify(value)}
    +      
    + + ); +} + +function AutocompletePopoverStory() { + const { contains } = useFilter({ sensitivity: 'base' }); + + return ( + + + + + + + + {(item: (typeof categories)[number]) => ( + {item.name} + )} + + + + + ); +} + +function TagGroupValueStory() { + const triggerRef = React.useRef(null); + const { contains } = useFilter({ sensitivity: 'base' }); + const [value, setValue] = React.useState([]); + + return ( + setValue(nextValue as string[])} + > + + + style={{ flex: 1 }}> + {({ selectedItems }) => ( + item != null, + )} + renderEmptyState={() => 'No selected items'} + onRemove={(keys: Set) => + setValue((current) => current.filter((key) => !keys.has(key))) + } + > + {(item: (typeof states)[number]) => {item.name}} + + )} + + + + + + + + {(item: (typeof states)[number]) => ( + {item.name} + )} + + + + + ); +} + +export const Default: Story = { + args: { + name: 'field-default', + label: 'Field title', + description: 'Optional help text', + placeholder: 'Select...', + children: ( + <> + Hello + Lorem Ipsum + + ), + }, +}; + +export const Items: Story = { + render: (args) => ( + + {...(args as SelectProps)} + > + {(item: SelectItemObject) => ( + {item.value} + )} + + ), + args: { + name: 'field-items', + label: 'Field title', + description: 'Optional help text', + placeholder: 'Select...', + items: options, + }, +}; + +export const Sections: Story = { + render: (args) => ( + + {...(args as SelectProps<(typeof groupedOptions)[number], 'single'>)} + items={groupedOptions} + > + {(section: (typeof groupedOptions)[number]) => ( + + {section.name} + + {(item: (typeof groupedOptions)[number]['children'][number]) => ( + {item.name} + )} + + + )} + + ), + args: { + name: 'field-sections', + label: 'Preferred fruit or vegetable', + placeholder: 'Select...', + }, +}; + +export const ControlledValue: Story = { + render: ControlledValueStory, + args: { + name: 'field-controlled', + label: 'Pick an industry', + placeholder: 'Select...', + }, +}; + +export const MultipleValue: Story = { + render: MultipleValueStory, + args: { + name: 'field-multiple', + label: 'Pick industries', + placeholder: 'Select...', + }, +}; + +export const AutocompletePopover: Story = { + render: AutocompletePopoverStory, +}; + +export const TagGroupValue: Story = { + render: TagGroupValueStory, +}; + +export const Required: Story = { + ...Items, + args: { + ...Items.args, + name: 'field-required', + isRequired: true, + }, +}; + +export const Filled: Story = { + ...Items, + args: { + ...Items.args, + name: 'field-filled', + label: 'Filled field title', + defaultValue: '10', + isRequired: true, + }, +}; + +export const Errored: Story = { + ...Items, + args: { + ...Items.args, + name: 'field-errored', + label: 'Errored field title', + defaultValue: '10', + errorMessage: 'This is the error', + isInvalid: true, + isRequired: true, + }, +}; + +export const Disabled: Story = { + ...Items, + args: { + ...Items.args, + name: 'field-disabled', + label: 'Disabled field title', + isDisabled: true, + }, +}; diff --git a/packages/components/src/components/Select/Select.quanta.tsx b/packages/components/src/components/Select/Select.quanta.tsx new file mode 100644 index 00000000000..9b426222086 --- /dev/null +++ b/packages/components/src/components/Select/Select.quanta.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { + Button, + Header, + ListBoxSection, + Select as RACSelect, + SelectValue, + type ListBoxItemProps, + type ListBoxProps, + type SectionProps, +} from 'react-aria-components'; +import { twMerge } from 'tailwind-merge'; +import { tv } from 'tailwind-variants'; + +import { Description, FieldError, Label } from '../Field/Field.quanta'; +import { DropdownItem, ListBox } from '../ListBox/ListBox.quanta'; +import { Popover } from '../Popover/Popover.quanta'; +import { composeTailwindRenderProps, focusRing } from '../utils'; +import { ChevrondownIcon } from '../icons'; +import { + SelectSectionHeader as BasicSelectSectionHeader, + type SelectItemObject, + type SelectProps, +} from './Select'; + +const triggerStyles = tv({ + extend: focusRing, + base: ` + flex min-h-11 min-w-[180px] items-center gap-3 rounded-lg bg-quanta-snow px-3 py-2 text-left + text-sm text-quanta-space transition + hover:bg-quanta-smoke + focus:bg-quanta-air + active:bg-quanta-air + forced-colors:bg-[Field] + `, + variants: { + isDisabled: { + true: ` + cursor-not-allowed bg-quanta-air text-quanta-silver + hover:bg-quanta-air + forced-colors:text-[GrayText] + `, + }, + isInvalid: { + true: ` + bg-quanta-ballet + hover:bg-quanta-flamingo + `, + }, + }, +}); + +function DefaultSelectItem(item: SelectItemObject) { + return {item.label}; +} + +export function Select< + T extends object = SelectItemObject, + M extends 'single' | 'multiple' = 'single', +>({ + label, + description, + errorMessage, + children, + items, + labelClassnames, + ...props +}: SelectProps) { + return ( + + {({ isOpen }) => ( + <> + {label && } + + {description && {description}} + {errorMessage} + + {children ? ( + {children} + ) : ( + + items={items as Iterable | undefined} + > + {DefaultSelectItem} + + )} + + + )} + + ); +} + +function SelectChevron({ isOpen }: { isOpen: boolean }) { + return ( + + ); +} + +export function SelectListBox(props: ListBoxProps) { + return ( + + ); +} + +export function SelectItem(props: ListBoxItemProps) { + return ; +} + +export function SelectSection(props: SectionProps) { + return ( + + ); +} + +export function SelectSectionHeader( + props: React.ComponentProps, +) { + return ( +
    + ); +} + +export type { SelectItemObject, SelectProps }; diff --git a/packages/components/src/components/Select/Select.stories.tsx b/packages/components/src/components/Select/Select.stories.tsx index 9c2e6b5f425..db51cf4d8be 100644 --- a/packages/components/src/components/Select/Select.stories.tsx +++ b/packages/components/src/components/Select/Select.stories.tsx @@ -1,8 +1,87 @@ import React from 'react'; -import { Select, SelectItem } from './Select'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Autocomplete, + Collection, + Group, + Label, + Popover, + Select as RACSelect, + SelectValue, + useFilter, +} from 'react-aria-components'; + +import { Button } from '../Button/Button'; +import { Form } from '../Form/Form'; +import { SearchField } from '../SearchField/SearchField'; +import { Tag, TagGroup } from '../TagGroup/TagGroup'; +import { + Select, + SelectItem, + SelectListBox, + SelectSection, + SelectSectionHeader, +} from './Select'; + +const options = [ + { label: '1', value: 'Aerospace' }, + { label: '2', value: 'Mechanical' }, + { label: '3', value: 'Civil' }, + { label: '4', value: 'Biomedical' }, + { label: '5', value: 'Nuclear' }, + { label: '6', value: 'Industrial' }, + { label: '7', value: 'Chemical' }, + { label: '8', value: 'Agricultural' }, + { label: '9', value: 'Electrical' }, + { label: '10', value: 'Telco' }, +]; + +const groupedOptions = [ + { + name: 'Fruit', + children: [ + { id: 'apple', name: 'Apple' }, + { id: 'banana', name: 'Banana' }, + { id: 'orange', name: 'Orange' }, + { id: 'pear', name: 'Pear' }, + ], + }, + { + name: 'Vegetable', + children: [ + { id: 'broccoli', name: 'Broccoli' }, + { id: 'carrots', name: 'Carrots' }, + { id: 'lettuce', name: 'Lettuce' }, + { id: 'spinach', name: 'Spinach' }, + ], + }, +]; + +const categories = [ + { id: 'news', name: 'News' }, + { id: 'travel', name: 'Travel' }, + { id: 'shopping', name: 'Shopping' }, + { id: 'business', name: 'Business' }, + { id: 'entertainment', name: 'Entertainment' }, + { id: 'food', name: 'Food' }, + { id: 'technology', name: 'Technology' }, + { id: 'health', name: 'Health' }, + { id: 'science', name: 'Science' }, +]; + +const states = [ + { id: 'AL', name: 'Alabama' }, + { id: 'AK', name: 'Alaska' }, + { id: 'AZ', name: 'Arizona' }, + { id: 'CA', name: 'California' }, + { id: 'CO', name: 'Colorado' }, + { id: 'FL', name: 'Florida' }, + { id: 'MA', name: 'Massachusetts' }, + { id: 'NY', name: 'New York' }, + { id: 'TX', name: 'Texas' }, + { id: 'WA', name: 'Washington' }, +]; -// More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction const meta = { title: 'Basic/Forms/Select', component: Select, @@ -15,8 +94,8 @@ const meta = {
    @@ -29,91 +108,204 @@ const meta = { export default meta; type Story = StoryObj; -/** - * Select gets a fixed children as JSX - */ +function ControlledValueStory(args: any) { + const [value, setValue] = React.useState('10'); + + return ( + <> + +
    +        Current selection: {JSON.stringify(value)}
    +      
    + + ); +} + +function AutocompletePopoverStory() { + const { contains } = useFilter({ sensitivity: 'base' }); + + return ( + + + + + + + + {(item: (typeof categories)[number]) => ( + {item.name} + )} + + + + + ); +} + +function TagGroupValueStory() { + const triggerRef = React.useRef(null); + const { contains } = useFilter({ sensitivity: 'base' }); + const [value, setValue] = React.useState([]); + const selectedStateItems = ( + selectedItems: Array<(typeof states)[number] | null>, + ) => + selectedItems.filter( + (item): item is (typeof states)[number] => item != null, + ); + + return ( + setValue(nextValue as string[])} + > + + + style={{ flex: 1 }}> + {({ selectedItems }) => ( + 'No selected items'} + onRemove={(keys) => + setValue((current) => current.filter((key) => !keys.has(key))) + } + > + {(item: (typeof states)[number]) => {item.name}} + + )} + + + + + + + + {(item: (typeof states)[number]) => ( + {item.name} + )} + + + + + ); +} + export const Default: Story = { args: { - name: 'empty', - label: 'field 1 title', + name: 'field-default', + label: 'Field title', description: 'Optional help text', placeholder: 'Select...', children: ( <> - Hello - Lorem Ipsum + Hello + Lorem Ipsum ), }, }; -/** - * Select renders options via render props `(item)=> React.ReactNode` - */ export const Items: Story = { args: { - name: 'field-empty', - label: 'field 1 title', + name: 'field-items', + label: 'Field title', description: 'Optional help text', placeholder: 'Select...', - items: [ - { label: '1', value: 'Aerospace' }, - { label: '2', value: 'Mechanical' }, - { label: '3', value: 'Civil' }, - { label: '4', value: 'Biomedical' }, - { label: '5', value: 'Nuclear' }, - { label: '6', value: 'Industrial' }, - { label: '7', value: 'Chemical' }, - { label: '8', value: 'Agricultural' }, - { label: '9', value: 'Electrical' }, - { label: '10', value: 'Telco' }, - ], - children: null, + items: options, }, }; -export const LotsOfItems: Story = { +export const Sections: Story = { + render: (args) => ( + + ), args: { - name: 'field-empty', - label: 'field 1 title', - description: 'Optional help text', + name: 'field-sections', + label: 'Preferred fruit or vegetable', + placeholder: 'Select...', + }, +}; + +export const ControlledValue: Story = { + render: ControlledValueStory, + args: { + name: 'field-controlled', + label: 'Pick an industry', + placeholder: 'Select...', + }, +}; + +export const MultipleValue: Story = { + render: MultipleValueStory, + args: { + name: 'field-multiple', + label: 'Pick industries', placeholder: 'Select...', - items: [ - { label: '1', value: 'Aerospace' }, - { label: '2', value: 'Mechanical' }, - { label: '3', value: 'Civil' }, - { label: '4', value: 'Biomedical' }, - { label: '5', value: 'Nuclear' }, - { label: '6', value: 'Industrial' }, - { label: '7', value: 'Chemical' }, - { label: '8', value: 'Agricultural' }, - { label: '9', value: 'Electrical' }, - { label: '10', value: 'Telco' }, - { label: '11', value: 'Aerospace' }, - { label: '12', value: 'Mechanical' }, - { label: '13', value: 'Civil' }, - { label: '14', value: 'Biomedical' }, - { label: '15', value: 'Nuclear' }, - { label: '16', value: 'Industrial' }, - { label: '17', value: 'Chemical' }, - { label: '18', value: 'Agricultural' }, - { label: '19', value: 'Electrical' }, - { label: '20', value: 'Telco' }, - { label: '21', value: 'Aerospace' }, - { label: '22', value: 'Mechanical' }, - { label: '23', value: 'Civil' }, - { label: '24', value: 'Biomedical' }, - { label: '25', value: 'Nuclear' }, - { label: '26', value: 'Industrial' }, - { label: '27', value: 'Chemical' }, - { label: '28', value: 'Agricultural' }, - { label: '29', value: 'Electrical' }, - { label: '30', value: 'Telco' }, - ], - children: null, }, }; +export const AutocompletePopover: Story = { + render: AutocompletePopoverStory, +}; + +export const TagGroupValue: Story = { + render: TagGroupValueStory, +}; + export const Required: Story = { ...Items, args: { @@ -129,7 +321,7 @@ export const Filled: Story = { ...Items.args, name: 'field-filled', label: 'Filled field title', - defaultSelectedKey: '10', + defaultValue: '10', isRequired: true, }, }; @@ -140,7 +332,7 @@ export const Errored: Story = { ...Items.args, name: 'field-errored', label: 'Errored field title', - defaultSelectedKey: '10', + defaultValue: '10', errorMessage: 'This is the error', isInvalid: true, isRequired: true, @@ -156,3 +348,25 @@ export const Disabled: Story = { isDisabled: true, }, }; + +export const Validation: Story = { + render: (args) => ( +
    + + +
    + ), + args: { + label: 'Animal', + name: 'animal', + isRequired: true, + description: 'Please select an animal.', + }, +}; diff --git a/packages/components/src/components/Select/Select.tsx b/packages/components/src/components/Select/Select.tsx index dd1c57df5b6..abccbc9a7b4 100644 --- a/packages/components/src/components/Select/Select.tsx +++ b/packages/components/src/components/Select/Select.tsx @@ -2,20 +2,23 @@ import React from 'react'; import { Button, FieldError, + Header, Label, ListBox, ListBoxItem, - type ListBoxItemProps, + ListBoxSection, Popover, PopoverContext, Select as RACSelect, - type SelectProps as RACSelectProps, SelectValue, Text, useContextProps, + type ListBoxItemProps, + type ListBoxProps, + type SectionProps, + type SelectProps as RACSelectProps, type ValidationResult, } from 'react-aria-components'; -// import { Popover } from '../Popover/Popover'; import { ChevrondownIcon } from '../icons/ChevrondownIcon'; import { ChevronupIcon } from '../icons/ChevronupIcon'; @@ -25,49 +28,41 @@ export interface SelectItemObject { value: string; } -export interface SelectProps - extends Omit, 'children'> { +interface SelectBaseProps + extends Omit, 'children'> { label?: string; + labelClassnames?: string; description?: string; errorMessage?: string | ((validation: ValidationResult) => string); items?: Iterable; - children: React.ReactNode | ((item: T) => React.ReactNode); + children?: React.ReactNode | ((item: T) => React.ReactNode); } -/** - * See https://react-spectrum.adobe.com/react-aria/Select.html - * - * An iterable list of options is passed to the Select using the items prop. Each item - * accepts an id prop, which is passed to the onSelectionChange handler to identify - * the selected item. Alternatively, if the item objects contain an id property, as - * shown in the example below, then this is used automatically and an id prop is not - * required. - * - * Setting a selected option can be done by using the defaultSelectedKey or selectedKey - * prop. The selected key corresponds to the id prop of an item. When Select is used - * with a dynamic collection as described above, the id of each item is derived from - * the data. - * - */ -export function Select({ +export interface SelectProps< + T extends object = SelectItemObject, + M extends 'single' | 'multiple' = 'single', +> extends SelectBaseProps {} + +export function Select< + T extends object = SelectItemObject, + M extends 'single' | 'multiple' = 'single', +>({ label, description, errorMessage, children, items, ...props -}: SelectProps) { - // In case that we want to customize the Popover, we proxy the PopoverContext props down +}: SelectProps) { const [popoverProps] = useContextProps({}, null, PopoverContext); return ( {({ isOpen }) => ( <> - + {label && } + + )} + {selectionBehavior === 'toggle' && ( + + + + )} + {children} + + ); +} + +const cellStyles = tv({ + extend: focusRing, + base: ` + box-border truncate border-b border-b-neutral-200 p-2 -outline-offset-2 + [--selected-border:var(--color-blue-200)] + [-webkit-tap-highlight-color:transparent] + group-last/row:border-b-0 + group-selected/row:border-(--selected-border) + group-last/row:first:rounded-bl-lg group-last/row:last:rounded-br-lg + dark:border-b-neutral-700 dark:[--selected-border:var(--color-blue-900)] + [:is(:has(+[data-selected])_*)]:border-(--selected-border) + `, +}); + +const expandButton = tv({ + extend: focusRing, + base: ` + shrink-0 cursor-default border-0 bg-transparent p-0 pr-1 align-middle + [-webkit-tap-highlight-color:transparent] + `, + variants: { + isDisabled: { + true: ` + text-neutral-300 + dark:text-neutral-600 + forced-colors:text-[GrayText] + `, + }, + }, +}); + +const chevron = tv({ + base: ` + h-4.5 w-4.5 text-neutral-500 transition-transform duration-200 ease-in-out + dark:text-neutral-400 + `, + variants: { + isExpanded: { + true: 'rotate-90 transform', + }, + isDisabled: { + true: ` + text-neutral-300 + dark:text-neutral-600 + forced-colors:text-[GrayText] + `, + }, + }, +}); + +export function Cell(props: CellProps) { + return ( + ({ + paddingInlineStart: isTreeColumn + ? 4 + (hasChildItems ? 0 : 20) + (level - 1) * 16 + : undefined, + })} + > + {composeRenderProps( + props.children, + (children, { hasChildItems, isTreeColumn, isExpanded, isDisabled }) => ( + <> + {hasChildItems && isTreeColumn && ( + + )} + {children} + + ), + )} + + ); +} diff --git a/packages/components/src/components/Table/Table.tsx b/packages/components/src/components/Table/Table.tsx index aa02923cd6c..5f99955b607 100644 --- a/packages/components/src/components/Table/Table.tsx +++ b/packages/components/src/components/Table/Table.tsx @@ -29,6 +29,7 @@ interface TableProps extends RACTableProps { rows?: R[]; resizableColumns?: boolean; dragColumnHeader?: ComponentProps['dragColumnHeader']; + renderEmptyState?: ComponentProps['renderEmptyState']; // TODO maybe a custom "selectall" component? Is it doable with react-aria-components? } @@ -42,6 +43,7 @@ export function Table({ rows, resizableColumns, dragColumnHeader, + renderEmptyState, ...otherProps }: TableProps) { let table = null; @@ -63,7 +65,7 @@ export function Table({ )} - + {(item) => ( {(column) => {item[column.id]}} diff --git a/packages/components/src/components/Tabs/Tabs.quanta.tsx b/packages/components/src/components/Tabs/Tabs.quanta.tsx index 3de79ad8fe1..043ed663d1f 100644 --- a/packages/components/src/components/Tabs/Tabs.quanta.tsx +++ b/packages/components/src/components/Tabs/Tabs.quanta.tsx @@ -94,7 +94,7 @@ export function Tab(props: RACTabProps) { const tabPanelStyles = tv({ extend: focusRing, base: ` - flex-1 p-4 text-sm text-gray-900 + flex-1 p-4 text-base text-gray-900 dark:text-zinc-100 `, }); diff --git a/packages/components/src/components/TagGroup/TagGroup.quanta.stories.tsx b/packages/components/src/components/TagGroup/TagGroup.quanta.stories.tsx index 76bd04dcd30..09b5cfde907 100644 --- a/packages/components/src/components/TagGroup/TagGroup.quanta.stories.tsx +++ b/packages/components/src/components/TagGroup/TagGroup.quanta.stories.tsx @@ -84,7 +84,6 @@ export const ItemsCollection: Story = { export const RemovableTags: Story = { args: { - allowsRemoving: true, renderEmptyState: () => ( No flavors left ), diff --git a/packages/components/src/components/TextField/TextField.quanta.tsx b/packages/components/src/components/TextField/TextField.quanta.tsx index 92193fe1d37..1e7d65ee4db 100644 --- a/packages/components/src/components/TextField/TextField.quanta.tsx +++ b/packages/components/src/components/TextField/TextField.quanta.tsx @@ -18,6 +18,7 @@ export interface TextFieldProps extends AriaTextFieldProps { description?: string; errorMessage?: string | ((validation: ValidationResult) => string); placeholder?: string; + minValue?: number; } export function TextField({ diff --git a/packages/components/src/components/Toolbar/Toolbar.quanta.stories.tsx b/packages/components/src/components/Toolbar/Toolbar.quanta.stories.tsx index 41a9c053c58..58567b81518 100644 --- a/packages/components/src/components/Toolbar/Toolbar.quanta.stories.tsx +++ b/packages/components/src/components/Toolbar/Toolbar.quanta.stories.tsx @@ -8,9 +8,14 @@ import { } from 'react-aria-components'; import { tv } from 'tailwind-variants'; import { Toolbar } from './Toolbar.quanta'; -import { Button } from '../Button/Button'; +import { Button } from '../Button/Button.quanta'; import { Checkbox } from '../Checkbox/Checkbox.quanta'; -import { Menu } from '../Menu/Menu.quanta'; +import { + Menu, + MenuItem, + MenuSeparator, + MenuTrigger, +} from '../Menu/Menu.quanta'; import { Separator } from '../Separator/Separator.quanta'; import { AligncenterIcon, @@ -113,15 +118,15 @@ export const Default: Story = { Allow comments - + + + + Undo + Redo + + Toolbar settings + + ), }; @@ -138,15 +143,25 @@ export const WithMenus: Story = { - } - placement="bottom end" - menuItems={[ - { id: 'cut', label: 'Cut', icon: CutIcon }, - { id: 'copy', label: 'Copy', icon: CopyIcon }, - { id: 'paste', label: 'Paste', icon: PasteIcon }, - ]} - /> + + + + + + Cut + + + + Copy + + + + Paste + + + @@ -173,14 +188,14 @@ export const Vertical: Story = { Wrap text - + + + + Tight + Normal + Loose + + ), }; diff --git a/packages/components/src/components/Toolbar/Toolbar.stories.tsx b/packages/components/src/components/Toolbar/Toolbar.stories.tsx index 5cb71c82eb7..f1ca9297d09 100644 --- a/packages/components/src/components/Toolbar/Toolbar.stories.tsx +++ b/packages/components/src/components/Toolbar/Toolbar.stories.tsx @@ -7,7 +7,7 @@ import { Separator, ToggleButton, } from 'react-aria-components'; -import { Menu, MenuItem } from '../Menu/Menu'; +import { Menu, MenuItem, MenuTrigger } from '../Menu/Menu'; import { BoldIcon } from '../icons/BoldIcon'; import { ItalicIcon } from '../icons/ItalicIcon'; @@ -55,10 +55,13 @@ export const Example = (args: any) => ( Night Mode - - Cut - Copy - Paste - + + + + Cut + Copy + Paste + + ); diff --git a/packages/components/src/components/Tooltip/Tooltip.quanta.stories.tsx b/packages/components/src/components/Tooltip/Tooltip.quanta.stories.tsx new file mode 100644 index 00000000000..63ab1efd167 --- /dev/null +++ b/packages/components/src/components/Tooltip/Tooltip.quanta.stories.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Button } from '../Button/Button.quanta'; +import { Tooltip } from './Tooltip.quanta'; +import { TooltipTrigger } from 'react-aria-components/Tooltip'; +import { BoldIcon } from '../icons/BoldIcon'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta = { + title: 'Quanta/Tooltip', + component: Tooltip, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args: any) => ( + + + Bold + + ), + args: { + children: null, + }, +}; diff --git a/packages/components/src/components/Tooltip/Tooltip.quanta.tsx b/packages/components/src/components/Tooltip/Tooltip.quanta.tsx new file mode 100644 index 00000000000..4c32752b395 --- /dev/null +++ b/packages/components/src/components/Tooltip/Tooltip.quanta.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { + Tooltip as AriaTooltip, + type TooltipProps as AriaTooltipProps, + OverlayArrow, +} from 'react-aria-components/Tooltip'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import { tv } from 'tailwind-variants'; + +export interface TooltipProps extends Omit { + children: React.ReactNode; +} + +const styles = tv({ + base: ` + group box-border rounded bg-quanta-denim px-2 py-0.5 text-quanta-air drop-shadow-lg + will-change-transform + `, + variants: { + isEntering: { + true: ` + duration-200 ease-out animate-in fade-in + placement-left:slide-in-from-right-0.5 + placement-right:slide-in-from-left-0.5 + placement-top:slide-in-from-bottom-0.5 + placement-bottom:slide-in-from-top-0.5 + `, + }, + isExiting: { + true: ` + duration-150 ease-in animate-out fade-out + placement-left:slide-out-to-right-0.5 + placement-right:slide-out-to-left-0.5 + placement-top:slide-out-to-bottom-0.5 + placement-bottom:slide-out-to-top-0.5 + `, + }, + }, +}); + +export function Tooltip({ children, ...props }: TooltipProps) { + return ( + + styles({ ...renderProps, className }), + )} + > + + + + + + {children} + + ); +} diff --git a/packages/components/src/components/quanta/Select/Select.stories.tsx b/packages/components/src/components/quanta/Select/Select.stories.tsx index 9191a13b382..becc1453e49 100644 --- a/packages/components/src/components/quanta/Select/Select.stories.tsx +++ b/packages/components/src/components/quanta/Select/Select.stories.tsx @@ -1,12 +1,49 @@ import React from 'react'; -import { QuantaSelect, SelectItem } from './Select'; - import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Collection } from 'react-aria-components'; -export interface SelectItemObject { - label: string; - value: string; -} +import { + QuantaSelect, + SelectItem, + SelectSection, + SelectSectionHeader, + type SelectItemObject, + type SelectProps, +} from './Select'; + +const options = [ + { label: '1', value: 'Aerospace' }, + { label: '2', value: 'Mechanical' }, + { label: '3', value: 'Civil' }, + { label: '4', value: 'Biomedical' }, + { label: '5', value: 'Nuclear' }, + { label: '6', value: 'Industrial' }, + { label: '7', value: 'Chemical' }, + { label: '8', value: 'Agricultural' }, + { label: '9', value: 'Electrical' }, + { label: '10', value: 'Telco' }, +]; + +const groupedOptions = [ + { + name: 'Fruit', + children: [ + { id: 'apple', name: 'Apple' }, + { id: 'banana', name: 'Banana' }, + { id: 'orange', name: 'Orange' }, + { id: 'pear', name: 'Pear' }, + ], + }, + { + name: 'Vegetable', + children: [ + { id: 'broccoli', name: 'Broccoli' }, + { id: 'carrots', name: 'Carrots' }, + { id: 'lettuce', name: 'Lettuce' }, + { id: 'spinach', name: 'Spinach' }, + ], + }, +]; const meta: Meta = { title: 'Basic/Quanta/Select', @@ -15,151 +52,92 @@ const meta: Meta = { layout: 'centered', }, tags: ['autodocs'], -} satisfies Meta; +}; export default meta; type Story = StoryObj; -/** - * Select gets a fixed children as JSX - */ +function ControlledValueStory(args: any) { + const [value, setValue] = React.useState('10'); + + return ( + <> + + {(item: SelectItemObject) => ( + {item.value} + )} + +
    +        Current selection: {JSON.stringify(value)}
    +      
    + + ); +} + export const Default: Story = { args: { - name: 'empty', - label: 'field 1 title', + name: 'field-default', + label: 'Field title', description: 'Optional help text', placeholder: 'Select...', children: ( <> - Hello - Lorem Ipsum + Hello + Lorem Ipsum ), }, }; -/** - * Select renders options via render props `(item)=> React.ReactNode` - */ export const Items: Story = { render: (args) => ( - // @ts-ignore I assume this is a storybook bug when passing args - + + {...(args as SelectProps)} + > {(item: SelectItemObject) => ( {item.value} )} ), args: { - name: 'field-empty', - label: 'field 1 title', + name: 'field-items', + label: 'Field title', description: 'Optional help text', placeholder: 'Select...', - items: [ - { label: '1', value: 'Aerospace' }, - { label: '2', value: 'Mechanical' }, - { label: '3', value: 'Civil' }, - { label: '4', value: 'Biomedical' }, - { label: '5', value: 'Nuclear' }, - { label: '6', value: 'Industrial' }, - { label: '7', value: 'Chemical' }, - { label: '8', value: 'Agricultural' }, - { label: '9', value: 'Electrical' }, - { label: '10', value: 'Telco' }, - ], - children: null, + items: options, }, }; -export const LotsOfItems: Story = { +export const Sections: Story = { render: (args) => ( - // @ts-ignore I assume this is a storybook bug when passing args - - {(item: SelectItemObject) => ( - {item.value} + + {...(args as SelectProps<(typeof groupedOptions)[number], 'single'>)} + items={groupedOptions} + > + {(section: (typeof groupedOptions)[number]) => ( + + {section.name} + + {(item: (typeof groupedOptions)[number]['children'][number]) => ( + {item.name} + )} + + )} ), args: { - name: 'field-empty', - label: 'field 1 title', - description: 'Optional help text', + name: 'field-sections', + label: 'Preferred fruit or vegetable', placeholder: 'Select...', - items: [ - { label: '1', value: 'Aerospace' }, - { label: '2', value: 'Mechanical' }, - { label: '3', value: 'Civil' }, - { label: '4', value: 'Biomedical' }, - { label: '5', value: 'Nuclear' }, - { label: '6', value: 'Industrial' }, - { label: '7', value: 'Chemical' }, - { label: '8', value: 'Agricultural' }, - { label: '9', value: 'Electrical' }, - { label: '10', value: 'Telco' }, - { label: '11', value: 'Aerospace' }, - { label: '12', value: 'Mechanical' }, - { label: '13', value: 'Civil' }, - { label: '14', value: 'Biomedical' }, - { label: '15', value: 'Nuclear' }, - { label: '16', value: 'Industrial' }, - { label: '17', value: 'Chemical' }, - { label: '18', value: 'Agricultural' }, - { label: '19', value: 'Electrical' }, - { label: '20', value: 'Telco' }, - { label: '21', value: 'Aerospace' }, - { label: '22', value: 'Mechanical' }, - { label: '23', value: 'Civil' }, - { label: '24', value: 'Biomedical' }, - { label: '25', value: 'Nuclear' }, - { label: '26', value: 'Industrial' }, - { label: '27', value: 'Chemical' }, - { label: '28', value: 'Agricultural' }, - { label: '29', value: 'Electrical' }, - { label: '30', value: 'Telco' }, - ], - children: null, - }, -}; - -export const Required: Story = { - ...Items, - args: { - ...Items.args, - name: 'field-required', - isRequired: true, }, }; -export const Filled: Story = { - ...Items, +export const ControlledValue: Story = { + render: ControlledValueStory, args: { - ...Items.args, - name: 'field-filled', - label: 'Filled field title', - defaultSelectedKey: '10', - isRequired: true, - }, -}; - -export const Errored: Story = { - ...Items, - args: { - ...Items.args, - name: 'field-errored', - label: 'Errored field title', - defaultSelectedKey: '10', - errorMessage: 'This is the error', - isInvalid: true, - isRequired: true, - }, -}; - -export const Disabled: Story = { - ...Items, - args: { - ...Items.args, - name: 'field-disabled', - label: 'Disabled field title', - isDisabled: true, + name: 'field-controlled', + label: 'Pick an industry', + placeholder: 'Select...', }, }; diff --git a/packages/components/src/components/quanta/Select/Select.tsx b/packages/components/src/components/quanta/Select/Select.tsx index 0f446c6b7d4..38c62fe1ef8 100644 --- a/packages/components/src/components/quanta/Select/Select.tsx +++ b/packages/components/src/components/quanta/Select/Select.tsx @@ -1,15 +1,20 @@ import React from 'react'; -import { SelectContext, PopoverContext } from 'react-aria-components'; +import { PopoverContext, SelectContext } from 'react-aria-components'; + import { Select, SelectItem, - type SelectProps, + SelectListBox, + SelectSection, + SelectSectionHeader, type SelectItemObject, + type SelectProps, } from '../../Select/Select'; -export function QuantaSelect( - props: SelectProps, -) { +export function QuantaSelect< + T extends object = SelectItemObject, + M extends 'single' | 'multiple' = 'single', +>(props: SelectProps) { return ( @@ -19,4 +24,5 @@ export function QuantaSelect( ); } -export { SelectItem }; +export { SelectItem, SelectListBox, SelectSection, SelectSectionHeader }; +export type { SelectItemObject, SelectProps }; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 0b3b447ff3a..b38a59c8a1e 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1,3 +1,4 @@ +export { AlignWidget } from './components/AlignWidget/AlignWidget'; export { BlockToolbar } from './components/BlockToolbar/BlockToolbar'; export { Breadcrumb, Breadcrumbs } from './components/Breadcrumbs/Breadcrumbs'; export { Button } from './components/Button/Button'; @@ -17,24 +18,41 @@ export { Container } from './components/Container/Container'; export { DateField } from './components/DateField/DateField'; export { DatePicker } from './components/DatePicker/DatePicker'; export { DateRangePicker } from './components/DateRangePicker/DateRangePicker'; -export { Dialog } from './components/Dialog/Dialog'; +export { Dialog, DialogTrigger } from './components/Dialog/Dialog'; export { Disclosure } from './components/Disclosure/Disclosure'; export { DisclosureGroup } from './components/DisclosureGroup/DisclosureGroup'; +export { DropZone, Text as DropZoneText } from './components/DropZone/DropZone'; export { Form } from './components/Form/Form'; export { GridList, GridListItem } from './components/GridList/GridList'; export { Icon } from './components/Icon/Icon'; export { Link } from './components/Link/Link'; export { ListBox, ListBoxItem } from './components/ListBox/ListBox'; -export { Menu, MenuItem } from './components/Menu/Menu'; +export { + Menu, + MenuItem, + MenuSection, + MenuSectionHeader, + MenuSeparator, + MenuTrigger, + SubmenuTrigger, +} from './components/Menu/Menu'; export { Meter } from './components/Meter/Meter'; export { Modal } from './components/Modal/Modal'; export { NumberField } from './components/NumberField/NumberField'; export { Popover, type PopoverProps } from './components/Popover/Popover'; export { ProgressBar } from './components/ProgressBar/ProgressBar'; +export { Pagination } from './components/Pagination/Pagination'; export { Radio, RadioGroup } from './components/RadioGroup/RadioGroup'; export { RangeCalendar } from './components/RangeCalendar/RangeCalendar'; export { SearchField } from './components/SearchField/SearchField'; -export { Select, SelectItem } from './components/Select/Select'; +export { + Select, + SelectItem, + SelectListBox, + SelectSection, + SelectSectionHeader, +} from './components/Select/Select'; +export { SizeWidget } from './components/SizeWidget/SizeWidget'; export { Spinner } from './components/Spinner/Spinner'; export { Slider } from './components/Slider/Slider'; export { Switch } from './components/Switch/Switch'; @@ -51,6 +69,8 @@ export { Toast } from './components/Toast/Toast'; export { ToggleButton } from './components/ToggleButton/ToggleButton'; export { Toolbar } from './components/Toolbar/Toolbar'; export { Tooltip } from './components/Tooltip/Tooltip'; +export { Tree, TreeItem, TreeItemContent } from './components/Tree/Tree'; +export { WidthWidget } from './components/WidthWidget/WidthWidget'; // Quanta components export { QuantaTextField } from './components/quanta/TextField/TextField'; diff --git a/packages/components/src/quanta/index.ts b/packages/components/src/quanta/index.ts index 9bec3940828..e4f855c6b31 100644 --- a/packages/components/src/quanta/index.ts +++ b/packages/components/src/quanta/index.ts @@ -4,8 +4,14 @@ export * from '../components/Accordion/Accordion.quanta'; export * from '../components/Calendar/Calendar.quanta'; export * from '../components/Checkbox/Checkbox.quanta'; export * from '../components/Container/Container.quanta'; +export * from '../components/Dialog/Dialog.quanta'; +export * from '../components/DropZone/DropZone.quanta'; export * from '../components/Field/Field.quanta'; export * from '../components/Link/Link.quanta'; +export * from '../components/Menu/Menu.quanta'; +export * from '../components/Modal/Modal.quanta'; +export * from '../components/Popover/Popover.quanta'; +export * from '../components/Select/Select.quanta'; export * from '../components/TextField/TextField.quanta'; export { SizeWidget } from '../components/SizeWidget/SizeWidget'; export { AlignWidget } from '../components/AlignWidget/AlignWidget'; @@ -21,5 +27,7 @@ export * from '../components/Separator/Separator.quanta'; export * from '../components/Spinner/Spinner.quanta'; export * from '../components/Tabs/Tabs.quanta'; export * from '../components/GridList/GridList.quanta'; +export * from '../components/Table/Table.quanta'; export * from '../components/TagGroup/TagGroup.quanta'; export * from '../components/Toolbar/Toolbar.quanta'; +export * from '../components/Tooltip/Tooltip.quanta'; diff --git a/packages/components/src/stories/Icons.stories.tsx b/packages/components/src/stories/Icons.stories.tsx index 1e22b971a02..f4593a06bfc 100644 --- a/packages/components/src/stories/Icons.stories.tsx +++ b/packages/components/src/stories/Icons.stories.tsx @@ -139,7 +139,7 @@ const IconsList: React.FC = () => { fontSize: '14px', }} > - +
    {QuantaIcon.name.replace('Icon', '')} diff --git a/packages/components/src/styles/basic/DropZone.css b/packages/components/src/styles/basic/DropZone.css new file mode 100644 index 00000000000..b79e0b06bb5 --- /dev/null +++ b/packages/components/src/styles/basic/DropZone.css @@ -0,0 +1,34 @@ +.react-aria-DropZone { + display: flex; + width: var(--plone-dropzone-width, 30%); + min-height: var(--plone-dropzone-min-height, 96px); + align-items: center; + justify-content: center; + padding: var(--plone-dropzone-padding-y, 24px) + var(--plone-dropzone-padding-x, 12px); + border: var(--plone-dropzone-border-width, 1px) + var(--plone-dropzone-border-style, solid) + var(--plone-dropzone-border-color, var(--border-color)); + border-radius: var(--radius); + margin: 0; + appearance: none; + background: var(--overlay-background); + color: var(--text-color); + font-size: 1rem; + forced-color-adjust: none; + line-height: 1.5; + outline: none; + text-align: center; + text-wrap: balance; + vertical-align: middle; + + &[data-focus-visible], + &[data-drop-target] { + outline: 2px solid var(--focus-ring-color); + outline-offset: -1px; + } + + &[data-drop-target] { + background: var(--highlight-overlay); + } +} diff --git a/packages/components/src/styles/basic/Menu.css b/packages/components/src/styles/basic/Menu.css index 48670e28b76..d5b5c7bd5f7 100644 --- a/packages/components/src/styles/basic/Menu.css +++ b/packages/components/src/styles/basic/Menu.css @@ -105,5 +105,14 @@ &[data-disabled] { color: var(--text-color-disabled); } + + &[data-has-submenu]::after { + position: absolute; + right: 0.571rem; + content: '›'; + content: '›' / ''; + font-size: 1rem; + line-height: 1; + } } } diff --git a/packages/components/src/styles/basic/Pagination.css b/packages/components/src/styles/basic/Pagination.css new file mode 100644 index 00000000000..024556db84d --- /dev/null +++ b/packages/components/src/styles/basic/Pagination.css @@ -0,0 +1,19 @@ +nav.pagination { + display: flex; + + font-size: 1.15rem; + + .react-aria-Button { + width: 2rem; + height: 2rem; + line-height: 1; + + &[data-disabled] { + color: var(--text-color-disabled); + } + + &.active { + font-weight: bold; + } + } +} diff --git a/packages/components/src/styles/basic/Popover.css b/packages/components/src/styles/basic/Popover.css index cec4741fabb..28b35a4626a 100644 --- a/packages/components/src/styles/basic/Popover.css +++ b/packages/components/src/styles/basic/Popover.css @@ -6,6 +6,7 @@ @layer plone-components.base { .react-aria-Popover { --background-color: var(--overlay-background); + overflow: auto; max-width: 250px; box-sizing: border-box; diff --git a/packages/components/src/styles/basic/Select.css b/packages/components/src/styles/basic/Select.css index a2d2ed72ab9..4d73c44a90b 100644 --- a/packages/components/src/styles/basic/Select.css +++ b/packages/components/src/styles/basic/Select.css @@ -7,7 +7,44 @@ @layer plone-components.base { .react-aria-Select { color: var(--text-color); - .react-aria-Button { + + .react-aria-Group { + display: flex; + min-width: var(--rac-select-min-width); + max-width: 250px; + align-items: center; + padding: 0.286rem; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--field-background); + box-shadow: 0 1px 2px rgba(0 0 0 / 0.1); + gap: 0.286rem; + + &[data-focus-visible] { + outline: 2px solid var(--focus-ring-color); + outline-offset: -1px; + } + + .react-aria-SelectValue { + min-width: 0; + flex: 1; + } + + .react-aria-TagGroup { + min-width: 0; + flex: 1; + } + + .react-aria-TagList { + min-height: 1.75rem; + } + + .react-aria-Button { + flex-shrink: 0; + } + } + + > .react-aria-Button { display: flex; min-width: var(--rac-select-min-width); max-width: 250px; @@ -30,10 +67,14 @@ } &[data-invalid] { - .react-aria-Button { + > .react-aria-Button { border: 1px solid var(--color-invalid); } + .react-aria-Group { + border-color: var(--color-invalid); + } + .react-aria-Label { color: var(--color-invalid); } @@ -73,7 +114,10 @@ } .react-aria-Popover[data-trigger='Select'] { + display: flex; min-width: var(--trigger-width); + flex-direction: column; + padding: 4px; box-shadow: 0 3px 6px 0 rgba(2, 19, 34, 0.12), 0 2px 4px 0 rgba(2, 19, 34, 0.06); @@ -87,10 +131,14 @@ width: unset; min-height: unset; max-height: inherit; + padding: 0; border: none; .react-aria-Header { - padding-left: 1.571rem; + padding: 0.25rem 0.571rem; + color: var(--text-color-placeholder); + font-size: 0.857rem; + text-transform: uppercase; } } @@ -122,6 +170,20 @@ color: var(--highlight-foreground); } } + + .react-aria-Autocomplete { + display: flex; + flex-direction: column; + gap: 4px; + } + + .react-aria-SearchField { + width: 100%; + + .react-aria-Input { + width: 100%; + } + } } .react-aria-ListBoxItem[href] { @@ -136,7 +198,7 @@ } } - .react-aria-Button { + > .react-aria-Button { &[data-disabled] { border-color: var(--border-color-disabled); color: var(--text-color-disabled); @@ -175,5 +237,12 @@ padding-top: 3px; font-size: 12px; } + + .react-aria-TagGroup { + [slot='description'], + [slot='errorMessage'] { + display: none; + } + } } } diff --git a/packages/components/src/styles/basic/Table.css b/packages/components/src/styles/basic/Table.css index ac17a2102f8..9003ea3c6e4 100644 --- a/packages/components/src/styles/basic/Table.css +++ b/packages/components/src/styles/basic/Table.css @@ -22,6 +22,8 @@ --plone-table-row-font-size: 1rem; --plone-table-row-pressed: var(--highlight-pressed); --plone-table-row-border-radius: 0; + --plone-table-row-selected-background: var(--highlight-background); + --plone-table-row-selected-foreground: var(--highlight-foreground); --plone-table-column-font-weight: 500; @@ -82,13 +84,13 @@ } &[data-selected] { - background: var(--highlight-background); - color: var(--highlight-foreground); - --text-color: var(--highlight-foreground); - --focus-ring-color: var(--highlight-foreground); - --link-color: var(--highlight-foreground); - --link-color-secondary: var(--highlight-foreground); - --button-background: var(--highlight-background); + background: var(--plone-table-row-selected-background); + color: var(--plone-table-row-selected-foreground); + --text-color: var(--plone-table-row-selected-foreground); + --focus-ring-color: var(--plone-table-row-selected-foreground); + --link-color: var(--plone-table-row-selected-foreground); + --link-color-secondary: var(--plone-table-row-selected-foreground); + --button-background: var(--plone-table-row-selected-background); &[data-focus-visible], .react-aria-Cell[data-focus-visible] { diff --git a/packages/components/src/styles/basic/main.css b/packages/components/src/styles/basic/main.css index 1570f2e8c07..7d579ab8305 100644 --- a/packages/components/src/styles/basic/main.css +++ b/packages/components/src/styles/basic/main.css @@ -18,6 +18,7 @@ @import './ColorSwatch.css'; @import './ColorSwatchPicker.css'; @import './Disclosure.css'; +@import './DropZone.css'; @import './NumberField.css'; @import './RadioGroup.css'; @import './Switch.css'; @@ -56,3 +57,4 @@ @import './Toast.css'; @import './Container.css'; +@import './Pagination.css'; diff --git a/packages/components/src/styles/quanta/Popover.css b/packages/components/src/styles/quanta/Popover.css index 3fe7b882321..9c22ba0722f 100644 --- a/packages/components/src/styles/quanta/Popover.css +++ b/packages/components/src/styles/quanta/Popover.css @@ -1,5 +1,25 @@ @layer plone-components.quanta { .q.react-aria-Popover { - --border-color: transparent; + border: none; + + fieldset legend { + color: var(--quanta-pigeon); + font-size: 0.8rem; + font-weight: 500; + + & + .popover-list { + margin-top: 0.25rem; + } + } + + .popover-list { + .react-aria-Checkbox { + font-size: 1rem; + } + } + + .react-aria-Dialog { + padding: 0.75rem; + } } } diff --git a/packages/components/src/styles/quanta/Table.css b/packages/components/src/styles/quanta/Table.css index f498a41c9f9..f9cb8b16980 100644 --- a/packages/components/src/styles/quanta/Table.css +++ b/packages/components/src/styles/quanta/Table.css @@ -1,6 +1,28 @@ @layer plone-components.quanta { .react-aria-Table { + .react-aria-Row { + --radius-top: 0; + --radius-bottom: 0; + } --plone-table-header-color: var(--quanta-sapphire); + --plone-table-header-border-bottom: 1px solid var(--quanta-silver); --plone-table-cell-border-bottom: 1px solid var(--quanta-smoke); + --plone-table-cell-hover-background: var(--quanta-snow); + --plone-table-row-selected-background: var(--quanta-arctic); + --plone-table-row-selected-foreground: var(--text-color); + + &.hoverable { + tbody { + tr:hover, + tr:focus-within { + background-color: var(--plone-table-cell-hover-background); + --button-background: var(--plone-table-cell-hover-background); + } + } + } + } + + .react-aria-Row[data-selected] { + --link-color: var(--quanta-sapphire); } } diff --git a/packages/components/src/styles/quanta/TextField.css b/packages/components/src/styles/quanta/TextField.css index e5d144e62cf..a6807819313 100644 --- a/packages/components/src/styles/quanta/TextField.css +++ b/packages/components/src/styles/quanta/TextField.css @@ -146,7 +146,7 @@ } &::placeholder { - color: var(--basic-400); + color: var(--quanta-sapphire); opacity: 0; transition: opacity $time-tiny ease-in-out; } diff --git a/packages/components/src/styles/quanta/Tooltip.css b/packages/components/src/styles/quanta/Tooltip.css new file mode 100644 index 00000000000..4f49fb8a6ce --- /dev/null +++ b/packages/components/src/styles/quanta/Tooltip.css @@ -0,0 +1,13 @@ +.react-aria-Tooltip, +.tooltip { + padding: 0.1875rem 0.375rem; + border-radius: 3px; + margin-top: 0.25rem; + background-color: var(--quanta-denim); + color: var(--quanta-air); + line-height: 1.5; + + .react-aria-OverlayArrow svg { + fill: var(--quanta-denim); + } +} diff --git a/packages/components/src/styles/quanta/colors.css b/packages/components/src/styles/quanta/colors.css index f52193b7166..710ef5f4c8f 100644 --- a/packages/components/src/styles/quanta/colors.css +++ b/packages/components/src/styles/quanta/colors.css @@ -26,7 +26,7 @@ --quanta-cream: #fcf3cf; --quanta-banana: #faeaad; - --quanta-lemmon: #f6d355; + --quanta-lemon: #f6d355; --quanta-gold: #b48f09; --quanta-dijon: #917308; --quanta-bronze: #6b5506; diff --git a/packages/components/src/styles/quanta/main.css b/packages/components/src/styles/quanta/main.css index 6547762ff1c..af1b908d376 100644 --- a/packages/components/src/styles/quanta/main.css +++ b/packages/components/src/styles/quanta/main.css @@ -1,8 +1,12 @@ @import './colors.css'; @import './theme.css'; +/*Typography*/ +@import '../typography.css'; + /* Components */ -@import './TextField.css'; +@import './Popover.css'; @import './Select.css'; @import './Table.css'; -@import './Popover.css'; +@import './TextField.css'; +@import './Tooltip.css'; diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index e17c47bec3e..a293300cf08 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -1,25 +1,6 @@ { + "extends": "tsconfig/react-library.json", "compilerOptions": { - "esModuleInterop": true, - "skipLibCheck": true, - "target": "es2022", - "allowJs": true, - "resolveJsonModule": true, - "moduleDetection": "force", - "isolatedModules": true, - "verbatimModuleSyntax": true, - - "strict": true, - "noImplicitOverride": true, - - "lib": ["es2022", "dom", "dom.iterable"], - "module": "preserve", - "noEmit": true, - - "jsx": "react-jsx", - - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, "paths": {} }, "include": ["src", "./setupTesting.ts"], diff --git a/packages/components/vite.config.ts b/packages/components/vite.config.ts index f0dc0199a99..0918e485e1d 100644 --- a/packages/components/vite.config.ts +++ b/packages/components/vite.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; import { PloneSVGRVitePlugin } from './vite-plugin-svgr'; export default defineConfig({ - plugins: [tsconfigPaths(), tailwindcss(), PloneSVGRVitePlugin(), react()], + plugins: [tailwindcss(), PloneSVGRVitePlugin(), react()], + resolve: { + tsconfigPaths: true, + }, }); diff --git a/packages/providers/.gitignore b/packages/contents/.gitignore similarity index 100% rename from packages/providers/.gitignore rename to packages/contents/.gitignore diff --git a/packages/providers/.npmignore b/packages/contents/.npmignore similarity index 77% rename from packages/providers/.npmignore rename to packages/contents/.npmignore index a6d10baa1ef..0d8afd57277 100644 --- a/packages/providers/.npmignore +++ b/packages/contents/.npmignore @@ -2,5 +2,7 @@ news towncrier.toml .changelog.draft node_modules/ +.parcel-cache +.parcelrc .release-it.json .eslintrc.js diff --git a/packages/contents/.prettierrc b/packages/contents/.prettierrc new file mode 100644 index 00000000000..6e778b4fb9c --- /dev/null +++ b/packages/contents/.prettierrc @@ -0,0 +1,4 @@ +{ + "trailingComma": "all", + "singleQuote": true +} diff --git a/packages/providers/.release-it.json b/packages/contents/.release-it.json similarity index 60% rename from packages/providers/.release-it.json rename to packages/contents/.release-it.json index 78a87343999..247e2ca01da 100644 --- a/packages/providers/.release-it.json +++ b/packages/contents/.release-it.json @@ -4,8 +4,9 @@ }, "hooks": { "after:bump": [ - "pipx run towncrier build --draft --yes --version ${version} > .changelog.draft && pipx run towncrier build --yes --version ${version}", - "pnpm build:force" + "pipx run towncrier build --draft --yes --version ${version} > .changelog.draft", + "pipx run towncrier build --yes --version ${version}", + "pnpm build" ], "after:release": "rm .changelog.draft" }, @@ -16,13 +17,13 @@ "changelog": "pipx run towncrier build --draft --yes --version 0.0.0", "requireUpstream": false, "requireCleanWorkingDir": false, - "commitMessage": "Release @plone/providers ${version}", - "tagName": "plone-providers-${version}", - "tagAnnotation": "Release @plone/providers ${version}" + "commitMessage": "Release @plone/contents ${version}", + "tagName": "plone-contents-${version}", + "tagAnnotation": "Release @plone/contents ${version}" }, "github": { "release": true, - "releaseName": "@plone/providers ${version}", + "releaseName": "@plone/contents ${version}", "releaseNotes": "cat .changelog.draft" } } diff --git a/packages/contents/.stylelintrc b/packages/contents/.stylelintrc new file mode 100644 index 00000000000..8ac62f8d0f9 --- /dev/null +++ b/packages/contents/.stylelintrc @@ -0,0 +1,14 @@ +{ + "extends": ["stylelint-config-idiomatic-order"], + "plugins": ["stylelint-prettier"], + "overrides": [ + { + "files": ["**/*.scss"], + "customSyntax": "postcss-scss" + } + ], + "rules": { + "prettier/prettier": true, + "order/properties-alphabetical-order": null + } +} diff --git a/packages/contents/AGENTS.md b/packages/contents/AGENTS.md new file mode 100644 index 00000000000..d4c3e040e17 --- /dev/null +++ b/packages/contents/AGENTS.md @@ -0,0 +1,54 @@ +# AGENTS.md + +This file applies only to `packages/contents` and its subdirectories. + +## What This Package Is + +- `@plone/contents` provides the folder contents views for Seven. +- It is the Seven-specific evolution of Volto's contents view, split into smaller React Aria Components based pieces. +- Keep the package focused on contents-specific behavior, route wiring, and composition. Do not turn it into a generic component library. + +## Architecture + +- Prefer granular components and package-local composition over large monolithic views. +- Keep route modules thin. Data loading, mutations, and UI state should stay clearly separated. +- Use React Aria Components patterns consistently. Do not reintroduce legacy Volto/Semantic UI style abstractions here. +- Shadowing matters in this package. Favor small, replaceable components and avoid tightly coupled internal helpers when a public seam is enough. + +## Styling Direction + +- The current implementation still uses package-local CSS files for several parts of the UI. +- Treat that CSS-based styling as transitional, not the target end state. +- The package should be refactored toward the `@plone/components` Quanta implementation, using Quanta/Tailwind-native components and patterns instead of bespoke CSS where possible. +- Do not add more ad hoc CSS if the same result can be achieved by composing or extending Quanta components. +- If CSS is temporarily necessary, keep it narrowly scoped and easy to delete during the Quanta refactor. + +## Routes And Mutations + +- Route files live under `routes/` and should stay focused on auth, request parsing, and Plone client calls. +- Keep mutation contracts explicit and small. Prefer passing the minimum payload required for delete, paste, and ordering actions. +- Error handling should remain compatible with React Router error boundaries and toast reporting. + +## Components + +- Components live under `components/` and should remain granular. +- Prefer package-local components over adding Seven-wide abstractions unless reuse is already proven. +- When touching table cells, actions, or popovers, preserve the shadowing-friendly split between: + - table/container orchestration + - per-item rendering + - action popovers and modals +- Keep accessibility intact when composing RAC primitives. + +## Validation + +- Prefer targeted checks from this package: + - `pnpm --filter @plone/contents test --run` + - `pnpm --filter @plone/contents check-ts` +- Acceptance tests live under `packages/contents/acceptance/tests/`. +- Run contents acceptance tests from the repo root with: + - `pnpm exec playwright test packages/contents/acceptance/tests --config=playwright.config.ts --project=chromium` +- For a single spec, run: + - `pnpm exec playwright test packages/contents/acceptance/tests/contents.test.ts --config=playwright.config.ts --project=chromium` +- At the moment, expect validation gaps: + - the package now has acceptance smoke tests, but still lacks package-local unit/integration tests + - `check-ts` may surface workspace-wide issues outside this package, so separate package-local regressions from existing repo noise diff --git a/packages/contents/CHANGELOG.md b/packages/contents/CHANGELOG.md new file mode 100644 index 00000000000..e4322345c36 --- /dev/null +++ b/packages/contents/CHANGELOG.md @@ -0,0 +1,9 @@ +# @plone/contents Release Notes + + + + diff --git a/packages/contents/IMPLEMENTATION_PLAN.md b/packages/contents/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000000..37236da7b25 --- /dev/null +++ b/packages/contents/IMPLEMENTATION_PLAN.md @@ -0,0 +1,612 @@ +# `@plone/contents` Implementation Plan + +This document tracks the work needed to bring `@plone/contents` to feature parity with Volto's folder contents view while keeping the Seven/RAC/granular architecture. + +## Goals + +- Reach behavioral parity with Volto contents for the main folder contents workflows. +- Preserve the current package direction: smaller RAC-based components with better shadowing seams than the legacy monolithic view. +- Refactor the current CSS-heavy styling toward `@plone/components` Quanta/Tailwind-native patterns after feature parity is in place. + +## Working Rules + +- Use Volto contents as the behavior reference. +- Use Figma as the visual reference once the relevant exports/screens are available. +- Complete behavior before the main Quanta styling refactor. +- Keep route logic, state management, and presentation separated. + +## Phase 1: Feature Matrix And Current-State Audit + +Status: `in_progress` + +Deliverable: +- A checked parity matrix added to this document or a follow-up tracker. + +### Parity Matrix + +Legend: +- `done`: implemented and broadly aligned with Volto behavior +- `partial`: present but incomplete, provisional, or behaviorally different +- `missing`: not implemented yet + +| Feature | Volto reference | `@plone/contents` status | Notes | +| --- | --- | --- | --- | +| Route registration and standalone contents layout | `Contents.jsx` route integration | `done` | Package route wiring exists and is package-local. | +| Loader-based listing of folder contents | `searchContent` usage in `Contents.jsx` | `partial` | Basic listing works, but query model is much narrower than Volto. | +| Breadcrumbs for contents navigation | `ContentsBreadcrumbs.jsx` | `partial` | Present, but needs parity review for multilingual/root behavior and final visual rules. | +| Display of content icon, title, expired/scheduled badges | `ContentsItem.jsx` | `partial` | Present in `ContentsCell`, but current implementation has API/type issues. | +| Row action popover with edit/view/cut/copy/delete/move actions | `ContentsItem.jsx` | `partial` | Present and granular, but needs permissions review and contract cleanup. | +| Search/filter input | `onChangeFilter` + `fetchContents` | `partial` | Debounced search exists, but only supports `SearchableText` and needs empty/no-results handling. | +| Pagination | `onChangePage` / `pageSize` logic | `partial` | Present, but page size parity and state handling are not aligned with Volto yet. | +| Column picker | `onSelectIndex` + popup in `Contents.jsx` | `partial` | Present, but persistence and some metadata details are incomplete. | +| Column ordering | `ContentsIndexHeader.jsx` drag-reorder | `missing` | Current package has index selection, not draggable column reordering. | +| Selection: single row | checkbox behavior in `ContentsItem.jsx` | `partial` | Context model exists, but exact UI/interaction parity still needs verification. | +| Selection: multi row | `selected` state in `Contents.jsx` | `partial` | Model exists, but needs parity validation and tests. | +| Selection: select all / select none | `onSelectAll` / `onSelectNone` | `missing` | Not clearly implemented in the current package surface. | +| Bulk actions toolbar | action toolbar in `Contents.jsx` | `partial` | Toolbar exists, but several actions are still placeholders. | +| Cut / copy selection | `cut` / `copy` in `Contents.jsx` | `partial` | Implemented via local storage clipboard, but lifecycle/error parity still incomplete. | +| Paste into current folder | `paste` in `Contents.jsx` | `partial` | Route exists and basic flow works, but target validation and post-paste lifecycle need work. | +| Delete selected items | `ContentsDeleteModal.jsx` + `deleteContent` | `partial` | Basic delete exists, but link integrity and richer partial failure handling are missing. | +| Upload modal | `ContentsUploadModal.jsx` | `missing` | Trigger exists as a placeholder only. | +| Rename modal | `ContentsRenameModal.jsx` | `missing` | Trigger exists as a placeholder only. | +| Workflow modal | `ContentsWorkflowModal.jsx` | `missing` | Trigger exists as a placeholder only. | +| Tags modal | `ContentsTagsModal.jsx` | `missing` | Trigger exists as a placeholder only. | +| Properties modal | `ContentsPropertiesModal.jsx` | `missing` | Trigger exists as a placeholder only. | +| Drag and drop item reorder | `react-dnd` item ordering in `Contents.jsx` / `ContentsItem.jsx` | `partial` | RAC drag-and-drop exists, but validation, loading, and final parity need work. | +| Move item to top / bottom | row menu actions in `ContentsItem.jsx` | `partial` | Present, but depends on the same ordering flow that still needs completion. | +| Rearrange / metadata sort UI | `onSortItems` + popup in `Contents.jsx` | `partial` | `RearrangePopover` exists, but the top-level handler is still a no-op. | +| Persisted sort state | `sort_on` / `sort_order` state in `Contents.jsx` | `missing` | Current loader always defaults to positional ordering. | +| Unauthorized handling | `Unauthorized` path in `Contents.jsx` | `missing` | Permission gating is commented out in the current package. | +| Empty state | contents view empty handling | `missing` | No dedicated empty-state UI yet. | +| No-results state | filtered search empty handling | `missing` | No dedicated no-results UI yet. | +| Mutation pending/loading UI | dimmers/loaders in Volto modals and table flows | `partial` | TODOs exist, but the package does not yet present strong pending feedback. | +| Success toasts | `Toast` usage in `Contents.jsx` | `partial` | Delete/paste/reorder toasts exist in places, but coverage and consistency are incomplete. | +| Error toasts | `Toast` usage in `Contents.jsx` | `partial` | Some error reporting exists, but not at Volto parity yet. | +| Link integrity warnings before delete | `ContentsDeleteModal.jsx` | `missing` | Explicitly absent in current package. | +| Tests for main contents flows | Volto has tests and snapshots | `missing` | `@plone/contents` currently has no tests. | + +### Current Assessment + +- The architectural rewrite is already an improvement over Volto’s monolithic `Contents.jsx` for Seven, RAC adoption, and shadowing. +- The current gap is mostly feature completeness and contract stability, not the overall package shape. +- The highest-risk missing areas are: + - object action modals and their backend flows + - select-all / full bulk interaction parity + - sort/rearrange completion + - permission/unauthorized and empty-state handling + - link integrity parity for delete + - package-local type/API cleanup + +Tasks: +- [x] Enumerate all Volto contents features and mark each one as `done`, `partial`, or `missing` in `@plone/contents`. +- [ ] Capture the current UX states to support in Seven: + - default listing + - loading + - empty + - unauthorized + - delete success and failure + - paste success and failure + - reorder success and failure + - search results and no-results + - mobile action layout +- [ ] Collect the relevant Figma exports/screens for: + - main contents view desktop + - main contents view mobile + - row action popover + - bulk actions toolbar + - delete modal + - rename/workflow/tags/properties/upload flows + - rearrange/sort UI + +Reference files: +- `packages/contents/routes/contents.tsx` +- `packages/contents/components/ContentsTable/ContentsTable.tsx` +- `packages/volto/src/components/manage/Contents/Contents.jsx` + +## Phase 2: Behavioral Parity + +Status: `todo` + +### 2.1 Loader, Query, Pagination, Search + +Tasks: +- [ ] Support Volto-equivalent search query parameters, not only `SearchableText` and `page`. +- [ ] Implement sort state in the loader query instead of always forcing `getObjPositionInParent`. +- [ ] Normalize pagination state and URL handling. +- [ ] Verify `b_start` and page index behavior matches current Seven pagination expectations. + +Primary files: +- `packages/contents/routes/contents.tsx` +- `packages/contents/components/ContentsTable/ContentsTable.tsx` + +### 2.2 Column Selection And Table Metadata + +Tasks: +- [ ] Persist or at least consistently manage selected columns. +- [ ] Remove dead state such as `selectedCount` if unused, or wire it properly. +- [ ] Review unsupported columns such as `id` and define the intended contract instead of carrying it as a permanent special case. +- [ ] Ensure all labels use translated message ids instead of raw strings where appropriate. + +Primary files: +- `packages/contents/types.ts` +- `packages/contents/components/Indexes.js` +- `packages/contents/components/TableIndexesPopover/TableIndexesPopover.tsx` +- `packages/contents/routes/contents.tsx` +- `packages/contents/components/ContentsTable/ContentsTable.tsx` + +### 2.3 Selection And Bulk Actions + +Tasks: +- [ ] Confirm single-row, multi-row, select-all, and clear-selection behavior matches Volto. +- [ ] Decide whether selection should survive pagination/search transitions. +- [ ] Ensure bulk actions only enable when the selected set is valid for the action. + +Primary files: +- `packages/contents/providers/contents.tsx` +- `packages/contents/components/ContentsActions/ContentsActions.tsx` +- `packages/contents/components/ContentsTable/ContentsTable.tsx` + +### 2.4 Clipboard: Cut / Copy / Paste + +Tasks: +- [ ] Define the final clipboard contract and lifecycle. +- [ ] Clear or update clipboard state after successful paste where required. +- [ ] Handle invalid clipboard targets with explicit UI feedback. +- [ ] Verify cut vs copy semantics match Volto and Plone backend expectations. +- [ ] Decide whether clipboard should remain local-storage based or move to a higher-level utility. + +Primary files: +- `packages/contents/components/ContentsTable/ContentsTable.tsx` +- `packages/contents/routes/paste.tsx` +- `packages/contents/config/constants.ts` + +### 2.5 Delete Flow + +Tasks: +- [ ] Keep the current modal flow but verify parity for single and bulk delete behavior. +- [ ] Confirm link integrity and related backend constraints are surfaced properly. +- [ ] Improve per-item error reporting for partial failures. +- [ ] Decide whether post-delete refresh should be explicit, optimistic, or fetcher-driven. + +Primary files: +- `packages/contents/components/DeleteModal/DeleteModal.tsx` +- `packages/contents/routes/delete.tsx` +- `packages/contents/providers/contents.tsx` + +### 2.6 Reorder And Rearrange + +Tasks: +- [ ] Implement actual sort/rearrange behavior from `RearrangePopover`. +- [ ] Support reordering by position and sorting by metadata as Volto does. +- [ ] Clarify whether metadata sorting is persisted or just a view-level ordering. +- [ ] Finalize drag-and-drop behavior, including disabled cases and conflict states. +- [ ] Ensure move-to-top and move-to-bottom use the same contract as drag-and-drop. + +Primary files: +- `packages/contents/components/RearrangePopover/RearrangePopover.tsx` +- `packages/contents/components/ContentsTable/ContentsTable.tsx` +- `packages/contents/routes/order.tsx` +- `packages/contents/routes/contents.tsx` + +### 2.7 Object Actions: Upload, Rename, Workflow, Tags, Properties + +Tasks: +- [ ] Replace the placeholder no-op handlers with real implementations. +- [ ] Decide whether these should be package-local modals, imported shared flows, or route-driven dialogs. +- [ ] Match Volto’s constraints for single-item vs multi-item actions. +- [ ] Ensure action completion refreshes table data and shows consistent toasts. + +Primary files: +- `packages/contents/routes/contents.tsx` +- `packages/contents/components/ContentsActions/ContentsActions.tsx` +- `packages/contents/components/ContentsTable/ContentsTable.tsx` + +Expected follow-up files: +- `packages/contents/components/RenameModal/*` +- `packages/contents/components/WorkflowModal/*` +- `packages/contents/components/TagsModal/*` +- `packages/contents/components/PropertiesModal/*` +- `packages/contents/components/UploadModal/*` + +### 2.8 Row Actions And Navigation + +Tasks: +- [ ] Verify all row actions match Volto behavior and permissions. +- [ ] Confirm folder rows open contents and item rows open view/edit correctly. +- [ ] Review whether the row action popover should expose additional actions. + +Primary files: +- `packages/contents/components/ContentsCell/ContentsCell.tsx` +- `packages/contents/components/ItemActionsPopover/ItemActionsPopover.tsx` + +### 2.9 Permissions, Unauthorized, Error And Empty States + +Tasks: +- [ ] Reintroduce or redesign the permission gating that is still commented out. +- [ ] Define the correct unauthorized experience for Seven. +- [ ] Add explicit empty-state and no-results-state rendering. +- [ ] Make loader/action errors deterministic and toast/error-boundary compatible. + +Primary files: +- `packages/contents/routes/contents.tsx` +- `packages/contents/helpers/Errors.ts` +- `packages/contents/routes/delete.tsx` +- `packages/contents/routes/order.tsx` +- `packages/contents/routes/paste.tsx` +- `packages/contents/components/ContentsTable/ContentsTable.tsx` + +## Phase 3: Technical Stabilization + +Status: `todo` + +### 3.1 TypeScript And API Contract Cleanup + +Tasks: +- [ ] Fix contents-specific TypeScript errors before treating workspace-wide errors as blockers. +- [ ] Remove `any` from route payloads, clipboard, and toast helpers. +- [ ] Align imports with the current public APIs of `@plone/components`, `@plone/helpers`, and Quanta components. +- [ ] Fix component prop contract mismatches such as `ref`, `aria-describedby`, and table row typing. + +Primary files: +- `packages/contents/helpers/Errors.ts` +- `packages/contents/types.ts` +- `packages/contents/components/ContentsCell/ContentsCell.tsx` +- `packages/contents/components/ItemActionsPopover/ItemActionsPopover.tsx` +- `packages/contents/components/ContentsTable/ContentsTable.tsx` +- `packages/contents/components/DeleteModal/DeleteModal.tsx` + +### 3.2 Data Refresh And Mutation UX + +Tasks: +- [ ] Decide the reload strategy after mutations: fetcher-only, route revalidation, or local optimistic updates. +- [ ] Add pending/loading UI for delete, paste, and reorder actions. +- [ ] Prevent duplicate submissions while a mutation is in progress. + +Primary files: +- `packages/contents/components/ContentsTable/ContentsTable.tsx` +- `packages/contents/components/DeleteModal/DeleteModal.tsx` +- `packages/contents/providers/contents.tsx` + +### 3.3 Test Coverage + +Tasks: +- [ ] Add the first package-local test suite. The package currently has no tests. +- [ ] Cover loader query generation. +- [ ] Cover selection and clipboard behavior. +- [ ] Cover delete and paste mutation payloads. +- [ ] Cover reorder payload generation and disabled states. +- [ ] Cover row rendering for title, date, workflow state, and actions. + +Suggested test file targets: +- `packages/contents/routes/contents.test.ts` +- `packages/contents/components/ContentsTable/ContentsTable.test.tsx` +- `packages/contents/components/DeleteModal/DeleteModal.test.tsx` +- `packages/contents/components/ContentsCell/ContentsCell.test.tsx` +- `packages/contents/providers/contents.test.tsx` + +## Phase 4: Quanta / Figma UI Refactor + +Status: `todo` + +This phase starts only after the main contents behaviors are complete. + +Tasks: +- [ ] Review each package-local CSS file and classify it as: + - removable + - temporary gap-fill + - should move into Quanta component APIs +- [ ] Replace bespoke CSS-driven layouts with Quanta/Tailwind composition where possible. +- [ ] Remove styling that duplicates existing `@plone/components` Quanta primitives. +- [ ] Align typography, spacing, colors, icon sizes, and states with the Figma designs. +- [ ] Validate desktop and mobile layouts against the design. +- [ ] Keep shadowing seams intact while reducing CSS surface area. + +Primary files: +- `packages/contents/components/ContentsTable/ContentsTable.css` +- `packages/contents/components/ContentsActions/ContentsActions.css` +- `packages/contents/components/ItemActionsPopover/ItemActionsPopover.css` +- `packages/contents/components/RearrangePopover/RearrangePopover.css` +- `packages/contents/styles/main.css` +- all package component `.tsx` files that currently mix Quanta classes with CSS overrides + +## Phase 5: Final Validation And Release Readiness + +Status: `todo` + +Tasks: +- [ ] Run targeted validation for the package: + - `pnpm --filter @plone/contents test --run` + - `pnpm --filter @plone/contents check-ts` +- [ ] Add Storybook stories for the main public states if this package is intended to expose them. +- [ ] Verify keyboard accessibility for: + - table navigation + - selection + - popovers + - dialogs + - drag and drop +- [ ] Validate responsive behavior on mobile/tablet. +- [ ] Review whether any behavior should move into shared Seven or `@plone/components` abstractions. + +## Proposed PR Split + +### PR 1: Behavioral parity + +Scope: +- route/query completion +- clipboard/delete/reorder stabilization +- object action implementation +- permissions and state handling + +### PR 2: Type safety and tests + +Scope: +- contents-specific TypeScript cleanup +- test coverage for the critical flows +- mutation/revalidation cleanup + +### PR 3: Quanta/Figma refactor + +Scope: +- reduce/remove package-local CSS +- align visuals with Quanta and Figma +- polish responsive and accessibility details + +## Prioritized Backlog + +This section turns the parity matrix into execution order. + +### P0: Make The Core View Complete And Trustworthy + +These items unblock real usage of the package and should land first. + +1. Selection parity + Status: `partial` + Why first: + Bulk actions are central to contents. Several later features depend on reliable selected-item state. + Tasks: + - [ ] Implement explicit select-all and clear-selection behavior. + - [ ] Verify row selection behavior on desktop and mobile. + - [ ] Decide and document whether selection survives search/pagination changes. + Primary files: + - `packages/contents/providers/contents.tsx` + - `packages/contents/components/ContentsTable/ContentsTable.tsx` + - `packages/contents/components/ContentsActions/ContentsActions.tsx` + +2. Rearrange and sort completion + Status: `partial` + Why first: + The current UI exposes rearrange affordances, but the main sort handler is still a no-op. + Tasks: + - [ ] Wire `RearrangePopover` to real sort state. + - [ ] Pass `sort_on` and `sort_order` through loader URL/query handling. + - [ ] Reconcile drag reorder with metadata sorting mode. + - [ ] Define the UX when manual ordering is not the active sort. + Primary files: + - `packages/contents/routes/contents.tsx` + - `packages/contents/components/RearrangePopover/RearrangePopover.tsx` + - `packages/contents/components/ContentsTable/ContentsTable.tsx` + - `packages/contents/routes/order.tsx` + +3. Delete flow parity + Status: `partial` + Why first: + Delete is already present, so closing the parity gap here is cheaper than building a new feature from scratch later. + Tasks: + - [ ] Improve partial-failure handling and post-delete refresh behavior. + - [ ] Add the missing empty-state transition after last-item delete. + - [ ] Decide whether link integrity parity belongs in this PR or a follow-up. + Primary files: + - `packages/contents/components/DeleteModal/DeleteModal.tsx` + - `packages/contents/routes/delete.tsx` + - `packages/contents/components/ContentsTable/ContentsTable.tsx` + +4. Clipboard lifecycle completion + Status: `partial` + Why first: + Cut/copy/paste already exists and is close enough that finishing it gives immediate user value. + Tasks: + - [ ] Define when clipboard is cleared after paste. + - [ ] Handle invalid paste targets and stale clipboard content cleanly. + - [ ] Ensure cut vs copy feedback is consistent and accurate. + Primary files: + - `packages/contents/components/ContentsTable/ContentsTable.tsx` + - `packages/contents/routes/paste.tsx` + +### P1: Implement The Missing Action Flows + +These are the largest remaining feature gaps. + +5. Rename modal and action + Status: `missing` + Dependencies: + - reliable selection model + Tasks: + - [ ] Create package-local rename modal flow. + - [ ] Support single and multi-item rename semantics. + - [ ] Refresh data and show completion/error feedback. + Expected files: + - `packages/contents/components/RenameModal/*` + - `packages/contents/routes/contents.tsx` + - `packages/contents/components/ContentsActions/ContentsActions.tsx` + +6. Workflow modal and action + Status: `missing` + Dependencies: + - reliable selection model + Tasks: + - [ ] Build workflow transition discovery and submission flow. + - [ ] Support recursive workflow changes where applicable. + - [ ] Match Volto constraints for mixed selections. + Expected files: + - `packages/contents/components/WorkflowModal/*` + - `packages/contents/routes/contents.tsx` + - related route/action helpers if needed + +7. Tags modal and action + Status: `missing` + Dependencies: + - reliable selection model + Tasks: + - [ ] Implement add/remove tags flow for one or more items. + - [ ] Decide whether vocabulary fetching is package-local or shared. + Expected files: + - `packages/contents/components/TagsModal/*` + - `packages/contents/routes/contents.tsx` + +8. Properties modal and action + Status: `missing` + Dependencies: + - reliable selection model + Tasks: + - [ ] Implement effective/expires/rights/creators/exclude-from-nav editing. + - [ ] Handle mixed-value initial state for multi-selection. + Expected files: + - `packages/contents/components/PropertiesModal/*` + - `packages/contents/routes/contents.tsx` + +9. Upload modal and action + Status: `missing` + Dependencies: + - none beyond base route integration + Tasks: + - [ ] Define Seven-native upload UX. + - [ ] Support file/image uploads with progress and cancellation behavior. + - [ ] Refresh contents and preserve expected page/filter state after upload. + Expected files: + - `packages/contents/components/UploadModal/*` + - `packages/contents/routes/contents.tsx` + +### P2: Complete The Missing View States + +These close the usability gaps around permissions and discoverability. + +10. Unauthorized and permission gating + Status: `missing` + Dependencies: + - clearer action contract + Tasks: + - [ ] Reintroduce folder-contents permission checks. + - [ ] Define Seven-native unauthorized behavior. + - [ ] Hide or disable actions the user cannot perform. + Primary files: + - `packages/contents/routes/contents.tsx` + - `packages/contents/components/ContentsTable/ContentsTable.tsx` + - `packages/contents/components/ItemActionsPopover/ItemActionsPopover.tsx` + - `packages/contents/components/ContentsActions/ContentsActions.tsx` + +11. Empty and no-results states + Status: `missing` + Dependencies: + - loader/search stabilization + Tasks: + - [ ] Add explicit empty-state UI when the folder has no items. + - [ ] Add explicit no-results UI when filtering returns no matches. + - [ ] Ensure action affordances differ correctly between empty and filtered-empty states. + Primary files: + - `packages/contents/components/ContentsTable/ContentsTable.tsx` + +12. Loading and pending states + Status: `partial` + Dependencies: + - mutation contract stabilization + Tasks: + - [ ] Show pending UI for delete, paste, reorder, and action modal submissions. + - [ ] Prevent duplicate submissions during pending states. + - [ ] Decide how much optimistic updating is desirable for Seven. + Primary files: + - `packages/contents/components/ContentsTable/ContentsTable.tsx` + - `packages/contents/components/DeleteModal/DeleteModal.tsx` + - future action modal components + +### P3: Stabilize The Package Contract + +These tasks reduce churn before the Quanta/Figma pass. + +13. TypeScript and API cleanup + Status: `partial` + Why here: + It should not block every feature PR, but it should be completed before visual refactor. + Tasks: + - [ ] Fix contents-local type/API issues. + - [ ] Remove `any` from core package contracts. + - [ ] Align with current public exports of helper and component packages. + +14. Test coverage + Status: `missing` + Why here: + Once P0 and P1 settle, tests become much less throwaway. + Tasks: + - [ ] Add first package-local tests for loader, selection, clipboard, delete, reorder, and cell rendering. + +### P4: Quanta / Figma Refactor + +15. Replace transitional CSS styling + Status: `todo` + Dependencies: + - behavioral parity + - package contract stabilization + - Figma exports/screens + Tasks: + - [ ] Reduce package-local CSS to the minimum necessary. + - [ ] Move styling decisions into Quanta/Tailwind-native composition where possible. + - [ ] Validate against Figma on desktop and mobile. + +## Recommended Execution Order + +If this is implemented incrementally, I would follow this sequence: + +1. Selection parity +2. Rearrange and sort completion +3. Delete and clipboard completion +4. Rename +5. Workflow +6. Tags +7. Properties +8. Upload +9. Unauthorized and empty/no-results states +10. Type/API cleanup +11. Tests +12. Quanta/Figma refactor + +## Suggested PR Mapping + +### PR A: Core table parity + +Include: +- selection parity +- rearrange/sort completion +- delete flow completion +- clipboard completion +- empty/no-results basics if cheap + +### PR B: Action flows + +Include: +- rename +- workflow +- tags +- properties +- upload + +### PR C: Hardening + +Include: +- unauthorized/permission gating +- pending/loading polish +- contents-local type/API cleanup +- initial tests + +### PR D: Quanta/Figma pass + +Include: +- style refactor +- responsive tuning +- accessibility polish + +## Immediate Next Steps + +- [x] Fill in the feature parity matrix from Volto. +- [ ] Decide the implementation strategy for upload/rename/workflow/tags/properties. +- [ ] Decide the route/query model for rearrange and metadata sorting. +- [ ] Decide whether link integrity delete warnings are in scope for parity v1 or follow-up parity. +- [ ] Gather the Figma exports for the key flows before starting the Quanta refactor. diff --git a/packages/contents/README.md b/packages/contents/README.md new file mode 100644 index 00000000000..ad5a1be36df --- /dev/null +++ b/packages/contents/README.md @@ -0,0 +1,8 @@ +# `@plone/contents` + +This package contains the folder Contents view for Plone. + +> [!WARNING] +> This package or app is experimental. +> The community offers no support whatsoever for it. +> Breaking changes may occur without notice. diff --git a/packages/contents/acceptance/tests/contents.test.ts b/packages/contents/acceptance/tests/contents.test.ts new file mode 100644 index 00000000000..20ec860e4a0 --- /dev/null +++ b/packages/contents/acceptance/tests/contents.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from '../../../tooling/playwright/test'; +import { login } from '../../../tooling/playwright/login'; +import { createContent } from '../../../tooling/playwright/content'; + +test.describe('Contents view', () => { + test.beforeEach(async ({ page }) => { + await createContent(page, { + contentType: 'Document', + contentId: 'root-document', + contentTitle: 'Root Document', + }); + await createContent(page, { + contentType: 'Document', + contentId: 'inside-news', + contentTitle: 'Inside News', + path: '/news', + }); + }); + + test('redirects anonymous users to login', async ({ page }) => { + await page.goto('/@@contents', { waitUntil: 'networkidle' }); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible(); + }); + + test('lists existing content for authenticated users', async ({ page }) => { + await login(page); + await page.goto('/@@contents', { waitUntil: 'networkidle' }); + + await expect( + page.getByRole('heading', { name: /Welcome to Plone/i }), + ).toBeVisible(); + await expect(page.getByRole('link', { name: /^Home$/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /News/i })).toBeVisible(); + await expect( + page.getByRole('link', { name: /Root Document/i }), + ).toBeVisible(); + }); + + test('navigates into a folder from the contents listing', async ({ + page, + }) => { + await login(page); + await page.goto('/@@contents', { waitUntil: 'networkidle' }); + + await page.getByRole('link', { name: /^News$/i }).click(); + + await expect(page).toHaveURL(/\/@@contents\/news$/); + await expect(page.getByRole('heading', { name: 'News' })).toBeVisible(); + await expect( + page.getByRole('link', { name: /Inside News/i }), + ).toBeVisible(); + }); + + test('navigates to the content view for non-folder items', async ({ + page, + }) => { + await login(page); + await page.goto('/@@contents', { waitUntil: 'networkidle' }); + + await page.getByRole('link', { name: /Root Document/i }).click(); + + await expect(page).toHaveURL(/\/root-document$/); + await expect( + page.getByRole('heading', { name: 'Root Document' }), + ).toBeVisible(); + }); +}); diff --git a/packages/contents/components/ContentsActions.tsx b/packages/contents/components/ContentsActions.tsx new file mode 100644 index 00000000000..554a4208e98 --- /dev/null +++ b/packages/contents/components/ContentsActions.tsx @@ -0,0 +1,163 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipTrigger } from 'react-aria-components'; +import { Button, Tooltip } from '@plone/components/quanta'; + +import { + UploadIcon, + StateIcon, + BinIcon, + PropertiesIcon, + RenameIcon, + TagIcon, + CutIcon, + CopyIcon, + PasteIcon, +} from '@plone/components/Icons'; + +import type { Brain } from '@plone/types'; + +import { useContentsContext } from '../providers/contents'; + +type Props = { + upload: () => void | Promise; + rename: () => Promise; + workflow: () => Promise; + tags: () => Promise; + properties: () => Promise; + cut: (item?: Brain) => void; + copy: (item?: Brain) => void; + paste: () => Promise; + deleteItem: (item?: Brain | null | undefined) => void; + canPaste: boolean; + // selected: Set; +}; + +export function ContentsActions({ + upload, + rename, + workflow, + tags, + properties, + cut, + copy, + paste, + deleteItem, + canPaste, + // selected, +}: Props) { + const { t } = useTranslation(); + const { selected } = useContentsContext(); + + return ( +
    + + + {t('contents.actions.upload')} + + + + {t('contents.actions.rename')} + + + + {t('contents.actions.state')} + + + + {t('contents.actions.tags')} + + + + {t('contents.actions.properties')} + + + + + {t('contents.actions.cut')} + + + + {t('contents.actions.copy')} + + + + {t('contents.actions.paste')} + + + + {t('contents.actions.delete')} + +
    + ); +} diff --git a/packages/contents/components/ContentsCell/ContentsCell.tsx b/packages/contents/components/ContentsCell/ContentsCell.tsx new file mode 100644 index 00000000000..eaf71510700 --- /dev/null +++ b/packages/contents/components/ContentsCell/ContentsCell.tsx @@ -0,0 +1,166 @@ +import { type ComponentProps, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDateFormatter } from 'react-aria'; +import { getContentIcon } from '@plone/helpers'; +import type { Brain } from '@plone/types'; +import { Link, Button } from '@plone/components/quanta'; +import MoreOptionsSVG from '@plone/components/icons/more-options.svg?react'; +import { ItemActionsPopover } from '../ItemActionsPopover/ItemActionsPopover'; +import ReviewState from '../ReviewState'; + +interface Props { + item: Brain; + column: keyof Brain | '_actions'; + indexes: { + order: string[]; + values: { + [index: string]: { + type: string; + label: string; + selected: boolean; + sort_on?: string; + }; + }; + }; + onMoveToTop: ComponentProps['onMoveToTop']; + onMoveToBottom: ComponentProps['onMoveToBottom']; + onCut: ComponentProps['onCut']; + onCopy: ComponentProps['onCopy']; + onDelete: ComponentProps['onDelete']; +} + +export function ContentsCell({ + item, + column, + indexes, + onMoveToTop, + onMoveToBottom, + onCut, + onCopy, + onDelete, +}: Props) { + const { t } = useTranslation(); + const [isMoreOptionsOpen, setIsMoreOptionsOpen] = useState(false); + const triggerRef = useRef(null); + const longFormatter = useDateFormatter({ + dateStyle: 'full', + timeStyle: 'full', + }); + const shortFormatter = useDateFormatter({ + dateStyle: 'short', + timeStyle: 'short', + }); + const Icon = getContentIcon(item['@type'], item.is_folderish); + + if (column === 'title') { + return ( + + + {item.title} + {item.ExpirationDate !== 'None' && + new Date(item.ExpirationDate).getTime() < new Date().getTime() && ( + {t('contents.item.expired')} + )} + {item.EffectiveDate !== 'None' && + new Date(item.EffectiveDate).getTime() > new Date().getTime() && ( + {t('contents.item.scheduled')} + )} + + ); + } else if (column === '_actions') { + return ( + <> + + { + const res = await onMoveToBottom(); + setIsMoreOptionsOpen(false); + return res; + }} + onMoveToTop={async () => { + const res = await onMoveToTop(); + setIsMoreOptionsOpen(false); + return res; + }} + onCopy={async () => { + const res = await onCopy(); + setIsMoreOptionsOpen(false); + return res; + }} + onCut={async () => { + const res = await onCut(); + setIsMoreOptionsOpen(false); + return res; + }} + onDelete={async () => { + const res = await onDelete(); + setIsMoreOptionsOpen(false); + return res; + }} + /> + + ); + } else { + if (indexes.values[column].type === 'boolean') { + return ( + <> + {item[column] + ? t('contents.indexes.boolean.yes') + : t('contents.indexes.boolean.no')} + + ); + } else if (indexes.values[column].type === 'string') { + if (column !== 'review_state') { + return <>{item[column]}; + } else { + return ( + + {t( + 'contents.indexes.review_state.' + + (item[column] ?? 'no_workflow_state'), + )} + + ); + } + } else if (indexes.values[column].type === 'date') { + const dateString = item[column]; + if (typeof dateString === 'string' && dateString !== 'None') { + const date = new Date(dateString); + + return ( + + ); + } else { + return <>{t('contents.indexes.date.none')}; + } + } else if (indexes.values[column].type === 'array') { + const value = item[column]; + return <>{Array.isArray(value) ? value.join(', ') : value}; + } else { + // TODO do we get here? needed for type checking? + return null; + } + } +} diff --git a/packages/contents/components/ContentsTable/ContentsDropZone.tsx b/packages/contents/components/ContentsTable/ContentsDropZone.tsx new file mode 100644 index 00000000000..0707107e471 --- /dev/null +++ b/packages/contents/components/ContentsTable/ContentsDropZone.tsx @@ -0,0 +1,92 @@ +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UploadIcon } from '@plone/components/Icons'; +import { useContentsContext } from '../../providers/contents'; + +interface ContentsDropZoneProps { + children: React.ReactNode; +} + +/** + * Wraps its children in a full-area file drop zone. + * + * Uses capture-phase event handlers so that file drags are intercepted before + * React Aria's table DnD system can call stopPropagation and swallow them. + * Non-file drags (e.g. row reordering) are ignored and left for child handlers. + */ +export function ContentsDropZone({ children }: ContentsDropZoneProps) { + const { t } = useTranslation(); + const { setShowUpload, setPendingDropFiles } = useContentsContext(); + const [isFileDragOver, setIsFileDragOver] = useState(false); + const dragCountRef = useRef(0); + + const isFileDrag = (e: React.DragEvent) => + e.dataTransfer.types.includes('Files'); + + return ( +
    { + if (!isFileDrag(e)) return; + e.stopPropagation(); + dragCountRef.current++; + setIsFileDragOver(true); + }} + onDragLeaveCapture={(e) => { + if (!isFileDrag(e)) return; + e.stopPropagation(); + dragCountRef.current--; + if (dragCountRef.current <= 0) { + dragCountRef.current = 0; + setIsFileDragOver(false); + } + }} + onDragOverCapture={(e) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + }} + onDropCapture={(e) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + dragCountRef.current = 0; + setIsFileDragOver(false); + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + setPendingDropFiles( + files.map((f) => ({ + file: f, + title: f.name, + })), + ); + setShowUpload(true); + } + }} + > + {isFileDragOver && ( +
    +
    + +
    +

    + {t('contents.modal_upload.upload_files')} +

    +
    + )} + {children} +
    + ); +} diff --git a/packages/contents/components/ContentsTable/ContentsTable.css b/packages/contents/components/ContentsTable/ContentsTable.css new file mode 100644 index 00000000000..b1bee1f581d --- /dev/null +++ b/packages/contents/components/ContentsTable/ContentsTable.css @@ -0,0 +1,14 @@ +.contents-table { + --plone-table-width: 100%; + + .react-aria-Table { + .react-aria-Link { + font-size: var(--plone-table-row-font-size); + } + } + + nav.pagination { + margin-top: 2rem; + float: right; + } +} diff --git a/packages/contents/components/ContentsTable/ContentsTable.tsx b/packages/contents/components/ContentsTable/ContentsTable.tsx new file mode 100644 index 00000000000..e04d7974741 --- /dev/null +++ b/packages/contents/components/ContentsTable/ContentsTable.tsx @@ -0,0 +1,651 @@ +import { useEffect, useState } from 'react'; +import { VisuallyHidden } from 'react-aria'; +import { + TooltipTrigger, + useDragAndDrop, + DialogTrigger, + MenuTrigger, +} from 'react-aria-components'; +import { useFetcher, useLoaderData, useNavigate } from 'react-router'; +import { useDebounceCallback, useMediaQuery } from 'usehooks-ts'; +import { Pagination } from '@plone/components'; + +import { + Button, + Container, + Breadcrumbs, + Breadcrumb, + Input, + Table, + Tooltip, + TableHeader, + Column, + TableBody, + Row, + Cell, +} from '@plone/components/quanta'; +import { + // AddIcon, + HomeIcon, + CollectionIcon, + MoreoptionsIcon, + PasteIcon, + CopyIcon, + CutIcon, + BinIcon, +} from '@plone/components/Icons'; +import type { ArrayElement, Brain } from '@plone/types'; + +import Topbar from '../Topbar'; +import { ContentsCell } from '../ContentsCell/ContentsCell'; +import { TableIndexesPopover } from '../TableIndexesPopover/TableIndexesPopover'; +import { RearrangePopover } from '../RearrangePopover/RearrangePopover'; +import { ContentsActions } from '../ContentsActions'; +import type { ContentsLoaderType } from '../../routes/contents'; +import { useTranslation } from 'react-i18next'; +import { useContentsContext } from '../../providers/contents'; +import { clipboardKey } from '../../config/constants'; +import { ContentsDropZone } from './ContentsDropZone'; +import { type TableIndexes } from '../../types'; + +import { type ToastItem } from '@plone/layout/config/toast'; + +import './ContentsTable.css'; + +interface ContentsTableProps { + pathname: string; + // objectActions: ActionsResponse['object']; + title: string; + // loading: boolean; + // canPaste: boolean; + // items: Brain[]; + indexes: TableIndexes; + onSelectIndex: (index: string) => void; + sortItems: (index: string) => void; + upload: () => Promise; + rename: () => Promise; + workflow: () => Promise; + tags: () => Promise; + properties: () => Promise; + // cut: (item?: object) => Promise; + // copy: (item?: object) => Promise; + // paste: () => Promise; + // orderItem: (id: string, delta: number) => Promise; + // moveToTop: (index: number) => Promise; + // moveToBottom: (index: number) => Promise; +} + +/** + * A table showing the contents of an object. + * + * It has a toolbar for interactions with the items and a searchbar for filtering. + * Items can be sorted by drag and drop. + */ +export function ContentsTable({ + pathname, + // objectActions, + // canPaste, + // items, + indexes: baseIndexes, + onSelectIndex, + sortItems, + upload, + rename, + workflow, + tags, + properties, + // cut, + // copy, + // paste, + // orderItem, + // moveToTop, + // moveToBottom, + // addableTypes, +}: ContentsTableProps) { + const isMobileScreenSize = useMediaQuery('(max-width: 992px)'); + const { t } = useTranslation(); + const navigate = useNavigate(); + const { + selected, + setSelected, + setShowDelete, + setItemsToDelete, + setShowUpload, + showToast, + } = useContentsContext(); + const fetcher = useFetcher(); + const { content, search, searchableText, page, b_size } = + useLoaderData(); + // const addableTypes = content['@components'].types.filter( + // (type) => type.addable, + // ); + const [currentPage, setCurrentPage] = useState(Number(page)); + + const { title = '' } = content; + const { items = [] } = search; + type Item = ArrayElement; + + const breadcrumbsItems = ( + content?.['@components']?.breadcrumbs.items ?? [] + ).map((item) => ({ + '@id': `/@@contents${item['@id']}`, + title: item.title, + })); + const breadcrumbsRoot = content?.['@components']?.breadcrumbs.root ?? ''; + const breadcrumbsRootItem = { + '@id': `/@@contents${breadcrumbsRoot}`, + title: 'Home', + icon: , + }; + + const breadcrumbs = [breadcrumbsRootItem, ...breadcrumbsItems]; + + // const folderContentsActions = objectActions.find( + // (action) => action.id === 'folderContents', + // ); + + // if (!folderContentsActions) { + // // TODO current volto returns the Unauthorized component here + // // it would be best if the permissions check was done at a higher level + // // and this remained null + // return null; + // } + + // TODO "id" is a reserved key for table rows, so we cannot add the "ID" column at this time + const indexes = { + ...baseIndexes, + order: baseIndexes.order.filter((index) => index !== 'id'), + values: Object.fromEntries( + Object.entries(baseIndexes.values).filter(([key]) => key !== 'id'), + ), + }; + + const deleteItem = (item?: Item | null) => { + setShowDelete(true); + setItemsToDelete(item ? new Set([item]) : selected); + }; + + const openUpload = () => { + setShowUpload(true); + }; + + const orderItem = async (id: string, delta: number | 'bottom' | 'top') => { + await fetcher.submit( + { + path: pathname, + obj_id: id, + delta, + }, + { + method: 'PATCH', + encType: 'application/json', + action: `/@@contents/@@order`, + }, + ); + }; + + const moveToBottom = (item: Item) => orderItem(item.id, 'bottom'); + const moveToTop = (item: Item) => orderItem(item.id, 'top'); + + /********* CLIPBOARD ************* */ + type ClipboardType = { + action: 'cut' | 'copy' | null; + source: string[]; + expiration: number; + items: any[]; + }; + + const showClipboardActionToast = (data: ClipboardType, toastConfig: any) => { + const l = data?.items?.length; + if (l > 0) { + if (l > 1) { + Object.keys(toastConfig).forEach( + (action) => + (toastConfig[action].title = + `${toastConfig[action].title}_multiple`), + ); + } + + const title = l > 1 ? undefined : data.items[0].title; + + const toastContent: ToastItem = data.action + ? toastConfig[data.action] + : null; + + if (toastContent) { + showToast({ + title: t(toastContent.title, { + number: l, + title, + }), + icon: toastContent.icon, + }); + } + } + }; + + // TODO try making a custom hook for the clipboard + const [clipboard, _setClipboard] = useState({ + action: null, + source: [], + expiration: 0, + items: [], + }); + + useEffect(() => { + try { + const storedClipboard = localStorage.getItem(clipboardKey); + if (storedClipboard) { + const parsedClipboard = JSON.parse(storedClipboard); + if (parsedClipboard.expiration < Date.now()) { + localStorage.removeItem(clipboardKey); + } else if ( + parsedClipboard && + typeof parsedClipboard === 'object' && + 'action' in parsedClipboard && + Array.isArray(parsedClipboard.source) + ) { + _setClipboard(parsedClipboard); + } else { + localStorage.removeItem(clipboardKey); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error reading clipboard from localStorage:', error); + localStorage.removeItem(clipboardKey); + } + }, []); + + const setClipboard: typeof _setClipboard = (value: any) => { + _setClipboard(value); + if (typeof window !== 'undefined') { + try { + localStorage.setItem(clipboardKey, JSON.stringify(value)); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error saving clipboard to localStorage:', error); + localStorage.removeItem(clipboardKey); + } + } + + // show toast + if (value.action) { + showClipboardActionToast(value, { + copy: { + title: 'contents.actions.copied', + icon: , + }, + cut: { + title: 'contents.actions.cutted', + icon: , + }, + }); + } + // TODO when do we clean the clipboard? + }; + + /********** ACTIONS ********* */ + const cut = (item?: Brain) => { + const items = item ? [item] : [...selected]; + const paths = items.map((i) => i['@id']); + + setClipboard({ + action: 'cut', + source: paths, + items, + expiration: Date.now() + 24 * 60 * 60 * 1000, // 24 hours expiration + }); + setSelected('none'); + }; + + const copy = (item?: Brain) => { + const items = item ? [item] : [...selected]; + const paths = items.map((i) => i['@id']); + + setClipboard({ + action: 'copy', + source: paths, + items, + expiration: Date.now() + 24 * 60 * 60 * 1000, // 24 hours expiration + }); + + setSelected('none'); + }; + + const canPaste = clipboard.action !== null && clipboard.source.length > 0; + + const paste = async () => { + await fetcher.submit(clipboard, { + method: 'POST', + encType: 'application/json', + action: `/@@contents/@@paste${pathname}`, + }); + // TODO when do we clean the clipboard? + }; + + // handle actions response. Handle fetcher state. Toast success on paste, delete + useEffect(() => { + if (fetcher.state === 'submitting' || fetcher.state == 'loading') { + // TODO: handle loading state, show something like a dimmer while operation is in progress + } else if (fetcher.state == 'idle') { + // La richiesta è terminata. + const data = fetcher.data; + + // Show toast for copy+paste and cut+paste + showClipboardActionToast(data, { + copy: { + title: 'contents.actions.pasted', + icon: , + }, + cut: { + title: 'contents.actions.pasted', + icon: , + }, + delete: { + title: 'contents.actions.deleted', + icon: , + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.state]); + + /******************* TABLE ITEMS ************** */ + const columns = [ + { + id: 'title', + name: t('contents.indexes.title'), + isRowHeader: true, + width: undefined, + }, + ...(!isMobileScreenSize + ? indexes.order + .filter((index) => indexes.values[index].selected) + .map((index) => ({ + id: index, + // TODO: use translation + name: indexes.values[index].label, + isRowHeader: false, + width: undefined, + })) + : []), + { + id: '_actions', + isRowHeader: false, + width: 50, + name: !isMobileScreenSize ? ( + + + + {t('Select columns to show')} + + + + ) : canPaste ? ( + + + {t('contents.actions.paste')} + + ) : null, + }, + ] as const; + + const rows = items.map((item, itemIndex) => + columns.reduce( + (cells, column) => ({ + ...cells, + [column.id]: ( + moveToBottom(item)} + onMoveToTop={() => moveToTop(item)} + onCut={() => cut(item)} + onCopy={() => copy(item)} + onDelete={async () => deleteItem(item)} + /> + ), + }), + { + id: item['@id'], + // Automatic textValue generation does not work + // because the title column is a ReactNode and not a string + textValue: item.title, + }, + ), + ); + + const { dragAndDropHooks } = useDragAndDrop({ + isDisabled: isMobileScreenSize || selected.size > 1, + getItems: (keys) => + [...keys].map((key) => ({ + 'text/plain': key.toString(), + })), + onReorder(e) { + if (e.keys.size !== 1) { + showToast({ + title: t('contents.error'), + description: t('contents.rearrange.error'), + }); + return; + } + const target = [...e.keys][0]; + if (target === e.target.key) return; + + const item = items.find((item) => item['@id'] === target); + if (!item) return; + + const initialPosition = rows.findIndex((row) => row.id === item['@id']); + if (initialPosition === -1) return; + + const finalPosition = rows.findIndex((row) => row.id === e.target.key); + + let delta = finalPosition - initialPosition; + if (delta > 0 && e.target.dropPosition === 'before') delta -= 1; + if (delta < 0 && e.target.dropPosition === 'after') delta += 1; + + if (delta !== 0) { + orderItem(item.id, delta); + } + }, + }); + + //search input and debounce it + const [searchInput, setSearchInput] = useState(searchableText ?? ''); + + const debouncedSearchableText = useDebounceCallback((text: string) => { + setSelected('none'); + const params = new URLSearchParams(window.location.search); + if (text) { + params.set('SearchableText', text); + } else { + params.delete('SearchableText'); + } + params.delete('page'); + const querystring = params.size > 0 ? '?' + params.toString() : ''; + navigate(`/@@contents${pathname}${querystring}`); + }, 500); + + useEffect(() => { + if ((searchableText ?? '') !== searchInput) { + debouncedSearchableText(searchInput); + } + }, [debouncedSearchableText, searchInput, searchableText]); + + //pagination change + + const onPageChange = (page: number) => { + setCurrentPage(page); + setSelected('none'); + const path = window.location.pathname; + const params = new URLSearchParams(window.location.search); + if (page > 0) { + params.set('page', page.toString()); + } else { + params.delete('page'); + } + const querystring = params.size > 0 ? '?' + params.toString() : ''; + navigate(`${path}${querystring}`); + }; + + return ( + + +
    + +
    + + {(item) => ( + + {item.title} + + )} + +

    {title}

    +
    +
    + {!isMobileScreenSize && ( + + )} + { + setSearchInput(e.target.value); + }} + aria-label={t('contents.actions.filter')} + className="flex-0 basis-60" + /> +
    +
    +
    + + + {t('contents.results', { count: items.length })} + + + {rows?.length > 0 ? ( + <> + s['@id'])} + onSelectionChange={setSelected} + dragAndDropHooks={dragAndDropHooks} + // onRowSelection={onRowSelection} + > + + + + + {t('contents.rearrange.by')} + + + + + } + > + {(column) => ( + + {column.name} + + )} + + + {(row) => ( + + {(column) => {row[column.id]}} + + )} + +
    + + onPageChange(page)} + /> + + ) : ( +
    +
    + {t('contents.results.no_results')} +
    +
    + )} +
    +
    +
    +
    + ); +} diff --git a/packages/contents/components/DeleteModal/DeleteModal.tsx b/packages/contents/components/DeleteModal/DeleteModal.tsx new file mode 100644 index 00000000000..d6e7c1504b2 --- /dev/null +++ b/packages/contents/components/DeleteModal/DeleteModal.tsx @@ -0,0 +1,129 @@ +import { useEffect } from 'react'; +import { useFetcher } from 'react-router'; +import { Heading } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; +import { Button, Dialog, Modal } from '@plone/components/quanta'; +import { BinIcon } from '@plone/components/Icons'; +import CloseSVG from '@plone/components/icons/close.svg?react'; +import BinSVG from '@plone/components/icons/bin.svg?react'; +import { type ToastItem } from '@plone/layout/config/toast'; +import { useContentsContext } from '../../providers/contents'; + +export default function DeleteModal() { + const { t } = useTranslation(); + const fetcher = useFetcher(); + const { + showDelete, + setShowDelete, + itemsToDelete, + setItemsToDelete, + setSelected, + showToast, + } = useContentsContext(); + + useEffect(() => { + if (fetcher.state === 'submitting' || fetcher.state == 'loading') { + // TODO: handle loading state, show something like a dimmer while operation is in progress + } else if (fetcher.state == 'idle') { + // La richiesta è terminata. + const data = fetcher.data; + + if (data?.ok?.length > 0) { + const toast: ToastItem = { title: '', icon: }; + if (data.ok.length === 1) { + toast.title = t('contents.actions.deleted', { + title: data.ok[0].title, + }); + } else { + toast.title = t('contents.actions.deleted_multiple', { + number: data.ok.length, + }); + } + + showToast(toast); + } + if (data?.errors?.length > 0) { + data.errors.forEach((e) => { + const toast: ToastItem = { + title: `${t('contents.error')} ${e.__error.status} - ${e.__error.data.type}`, + description: e.__error.data?.message, + icon: , + className: 'error', + }; + showToast(toast); + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.state]); + + if (!showDelete) return null; + + const close = () => { + setItemsToDelete(new Set()); + setSelected('none'); + setShowDelete(false); + }; + + const submitDelete = async () => { + const items: any[] = [...itemsToDelete]; + await fetcher.submit( + { + action: 'delete', + paths: [...itemsToDelete].map((i) => i['@id']), + items, + }, + { + method: 'DELETE', + encType: 'application/json', + action: '/@@contents/@@delete', + }, + ); + close(); + }; + + //TODO: check linkintegrity + return ( + + + + {itemsToDelete.size > 1 + ? t('contents.modal_delete.multi.title', { + n: itemsToDelete.size, + }) + : t('contents.modal_delete.single.title', { + item: [...itemsToDelete][0].title, + })} + + +

    + {t('contents.modal_delete.description')} +

    +
    + + +
    +
    +
    + ); +} diff --git a/packages/contents/components/Indexes.js b/packages/contents/components/Indexes.js new file mode 100644 index 00000000000..d4dd0731b69 --- /dev/null +++ b/packages/contents/components/Indexes.js @@ -0,0 +1,44 @@ +/** + * Layouts. + * @module constants/indexes + */ + +const Indexes = { + sortable_title: { label: 'Title', type: 'string', sort_on: 'sortable_title' }, + review_state: { label: 'Review state', type: 'string' }, + ModificationDate: { + label: 'Last modified', + type: 'date', + sort_on: 'modified', + }, + EffectiveDate: { + label: 'Publication date', + type: 'date', + sort_on: 'effective', + }, + id: { label: 'ID', type: 'string', sort_on: 'id' }, + ExpirationDate: { label: 'Expiration date', type: 'date' }, + CreationDate: { label: 'Created on', type: 'date', sort_on: 'created' }, + Subject: { label: 'Tags', type: 'array' }, + portal_type: { label: 'Type', type: 'string', sort_on: 'portal_type' }, + is_folderish: { label: 'Folder', type: 'boolean' }, + exclude_from_nav: { label: 'Excluded from navigation', type: 'boolean' }, + getObjSize: { label: 'Object Size', type: 'string' }, + last_comment_date: { label: 'Last comment date', type: 'date' }, + total_comments: { label: 'Total comments', type: 'number' }, + end: { label: 'End Date', type: 'date' }, + Description: { label: 'Description', type: 'string' }, + Creator: { label: 'Creator', type: 'string' }, + location: { label: 'Location', type: 'string' }, + UID: { label: 'UID', type: 'string' }, + start: { label: 'Start Date', type: 'date' }, + Type: { label: 'Type', type: 'string' }, +}; + +export default Indexes; + +export const defaultIndexes = [ + 'review_state', + 'ModificationDate', + 'EffectiveDate', +]; diff --git a/packages/contents/components/ItemActionsPopover/ItemActionsPopover.css b/packages/contents/components/ItemActionsPopover/ItemActionsPopover.css new file mode 100644 index 00000000000..ef6b3c11c3d --- /dev/null +++ b/packages/contents/components/ItemActionsPopover/ItemActionsPopover.css @@ -0,0 +1,36 @@ +.item-actions-popover { + .popover-list-item { + --link-color: var(--quanta-denim); + + &.view, + &.move-to-bottom { + border-bottom: 1px solid var(--quanta-smoke); + margin-bottom: 0.5rem; + } + + &.delete { + --link-color: var(--quanta-candy); + .icon { + color: var(--link-color); + } + } + + .icon { + width: 18px; + height: 18px; + color: var(--quanta-pigeon); + margin-inline-end: 1rem; + } + + .react-aria-Link { + display: inline-flex; + align-items: center; + padding: 0; + border: 0; + background: 0 none; + font-size: 1rem; + text-align: left; + text-decoration: none; + } + } +} diff --git a/packages/contents/components/ItemActionsPopover/ItemActionsPopover.tsx b/packages/contents/components/ItemActionsPopover/ItemActionsPopover.tsx new file mode 100644 index 00000000000..d88ba7e01aa --- /dev/null +++ b/packages/contents/components/ItemActionsPopover/ItemActionsPopover.tsx @@ -0,0 +1,89 @@ +import { Link, Button, type PopoverProps, Popover } from '@plone/components'; +import EditIcon from '@plone/components/icons/edit.svg?react'; +import EyeIcon from '@plone/components/icons/eye.svg?react'; +import RowbeforeIcon from '@plone/components/icons/row-before.svg?react'; +import RowafterIcon from '@plone/components/icons/row-after.svg?react'; +import CutIcon from '@plone/components/icons/cut.svg?react'; +import CopyIcon from '@plone/components/icons/copy.svg?react'; +import BinIcon from '@plone/components/icons/bin.svg?react'; +import { useTranslation } from 'react-i18next'; +import PopoverListItem from '../PopoverListItem'; +import './ItemActionsPopover.css'; + +interface Props extends Omit { + editLink: string; + viewLink: string; + onMoveToTop: () => Promise; + onMoveToBottom: () => Promise; + onCut: () => void; + onCopy: () => void; + onDelete: () => Promise; +} + +export function ItemActionsPopover({ + editLink, + viewLink, + onMoveToTop, + onMoveToBottom, + onCut, + onCopy, + onDelete, + ...popoverProps +}: Props) { + const { t } = useTranslation(); + + return ( + +
      + + + + {t('contents.actions.edit')} + + + + + + + {t('contents.actions.view')} + + + + + + + + + + + + + + + + + + +
    +
    + ); +} diff --git a/packages/contents/components/PopoverListItem.tsx b/packages/contents/components/PopoverListItem.tsx new file mode 100644 index 00000000000..158cd3fc2f9 --- /dev/null +++ b/packages/contents/components/PopoverListItem.tsx @@ -0,0 +1,21 @@ +import clsx from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export default function PopoverListItem({ + className, + children, + ariaDescribedby, +}: { + className?: string; + children: React.ReactNode; + ariaDescribedby: string; +}) { + return ( +
  • + {children} +
  • + ); +} diff --git a/packages/contents/components/RearrangePopover/RearrangePopover.css b/packages/contents/components/RearrangePopover/RearrangePopover.css new file mode 100644 index 00000000000..7f5b92e05ba --- /dev/null +++ b/packages/contents/components/RearrangePopover/RearrangePopover.css @@ -0,0 +1,25 @@ +.rearrange-popover { + .react-aria-MenuItem { + border-radius: 0; + font-size: 1rem; + } + + .rearrange-menu-item { + display: flex; + justify-content: space-between; + + .icon { + flex-shrink: 0; + } + + /* &.open { + background-color: var(--highlight-pressed); + } */ + + &.focused, + &.open { + background-color: var(--quanta-smoke); + color: var(--text-color); + } + } +} diff --git a/packages/contents/components/RearrangePopover/RearrangePopover.tsx b/packages/contents/components/RearrangePopover/RearrangePopover.tsx new file mode 100644 index 00000000000..e58b5e43e18 --- /dev/null +++ b/packages/contents/components/RearrangePopover/RearrangePopover.tsx @@ -0,0 +1,117 @@ +import { Menu, MenuItem, SubmenuTrigger } from 'react-aria-components'; +import { Popover, type PopoverProps } from '@plone/components'; +import ChevronrightIcon from '@plone/components/icons/chevron-right.svg?react'; +import { useTranslation } from 'react-i18next'; +import './RearrangePopover.css'; + +interface Props extends Omit { + indexes: { + [index: string]: { + label: string; + sort_on?: string; + }; + }; + sortItems: (index: string) => void; +} + +const sortIndexes = [ + // TODO "id" is a reserved key for table rows, so we cannot add the "ID" column at this time + // 'id', + 'sortable_title', + 'EffectiveDate', + 'CreationDate', + 'ModificationDate', + 'portal_type', +]; + +interface MenuItem { + id: string; + name: string; + children?: MenuItem[]; +} + +export function RearrangePopover({ + indexes, + sortItems, + ...popoverProps +}: Props) { + const { t } = useTranslation(); + + const menuItems: MenuItem[] = sortIndexes.map((index) => ({ + id: index, + name: t(indexes[index].label), + children: [ + { + id: `${indexes[index].sort_on}|ascending`, + name: t('contents.rearrange.asc'), + }, + { + id: `${indexes[index].sort_on}|descending`, + name: t('contents.rearrange.desc'), + }, + ], + })); + + return ( + +
    + {t('contents.rearrange.by')} + + + {function renderSubmenu(item) { + if (item.children) { + return ( + + + `react-aria-MenuItem rearrange-menu-item ${ + isFocused ? 'focused' : '' + } ${isOpen ? 'open' : ''}` + } + > + {({ hasSubmenu }) => ( + <> + {item.name} + {hasSubmenu && } + + )} + + +
    + + {t('contents.rearrange.pickOrder', { + index: item.name, + })} + + { + sortItems(id.toString()); + }} + > + {(item) => renderSubmenu(item)} + +
    +
    +
    + ); + } else { + return {item.name}; + } + }} +
    +
    +
    + ); +} diff --git a/packages/contents/components/ReviewState.tsx b/packages/contents/components/ReviewState.tsx new file mode 100644 index 00000000000..1f372497067 --- /dev/null +++ b/packages/contents/components/ReviewState.tsx @@ -0,0 +1,43 @@ +import { tv } from 'tailwind-variants'; + +const styles = tv({ + base: ` + flex min-w-32 items-center + before:me-4 before:h-2.25 before:w-2.25 before:rounded-full before:bg-quanta-dolphin + `, + variants: { + state: { + private: 'before:bg-quanta-rose', + published: 'before:bg-quanta-cobalt', + intranet: 'before:bg-quanta-neon', + draft: 'before:bg-[#f6a808]', + }, + }, +}); + +function stateIsValid( + state: string, +): state is 'private' | 'published' | 'intranet' | 'draft' { + return ['private', 'published', 'intranet', 'draft'].includes(state); +} + +export default function ReviewState({ + state, + className, + children, +}: { + state: string; + className?: string; + children?: React.ReactNode; +}) { + return ( +
    + {children} +
    + ); +} diff --git a/packages/contents/components/TableIndexesPopover/TableIndexesPopover.tsx b/packages/contents/components/TableIndexesPopover/TableIndexesPopover.tsx new file mode 100644 index 00000000000..594a3ce3f18 --- /dev/null +++ b/packages/contents/components/TableIndexesPopover/TableIndexesPopover.tsx @@ -0,0 +1,48 @@ +import { Popover, Checkbox } from '@plone/components'; +import { useTranslation } from 'react-i18next'; +import PopoverListItem from '../PopoverListItem'; +import type { TableIndexes } from '../../types'; + +interface Props { + indexes: TableIndexes; + onSelectIndex: (index: string) => void; +} + +export const TableIndexesPopover = ({ indexes, onSelectIndex }: Props) => { + const { t } = useTranslation(); + + return ( + +
    + + {t('contents.indexes.select_columns')} + + +
      + {indexes.order.map((index) => { + if (index === 'sortable_title') return null; + return ( + + { + onSelectIndex(index); + }} + label={t(indexes.values[index].label)} + slot={null} + /> + + ); + })} +
    +
    +
    + ); +}; diff --git a/packages/contents/components/Topbar.tsx b/packages/contents/components/Topbar.tsx new file mode 100644 index 00000000000..b977a8d65a6 --- /dev/null +++ b/packages/contents/components/Topbar.tsx @@ -0,0 +1,12 @@ +export default function Topbar({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ); +} diff --git a/packages/contents/components/UploadModal/UploadModal.tsx b/packages/contents/components/UploadModal/UploadModal.tsx new file mode 100644 index 00000000000..f89758b7f3c --- /dev/null +++ b/packages/contents/components/UploadModal/UploadModal.tsx @@ -0,0 +1,301 @@ +import { useEffect } from 'react'; +import { useFetcher } from 'react-router'; +import { Heading, FileTrigger } from 'react-aria-components'; +import { type FileDropItem, useDateFormatter } from 'react-aria'; +import { useTranslation, Trans } from 'react-i18next'; +import { + Button, + Dialog, + DropZone, + DropZoneText, + Input, + Modal, +} from '@plone/components/quanta'; +import { + BinIcon, + CloseIcon, + PageIcon, + UploadIcon, +} from '@plone/components/Icons'; +import { type ToastItem } from '@plone/layout/config/toast'; +import { useContentsContext } from '../../providers/contents'; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const exp = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / Math.pow(1024, exp); + return `${exp === 0 ? value : value.toFixed(1)} ${units[exp]}`; +} + +export default function UploadModal() { + const { t } = useTranslation(); + const { + showUpload, + setShowUpload, + contentTitle, + contentPath, + showToast, + pendingDropFiles: entries, + setPendingDropFiles: setEntries, + } = useContentsContext(); + const fetcher = useFetcher(); + const longFormatter = useDateFormatter({ + dateStyle: 'full', + timeStyle: 'full', + }); + const shortFormatter = useDateFormatter({ + dateStyle: 'short', + timeStyle: 'short', + }); + + const addFiles = (newFiles: File[]) => { + const existingNames = new Set(entries.map((e) => e.file.name)); + const unique = newFiles + .filter((f) => !existingNames.has(f.name)) + .map((f) => ({ + file: f, + title: f.name, + })); + setEntries([...entries, ...unique]); + }; + + const removeFile = (index: number) => { + setEntries(entries.filter((_, i) => i !== index)); + }; + + const updateTitle = (index: number, title: string) => { + setEntries(entries.map((e, i) => (i === index ? { ...e, title } : e))); + }; + + useEffect(() => { + if (fetcher.state === 'idle') { + const responseData = fetcher.data; + if (responseData?.ok?.length > 0) { + const toast: ToastItem = { title: '', icon: }; + if (responseData.ok.length === 1) { + toast.title = t('contents.actions.uploaded', { + title: responseData.ok[0].title, + }); + } else { + toast.title = t('contents.actions.uploaded_multiple', { + number: responseData.ok.length, + }); + } + showToast(toast); + } + if (responseData?.errors?.length > 0) { + responseData.errors.forEach((e: any) => { + const toast: ToastItem = { + title: `${t('contents.error')} ${e.__error?.status} - ${e.__error?.data?.type}`, + description: e.__error?.data?.message, + icon: , + className: 'error', + }; + showToast(toast); + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.state]); + + if (!showUpload) return null; + + const close = () => { + setEntries([]); + setShowUpload(false); + }; + + const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + resolve(result.split(',')[1]); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + const confirm = async () => { + const encodedFiles = await Promise.all( + entries.map(async ({ file, title }) => ({ + name: file.name, + type: file.type || 'application/octet-stream', + data: await fileToBase64(file), + title, + })), + ); + fetcher.submit( + { path: contentPath, files: encodedFiles }, + { + method: 'POST', + encType: 'application/json', + action: '/@@contents/@@upload', + }, + ); + close(); + }; + + return ( + + + + {t('contents.modal_upload.title', { title: contentTitle })} + +
    + {entries.length > 0 && ( + + + + + + + + + + {entries.map((entry, index) => ( + + + + + + + ))} + +
    + {t('contents.modal_upload.columns.name')} + + {t('contents.modal_upload.columns.size')} + + {t('contents.modal_upload.columns.last_modified')} + +
    + {entry.file.type.startsWith('image/') ? ( + {entry.file.name} + ) : ( +
    + +
    + )} + updateTitle(index, e.target.value)} + className="me-2 flex-1" + aria-label={`${t('contents.modal_upload.columns.name')}: ${entry.file.name}`} + /> +
    {formatBytes(entry.file.size)} + {(() => { + const date = new Date(entry.file.lastModified); + return ( + + ); + })()} + + +
    + )} + + 'copy'} + onDrop={async (e) => { + const fileItems = e.items.filter( + (item): item is FileDropItem => item.kind === 'file', + ); + // TODO catch errors, use a toast to tell the user which files had errors + const dropped = await Promise.all( + fileItems.map((item) => item.getFile()), + ); + addFiles(dropped); + }} + > +
    + +
    +

    + {entries.length > 0 + ? t('contents.modal_upload.upload_more_files') + : t('contents.modal_upload.upload_files')} +

    + + + Drag and drop or + { + if (fileList) addFiles([...fileList]); + }} + > + + + + +
    + +
    + + +
    +
    +
    +
    + ); +} diff --git a/packages/contents/config/ContentIcons.tsx b/packages/contents/config/ContentIcons.tsx new file mode 100644 index 00000000000..e494c72a896 --- /dev/null +++ b/packages/contents/config/ContentIcons.tsx @@ -0,0 +1,17 @@ +import newsSVG from '@plone/components/icons/news.svg?react'; +import linkSVG from '@plone/components/icons/link.svg?react'; +import calendarSVG from '@plone/components/icons/calendar.svg?react'; +import folderSVG from '@plone/components/icons/folder.svg?react'; + +import pageSVG from '@plone/components/icons/page.svg?react'; +import imageSVG from '@plone/components/icons/image.svg?react'; + +export const contentIcons = { + Document: pageSVG, + Folder: folderSVG, + 'News Item': newsSVG, + Event: calendarSVG, + Image: imageSVG, + File: pageSVG, + Link: linkSVG, +}; diff --git a/packages/contents/config/constants.ts b/packages/contents/config/constants.ts new file mode 100644 index 00000000000..ac0666098f0 --- /dev/null +++ b/packages/contents/config/constants.ts @@ -0,0 +1 @@ +export const clipboardKey = '__cp'; diff --git a/packages/contents/helpers/Errors.ts b/packages/contents/helpers/Errors.ts new file mode 100644 index 00000000000..a43ca358a98 --- /dev/null +++ b/packages/contents/helpers/Errors.ts @@ -0,0 +1,14 @@ +export const HandleCatchedError = (e: any, defaultStatusText = 'Error') => { + // eslint-disable-next-line no-console + console.error('Error', e); + + if (typeof e === 'object' && e !== null) { + throw { + ...e, + statusText: (e as any).statusText ?? defaultStatusText, + ...(e as any).data, + }; + } else { + throw { statusText: `Unknown ${defaultStatusText}`, status: 500 }; + } +}; diff --git a/packages/contents/index.ts b/packages/contents/index.ts new file mode 100644 index 00000000000..3d8dbc2768b --- /dev/null +++ b/packages/contents/index.ts @@ -0,0 +1,45 @@ +import type { ConfigType } from '@plone/registry'; +import { contentIcons } from './config/ContentIcons'; + +export default function install(config: ConfigType) { + config.settings.contentIcons = contentIcons; + config.registerRoute({ + type: 'layout', + file: '@plone/contents/routes/layout.tsx', + children: [ + { + type: 'prefix', + path: '@@contents', + children: [ + { + path: '@@delete/*', + type: 'route', + file: '@plone/contents/routes/delete.tsx', + }, + { + path: '@@upload/*', + type: 'route', + file: '@plone/contents/routes/upload.tsx', + }, + { + path: '@@order/*', + type: 'route', + file: '@plone/contents/routes/order.tsx', + }, + { + path: '@@paste/*', + type: 'route', + file: '@plone/contents/routes/paste.tsx', + }, + { + type: 'route', + path: '*', + file: '@plone/contents/routes/contents.tsx', + }, + ], + }, + ], + }); + + return config; +} diff --git a/packages/contents/locales/en/common.json b/packages/contents/locales/en/common.json new file mode 100644 index 00000000000..e7155fb38bd --- /dev/null +++ b/packages/contents/locales/en/common.json @@ -0,0 +1,98 @@ +{ + "contents": { + "loading": "Loading...", + "results": { + "number": "{{count}} items", + "contents_of": "Contents of {{title}}", + "no_results": "No items found" + }, + "actions": { + "actions": "Actions", + "upload": "Upload", + "rename": "Rename", + "state": "Change state", + "tags": "Tags", + "properties": "Properties", + "cut": "Cut", + "cutted": "Content '{{title}}' successfully cutted", + "cutted_multiple": "{{number}} contents successfully cutted", + "copy": "Copy", + "copied": "Content '{{title}}' successfully copied", + "copied_multiple": "{{number}} contents successfully copied", + "paste": "Paste", + "pasted": "Content '{{title}}' successfully pasted", + "pasted_multiple": "{{number}} contents successfully pasted", + "delete": "Delete", + "deleted": "Content '{{title}}' successfully deleted", + "deleted_multiple": "{{number}} contents successfully deleted", + "filter": "Filter…", + "edit": "Edit", + "view": "View", + "add": "Add content", + "move_to_top_folder": "Move to top folder", + "move_to_bottom_folder": "Move to bottom folder", + "uploaded": "Content '{{title}}' successfully uploaded", + "uploaded_multiple": "{{number}} contents successfully uploaded" + }, + "item": { + "expired": "Expired", + "scheduled": "Scheduled", + "more_options": "More options" + }, + "indexes": { + "title": "Title", + "select_columns": "Select columns to show", + "boolean": { + "yes": "Yes", + "no": "No" + }, + "review_state": { + "published": "Published", + "private": "Private", + "pending": "Pending", + "intranet": "Intranet", + "draft": "Draft", + "none": "None", + "no_workflow_state": "—" + }, + "date": { + "none": "" + } + }, + "error": "Error", + "unknown_error": "Unknown error", + "rearrange": { + "error": "Error reordering items", + "by": "Rearrange items by…", + "asc": "Ascending", + "desc": "Descending", + "pickOrder": "Pick order for {{index}}" + }, + "modal": { + "close": "Close" + }, + "modal_upload": { + "title": "Upload to \"{{title}}\"", + "upload_more_files": "Upload more files", + "upload_files": "Upload files", + "dropzone_label": "Drag and drop or <1><0>Browse", + "confirm": "Upload", + "remove_file": "Remove file", + "columns": { + "name": "Name", + "size": "Size", + "last_modified": "Last modified" + } + }, + "modal_delete": { + "single": { + "title": "Delete \"{{item}}\"?" + }, + "multi": { + "title": "Delete {{n}} items?" + }, + "description": "This action cannot be undone.", + "confirm": "Delete" + } + } +} diff --git a/packages/contents/locales/it/common.json b/packages/contents/locales/it/common.json new file mode 100644 index 00000000000..d23f9ebba38 --- /dev/null +++ b/packages/contents/locales/it/common.json @@ -0,0 +1,98 @@ +{ + "contents": { + "loading": "Caricamento...", + "results": { + "number": "{{count}} elementi", + "contents_of": "Contenuti di {title}", + "no_results": "Nessun elemento trovato" + }, + "actions": { + "actions": "Azioni", + "upload": "Carica", + "rename": "Rinomina", + "state": "Cambia stato", + "tags": "Etichette", + "properties": "Proprietà", + "cut": "Taglia", + "cutted": "Contenuto '{{title}}' tagliato con successo", + "cutted_multiple": "{{number}} contenuti tagliati con successo", + "copy": "Copia", + "copied": "Contenuto '{{title}}' copiato con successo", + "copied_multiple": "{{number}} contenuti copiato con successo", + "paste": "Incolla", + "pasted": "Contenuto '{{title}}' incollato con successo", + "pasted_multiple": "{{number}} contenuti incollati con successo", + "delete": "Elimina", + "deleted": "Contenuto '{{title}}' eliminato con successo", + "deleted_multiple": "{{number}} contenuti eliminati con successo", + "filter": "Filtra…", + "edit": "Modifica", + "view": "Vedi", + "add": "Aggiungi un contenuto", + "move_to_top_folder": "Sposta in cima alla cartella", + "move_to_bottom_folder": "Sposta in fondo alla cartella", + "uploaded": "Contenuto '{{title}}' caricato con successo", + "uploaded_multiple": "{{number}} contenuti caricati con successo" + }, + "item": { + "expired": "Scaduto", + "scheduled": "Pianificato", + "more_options": "Altre opzioni" + }, + "indexes": { + "title": "Titolo", + "select_columns": "Seleziona le colonne da visualizzare", + "boolean": { + "yes": "Sì", + "no": "No" + }, + "review_state": { + "published": "Pubblicato", + "private": "Privato", + "pending": "In attesa di revisione", + "intranet": "Intranet", + "draft": "In bozza", + "none": "Nessuno", + "no_workflow_state": "—" + }, + "date": { + "none": "" + } + }, + "error": "Errore", + "unknown_error": "Errore sconsciuto", + "rearrange": { + "error": "Errore durante la riposizione degli elementi", + "by": "Riordina gli elementi per…", + "asc": "Crescente", + "desc": "Decrescente", + "pickOrder": "Riposiziona {{index}}" + }, + "modal": { + "close": "Annulla" + }, + "modal_upload": { + "title": "Carica in \"{{title}}\"", + "upload_more_files": "Carica altri file", + "upload_files": "Carica file", + "dropzone_label": "Trascina i file qui, o clicca per <1><0>sfogliare", + "confirm": "Carica", + "remove_file": "Rimuovi file", + "columns": { + "name": "Nome", + "size": "Dimensione", + "last_modified": "Ultima modifica" + } + }, + "modal_delete": { + "single": { + "title": "Vuoi eliminare \"{{item}}\"?" + }, + "multi": { + "title": "Vuoi eliminare {{n}} elementi?" + }, + "description": "Questa azione non può essere annullata.", + "confirm": "Procedi con l'eliminazione" + } + } +} diff --git a/packages/contents/news/+contents-package.feature b/packages/contents/news/+contents-package.feature new file mode 100644 index 00000000000..fd58bd56f50 --- /dev/null +++ b/packages/contents/news/+contents-package.feature @@ -0,0 +1 @@ +Added the `@plone/contents` package with Seven folder contents views, actions, upload, ordering, filtering, and pagination. @pnicolli @giuliaghisini diff --git a/packages/providers/news/.gitkeep b/packages/contents/news/.gitkeep similarity index 100% rename from packages/providers/news/.gitkeep rename to packages/contents/news/.gitkeep diff --git a/packages/contents/package.json b/packages/contents/package.json new file mode 100644 index 00000000000..08f0a69d533 --- /dev/null +++ b/packages/contents/package.json @@ -0,0 +1,91 @@ +{ + "name": "@plone/contents", + "description": "Plone core contents", + "maintainers": [ + { + "name": "Plone Foundation", + "url": "https://plone.org" + } + ], + "funding": "https://github.com/sponsors/plone", + "license": "MIT", + "version": "0.1.0", + "repository": { + "type": "git", + "url": "https://github.com/plone/volto.git", + "directory": "packages/contents" + }, + "bugs": { + "url": "https://github.com/plone/volto/issues" + }, + "homepage": "https://plone.org", + "keywords": [ + "volto", + "plone", + "plone6", + "react", + "contents" + ], + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "index.ts", + "scripts": { + "test": "vitest --coverage", + "check:ts": "tsc --project tsconfig.json", + "dry-release": "release-it --dry-run", + "release": "release-it", + "release-major-alpha": "release-it major --preRelease=alpha", + "release-alpha": "release-it --preRelease=alpha", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "peerDependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + }, + "dependencies": { + "@plone/client": "workspace:*", + "@plone/cmsui": "workspace:*", + "@plone/components": "workspace:*", + "@plone/helpers": "workspace:*", + "@plone/layout": "workspace:*", + "@plone/react-router": "workspace:*", + "@plone/registry": "workspace:*", + "@react-types/shared": "^3.29.1", + "clsx": "^2.1.1", + "react-aria": "catalog:", + "react-aria-components": "catalog:", + "react-i18next": "catalog:", + "react-router": "catalog:", + "tailwind-merge": "catalog:", + "tailwind-variants": "catalog:", + "tailwindcss": "catalog:", + "tailwindcss-animate": "^1.0.7", + "usehooks-ts": "^3.1.1" + }, + "devDependencies": { + "@plone/types": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/jest-axe": "^3.5.7", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitest/coverage-v8": "catalog:", + "jest-axe": "^8.0.0", + "release-it": "catalog:", + "tailwindcss-react-aria-components": "^2.0.0", + "tsconfig": "workspace:*", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:", + "vitest-axe": "^0.1.0" + } +} diff --git a/packages/contents/providers/contents.tsx b/packages/contents/providers/contents.tsx new file mode 100644 index 00000000000..1608beb67d1 --- /dev/null +++ b/packages/contents/providers/contents.tsx @@ -0,0 +1,121 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useState, +} from 'react'; +import { useLoaderData } from 'react-router'; +import type { ContentsLoaderType } from '../routes/contents'; + +import type { Key } from '@react-types/shared'; +import type { ArrayElement } from '@plone/types'; +import config from '@plone/registry'; +import { type ToastItem } from '@plone/layout/config/toast'; + +type SetSelectedType = 'all' | 'none' | Set; + +export type FileEntry = { file: File; title: string }; + +type Item = ArrayElement< + Awaited>['search']['items'] +>; + +interface ContentsContext { + selected: Set; + setSelected: (selected: SetSelectedType) => void; + showDelete: boolean; + setShowDelete: (s: boolean) => void; + itemsToDelete: Set; + setItemsToDelete: (s: Set) => void; + showUpload: boolean; + setShowUpload: (s: boolean) => void; + pendingDropFiles: FileEntry[]; + setPendingDropFiles: (files: FileEntry[]) => void; + contentTitle: string; + contentPath: string; + showToast: (c: ToastItem) => void; +} + +const ContentsContext = createContext({ + selected: new Set(), + setSelected: () => {}, + showDelete: false, + setShowDelete: () => {}, + itemsToDelete: new Set(), + setItemsToDelete: () => {}, + showUpload: false, + setShowUpload: () => {}, + pendingDropFiles: [], + setPendingDropFiles: () => {}, + contentTitle: '', + contentPath: '/', + showToast: (t: ToastItem) => {}, +}); + +type ContentsProviderProps = PropsWithChildren; + +export function ContentsProvider(props: ContentsProviderProps) { + const { children } = props; + + const { search, content } = useLoaderData(); + const { items = [] } = search ?? {}; + const contentTitle = content?.title ?? ''; + const contentPath = content?.['@id'] ?? '/'; + //selected + const [selected, _setSelected] = useState>(new Set()); + + const setSelected = useCallback( + (s: SetSelectedType) => { + if (s === 'all') { + _setSelected(new Set(items)); + } else if (s === 'none') { + _setSelected(new Set()); + } else { + _setSelected(new Set(items.filter((i) => s.has(i['@id'])))); + } + }, + [items], + ); + + //delete + const [itemsToDelete, setItemsToDelete] = useState>(new Set()); + const [showDelete, setShowDelete] = useState(false); + + //upload + const [showUpload, setShowUpload] = useState(false); + const [pendingDropFiles, setPendingDropFiles] = useState([]); + + //show toast + const showToast = (queueElement: ToastItem) => { + config + .getUtility({ + name: 'show', + type: 'toast', + }) + .method(queueElement); + }; + const ctx = { + selected, + setSelected, + itemsToDelete, + setItemsToDelete, + showDelete, + setShowDelete, + showUpload, + setShowUpload, + pendingDropFiles, + setPendingDropFiles, + contentTitle, + contentPath, + showToast, + }; + + return ( + {children} + ); +} + +export function useContentsContext(): ContentsContext { + return useContext(ContentsContext); +} diff --git a/packages/contents/routes/contents.test.tsx b/packages/contents/routes/contents.test.tsx new file mode 100644 index 00000000000..8ea036eff62 --- /dev/null +++ b/packages/contents/routes/contents.test.tsx @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RouterContextProvider } from 'react-router'; +import { loader } from './contents'; +import { + ploneClientContext, + ploneContentContext, +} from 'seven/app/middleware.server'; + +vi.mock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), +})); + +describe('Contents route loader', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses middleware contexts for content and client search', async () => { + const searchMock = vi.fn().mockResolvedValue({ + data: { + '@id': 'http://example.com/++api++/@search', + items: [ + { + '@id': 'http://example.com/++api++/doc-1', + '@type': 'Document', + title: 'Doc 1', + }, + ], + items_total: 1, + }, + }); + + const context = new RouterContextProvider(); + context.set(ploneClientContext, { search: searchMock } as any); + context.set(ploneContentContext, { + '@id': '/plone', + title: 'Plone Site', + } as any); + + const request = new Request( + 'http://example.com/@@contents/news?SearchableText=foo&page=2&sort_on=modified&sort_order=descending', + ); + + const result = await loader({ + request, + params: { '*': 'news' }, + context, + unstable_pattern: '/@@contents/*', + unstable_url: new URL(request.url), + }); + + expect(searchMock).toHaveBeenCalledWith({ + query: { + path: { + query: '/news', + depth: 1, + }, + sort_on: 'modified', + sort_order: 'descending', + metadata_fields: '_all', + show_inactive: true, + b_size: 10, + SearchableText: 'foo**', + b_start: 20, + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + content: { + '@id': '/plone', + title: 'Plone Site', + }, + searchableText: 'foo', + page: '2', + b_size: 10, + sort_on: 'modified', + sort_order: 'descending', + }), + ); + expect(result.search.items_total).toBe(1); + expect(result.search.items[0]).toEqual( + expect.objectContaining({ + '@type': 'Document', + title: 'Doc 1', + }), + ); + }); + + it('uses the root path when no wildcard param is present', async () => { + const searchMock = vi.fn().mockResolvedValue({ + data: { + '@id': 'http://example.com/++api++/@search', + items: [], + items_total: 0, + }, + }); + + const context = new RouterContextProvider(); + context.set(ploneClientContext, { search: searchMock } as any); + context.set(ploneContentContext, { '@id': '/plone', title: 'Home' } as any); + + const request = new Request('http://example.com/@@contents'); + + await loader({ + request, + params: {}, + context, + unstable_pattern: '/@@contents', + unstable_url: new URL(request.url), + }); + + expect(searchMock).toHaveBeenCalledWith({ + query: expect.objectContaining({ + path: { + query: '/', + depth: 1, + }, + sort_on: 'getObjPositionInParent', + sort_order: 'ascending', + }), + }); + }); +}); diff --git a/packages/contents/routes/contents.tsx b/packages/contents/routes/contents.tsx new file mode 100644 index 00000000000..011bea12bbb --- /dev/null +++ b/packages/contents/routes/contents.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react'; +import { + type LoaderFunctionArgs, + RouterContextProvider, + useLoaderData, + useNavigate, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import config from '@plone/registry'; +import { flattenToAppURL } from '@plone/helpers'; +import { ContentsTable } from '../components/ContentsTable/ContentsTable'; +import Indexes, { defaultIndexes } from '../components/Indexes'; +import { ContentsProvider } from '../providers/contents'; +import DeleteModal from '../components/DeleteModal/DeleteModal'; +import UploadModal from '../components/UploadModal/UploadModal'; +import ErrorToast from '@plone/layout/components/Toast/ErrorToast'; + +import type { TableIndexes } from '../types'; + +import { + ploneClientContext, + ploneContentContext, +} from 'seven/app/middleware.server'; + +// This is needed because to prevent circular import loops +export type ContentsLoaderType = typeof loader; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function loader({ + params, + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + const b_size = 10; + const cli = context.get(ploneClientContext); + const content = context.get(ploneContentContext); + + const path = `/${params['*'] || ''}`; + + const searchParams = new URL(request.url).searchParams; + const searchableText = searchParams.get('SearchableText') || ''; + const page = searchParams.get('page') || ''; + const sort_on = searchParams.get('sort_on') || 'getObjPositionInParent'; + const sort_order = searchParams.get('sort_order') || 'ascending'; + + const searchQuery: Parameters[0]['query'] = { + path: { + query: path, + depth: 1, + }, + sort_on, + sort_order, + metadata_fields: '_all', + show_inactive: true, + b_size, + }; + if (searchableText.length > 0) { + searchQuery.SearchableText = searchableText + '**'; + } + if (page.length > 0) { + searchQuery.b_start = Number(page) * b_size; + } + + const search = flattenToAppURL( + ( + await cli.search({ + query: searchQuery, + }) + ).data, + ); + + return { content, search, searchableText, page, b_size, sort_on, sort_order }; +} + +const DEFAULT_TABLE_INDEXES: TableIndexes = { + order: Object.keys(Indexes), + values: Object.fromEntries( + Object.entries(Indexes).map(([key, value]) => [ + key, + { + ...value, + selected: defaultIndexes.indexOf(key) !== -1, + }, + ]), + ), + selectedCount: defaultIndexes.length + 1, +}; + +export default function Contents() { + const { content } = useLoaderData(); + const navigate = useNavigate(); + const [indexes, setIndexes] = useState(DEFAULT_TABLE_INDEXES); + + const upload = () => Promise.resolve(); + const properties = () => Promise.resolve(); + const workflow = () => Promise.resolve(); + const tags = () => Promise.resolve(); + const rename = () => Promise.resolve(); + + const onSortItems = (_: any, { value }: { value: string }) => { + const [sort_on, sort_order] = value.split('|'); + const params = new URLSearchParams(window.location.search); + + params.set('sort_on', sort_on); + params.set('sort_order', sort_order); + params.delete('page'); + + const querystring = params.size > 0 ? '?' + params.toString() : ''; + navigate(`/@@contents${content['@id']}${querystring}`); + }; + + //Indexes + + const onSelectIndex = (index: string) => { + const new_indexes = { ...indexes }; + + new_indexes.values[index].selected = !new_indexes.values[index].selected; + setIndexes(new_indexes); + }; + + return ( +
    + + + + onSortItems(undefined, { value: id })} + upload={upload} + rename={rename} + workflow={workflow} + tags={tags} + properties={properties} + + // addableTypes={props.addableTypes} + /> + +
    + ); +} + +//todo: fix handling errors with toast +export function ErrorBoundary() { + const queue = config.getUtility({ name: 'queue', type: 'toast' }).method(); + return ErrorToast(queue); +} diff --git a/packages/contents/routes/delete.tsx b/packages/contents/routes/delete.tsx new file mode 100644 index 00000000000..6855d4aa29e --- /dev/null +++ b/packages/contents/routes/delete.tsx @@ -0,0 +1,42 @@ +import { + data, + RouterContextProvider, + type ActionFunctionArgs, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import { HandleCatchedError } from '../helpers/Errors'; + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + + const payload = await request.json(); + const errors: Array> = []; + const ok: Array = []; + let responses: Array = []; + + try { + responses = await Promise.allSettled( + payload.paths.map(async (i: string) => { + await cli.deleteContent({ path: i }); + }), + ); + } catch (e) { + HandleCatchedError(e, 'Error on delete'); + } + + responses.forEach((r, i) => { + if (r.status === 'fulfilled') { + ok.push(payload.items[i]); + } else { + errors.push({ ...payload.items[i], __error: r.reason }); + } + }); + + return data({ ok, errors }, 200); +} diff --git a/packages/contents/routes/layout.test.tsx b/packages/contents/routes/layout.test.tsx new file mode 100644 index 00000000000..9504a7e5a88 --- /dev/null +++ b/packages/contents/routes/layout.test.tsx @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RouterContextProvider } from 'react-router'; +import { loader } from './layout'; +import { ploneContentContext } from 'seven/app/middleware.server'; + +vi.mock('seven/app/i18next.server', () => ({ + default: { + getLocale: vi.fn().mockResolvedValue('en'), + }, +})); + +describe('Contents layout loader', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reads content from middleware context and returns locale', async () => { + const context = new RouterContextProvider(); + context.set(ploneContentContext, { + '@id': '/plone/news', + title: 'News', + language: { token: 'en' }, + } as any); + + const request = new Request('http://example.com/@@contents/news'); + + const result = await loader({ + request, + context, + params: { '*': 'news' }, + unstable_pattern: '/@@contents/*', + unstable_url: new URL(request.url), + }); + + expect(result).toEqual({ + locale: 'en', + content: { + '@id': '/plone/news', + title: 'News', + language: { token: 'en' }, + }, + }); + }); +}); diff --git a/packages/contents/routes/layout.tsx b/packages/contents/routes/layout.tsx new file mode 100644 index 00000000000..500e032aa81 --- /dev/null +++ b/packages/contents/routes/layout.tsx @@ -0,0 +1,138 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useNavigate, + useLoaderData, + type LinksFunction, + type MetaFunction, + type LoaderFunctionArgs, + RouterContextProvider, +} from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { RouterProvider as RACRouterProvider } from 'react-aria-components'; +import clsx from 'clsx'; +import i18next from 'seven/app/i18next.server'; +import { ploneContentContext } from 'seven/app/middleware.server'; +import { type RootLoader } from 'seven/app/root'; +import { Link } from '@plone/components/quanta'; +import { PluggablesProvider, Plug } from '@plone/layout/components/Pluggable'; +import Toast from '@plone/layout/components/Toast/Toast'; +import Toolbar from '@plone/layout/components/Toolbar/Toolbar'; +import { shouldShowToolbar } from '@plone/layout/helpers'; +import config from '@plone/registry'; +import Back from '@plone/components/icons/arrow-left.svg?react'; + +// eslint-disable-next-line import/no-unresolved +import stylesheet from 'seven/.plone/cmsui.css?url'; +// TODO these imports are temporary and will need to be fully replaced with quanta tailwind styles +import basicComponentsStylesheets from '@plone/components/dist/basic.css?url'; +import quantaComponentsStylesheet from '@plone/components/dist/quanta.css?url'; + +export const meta: MetaFunction = ({ + matches, +}) => { + const content = matches.find((match) => match.id === 'root')?.data?.content; + if (!content) { + return []; + } + + return [ + { title: content.title }, + { name: 'description', content: content.description }, + { name: 'generator', content: 'Plone 7 - https://plone.org' }, + ]; +}; + +export const links: LinksFunction = () => [ + { rel: 'stylesheet', href: basicComponentsStylesheets }, + { rel: 'stylesheet', href: quantaComponentsStylesheet }, + { rel: 'stylesheet', href: stylesheet }, + { + rel: 'icon', + href: '/favicon.ico', + type: 'image/x-icon', + sizes: 'any', + }, + { + rel: 'icon', + href: '/icon.svg', + type: 'image/svg+xml', + }, + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap', + }, +]; + +export async function loader({ + request, + context, + params, +}: LoaderFunctionArgs) { + const content = context.get(ploneContentContext); + const locale = await i18next.getLocale(request); + const path = `/${params['*'] || ''}`; + return { locale, content, path }; +} + +export default function Index() { + const { locale, content, path } = useLoaderData(); + const { i18n } = useTranslation(); + const navigate = useNavigate(); + + const contentLanguage = (content?.language as { token?: string } | undefined) + ?.token; + const showToolbar = shouldShowToolbar(content); + + return ( + + + + + + + + + + {/* We pre-define here the @layer before tailwind does, adding our own layers in a React 19 managed tag */} + + + + {showToolbar && } +
    + +
    + + + + + +
    +
    + + + + + + ); +} diff --git a/packages/contents/routes/order.tsx b/packages/contents/routes/order.tsx new file mode 100644 index 00000000000..5ddcc6b395f --- /dev/null +++ b/packages/contents/routes/order.tsx @@ -0,0 +1,37 @@ +import { + data, + RouterContextProvider, + type ActionFunctionArgs, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import { HandleCatchedError } from '../helpers/Errors'; + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + + const payload = await request.json(); + // const errors = []; + + try { + await cli.updateContent({ + path: payload.path, + data: { + ordering: { + obj_id: payload.obj_id, + delta: payload.delta, + subset_ids: payload.subset_ids, + }, + }, + }); + } catch (e) { + HandleCatchedError(e, 'Error on order'); + } + + return data(null, 204); +} diff --git a/packages/contents/routes/paste.tsx b/packages/contents/routes/paste.tsx new file mode 100644 index 00000000000..ed951a8a40c --- /dev/null +++ b/packages/contents/routes/paste.tsx @@ -0,0 +1,43 @@ +import { + data, + RouterContextProvider, + type ActionFunctionArgs, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import { HandleCatchedError } from '../helpers/Errors'; + +export async function action({ + params, + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + + const path = `/${params['*'] || ''}`; + + const payload = await request.json(); + // const errors = []; + // let response = null; + + try { + const options = { + path, + data: { + source: payload.source, + }, + }; + + if (payload.action === 'copy') { + await cli.copyContent(options); + } else if (payload.action === 'cut') { + await cli.moveContent(options); + } + } catch (e) { + HandleCatchedError(e, 'Error on paste'); + } + + return data(payload, 200); +} diff --git a/packages/contents/routes/upload.tsx b/packages/contents/routes/upload.tsx new file mode 100644 index 00000000000..2dc4fc1d7d8 --- /dev/null +++ b/packages/contents/routes/upload.tsx @@ -0,0 +1,72 @@ +import { + data, + RouterContextProvider, + type ActionFunctionArgs, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import { HandleCatchedError } from '../helpers/Errors'; + +interface UploadFilePayload { + name: string; + type: string; + data: string; // base64-encoded content + title: string; +} + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + + const payload = await request.json(); + const errors: Array> = []; + const ok: Array = []; + let responses: Array = []; + + try { + responses = await Promise.allSettled( + payload.files.map(async (file: UploadFilePayload) => { + const isImage = file.type.startsWith('image/'); + const contentData = isImage + ? { + '@type': 'Image' as const, + title: file.title, + image: { + 'content-type': file.type, + data: file.data, + encoding: 'base64' as const, + filename: file.name, + }, + } + : { + '@type': 'File' as const, + title: file.title, + file: { + 'content-type': file.type, + data: file.data, + encoding: 'base64' as const, + filename: file.name, + }, + }; + + return cli.createContent({ path: payload.path, data: contentData }); + }), + ); + } catch (e) { + HandleCatchedError(e, 'Error on upload'); + } + + responses.forEach((r, i) => { + if (r.status === 'fulfilled') { + ok.push(payload.files[i]); + } else { + errors.push({ ...payload.files[i], __error: r.reason }); + } + }); + + return data({ ok, errors }, 200); +} diff --git a/packages/contents/setupTesting.ts b/packages/contents/setupTesting.ts new file mode 100644 index 00000000000..8bc87fa36e0 --- /dev/null +++ b/packages/contents/setupTesting.ts @@ -0,0 +1,3 @@ +import '@testing-library/jest-dom'; +import { toHaveNoViolations } from 'jest-axe'; +expect.extend(toHaveNoViolations); diff --git a/packages/contents/styles/cmsui.css b/packages/contents/styles/cmsui.css new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/providers/towncrier.toml b/packages/contents/towncrier.toml similarity index 100% rename from packages/providers/towncrier.toml rename to packages/contents/towncrier.toml diff --git a/packages/providers/tsconfig.json b/packages/contents/tsconfig.json similarity index 55% rename from packages/providers/tsconfig.json rename to packages/contents/tsconfig.json index d6fbf62fe01..e8a9d912d3d 100644 --- a/packages/providers/tsconfig.json +++ b/packages/contents/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "tsconfig/react-library.json", - "include": ["src"], + "include": ["**/*.ts", "**/*.tsx", "../components/src/icons.d.ts"], "exclude": [ "node_modules", "build", @@ -9,5 +9,11 @@ "src/**/*.test.{js,jsx,ts,tsx}", "src/**/*.spec.{js,jsx,ts,tsx}", "src/**/*.stories.{js,jsx,ts,tsx}" - ] + ], + "compilerOptions": { + "types": ["vite/client"], + "paths": { + "seven/*": ["../../apps/seven/*"] + } + } } diff --git a/packages/contents/types.ts b/packages/contents/types.ts new file mode 100644 index 00000000000..d04c92f3f17 --- /dev/null +++ b/packages/contents/types.ts @@ -0,0 +1,12 @@ +export type TableIndexes = { + order: string[]; + values: { + [index: string]: { + type: string; + label: string; + selected: boolean; + sort_on?: string; + }; + }; + selectedCount: number; +}; diff --git a/packages/providers/vitest.config.ts b/packages/contents/vitest.config.ts similarity index 66% rename from packages/providers/vitest.config.ts rename to packages/contents/vitest.config.ts index 53edf56869a..405a6b28be7 100644 --- a/packages/providers/vitest.config.ts +++ b/packages/contents/vitest.config.ts @@ -2,13 +2,16 @@ import { defineConfig } from 'vitest/config'; // https://vitejs.dev/config/ export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, test: { globals: true, environment: 'jsdom', + setupFiles: './setupTesting.ts', // you might want to disable it, if you don't have tests that rely on CSS // since parsing CSS is slow css: true, - exclude: ['**/node_modules/**', '**/lib/**'], - passWithNoTests: true, + exclude: ['**/node_modules/**', '**/lib/**', '**/acceptance/**'], }, }); diff --git a/packages/coresandbox/README.md b/packages/coresandbox/README.md deleted file mode 100644 index 1f01acdc346..00000000000 --- a/packages/coresandbox/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# @plone/volto-coresandbox - -This package is part of the Volto CI fixtures. -It's used to provide example use cases that are not present in vanilla Volto. diff --git a/packages/coresandbox/package.json b/packages/coresandbox/package.json deleted file mode 100644 index 2f5f0437f36..00000000000 --- a/packages/coresandbox/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@plone/volto-coresandbox", - "description": "Volto Core Sandbox addon - Contains configuration and elements that are not present in vanilla Volto", - "maintainers": [ - { - "name": "Plone Foundation", - "email": "plone-developers@lists.sourceforge.net", - "url": "http://plone.org" - } - ], - "main": "src/index.ts", - "license": "MIT", - "version": "1.0.0", - "repository": { - "type": "git", - "url": "git@github.com:plone/volto.git" - }, - "bugs": { - "url": "https://github.com/plone/volto/issues", - "email": "plone-developers@lists.sourceforge.net" - }, - "homepage": "https://plone.org", - "keywords": [ - "volto", - "plone", - "react" - ], - "peerDependencies": { - "react": "catalog:", - "react-dom": "catalog:", - "react-intl": "3.12.1", - "react-redux": "8.1.2", - "semantic-ui-react": "2.1.5" - }, - "devDependencies": { - "@plone/types": "workspace:*", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "@plone/registry": "workspace:*", - "@types/react-redux": "^7.1.33" - } -} diff --git a/packages/coresandbox/src/components/Blocks/FormBlock/Data.tsx b/packages/coresandbox/src/components/Blocks/FormBlock/Data.tsx deleted file mode 100644 index a0f7ce8d682..00000000000 --- a/packages/coresandbox/src/components/Blocks/FormBlock/Data.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useIntl } from 'react-intl'; -import { BlockDataForm } from '@plone/volto/components/manage/Form'; -import type { BlockEditProps } from '@plone/types'; - -const FormBlockData = (props: BlockEditProps) => { - const { block, blocksConfig, contentType, data, navRoot, onChangeBlock } = - props; - const intl = useIntl(); - const schema = blocksConfig[data['@type']].blockSchema({ intl, props }); - - return ( - { - onChangeBlock(block, { - ...data, - [id]: value, - }); - }} - onChangeBlock={onChangeBlock} - formData={data} - blocksConfig={blocksConfig} - navRoot={navRoot} - contentType={contentType} - /> - ); -}; - -export default FormBlockData; diff --git a/packages/coresandbox/src/components/Blocks/FormBlock/Edit.tsx b/packages/coresandbox/src/components/Blocks/FormBlock/Edit.tsx deleted file mode 100644 index 866f4e4c38f..00000000000 --- a/packages/coresandbox/src/components/Blocks/FormBlock/Edit.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import SidebarPortal from '@plone/volto/components/manage/Sidebar/SidebarPortal'; -import Data from './Data'; -import type { BlockEditProps } from '@plone/types'; -import { Helmet } from '@plone/volto/helpers/Helmet/Helmet'; - -import { defineMessages, useIntl } from 'react-intl'; -import { Container } from 'semantic-ui-react'; -import { Form } from '@plone/volto/components/manage/Form'; - -const messages = defineMessages({ - testForm: { - id: 'Test Form', - defaultMessage: 'Test Form', - }, - default: { - id: 'Default', - defaultMessage: 'Default', - }, - textlineTitle: { - id: 'Title', - defaultMessage: 'Title', - }, - emailTitle: { - id: 'Email', - defaultMessage: 'Email', - }, - - urlTitle: { - id: 'URL', - defaultMessage: 'Enter URL', - }, - - datetimeTitle: { - id: 'Date/Time', - defaultMessage: 'Enter Date/Time', - }, - - idTitle: { - id: 'Id', - defaultMessage: 'Enter ID', - }, - - richTextTitle: { - id: 'RichText', - defaultMessage: 'Enter RichText', - }, - - PasswordTitle: { - id: 'password', - defaultMessage: 'Password', - }, -}); -const FormBlockEdit = (props: BlockEditProps) => { - const intl = useIntl(); - const { selected } = props; - - return ( - <> -

    - Form Block -

    - - -
    - - - - - - ); -}; - -export default FormBlockEdit; diff --git a/packages/coresandbox/src/components/Blocks/FormBlock/View.tsx b/packages/coresandbox/src/components/Blocks/FormBlock/View.tsx deleted file mode 100644 index 3d44816ed09..00000000000 --- a/packages/coresandbox/src/components/Blocks/FormBlock/View.tsx +++ /dev/null @@ -1,9 +0,0 @@ -const FormBlockView = () => { - return ( -
    -
    Form Block View
    -
    - ); -}; - -export default FormBlockView; diff --git a/packages/coresandbox/src/components/Blocks/FormBlock/schema.ts b/packages/coresandbox/src/components/Blocks/FormBlock/schema.ts deleted file mode 100644 index 3903ac86c27..00000000000 --- a/packages/coresandbox/src/components/Blocks/FormBlock/schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { BlockConfigBase } from '@plone/types'; - -export const formBlockSchema: BlockConfigBase['blockSchema'] = ({ intl }) => ({ - title: 'form Block', - fieldsets: [ - { - id: 'default', - title: 'Default', - fields: ['title'], - }, - ], - properties: { - title: { - widget: 'textLine', - title: 'Title', - }, - }, - required: [], -}); diff --git a/packages/coresandbox/src/components/Blocks/InputBlock/Data.tsx b/packages/coresandbox/src/components/Blocks/InputBlock/Data.tsx deleted file mode 100644 index be3693be483..00000000000 --- a/packages/coresandbox/src/components/Blocks/InputBlock/Data.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useIntl } from 'react-intl'; -import { BlockDataForm } from '@plone/volto/components/manage/Form'; -import type { BlockEditProps } from '@plone/types'; - -const InputBlockData = (props: BlockEditProps) => { - const { block, blocksConfig, contentType, data, navRoot, onChangeBlock } = - props; - const intl = useIntl(); - const schema = blocksConfig[data['@type']].blockSchema({ intl, props }); - - return ( - { - onChangeBlock(block, { - ...data, - [id]: value, - }); - }} - onChangeBlock={onChangeBlock} - formData={data} - blocksConfig={blocksConfig} - navRoot={navRoot} - contentType={contentType} - /> - ); -}; - -export default InputBlockData; diff --git a/packages/coresandbox/src/components/Blocks/InputBlock/Edit.tsx b/packages/coresandbox/src/components/Blocks/InputBlock/Edit.tsx deleted file mode 100644 index 01bc774bd49..00000000000 --- a/packages/coresandbox/src/components/Blocks/InputBlock/Edit.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useEffect } from 'react'; -import SidebarPortal from '@plone/volto/components/manage/Sidebar/SidebarPortal'; -import Data from './Data'; -import type { BlockEditProps } from '@plone/types'; -import { Input, Button } from 'semantic-ui-react'; -import Icon from '@plone/volto/components/theme/Icon/Icon'; -import aheadSVG from '@plone/volto/icons/ahead.svg'; - -const InputBlockEdit = (props: BlockEditProps) => { - const { selected, block, data, onChangeBlock } = props; - const [url, setUrl] = React.useState(data?.url); - - useEffect(() => { - setUrl(data?.url); - }, [data?.url]); - - return ( - <> -
    Input Block Edit
    - - setUrl(e.target.value)} - placeholder={ - 'Change url to check if the widgets from sidebar are getting updated' - } - value={url} - id="input_block" - /> - - - - - - ); -}; - -export default InputBlockEdit; diff --git a/packages/coresandbox/src/components/Blocks/InputBlock/View.tsx b/packages/coresandbox/src/components/Blocks/InputBlock/View.tsx deleted file mode 100644 index d3605c5ab0f..00000000000 --- a/packages/coresandbox/src/components/Blocks/InputBlock/View.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { BlockViewProps } from '@plone/types'; - -const InputBlockView = (props: BlockViewProps) => { - return ( -
    -
    Input Block
    -

    {JSON.stringify(props.data)}

    -
    - ); -}; - -export default InputBlockView; diff --git a/packages/coresandbox/src/components/Blocks/InputBlock/schema.ts b/packages/coresandbox/src/components/Blocks/InputBlock/schema.ts deleted file mode 100644 index 0451ac4f70a..00000000000 --- a/packages/coresandbox/src/components/Blocks/InputBlock/schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { BlockConfigBase } from '@plone/types'; - -export const inputBlockSchema: BlockConfigBase['blockSchema'] = ({ intl }) => ({ - title: 'Input Block', - fieldsets: [ - { - id: 'default', - title: 'Default', - fields: ['url'], - }, - ], - properties: { - url: { - widget: 'internal_url', - title: 'url', - }, - }, - required: [], -}); diff --git a/packages/coresandbox/src/components/Blocks/Listing/ListingBlockVariationTeaserContent.tsx b/packages/coresandbox/src/components/Blocks/Listing/ListingBlockVariationTeaserContent.tsx deleted file mode 100644 index 8a87bbf48d9..00000000000 --- a/packages/coresandbox/src/components/Blocks/Listing/ListingBlockVariationTeaserContent.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import RenderBlocks from '@plone/volto/components/theme/View/RenderBlocks'; -import { Content } from '@plone/types'; - -const ListingBlockVariationTeaserContent = ({ - items, -}: { - items: Content[]; -}) => { - return ( -
    -

    listing block variation "ListingBlockVariationTeaserContent"

    - {items.map((item, index) => ( -
    - -
    - ))} -
    - ); -}; - -export default ListingBlockVariationTeaserContent; diff --git a/packages/coresandbox/src/components/Blocks/TestBlock/Data.tsx b/packages/coresandbox/src/components/Blocks/TestBlock/Data.tsx deleted file mode 100644 index 04854298768..00000000000 --- a/packages/coresandbox/src/components/Blocks/TestBlock/Data.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useIntl } from 'react-intl'; -import { BlockDataForm } from '@plone/volto/components/manage/Form'; -import type { BlockEditProps } from '@plone/types'; - -const TestBlockData = (props: BlockEditProps) => { - const { - block, - blocksConfig, - contentType, - data, - navRoot, - onChangeBlock, - blocksErrors, - } = props; - const intl = useIntl(); - const schema = blocksConfig[data['@type']].blockSchema({ intl, props }); - - return ( - { - onChangeBlock(block, { - ...data, - [id]: value, - }); - }} - onChangeBlock={onChangeBlock} - formData={data} - blocksConfig={blocksConfig} - navRoot={navRoot} - contentType={contentType} - errors={blocksErrors} - /> - ); -}; - -export default TestBlockData; diff --git a/packages/coresandbox/src/components/Blocks/TestBlock/Edit.tsx b/packages/coresandbox/src/components/Blocks/TestBlock/Edit.tsx deleted file mode 100644 index 0d075c9c5da..00000000000 --- a/packages/coresandbox/src/components/Blocks/TestBlock/Edit.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import SidebarPortal from '@plone/volto/components/manage/Sidebar/SidebarPortal'; -import Data from './Data'; -import type { BlockEditProps } from '@plone/types'; - -const TestBlockEdit = (props: BlockEditProps) => { - const { selected } = props; - - return ( - <> -
    Test Block Edit
    - - - - - ); -}; - -export default TestBlockEdit; diff --git a/packages/coresandbox/src/components/Blocks/TestBlock/View.tsx b/packages/coresandbox/src/components/Blocks/TestBlock/View.tsx deleted file mode 100644 index 76ad81def24..00000000000 --- a/packages/coresandbox/src/components/Blocks/TestBlock/View.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { BlockViewProps } from '@plone/types'; - -const TestBlockView = (props: BlockViewProps) => { - return ( -
    -
    Test Block
    -

    {JSON.stringify(props.data)}

    -
    - ); -}; - -export default TestBlockView; diff --git a/packages/coresandbox/src/components/Blocks/TestBlock/schema.ts b/packages/coresandbox/src/components/Blocks/TestBlock/schema.ts deleted file mode 100644 index 756f93966d7..00000000000 --- a/packages/coresandbox/src/components/Blocks/TestBlock/schema.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { defineMessages } from 'react-intl'; -import type { BlockConfigBase } from '@plone/types'; - -const messages = defineMessages({ - Source: { - id: 'Source', - defaultMessage: 'Source', - }, - Slider: { - id: 'Slider', - defaultMessage: 'Slider', - }, - title: { - id: 'Title', - defaultMessage: 'Title', - }, - description: { - id: 'Description', - defaultMessage: 'Description', - }, - imageOverride: { - id: 'Image override', - defaultMessage: 'Image override', - }, - item: { - id: 'Item', - defaultMessage: 'Item', - }, - items: { - id: 'Items', - defaultMessage: 'Items', - }, - addItem: { - id: 'Add item', - defaultMessage: 'Add item', - }, -}); - -const itemSchema: BlockConfigBase['blockSchema'] = ({ intl }) => { - return { - title: intl.formatMessage(messages.item), - addMessage: intl.formatMessage(messages.addItem), - fieldsets: [ - { - id: 'default', - title: 'Default', - fields: [ - 'href', - 'title', - 'description', - 'preview_image', - 'extraDefault', - ], - }, - ], - - properties: { - href: { - title: intl.formatMessage(messages.Source), - widget: 'object_browser', - mode: 'link', - selectedItemAttrs: [ - 'Title', - 'Description', - 'hasPreviewImage', - 'headtitle', - ], - allowExternals: true, - }, - title: { - title: intl.formatMessage(messages.title), - }, - description: { - title: intl.formatMessage(messages.description), - }, - preview_image: { - title: intl.formatMessage(messages.imageOverride), - widget: 'object_browser', - mode: 'image', - allowExternals: true, - }, - extraDefault: { - title: 'Extra', - default: 'Extra default', - }, - }, - required: [], - }; -}; - -export const SliderSchema: BlockConfigBase['blockSchema'] = ({ intl }) => ({ - title: intl.formatMessage(messages.Slider), - fieldsets: [ - { - id: 'default', - title: 'Default', - fields: [ - 'html', - 'slides', - 'fieldAfterObjectList', - 'href', - 'firstWithDefault', - 'style', - ], - }, - ], - properties: { - slides: { - widget: 'object_list', - title: intl.formatMessage(messages.items), - schema: itemSchema, - }, - fieldAfterObjectList: { - title: 'Field after OL', - }, - href: { - title: intl.formatMessage(messages.Source), - widget: 'object_browser', - mode: 'link', - selectedItemAttrs: [ - 'Title', - 'Description', - 'hasPreviewImage', - 'headtitle', - ], - allowExternals: true, - }, - firstWithDefault: { - title: 'Field with default', - default: 'Some default value', - }, - style: { - widget: 'object', - schema: { - title: 'Style', - fieldsets: [ - { - id: 'default', - fields: ['color'], - title: 'Default', - }, - ], - properties: { - color: { - title: 'Color', - default: 'red', - }, - }, - required: [], - }, - }, - html: { - title: 'HTML', - widget: 'richtext', - }, - }, - required: [], -}); - -export const multipleFieldsetsSchema: BlockConfigBase['blockSchema'] = ({ - intl, -}) => ({ - title: intl.formatMessage(messages.Slider), - fieldsets: [ - { - id: 'default', - title: 'Default', - fields: ['html'], - }, - { - id: 'second', - title: 'second', - fields: ['slides'], - }, - { - id: 'third', - title: 'third', - fields: ['fieldAfterObjectList'], - }, - { - id: 'fourth', - title: 'fourth', - fields: ['href', 'firstWithDefault', 'style'], - }, - { - id: 'fifth', - title: 'fifth', - fields: ['fieldRequiredInFieldset'], - }, - ], - properties: { - slides: { - widget: 'object_list', - title: intl.formatMessage(messages.items), - schema: itemSchema, - }, - fieldAfterObjectList: { - title: 'Field after OL', - }, - href: { - title: intl.formatMessage(messages.Source), - widget: 'object_browser', - mode: 'link', - selectedItemAttrs: [ - 'Title', - 'Description', - 'hasPreviewImage', - 'headtitle', - ], - allowExternals: true, - }, - firstWithDefault: { - title: 'Field with default', - default: 'Some default value', - }, - style: { - widget: 'object', - schema: { - title: 'Style', - fieldsets: [ - { - id: 'default', - fields: ['color'], - title: 'Default', - }, - ], - properties: { - color: { - title: 'Color', - default: 'red', - }, - }, - required: [], - }, - }, - html: { - title: 'HTML', - widget: 'richtext', - }, - fieldRequiredInFieldset: { - title: 'Field required in fieldset', - }, - }, - required: ['fieldRequiredInFieldset'], -}); diff --git a/packages/coresandbox/src/components/Blocks/schemaEnhancers.ts b/packages/coresandbox/src/components/Blocks/schemaEnhancers.ts deleted file mode 100644 index d4b47955e9d..00000000000 --- a/packages/coresandbox/src/components/Blocks/schemaEnhancers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { defineMessages } from 'react-intl'; -import { addExtensionFieldToSchema } from '@plone/volto/helpers/Extensions'; -import type { BlockConfigBase } from '@plone/types'; - -const messages = defineMessages({ - variation: { - id: 'Variation', - defaultMessage: 'Variation', - }, -}); - -export const conditionalVariationsSchemaEnhancer: BlockConfigBase['schemaEnhancer'] = - ({ schema, formData, intl, navRoot, contentType }) => { - if (contentType === 'Event') { - // We redefine the variations in the case that it's an Event content type - const variations = [ - { - id: 'default', - title: 'Default', - isDefault: true, - }, - { - id: 'custom', - title: 'Custom modified variation', - }, - ]; - - schema = addExtensionFieldToSchema({ - schema, - name: 'variation', - items: variations, - intl, - title: messages.variation, - }); - } - return schema; - }; diff --git a/packages/coresandbox/src/components/Slots/SlotTest.tsx b/packages/coresandbox/src/components/Slots/SlotTest.tsx deleted file mode 100644 index 66f095c5525..00000000000 --- a/packages/coresandbox/src/components/Slots/SlotTest.tsx +++ /dev/null @@ -1,10 +0,0 @@ -const SlotComponentTest = () => { - return ( -
    -

    This is a test slot component

    -

    It should appear above the Content

    -
    - ); -}; - -export default SlotComponentTest; diff --git a/packages/coresandbox/src/components/TestForm.jsx b/packages/coresandbox/src/components/TestForm.jsx deleted file mode 100644 index 1a916a22d88..00000000000 --- a/packages/coresandbox/src/components/TestForm.jsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from 'react'; -import { Helmet } from '@plone/volto/helpers/Helmet/Helmet'; - -import { defineMessages, useIntl } from 'react-intl'; -import { Container } from 'semantic-ui-react'; -import { Form } from '@plone/volto/components/manage/Form'; - -const messages = defineMessages({ - testForm: { - id: 'Test Form', - defaultMessage: 'Test Form', - }, - default: { - id: 'Default', - defaultMessage: 'Default', - }, - textlineTitle: { - id: 'Title', - defaultMessage: 'Title', - }, - emailTitle: { - id: 'Email', - defaultMessage: 'Email', - }, - - urlTitle: { - id: 'URL', - defaultMessage: 'Enter URL', - }, - - datetimeTitle: { - id: 'Date/Time', - defaultMessage: 'Enter Date/Time', - }, - - idTitle: { - id: 'Id', - defaultMessage: 'Enter ID', - }, - - linkTitle: { - id: 'Link', - defaultMessage: 'Link to Document/Event/News', - }, - - linkDescription: { - id: 'Enter Link', - defaultMessage: 'Enter Link', - }, - - richTextTitle: { - id: 'RichText', - defaultMessage: 'Enter RichText', - }, - - PasswordTitle: { - id: 'password', - defaultMessage: 'Password', - }, -}); - -const TestForm = (props) => { - const intl = useIntl(); - /** - * Cancel handler - * @method onCancel - * @returns {undefined} - */ - const onCancel = () => { - props.history.goBack(); - }; - - return ( - - - - - ); -}; - -export default TestForm; diff --git a/packages/coresandbox/src/components/Views/NewsAndEvents.tsx b/packages/coresandbox/src/components/Views/NewsAndEvents.tsx deleted file mode 100644 index acdba48de22..00000000000 --- a/packages/coresandbox/src/components/Views/NewsAndEvents.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useEffect } from 'react'; -import { searchContent } from '@plone/volto/actions/search/search'; -import { useDispatch, useSelector } from 'react-redux'; -import { Container } from 'semantic-ui-react'; - -type RootState = { - search: { - subrequests: { - newsandevents: { - items: { - '@id': string; - '@type': string; - title: string; - subject: string[]; - }[]; - }; - }; - }; -}; - -const NewsAndEvents = () => { - const newsandevents = useSelector( - (state: RootState) => state.search.subrequests.newsandevents?.items, - ); - const dispatch = useDispatch(); - - useEffect(() => { - dispatch( - searchContent( - '/', - { - portal_type: ['News Item', 'Event'], - metadata_fields: ['subject', 'modified', 'someotherinfo'], - - // That's OK: - // portal_type: ['News Item'], - // metadata_fields: ['subject'], - }, - 'newsandevents', - ), - ); - }, [dispatch]); - - return ( - -

    News And Events

    - {newsandevents && - newsandevents.map((item) => ( -
    -
    {item.title}
    -
    {item['@type']}
    -
    {item.subject}
    -
    - ))} -
    - ); -}; - -export default NewsAndEvents; diff --git a/packages/coresandbox/src/index.ts b/packages/coresandbox/src/index.ts deleted file mode 100644 index 6bb16df1e88..00000000000 --- a/packages/coresandbox/src/index.ts +++ /dev/null @@ -1,245 +0,0 @@ -import ListingBlockVariationTeaserContent from './components/Blocks/Listing/ListingBlockVariationTeaserContent'; -import NewsAndEvents from './components/Views/NewsAndEvents'; -import TestBlockView from './components/Blocks/TestBlock/View'; -import TestBlockEdit from './components/Blocks/TestBlock/Edit'; -import InputBlockView from './components/Blocks/InputBlock/View'; -import InputBlockEdit from './components/Blocks/InputBlock/Edit'; -import { flattenToAppURL } from '@plone/volto/helpers/Url/Url'; -import { SliderSchema as TestBlockSchema } from './components/Blocks/TestBlock/schema'; -import { inputBlockSchema } from './components/Blocks/InputBlock/schema'; -import { multipleFieldsetsSchema } from './components/Blocks/TestBlock/schema'; -import { conditionalVariationsSchemaEnhancer } from './components/Blocks/schemaEnhancers'; -import codeSVG from '@plone/volto/icons/code.svg'; -import type { BlockConfigBase } from '@plone/types'; -import type { ConfigType } from '@plone/registry'; -import SlotComponentTest from './components/Slots/SlotTest'; -import { ContentTypeCondition } from '@plone/volto/helpers/Slots'; -import { RouteCondition } from '@plone/volto/helpers/Slots'; -import TestForm from './components/TestForm'; -import FormBlockView from './components/Blocks/FormBlock/View'; -import FormBlockEdit from './components/Blocks/FormBlock/Edit'; -import { formBlockSchema } from './components/Blocks/FormBlock/schema'; -import Login from '@plone/volto/components/theme/Login/Login'; - -const testBlock: BlockConfigBase = { - id: 'testBlock', - title: 'testBlock', - icon: codeSVG, - group: 'common', - view: TestBlockView, - edit: TestBlockEdit, - blockSchema: TestBlockSchema, - restricted: false, - mostUsed: true, - sidebarTab: 1, - variations: [ - { - id: 'default', - title: 'Default', - isDefault: true, - }, - { - id: 'custom', - title: 'Custom', - }, - ], - extensions: {}, -}; -const inputBlock: BlockConfigBase = { - id: 'inputBlock', - title: 'Input Block', - icon: codeSVG, - group: 'common', - view: InputBlockView, - edit: InputBlockEdit, - blockSchema: inputBlockSchema, - restricted: false, - mostUsed: true, - sidebarTab: 1, - - extensions: {}, -}; - -const testBlockConditional: BlockConfigBase = { - ...testBlock, - id: 'testBlockConditional', - title: 'Test Conditional Block', - restricted: ({ properties, navRoot, contentType }) => { - if (contentType === 'News Item') { - return false; - } else if (flattenToAppURL(properties?.parent?.['@id']) === '/folder') { - return false; - } - return true; - }, -}; - -const testBlockWithConditionalVariations: BlockConfigBase = { - ...testBlock, - id: 'testBlockWithConditionalVariations', - title: 'Test Block with Conditional Variations', - schemaEnhancer: conditionalVariationsSchemaEnhancer, -}; - -const testBlockMultipleFieldsets: BlockConfigBase = { - id: 'testBlockMultipleFieldsets', - title: 'testBlockMultipleFieldsets', - icon: codeSVG, - group: 'common', - view: TestBlockView, - edit: TestBlockEdit, - blockSchema: multipleFieldsetsSchema, - restricted: false, - mostUsed: true, - sidebarTab: 1, -}; - -const testBlockDefaultEdit: BlockConfigBase = { - id: 'testBlockDefaultEdit', - title: 'testBlockDefaultEdit', - icon: codeSVG, - group: 'common', - view: TestBlockView, - blockSchema: TestBlockSchema, - restricted: false, - mostUsed: true, - sidebarTab: 1, - variations: [ - { - id: 'default', - title: 'Default', - }, - { - id: 'custom', - title: 'Custom', - }, - ], - extensions: {}, -}; - -const testBlockDefaultView: BlockConfigBase = { - id: 'testBlockDefaultView', - title: 'testBlockDefaultView', - icon: codeSVG, - group: 'common', - blockSchema: TestBlockSchema, - restricted: false, - mostUsed: true, - sidebarTab: 1, - variations: [ - { - id: 'default', - title: 'Default', - }, - { - id: 'custom', - title: 'Custom', - }, - ], - extensions: {}, -}; -const testformBlock: BlockConfigBase = { - id: 'testformBlock', - title: 'Form Block', - icon: codeSVG, - group: 'common', - view: FormBlockView, - edit: FormBlockEdit, - blockSchema: formBlockSchema, - restricted: false, - mostUsed: true, - sidebarTab: 1, - - extensions: {}, -}; - -const listing = (config: ConfigType) => { - return { - ...config.blocks.blocksConfig.listing, - variations: [ - ...(config.blocks.blocksConfig.listing.variations || []), - { - id: 'listingBlockVariationWithFullobjectsAndData', - title: 'Listing with items content', - template: ListingBlockVariationTeaserContent, - fullobjects: true, - }, - { - id: 'listingBlockVariationWithFullobjectsButNoData', - title: 'Listing without items content', - template: ListingBlockVariationTeaserContent, - }, - ], - }; -}; - -export const multilingualFixture = (config: ConfigType) => { - config.settings.isMultilingual = true; - config.settings.supportedLanguages = ['en', 'it']; - - return config; -}; - -// We extend the block types with the custom ones -declare module '@plone/types' { - export interface BlocksConfigData { - testBlock: BlockConfigBase; - inputBlock: BlockConfigBase; - testBlockConditional: BlockConfigBase; - testBlockWithConditionalVariations: BlockConfigBase; - testBlockMultipleFieldsets: BlockConfigBase; - testBlockDefaultEdit: BlockConfigBase; - testBlockDefaultView: BlockConfigBase; - testformBlock: BlockConfigBase; - } -} - -const applyConfig = (config: ConfigType) => { - config.addonRoutes = [ - ...config.addonRoutes, - { - path: '/form', - component: TestForm, - exact: false, - }, - ]; - - config.addonRoutes.push({ - path: '/fallback_login', - component: Login, - exact: false, - }); - config.blocks.blocksConfig.testBlock = testBlock; - config.blocks.blocksConfig.inputBlock = inputBlock; - config.blocks.blocksConfig.testBlockConditional = testBlockConditional; - config.blocks.blocksConfig.testBlockWithConditionalVariations = - testBlockWithConditionalVariations; - config.blocks.blocksConfig.testBlockMultipleFieldsets = - testBlockMultipleFieldsets; - config.blocks.blocksConfig.testBlockDefaultEdit = testBlockDefaultEdit; - config.blocks.blocksConfig.testBlockDefaultView = testBlockDefaultView; - config.blocks.blocksConfig.testformBlock = testformBlock; - config.blocks.blocksConfig.listing = listing(config); - config.views.contentTypesViews.Folder = NewsAndEvents; - - config.registerSlotComponent({ - slot: 'aboveContent', - name: 'testSlotComponent', - component: SlotComponentTest, - predicates: [ContentTypeCondition(['Document']), RouteCondition('/hello')], - }); - - config.registerRoute({ - type: 'route', - path: '/hello', - file: 'src/components/Views/NewsAndEvents/asd.tsx', - options: { - id: 'hello', - index: true, - }, - }); - - return config; -}; - -export default applyConfig; diff --git a/packages/coresandbox/tsconfig.json b/packages/coresandbox/tsconfig.json deleted file mode 100644 index 81ac9f333d4..00000000000 --- a/packages/coresandbox/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "module": "commonjs", - "allowJs": true, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "Node", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "paths": { - "@plone/volto/*": ["../volto/src/*"] - }, - "baseUrl": "." - }, - "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jx"], - "exclude": [ - "node_modules", - "src/**/*.test.{js,jsx,ts,tsx}", - "src/**/*.spec.{js,jsx,ts,tsx}", - "src/**/*.stories.{js,jsx,ts,tsx}" - ] -} diff --git a/packages/helpers/AGENTS.md b/packages/helpers/AGENTS.md new file mode 100644 index 00000000000..01d82ff18c4 --- /dev/null +++ b/packages/helpers/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md + +This file applies only to `packages/helpers` and its subdirectories. + +## What This Package Is + +- `@plone/helpers` provides **generic, reusable utility functions** for Plone frontend development. +- It is a **framework-agnostic** library — no React, no UI dependencies. +- It covers URL normalization (`flattenToAppURL`, `isInternalURL`), block helpers, content helpers, and shared primitives. + +> [!WARNING] +> This package is experimental. Breaking changes may occur without notice. + +## Package Model + +- Each concern has its own file: `atoms.ts`, `blocks.ts`, `contents.ts`, `flattenToAppURL.ts`, `isInternalURL.ts`, `languageMap.ts`, `primitives.ts`. +- Everything is re-exported from `src/index.ts`. +- The package is compiled with **tsup**. + +## Editing Rules + +- **Keep it framework-agnostic.** Do not import React, hooks, or any UI framework. +- Keep functions pure and side-effect-free wherever possible. +- Do not add helpers that are specific to a single consuming package — helpers here must be genuinely reusable. +- Add unit tests for every non-trivial function. Colocate test files alongside their source file (`*.test.ts`). + +## Validation + +```sh +pnpm --filter @plone/helpers test --run +pnpm --filter @plone/helpers build +``` diff --git a/packages/helpers/CHANGELOG.md b/packages/helpers/CHANGELOG.md index ce44b25bd81..71850eac0e2 100644 --- a/packages/helpers/CHANGELOG.md +++ b/packages/helpers/CHANGELOG.md @@ -8,6 +8,32 @@ +## 2.0.0-alpha.6 (2026-05-13) + +### Bugfix + +- Make the import for the styleFields helper resilient and Volto-compatible. @sneridagh + +## 2.0.0-alpha.5 (2026-05-08) + +### Feature + +- Added shared helpers to derive schema-driven style fields, resolve their style definitions, and read or write nested style field values. @sneridagh + +## 2.0.0-alpha.4 (2026-05-07) + +### Internal + +- Added AGENTS.md file. @pnicolli +- Aligned Helpers' local formatting and typecheck scripts with the monorepo-wide package script cleanup. + +## 2.0.0-alpha.3 (2026-04-16) + +### Feature + +- Added `isDeepEqual`. @sneridagh [#7921](https://github.com/plone/volto/issues/7921) +- Add isInternalURL helper. @tedw87 [#8004](https://github.com/plone/volto/issues/8004) + ## 2.0.0-alpha.2 (2025-12-23) ### Feature diff --git a/packages/helpers/Makefile b/packages/helpers/Makefile new file mode 100644 index 00000000000..6dffbcd8592 --- /dev/null +++ b/packages/helpers/Makefile @@ -0,0 +1,25 @@ +# Project settings +include ../../variables.mk + +.PHONY: all +all: help + +.PHONY: help +help: ## This help message + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/$(CYAN)\1$(RESET):\2/' | column -c2 -t -s :)" + +.PHONY: install +install: ## Install dependencies + pnpm install + +.PHONY: build +build: ## Build the package + pnpm run --if-present build + +# .PHONY: storybook-start +# storybook-start: ## Start Storybook +# pnpm run storybook + +# .PHONY: storybook-build +# storybook-build: ## Build Storybook +# pnpm run build-storybook diff --git a/packages/helpers/news/+content-icons-helper.feature b/packages/helpers/news/+content-icons-helper.feature new file mode 100644 index 00000000000..8fff36ad15a --- /dev/null +++ b/packages/helpers/news/+content-icons-helper.feature @@ -0,0 +1 @@ +Added a reusable `getContentIcon` helper for resolving configured content type icons. @pnicolli @giuliaghisini diff --git a/packages/helpers/news/+unify-makefiles.internal b/packages/helpers/news/+unify-makefiles.internal new file mode 100644 index 00000000000..5da674df4e4 --- /dev/null +++ b/packages/helpers/news/+unify-makefiles.internal @@ -0,0 +1 @@ +Unify Makefile files across the packages. @ionlizarazu diff --git a/packages/helpers/news/7921.feature b/packages/helpers/news/7921.feature deleted file mode 100644 index 45268b8959d..00000000000 --- a/packages/helpers/news/7921.feature +++ /dev/null @@ -1 +0,0 @@ -Added `isDeepEqual`. @sneridagh diff --git a/packages/helpers/package.json b/packages/helpers/package.json index e63712f3a6e..5dd982dc195 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -9,7 +9,7 @@ ], "funding": "https://github.com/sponsors/plone", "license": "MIT", - "version": "2.0.0-alpha.2", + "version": "2.0.0-alpha.6", "repository": { "type": "git", "url": "https://github.com/plone/volto.git" @@ -48,7 +48,11 @@ "dry-release": "release-it --dry-run", "release": "release-it", "release-major-alpha": "release-it major --preRelease=alpha", - "release-alpha": "release-it --preRelease=alpha" + "release-alpha": "release-it --preRelease=alpha", + "prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", + "lint:fix": "eslint --max-warnings=0 './**/*.{js,jsx,ts,tsx}' --fix --no-error-on-unmatched-pattern", + "stylelint:fix": "sh -c 'if [ -f .stylelintrc ] || [ -f .stylelintrc.json ] || [ -f .stylelintrc.js ] || [ -f .stylelintrc.cjs ] || [ -f stylelint.config.js ] || [ -f stylelint.config.cjs ] || [ -f stylelint.config.mjs ]; then stylelint '''./**/*.{css,scss,less}''' --fix --allow-empty-input; else echo \"No local stylelint config, skipping\"; fi'", + "format": "pnpm prettier:fix && pnpm lint:fix && pnpm stylelint:fix" }, "peerDependencies": { "@plone/registry": "workspace:*", @@ -61,6 +65,7 @@ } }, "dependencies": { + "@plone/registry": "workspace:*", "jotai": "^2.12.3", "jotai-optics": "^0.4.0", "optics-ts": "^2.4.1" @@ -68,7 +73,6 @@ "devDependencies": { "@tanstack/react-form": "^1.3.3", "@plone/types": "workspace:*", - "@plone/registry": "workspace:*", "@types/react": "catalog:", "@types/react-dom": "catalog:", "release-it": "catalog:", diff --git a/packages/helpers/src/contents.ts b/packages/helpers/src/contents.ts new file mode 100644 index 00000000000..c25881a492f --- /dev/null +++ b/packages/helpers/src/contents.ts @@ -0,0 +1,13 @@ +import config from '@plone/registry'; + +export const getContentIcon = ( + contentType: string, + isFolderish: boolean = false, +) => { + const { settings } = config; + const { contentIcons = {} } = settings; + + let icon = isFolderish ? contentIcons.Folder : contentIcons.File; + if (contentType in contentIcons) icon = contentIcons[contentType]; + return icon; +}; diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 8aa5f5ec409..11cacefe284 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -1,5 +1,8 @@ export * from './primitives'; export * from './atoms'; export * from './blocks'; +export * from './contents'; export * from './flattenToAppURL'; +export * from './isInternalURL'; export * from './languageMap'; +export * from './styleFields'; diff --git a/packages/helpers/src/isInternalURL.test.ts b/packages/helpers/src/isInternalURL.test.ts new file mode 100644 index 00000000000..57272b33215 --- /dev/null +++ b/packages/helpers/src/isInternalURL.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest'; +import { isInternalURL } from './isInternalURL'; + +describe('isInternalURL', () => { + test('returns false for empty/undefined input', () => { + expect(isInternalURL()).toBe(false); + expect(isInternalURL('')).toBe(false); + }); + + test('returns true for root-relative URLs', () => { + expect(isInternalURL('/page')).toBe(true); + expect(isInternalURL('/some/path')).toBe(true); + }); + + test('returns true for relative URLs starting with dot', () => { + expect(isInternalURL('./page')).toBe(true); + expect(isInternalURL('../page')).toBe(true); + }); + + test('returns true for anchor URLs', () => { + expect(isInternalURL('#section')).toBe(true); + }); + + test('returns false for external URLs', () => { + expect(isInternalURL('https://external.com')).toBe(false); + expect(isInternalURL('https://google.com/search')).toBe(false); + }); +}); diff --git a/packages/helpers/src/isInternalURL.ts b/packages/helpers/src/isInternalURL.ts new file mode 100644 index 00000000000..d3aa07e3d6c --- /dev/null +++ b/packages/helpers/src/isInternalURL.ts @@ -0,0 +1,28 @@ +import config from '@plone/registry'; + +/** + * Lightweight check to detect if a URL points to the current Plone instance. + * For absolute URLs, compares origins to avoid prefix-matching attacks + */ +export function isInternalURL(url?: string): boolean { + if (!url) return false; + + if (url.startsWith('/') || url.startsWith('.') || url.startsWith('#')) { + return true; + } + + const settings = config.settings ?? ({} as Record); + const apiPath = settings.apiPath as string | undefined; + + if (apiPath) { + try { + const urlOrigin = new URL(url).origin; + const apiOrigin = new URL(apiPath).origin; + return urlOrigin === apiOrigin; + } catch { + return false; + } + } + + return false; +} diff --git a/packages/helpers/src/styleFields.test.ts b/packages/helpers/src/styleFields.test.ts new file mode 100644 index 00000000000..b78d81dc13b --- /dev/null +++ b/packages/helpers/src/styleFields.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + applyStyleFieldDefaultsInData, + getStyleFieldsFromBlockSchema, + getStyleFieldsFromSchema, + resolveStyleFields, +} from './styleFields'; + +const resolveDefinitions = vi.fn((fieldName: string) => { + if (fieldName === 'blockWidth') { + return [ + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + { + name: 'full', + label: 'Full', + style: { '--block-width': '100%' }, + }, + ]; + } + + if (fieldName === 'theme') { + return [ + { + name: 'default', + label: 'Default', + style: { '--theme-color': 'white' }, + }, + { + name: 'sand', + label: 'Sand', + style: { '--theme-color': 'wheat' }, + }, + ]; + } + + return []; +}); + +describe('style fields helpers', () => { + it('resolves style objects from root-level fields', () => { + expect( + resolveStyleFields({ + data: { + type: 'unknown', + '@type': 'image', + blockWidth: 'full', + }, + fieldConfigs: { + blockWidth: {}, + }, + resolveDefinitions, + }), + ).toEqual({ + style: { '--block-width': '100%' }, + values: { blockWidth: 'full' }, + }); + }); + + it('falls back to legacy styles storage when present', () => { + expect( + resolveStyleFields({ + data: { + '@type': 'teaser', + styles: { + theme: 'sand', + }, + }, + fieldConfigs: { + theme: { + path: 'styles.theme', + }, + }, + resolveDefinitions, + }), + ).toEqual({ + style: { '--theme-color': 'wheat' }, + values: { theme: 'sand' }, + }); + }); + + it('falls back to the root field when path is configured but nested data is missing', () => { + expect( + resolveStyleFields({ + data: { + '@type': 'teaser', + theme: 'sand', + styles: {}, + }, + fieldConfigs: { + theme: { + path: 'styles.theme', + }, + }, + resolveDefinitions, + }), + ).toEqual({ + style: { '--theme-color': 'wheat' }, + values: { theme: 'sand' }, + }); + }); + + it('applies configured defaults for missing values', () => { + const data = { + type: 'p', + children: [], + } as Record; + + applyStyleFieldDefaultsInData({ + data, + fieldConfigs: { + blockWidth: { + defaultValue: 'default', + values: ['default', 'full'], + }, + }, + resolveDefinitions, + }); + + expect(data).toEqual({ + type: 'p', + children: [], + blockWidth: 'default', + }); + }); + + it('writes defaults to a configured nested path', () => { + const data = { + '@type': 'teaser', + styles: {}, + } as Record; + + applyStyleFieldDefaultsInData({ + data, + fieldConfigs: { + theme: { + defaultValue: 'default', + path: 'styles.theme', + }, + }, + resolveDefinitions, + }); + + expect(data).toEqual({ + '@type': 'teaser', + styles: { + theme: 'default', + }, + }); + }); + + it('extracts style field metadata from schema properties', () => { + expect( + getStyleFieldsFromSchema({ + title: 'Test', + fieldsets: [], + required: [], + properties: { + theme: { + widget: 'theme', + default: 'default', + choices: [ + ['default', 'Default'], + ['sand', 'Sand'], + ], + styleField: true, + }, + variant: { + widget: 'theme', + actions: ['primary', 'secondary'], + styleField: { + path: 'styles.variant', + }, + }, + }, + }), + ).toEqual({ + theme: { + defaultValue: 'default', + values: ['default', 'sand'], + path: undefined, + }, + variant: { + defaultValue: undefined, + values: ['primary', 'secondary'], + path: 'styles.variant', + }, + }); + }); + + it('extracts style fields from blockSchema functions', () => { + expect( + getStyleFieldsFromBlockSchema( + { + blockSchema: ({ formData }: any) => ({ + title: 'Theme block', + fieldsets: [], + required: [], + properties: { + theme: { + default: formData?.themeDefault ?? 'default', + styleField: true, + }, + }, + }), + }, + { themeDefault: 'sand' } as any, + ), + ).toEqual({ + theme: { + defaultValue: 'sand', + values: undefined, + path: undefined, + }, + }); + }); +}); diff --git a/packages/helpers/src/styleFields.ts b/packages/helpers/src/styleFields.ts new file mode 100644 index 00000000000..96b2ea46498 --- /dev/null +++ b/packages/helpers/src/styleFields.ts @@ -0,0 +1,335 @@ +import registryModule from '@plone/registry'; +import type { + BlockConfigBase, + BlocksFormData, + JSONSchema, + StyleDefinition, +} from '@plone/types'; + +type DataRecord = Record; +type StyleFieldConfig = { + defaultValue?: string; + values?: readonly string[]; + path?: string; +}; +type StyleFieldsConfig = Record; + +const config: { + getUtility: (args: { name: string; type: string }) => { + method?: (...args: any[]) => any; + }; +} = (registryModule as any).getUtility + ? (registryModule as any) + : (registryModule as any).default; + +type RegistryUtilityArgs = { + data: DataRecord; + container?: DataRecord; + blockType?: string; + fieldName: string; +}; + +export type ResolveStyleDefinitions = ( + fieldName: string, + args: RegistryUtilityArgs, +) => readonly StyleDefinition[]; + +export type ResolveStyleFieldsArgs = { + data: DataRecord; + fieldConfigs?: StyleFieldsConfig; + container?: DataRecord; + resolveDefinitions: ResolveStyleDefinitions; +}; + +export type ResolvedStyleFields = { + style: Record<`--${string}`, string>; + values: Record; +}; + +const isRecord = (value: unknown): value is DataRecord => + !!value && typeof value === 'object' && !Array.isArray(value); + +const isStyleFieldMarker = ( + value: unknown, +): value is true | { path?: string } => value === true || isRecord(value); + +const splitPath = (path?: string) => + path?.split('.').filter((segment) => !!segment) ?? []; + +const getPathValue = (data: DataRecord, path?: string) => { + const segments = splitPath(path); + + if (!segments.length) return undefined; + + let current: unknown = data; + + for (const segment of segments) { + if (!isRecord(current)) return undefined; + current = current[segment]; + } + + return current; +}; + +const setPathValue = ( + data: DataRecord, + path: string | undefined, + value: unknown, +) => { + const segments = splitPath(path); + + if (!segments.length) return; + + let current: DataRecord = data; + + segments.forEach((segment, index) => { + if (index === segments.length - 1) { + current[segment] = value; + return; + } + + const next = current[segment]; + + if (!isRecord(next)) { + current[segment] = {}; + } + + current = current[segment] as DataRecord; + }); +}; + +const getBlockType = (data: DataRecord) => { + const plateType = data.type; + const ploneType = data['@type']; + + if (typeof plateType === 'string' && plateType !== 'unknown') + return plateType; + if (typeof ploneType === 'string') return ploneType; + + return undefined; +}; + +export const findStyleDefinitionByName = ( + definitions: readonly StyleDefinition[], + name?: string, +) => definitions.find((definition) => definition.name === name); + +export const getStyleFieldValue = ( + data: DataRecord, + fieldName: string, + fieldConfig?: StyleFieldConfig, +) => { + const configuredValue = + typeof fieldConfig?.path === 'string' + ? getPathValue(data, fieldConfig.path) + : undefined; + + if (typeof configuredValue === 'string') return configuredValue; + + const rootValue = data[fieldName]; + + if (typeof rootValue === 'string') return rootValue; + + const legacyStyles = data.styles; + + if (isRecord(legacyStyles) && typeof legacyStyles[fieldName] === 'string') { + return legacyStyles[fieldName] as string; + } + + return undefined; +}; + +export const setStyleFieldValue = ( + data: DataRecord, + fieldName: string, + value: string, + fieldConfig?: StyleFieldConfig, +) => { + if (typeof fieldConfig?.path === 'string') { + setPathValue(data, fieldConfig.path, value); + return; + } + + if (fieldName in data || !isRecord(data.styles)) { + data[fieldName] = value; + return; + } + + const styles = data.styles as DataRecord; + styles[fieldName] = value; +}; + +const getCandidateFields = (fieldConfigs?: StyleFieldsConfig) => + Object.keys(fieldConfigs ?? {}); + +const isAllowedValue = ( + definitions: readonly StyleDefinition[], + value: string, + fieldConfig?: StyleFieldConfig, +) => { + const allowedValues = fieldConfig?.values?.length + ? fieldConfig.values + : definitions + .map((definition) => definition.name) + .filter((name): name is string => !!name); + + return allowedValues.includes(value); +}; + +const getValuesFromSchemaProperty = (property: Record) => { + if (Array.isArray(property.choices)) { + return property.choices + .map((choice) => + Array.isArray(choice) && typeof choice[0] === 'string' + ? choice[0] + : undefined, + ) + .filter((choice): choice is string => !!choice); + } + + if (Array.isArray(property.actions)) { + return property.actions.filter( + (action): action is string => typeof action === 'string', + ); + } + + return undefined; +}; + +export const getStyleFieldsFromSchema = ( + schema?: JSONSchema, +): StyleFieldsConfig => { + if (!schema || !isRecord(schema.properties)) return {}; + + return Object.entries(schema.properties).reduce( + (acc, [fieldName, property]) => { + if (!isRecord(property) || !isStyleFieldMarker(property.styleField)) { + return acc; + } + + acc[fieldName] = { + defaultValue: + typeof property.default === 'string' ? property.default : undefined, + values: getValuesFromSchemaProperty(property), + path: + property.styleField === true + ? undefined + : typeof property.styleField.path === 'string' + ? property.styleField.path + : undefined, + }; + + return acc; + }, + {}, + ); +}; + +export const getStyleFieldsFromBlockSchema = ( + blockConfig: Pick | undefined, + formData?: BlocksFormData, +) => { + if (!blockConfig?.blockSchema) return {}; + + try { + const schema = + typeof blockConfig.blockSchema === 'function' + ? blockConfig.blockSchema({ formData, data: formData }) + : blockConfig.blockSchema; + + return getStyleFieldsFromSchema(schema); + } catch { + return {}; + } +}; + +export const resolveStyleFields = ({ + data, + fieldConfigs, + container, + resolveDefinitions, +}: ResolveStyleFieldsArgs): ResolvedStyleFields => { + const style: Record<`--${string}`, string> = {}; + const values: Record = {}; + const blockType = getBlockType(data); + + getCandidateFields(fieldConfigs).forEach((fieldName) => { + const definitions = resolveDefinitions(fieldName, { + data, + container, + blockType, + fieldName, + }); + + if (!definitions.length) return; + + const fieldConfig = fieldConfigs?.[fieldName]; + const rawValue = getStyleFieldValue(data, fieldName, fieldConfig); + const effectiveValue = + typeof rawValue === 'string' && + isAllowedValue(definitions, rawValue, fieldConfig) + ? rawValue + : fieldConfig?.defaultValue; + + if (!effectiveValue) return; + + const definition = findStyleDefinitionByName(definitions, effectiveValue); + + if (!definition?.style) return; + + values[fieldName] = effectiveValue; + Object.assign(style, definition.style); + }); + + return { style, values }; +}; + +export const applyStyleFieldDefaultsInData = ({ + data, + fieldConfigs, + container, + resolveDefinitions, +}: ResolveStyleFieldsArgs) => { + const blockType = getBlockType(data); + + getCandidateFields(fieldConfigs).forEach((fieldName) => { + const definitions = resolveDefinitions(fieldName, { + data, + container, + blockType, + fieldName, + }); + + if (!definitions.length) return; + + const fieldConfig = fieldConfigs?.[fieldName]; + const currentValue = getStyleFieldValue(data, fieldName, fieldConfig); + const defaultValue = fieldConfig?.defaultValue; + + if (!defaultValue) return; + if ( + typeof currentValue === 'string' && + isAllowedValue(definitions, currentValue, fieldConfig) + ) { + return; + } + + setStyleFieldValue(data, fieldName, defaultValue, fieldConfig); + }); + + return data; +}; + +export const getStyleFieldDefinitionsFromRegistry: ResolveStyleDefinitions = ( + fieldName, + args, +) => { + const utility = config.getUtility({ + type: 'styleFieldDefinition', + name: fieldName, + }) as { + method?: (args: RegistryUtilityArgs) => readonly StyleDefinition[]; + }; + + return utility.method?.(args) ?? []; +}; diff --git a/packages/layout/.storybook/main.ts b/packages/layout/.storybook/main.ts index 16571c29a88..aa9860a7ea6 100644 --- a/packages/layout/.storybook/main.ts +++ b/packages/layout/.storybook/main.ts @@ -26,6 +26,9 @@ const config: StorybookConfig = { }, async viteFinal(config) { return mergeConfig(config, { + resolve: { + tsconfigPaths: true, + }, build: { minify: false, }, diff --git a/packages/layout/.storybook/preview.tsx b/packages/layout/.storybook/preview.tsx index 0318589f23d..ddd38bd191c 100644 --- a/packages/layout/.storybook/preview.tsx +++ b/packages/layout/.storybook/preview.tsx @@ -64,9 +64,7 @@ const withRR7FrameworkRouter = (Story: any, context: any) => { export const decorators = [withRR7FrameworkRouter]; export const parameters = { - backgrounds: { - default: 'light', - }, + backgrounds: {}, actions: { argTypesRegex: '^on[A-Z].*' }, controls: { matchers: { @@ -75,3 +73,9 @@ export const parameters = { }, }, }; + +export const initialGlobals = { + backgrounds: { + value: 'light', + }, +}; diff --git a/packages/layout/AGENTS.md b/packages/layout/AGENTS.md new file mode 100644 index 00000000000..ef6ebe74c1f --- /dev/null +++ b/packages/layout/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +This file applies only to `packages/layout` and its subdirectories. + +## What This Package Is + +- `@plone/layout` provides **shared structural layout elements** for Seven. +- It is consumed by both `@plone/cmsui` (editor UI) and `@plone/publicui` (public-facing pages). +- It contains: page header, footer, breadcrumbs, block renderer (`RenderBlocks`, `SomersaultRenderer`, `BlockWrapper`, `DefaultBlockView`), toast notifications, and slot configuration. + +## Package Model + +- **Blocks rendering** (`blocks/`): `RenderBlocks` and `SomersaultRenderer` orchestrate how blocks are rendered on a page. `BlockWrapper` wraps individual blocks. +- **Components** (`components/`): structural UI elements (header, footer, breadcrumbs, toast). +- **Config** (`config/`): slot definitions (`slots.ts`), toast config (`toast.ts`), and layout settings (`settings.ts`). +- **Styles** (`styles/`): CSS for each layout section, organized by zone (`header.css`, `footer.css`, `content-area.css`, `publicui.css`). +- Storybook is configured under `.storybook/`. + +## Editing Rules + +- Do not add **app-specific or CMS-specific logic** here. Layout components must work equally in both `cmsui` and `publicui` contexts. +- Keep block renderer logic in `blocks/` — do not spread rendering concerns into component files. +- When adding a new layout zone or structural component, add a corresponding CSS file in `styles/`. +- Write Storybook stories for new visual components. +- Do not add data-fetching logic here; layout components receive data via props or slots. + +## Validation + +```sh +pnpm --filter @plone/layout test --run +pnpm --filter @plone/layout check:ts +``` + +For Storybook: + +```sh +pnpm --filter @plone/layout storybook +``` diff --git a/packages/layout/CHANGELOG.md b/packages/layout/CHANGELOG.md index cd4fb49f32a..99910410fd1 100644 --- a/packages/layout/CHANGELOG.md +++ b/packages/layout/CHANGELOG.md @@ -8,6 +8,45 @@ +## 1.0.0-alpha.8 (2026-05-13) + +### Feature + +- Added first-class generic style field support while preserving `blockWidth` fallback for Plone blocks and explicit width handling for Plate-native blocks. @sneridagh + +## 1.0.0-alpha.7 (2026-05-08) + +### Feature + +- Enabled public block rendering to resolve schema-driven style fields, including the special `blockWidth` bridge from block config. @sneridagh + +## 1.0.0-alpha.6 (2026-05-07) + +### Internal + +- Added AGENTS.md file. @pnicolli +- Aligned Layout's app-aware TypeScript project setup and internal view typings with the monorepo-wide typecheck cleanup. +- Switched Layout's local `@testing-library/jest-dom` dev dependency to the shared catalog entry to keep test tooling consistent with the monorepo dependency refresh. + +## 1.0.0-alpha.5 (2026-04-16) + +### Feature + +- Added the left toolbar @pnicolli [#6649](https://github.com/plone/volto/issues/6649) +- Somersault editor support (renderers). @sneridagh [#7921](https://github.com/plone/volto/issues/7921) +- Fixed CSS definition. @sneridagh [#8106](https://github.com/plone/volto/issues/8106) +- Moved basic data fetching to a middleware to allow all loaders and actions to use it @pnicolli + +### Bugfix + +- Remove edit button from the preliminary tools. @sneridagh [#8018](https://github.com/plone/volto/issues/8018) +- Hardened `shouldShowToolbar` helper. @sneridagh [#8030](https://github.com/plone/volto/issues/8030) +- Fixed SOMERSAULT_KEY constant, it is centralized now. @sneridagh [#8078](https://github.com/plone/volto/issues/8078) + +### Internal + +- Updated packages configuration for vite 8. @pnicolli + ## 1.0.0-alpha.4 (2025-12-23) ### Breaking diff --git a/packages/layout/Makefile b/packages/layout/Makefile new file mode 100644 index 00000000000..d141aebdf63 --- /dev/null +++ b/packages/layout/Makefile @@ -0,0 +1,25 @@ +# Project settings +include ../../variables.mk + +.PHONY: all +all: help + +.PHONY: help +help: ## This help message + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/$(CYAN)\1$(RESET):\2/' | column -c2 -t -s :)" + +.PHONY: install +install: ## Install dependencies + pnpm install + +.PHONY: build +build: ## Build the package + pnpm run --if-present build + +.PHONY: storybook-start +storybook-start: ## Start Storybook + pnpm run storybook + +.PHONY: storybook-build +storybook-build: ## Build Storybook + pnpm run build-storybook diff --git a/packages/layout/blocks/BlockWrapper.test.tsx b/packages/layout/blocks/BlockWrapper.test.tsx new file mode 100644 index 00000000000..a56f686533d --- /dev/null +++ b/packages/layout/blocks/BlockWrapper.test.tsx @@ -0,0 +1,92 @@ +import { render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import config from '@plone/registry'; +import BlockWrapper from './BlockWrapper'; + +type RegistryState = { + utilities?: unknown; +}; + +const snapshotRegistryState = (): RegistryState => ({ + utilities: config.utilities, +}); + +const restoreRegistryState = (state: RegistryState) => { + config.utilities = state.utilities as any; +}; + +const initialRegistryState = snapshotRegistryState(); + +afterEach(() => { + restoreRegistryState(initialRegistryState); +}); + +beforeEach(() => { + config.registerUtility({ + type: 'styleFieldDefinition', + name: 'theme', + method: () => [ + { + name: 'default', + label: 'Default', + style: { '--theme-color': 'white' }, + }, + { + name: 'sand', + label: 'Sand', + style: { '--theme-color': 'wheat' }, + }, + ], + }); +}); + +describe('BlockWrapper', () => { + it('injects schema-driven theme styles into the wrapper element', () => { + const { container } = render( + +
    Body
    +
    , + ); + + const wrapper = container.firstElementChild as HTMLElement; + + expect(wrapper).toBeTruthy(); + expect(wrapper.style.getPropertyValue('--theme-color')).toBe('wheat'); + }); +}); diff --git a/packages/layout/blocks/BlockWrapper.tsx b/packages/layout/blocks/BlockWrapper.tsx index 0b03728d8dd..2fb52e1d1df 100644 --- a/packages/layout/blocks/BlockWrapper.tsx +++ b/packages/layout/blocks/BlockWrapper.tsx @@ -2,6 +2,11 @@ import type { ReactNode } from 'react'; import cx from 'clsx'; import type { RenderBlocksProps } from './RenderBlocks'; import type { BlocksFormData } from '@plone/types'; +import { + getStyleFieldDefinitionsFromRegistry, + resolveStyleFields, +} from '@plone/helpers'; +import { getBlockStyleFieldConfigs } from '../helpers'; type BlockWrapperProps = Partial & { children: ReactNode; @@ -10,10 +15,16 @@ type BlockWrapperProps = Partial & { const BlockWrapper = (props: BlockWrapperProps) => { const { blocksConfig, children, data } = props; - const category = blocksConfig?.[data['@type']]?.category; - // TODO: Bring in the StyleWrapper helpers for calculating styles and classes + const category = + blocksConfig?.[data['@type'] as keyof typeof blocksConfig]?.category; + const { style } = resolveStyleFields({ + data, + fieldConfigs: getBlockStyleFieldConfigs(data, blocksConfig), + container: undefined, + resolveDefinitions: getStyleFieldDefinitionsFromRegistry, + }); + // TODO: Bring in the StyleWrapper helpers for calculating classes const classNames = undefined; - const style = undefined; return (
    import('./SomersaultRenderer')); export type RenderBlocksProps = { diff --git a/packages/layout/blocks/SomersaultRenderer.tsx b/packages/layout/blocks/SomersaultRenderer.tsx index c2003269110..94975217cba 100644 --- a/packages/layout/blocks/SomersaultRenderer.tsx +++ b/packages/layout/blocks/SomersaultRenderer.tsx @@ -5,8 +5,7 @@ import { type Value, } from '@plone/plate/components/editor'; import somersaultRendererConfig from '@plone/plate/config/presets/somersault-renderer'; - -const SOMERSAULT_KEY = '__somersault__'; +import { SOMERSAULT_KEY } from '@plone/plate/constants'; type SomersaultRendererProps = { content: Content; diff --git a/packages/layout/components/Component/Component.test.tsx b/packages/layout/components/Component/Component.test.tsx new file mode 100644 index 00000000000..34856d50db9 --- /dev/null +++ b/packages/layout/components/Component/Component.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import config from '@plone/registry'; +import { Component } from './Component'; + +const MockButton = ({ label }: { label?: string }) => ( + +); + +const MockCard = ({ title }: { title?: string }) => ( +
    {title}
    +); + +beforeEach(() => { + config.set('components', {}); + config.set('slots', {}); + config.set('utilities', {}); +}); + +describe('Component', () => { + it('renders a registered component by name', () => { + config.registerComponent({ name: 'Button', component: MockButton }); + render( + componentName="Button" label="Click me" />, + ); + expect(screen.getByTestId('mock-button')).toHaveTextContent('Click me'); + }); + + it('returns null and warns for an unregistered component', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(warn).toHaveBeenCalledWith('Component not found in registry: Foo'); + warn.mockRestore(); + }); + + it('renders a registered component with a single string dependency', () => { + config.registerComponent({ + name: 'Card', + dependencies: 'News Item', + component: MockCard, + }); + render( + + componentName="Card" + dependencies="News Item" + title="My news item" + />, + ); + expect(screen.getByTestId('mock-card')).toHaveTextContent('My news item'); + }); + + it('renders a registered component with multiple dependencies', () => { + config.registerComponent({ + name: 'Card', + dependencies: ['News Item', 'featured'], + component: MockCard, + }); + render( + + componentName="Card" + dependencies={['News Item', 'featured']} + title="Featured news" + />, + ); + expect(screen.getByTestId('mock-card')).toHaveTextContent('Featured news'); + }); + + it('falls back to null when dependency variant is not registered', () => { + config.registerComponent({ name: 'Card', component: MockCard }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + warn.mockRestore(); + }); +}); diff --git a/packages/layout/components/Component/Component.tsx b/packages/layout/components/Component/Component.tsx new file mode 100644 index 00000000000..28487de8009 --- /dev/null +++ b/packages/layout/components/Component/Component.tsx @@ -0,0 +1,32 @@ +import config from '@plone/registry'; + +type BaseProps = { + componentName: string; + dependencies?: string | string[]; +}; + +type ComponentProps = BaseProps & + Omit; + +export function Component({ + componentName, + dependencies, + ...props +}: ComponentProps) { + const hasDependencies = dependencies !== undefined && dependencies.length > 0; + + const componentOptions = { + name: componentName, + dependencies: hasDependencies ? dependencies : undefined, + }; + + const RegisteredComponent = config.getComponent(componentOptions).component; + + if (!RegisteredComponent) { + // eslint-disable-next-line no-console + console.warn(`Component not found in registry: ${componentName}`); + return null; + } + + return ; +} diff --git a/packages/layout/components/EventDate/EventDate.tsx b/packages/layout/components/EventDate/EventDate.tsx new file mode 100644 index 00000000000..5fba0a71fd1 --- /dev/null +++ b/packages/layout/components/EventDate/EventDate.tsx @@ -0,0 +1,72 @@ +import type { EventContent } from '@plone/types'; +import { getDate, isSameDay } from '../../helpers'; +import { useTranslation } from 'react-i18next'; + +interface EventDateProps { + content: EventContent; + locale: string; +} + +const getDateTime = (date: string, locale: string): string => { + const dateObject = new Date(date); + const dateTimeFormat = Intl.DateTimeFormat([locale], { + timeStyle: 'short', + hourCycle: 'h12', + }); + return dateTimeFormat.format(dateObject); +}; + +export default function EventDate({ locale, content }: EventDateProps) { + const { t } = useTranslation(); + + const { start, end, whole_day, open_end } = content; + + const startDate = getDate(start, locale); + const startTime = getDateTime(start, locale); + const endDate = getDate(end, locale); + const endTime = getDateTime(end, locale); + + return ( +
    + {isSameDay(start, end) ? ( + <> + {whole_day + ? startDate + : open_end + ? t('layout.views.event.time.sameDayOpenEnd', { + date: startDate, + fromTime: startTime, + }) + : t('layout.views.event.time.sameDayRange', { + date: startDate, + fromTime: startTime, + toTime: endTime, + })} + + ) : whole_day ? ( + open_end ? ( + t('layout.views.event.time.multiDayWholeDayOpen', { + fromDate: startDate, + }) + ) : ( + t('layout.views.event.time.multiDayWholeDay', { + fromDate: startDate, + toDate: endDate, + }) + ) + ) : open_end ? ( + t('layout.views.event.time.multiDayRangeOpen', { + fromDate: startDate, + fromTime: startTime, + }) + ) : ( + t('layout.views.event.time.multiDayRange', { + fromDate: startDate, + fromTime: startTime, + toDate: endDate, + toTime: endTime, + }) + )} +
    + ); +} diff --git a/packages/layout/components/EventDetails/EventDetails.module.css b/packages/layout/components/EventDetails/EventDetails.module.css new file mode 100644 index 00000000000..99c43ebe971 --- /dev/null +++ b/packages/layout/components/EventDetails/EventDetails.module.css @@ -0,0 +1,31 @@ +@layer custom { + .event-details { + padding: 1rem; + border: 1px solid lightgray; + box-shadow: 0 1px 2px 0 lightgray; + + dt { + display: block; + padding-bottom: 0.1rem; + border-bottom: 1px solid gray; + font-weight: bold; + } + + dd { + display: block; + margin-bottom: 1rem; + } + + :global(.download-event) { + display: flex; + flex-direction: row; + align-content: center; + + a { + align-self: center; + margin-bottom: 0; + margin-left: 4px; + } + } + } +} diff --git a/packages/layout/components/EventDetails/EventDetails.tsx b/packages/layout/components/EventDetails/EventDetails.tsx new file mode 100644 index 00000000000..525543f6370 --- /dev/null +++ b/packages/layout/components/EventDetails/EventDetails.tsx @@ -0,0 +1,139 @@ +import EventDate from '../EventDate/EventDate'; +import { Link } from '@plone/components/quanta'; +import * as RRuleLib from 'rrule'; +import type * as RRuleTypes from 'rrule'; +import type { EventContent } from '@plone/types'; +import { getDate } from '../../helpers'; +import Calendar from '@plone/components/icons/calendar.svg?react'; +import { useTranslation } from 'react-i18next'; +import styles from './EventDetails.module.css'; + +interface EventDetailsProps { + content: EventContent; + locale: string; +} + +interface RecurrenceProps { + recurrence: string; + start: string; + locale: string; +} + +export default function EventDetails({ content, locale }: EventDetailsProps) { + const { t } = useTranslation(); + + return ( + + ); +} + +export const Recurrence = ({ recurrence, start, locale }: RecurrenceProps) => { + const { RRule, rrulestr } = ((RRuleLib as any).default || + RRuleLib) as typeof RRuleTypes; + + const newRecurrence = !recurrence.includes('DTSTART') + ? RRule.optionsToString({ dtstart: new Date(start) }) + '\n' + recurrence + : recurrence; + + const rule = rrulestr(newRecurrence, { unfold: true, forceset: true }); + const ruleItems = rule.all().map((date) => getDate(date, locale)); + + return ( +
    +
      + {ruleItems.map((date, index) => ( +
    • {date}
    • + ))} +
    +
    + ); +}; diff --git a/packages/layout/components/Image/Image.tsx b/packages/layout/components/Image/Image.tsx index b80ac73c6aa..6f0113f748c 100644 --- a/packages/layout/components/Image/Image.tsx +++ b/packages/layout/components/Image/Image.tsx @@ -7,8 +7,8 @@ import type { RelatedItem, Brain, } from '@plone/types'; -import type { RootLoader } from 'seven/app/root'; import { useRouteLoaderData } from 'react-router'; +import type { RootLoader } from 'seven/app/root'; function removeObjectIdFromURL(basePath: string, scale: string) { return scale.replace(`${basePath}/`, ''); @@ -64,6 +64,9 @@ export default function Image(props: ImageProps) { if (!item && src) { attrs.src = src; } else if (item) { + const itemId = item['@id']; + if (!itemId && !src) return null; + const isFromRealObject = !('image_scales' in item); let imageFieldWithDefault = 'image'; if (imageField) { @@ -73,9 +76,9 @@ export default function Image(props: ImageProps) { } const image = isFromRealObject - ? flattenScales(item['@id'], (item as any)[imageFieldWithDefault]) + ? flattenScales(itemId ?? '', (item as any)[imageFieldWithDefault]) : flattenScales( - item['@id'], + itemId ?? '', (item as any)?.image_scales?.[imageFieldWithDefault]?.[0], ); @@ -86,7 +89,7 @@ export default function Image(props: ImageProps) { } else if (image) { const isSvg = image['content-type'] === 'image/svg+xml'; // In case `base_path` is present (`preview_image_link`) use it as base path - const basePath = image.base_path || item['@id']; + const basePath = image.base_path || itemId; attrs.src = `${basePath}/${image.download}`; attrs.width = image.width; diff --git a/packages/layout/components/SectionWrapper/SectionWrapper.tsx b/packages/layout/components/SectionWrapper/SectionWrapper.tsx index fb2450813bc..3d49335bf05 100644 --- a/packages/layout/components/SectionWrapper/SectionWrapper.tsx +++ b/packages/layout/components/SectionWrapper/SectionWrapper.tsx @@ -18,9 +18,11 @@ type SectionWrapperProps = { width?: 'layout' | 'default' | 'narrow' | 'full'; /** Inline styles for the wrapper. (not wired yet) */ style?: React.CSSProperties; -} & React.ComponentPropsWithoutRef; +} & React.ComponentPropsWithoutRef; -const SectionWrapper = (props: SectionWrapperProps) => { +const SectionWrapper = ( + props: SectionWrapperProps, +) => { const { as: Component = 'div', children, @@ -42,7 +44,11 @@ const SectionWrapper = (props: SectionWrapperProps) => { ); return ( - + )} + className={sectionClasses} + style={style} + >
    {children}
    ); diff --git a/packages/layout/components/Toast/ErrorToast.tsx b/packages/layout/components/Toast/ErrorToast.tsx index dba927be024..8e9e21259dd 100644 --- a/packages/layout/components/Toast/ErrorToast.tsx +++ b/packages/layout/components/Toast/ErrorToast.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useRouteError, isRouteErrorResponse } from 'react-router'; -import { UNSTABLE_ToastQueue as ToastQueue } from 'react-aria-components'; +import { type ToastQueue } from '../../config/toast'; /* Use ErrorToast in your ErrorBoundary of your layout. @@ -13,14 +13,15 @@ export function ErrorBoundary() { *** Params: *** - queue: a ToastQueue from react-aria-components. You could istantiate your queue, or use the global basic queue from registry */ -export default function ErrorToast(queue: ToastQueue) { +export default function ErrorToast(queue: ToastQueue) { const error = useRouteError(); useEffect(() => { if (isRouteErrorResponse(error)) { + const e = { ...error, ...error.data }; queue.add({ - title: `Error: ${error.status}:`, - description: error.statusText, + title: `Error: ${e.status} - ${e.statusText}`, + description: e.message, className: 'error', }); } else if (error instanceof Error) { diff --git a/packages/layout/components/Toast/Toast.tsx b/packages/layout/components/Toast/Toast.tsx index 03f263266ac..a766e134b3c 100644 --- a/packages/layout/components/Toast/Toast.tsx +++ b/packages/layout/components/Toast/Toast.tsx @@ -4,18 +4,17 @@ import { Button, UNSTABLE_Toast as Toast, UNSTABLE_ToastContent as ToastContent, - UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastRegion as ToastRegion, } from 'react-aria-components'; import { CloseIcon } from '@plone/components/Icons'; -import { type ToastItem } from '../../config/toast'; +import { type ToastQueue } from '../../config/toast'; /** * Props Types for the SectionWrapper component. * They are able to infer the props of the element type passed to the `as` prop. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars type AppToastPropsType = { - queue: ToastQueue; + queue: ToastQueue; } & React.ComponentPropsWithoutRef; const AppToast = (props: AppToastPropsType) => { diff --git a/packages/layout/components/Toolbar/Toolbar-inner.css b/packages/layout/components/Toolbar/Toolbar-inner.css new file mode 100644 index 00000000000..d5c616caa4c --- /dev/null +++ b/packages/layout/components/Toolbar/Toolbar-inner.css @@ -0,0 +1,155 @@ +@property --plone-toolbar-button-background-color { + inherits: false; + initial-value: transparent; + syntax: ''; +} + +.toolbar { + position: fixed; + z-index: 10; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: var(--plone-toolbar-width, 80px); + justify-content: center; + background: var(--plone-toolbar-background, var(--quanta-smoke)); + transition: + left 0.2s ease, + right 0.2s ease, + width 0.2s ease; +} + +.toolbar-buttons { + position: fixed; + z-index: 1; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: var(--plone-toolbar-width, 80px); + flex-direction: column; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-block: 1rem; +} + +.toolbar-region { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.toolbar-region > button, +.toolbar-region > a, +.toolbar-button { + display: inline-flex; + width: 44px; + height: 44px; + box-sizing: border-box; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 50%; + background-color: var(--plone-toolbar-button-background-color, transparent); + color: var(--plone-toolbar-button-color, var(--quanta-iron)); + cursor: pointer; + /* Reset any inherited browser defaults */ + font-family: inherit; + font-size: 14px; + line-height: 1; + transition: background-color 0.15s ease; +} + +.toolbar-region > button:focus-visible, +.toolbar-region > a:focus-visible, +.toolbar-button:focus-visible { + outline: 2px solid + var(--plone-toolbar-button-focus-outline, var(--quanta-royal)); + outline-offset: 2px; +} + +.toolbar-region > button:hover, +.toolbar-region > a:hover, +.toolbar-button:hover { + --plone-toolbar-button-background-color: var( + --plone-toolbar-button-background-color-hover, + white + ); +} + +.toolbar-region > button:active, +.toolbar-region > a:active, +.toolbar-button:active { + --plone-toolbar-button-background-color: var( + --plone-toolbar-button-background-color-active, + var(--quanta-silver) + ); +} + +.toolbar-region > button.primary, +.toolbar-region > a.primary, +.toolbar-button.primary { + --plone-toolbar-button-background-color: var( + --plone-toolbar-button-background-color-primary, + var(--quanta-sapphire) + ); + --plone-toolbar-button-color: var(--quanta-air); +} + +.toolbar-region > button.primary:hover, +.toolbar-region > a.primary:hover, +.toolbar-button.primary:hover { + --plone-toolbar-button-background-color: var( + --plone-toolbar-button-background-color-primary-hover, + var(--quanta-royal) + ); +} + +.toolbar-region > button.primary:active, +.toolbar-region > a.primary:active, +.toolbar-button.primary:active { + --plone-toolbar-button-background-color: var( + --plone-toolbar-button-background-color-primary-active, + var(--quanta-denim) + ); +} + +.toolbar-region > button.secondary, +.toolbar-region > a.secondary, +.toolbar-button.secondary { + --plone-toolbar-button-background-color: var( + --plone-toolbar-button-background-secondary, + var(--quanta-snow) + ); +} + +.toolbar-region > button.secondary:hover, +.toolbar-region > a.secondary:hover, +.toolbar-button.secondary:hover { + --plone-toolbar-button-background-color: var( + --plone-toolbar-button-background-secondary-hover, + white + ); +} + +.toolbar-region > button.secondary:active, +.toolbar-region > a.secondary:active, +.toolbar-button.secondary:active { + --plone-toolbar-button-background-color: var( + --plone-toolbar-button-background-color-secondary-active, + var(--quanta-silver) + ); +} + +.toolbar-region > button svg, +.toolbar-region > a svg, +.toolbar-button svg { + display: block; + width: 24px; + height: 24px; + pointer-events: none; +} diff --git a/packages/layout/components/Toolbar/Toolbar.css b/packages/layout/components/Toolbar/Toolbar.css deleted file mode 100644 index f0df80bc4c7..00000000000 --- a/packages/layout/components/Toolbar/Toolbar.css +++ /dev/null @@ -1,100 +0,0 @@ -.toolbar { - position: fixed; - z-index: 10; - top: 0; - bottom: 0; - left: 0; - display: flex; - width: var(--plone-toolbar-width); - justify-content: center; - background-color: var(--quanta-smoke); - transition: - left 0.2s ease, - right 0.2s ease, - width 0.2s ease; -} - -.toolbar-buttons { - position: fixed; - z-index: 1; - top: 0; - bottom: 0; - left: 0; - display: flex; - width: var(--plone-toolbar-width); - flex-direction: column; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - margin-block: 1rem; -} - -.toolbar-region { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; -} - -.toolbar-region > button, -.toolbar-region > a, -.toolbar-button { - display: inline-flex; - width: 44px; - height: 44px; - box-sizing: border-box; - align-items: center; - justify-content: center; - padding: 0; - border: none; - border-radius: 50%; - background-color: transparent; - color: var(--quanta-iron); - cursor: pointer; - /* Reset any inherited browser defaults */ - font-family: inherit; - font-size: 14px; - line-height: 1; - transition: background-color 0.15s ease; -} - -.toolbar-region > button:hover, -.toolbar-region > a:hover, -.toolbar-button:hover { - background-color: var(--quanta-snow); -} - -.toolbar-region > button:active, -.toolbar-region > a:active, -.toolbar-button:active { - background-color: var(--quanta-silver); -} - -.toolbar-region > button:focus-visible, -.toolbar-region > a:focus-visible, -.toolbar-button:focus-visible { - outline: 2px solid var(--quanta-royal); - outline-offset: 2px; -} - -.toolbar-region > button.primary, -.toolbar-region > a.primary, -.toolbar-button.primary { - background-color: var(--quanta-sapphire); - color: var(--quanta-air); -} - -.toolbar-region > button.primary:hover, -.toolbar-region > a.primary:hover, -.toolbar-button.primary:hover { - background-color: var(--quanta-royal); -} - -.toolbar-region > button svg, -.toolbar-region > a svg, -.toolbar-button svg { - display: block; - width: 24px; - height: 24px; - pointer-events: none; -} diff --git a/packages/layout/components/Toolbar/Toolbar.module.css b/packages/layout/components/Toolbar/Toolbar.module.css new file mode 100644 index 00000000000..d421b0020db --- /dev/null +++ b/packages/layout/components/Toolbar/Toolbar.module.css @@ -0,0 +1,9 @@ +.toolbar { + position: fixed; + z-index: 10; + top: 0; + bottom: 0; + left: 0; + width: var(--plone-toolbar-width, 80px); + background: var(--plone-toolbar-background, var(--quanta-smoke)); +} diff --git a/packages/layout/components/Toolbar/Toolbar.tsx b/packages/layout/components/Toolbar/Toolbar.tsx index 2bfe290ba6c..8cecab84ae6 100644 --- a/packages/layout/components/Toolbar/Toolbar.tsx +++ b/packages/layout/components/Toolbar/Toolbar.tsx @@ -20,11 +20,9 @@ import { createPortal } from 'react-dom'; import { useEffect, useRef, useState } from 'react'; -import { useRouteLoaderData } from 'react-router'; -import type { RootLoader } from 'seven/app/root'; import { Pluggable } from '../Pluggable'; -import { shouldShowToolbar } from '../../helpers'; -import toolbarStyles from './Toolbar.css?inline'; +import styles from './Toolbar.module.css'; +import toolbarInnerStyles from './Toolbar-inner.css?inline'; import { useTranslation } from 'react-i18next'; function ToolbarInner() { @@ -56,14 +54,9 @@ function ToolbarInner() { * More info about Pluggables: https://6.docs.plone.org/volto/development/pluggables.html */ const Toolbar = () => { - const rootData = useRouteLoaderData('root'); const [shadowRoot, setShadowRoot] = useState(null); const hostRef = useRef(null); - const showToolbar = !!rootData?.content - ? shouldShowToolbar(rootData.content) - : false; - useEffect(() => { if (!hostRef.current || hostRef.current.shadowRoot) return; const root = hostRef.current.attachShadow({ mode: 'open' }); @@ -73,12 +66,11 @@ const Toolbar = () => { // The host element is always rendered so the ref is stable. No content is // placed in the shadow root until we know the user can edit. return ( -
    +
    {shadowRoot && - showToolbar && createPortal( <> - + , shadowRoot, diff --git a/packages/layout/config/toast.ts b/packages/layout/config/toast.ts index 642d80bb5e8..9cb523b5b35 100644 --- a/packages/layout/config/toast.ts +++ b/packages/layout/config/toast.ts @@ -3,6 +3,7 @@ import { flushSync } from 'react-dom'; import type { ConfigType } from '@plone/registry'; import { UNSTABLE_ToastQueue as RACToastQueue } from 'react-aria-components'; + export type Toast = { error: (content: ReactNode | null) => string | number; }; @@ -13,6 +14,7 @@ export type ToastItem = { icon?: ReactNode; className?: string; }; +export type ToastQueue = RACToastQueue; // Create a global ToastQueue. export const toastQueue = new RACToastQueue({ diff --git a/packages/layout/helpers/index.ts b/packages/layout/helpers/index.ts index 6bec2ba4f17..c61f44f3344 100644 --- a/packages/layout/helpers/index.ts +++ b/packages/layout/helpers/index.ts @@ -1,7 +1,14 @@ import { matchPath } from 'react-router'; -import type { Content } from '@plone/types'; +import { getStyleFieldsFromBlockSchema } from '@plone/helpers'; +import type { BlocksConfigData, BlocksFormData, Content } from '@plone/types'; import type { Location, PathPattern } from 'react-router'; +type StyleFieldConfig = { + defaultValue?: string; + values?: readonly string[]; + path?: string; +}; + export function RouteCondition(path: string | PathPattern) { return ({ location }: { location: Location }) => Boolean(matchPath(path, location.pathname)); @@ -29,11 +36,55 @@ export function NotContentTypeCondition(contentType: string[]) { }; } -export function shouldShowToolbar(content: Content) { - const actions = content['@components']?.actions; +export function shouldShowToolbar(content?: Content | null) { + const actions = content?.['@components']?.actions; const isVisible = (actions?.object?.some((a) => a.id === 'edit') ?? false) || (actions?.object_buttons?.some((a) => a.id === 'edit') ?? false); return isVisible; } + +export const getBlockStyleFieldConfigs = ( + data: BlocksFormData, + blocksConfig?: BlocksConfigData, +) => { + const blockType = data['@type']; + + if (!blockType) return {}; + + const blockConfig = blocksConfig?.[blockType]; + const styleFields = getStyleFieldsFromBlockSchema(blockConfig, data); + + // Keep `blockWidth` as a fallback for Plone blocks that wants to configure it + // in blocksConfig instead using a explicit width schema field marked with `styleField: true`. + if (blockConfig?.blockWidth) { + styleFields.blockWidth = { + defaultValue: blockConfig.blockWidth.defaultWidth, + values: blockConfig.blockWidth.widths, + }; + } + + return styleFields as Record; +}; + +export function isSameDay(start: string, end: string): boolean { + const startDate = new Date(start); + const endDate = new Date(end); + + return ( + startDate.getDate() === endDate.getDate() && + startDate.getMonth() === endDate.getMonth() && + startDate.getFullYear() === endDate.getFullYear() + ); +} + +export function getDate(date: string | Date, locale: string): string { + const dateObject = typeof date === 'string' ? new Date(date) : date; + + const dateTimeFormat = Intl.DateTimeFormat([locale], { + dateStyle: 'medium', + }); + + return dateTimeFormat.format(dateObject); +} diff --git a/packages/layout/index.ts b/packages/layout/index.ts index 6ec0caef2c3..7b34d26c9c6 100644 --- a/packages/layout/index.ts +++ b/packages/layout/index.ts @@ -5,6 +5,8 @@ import installToast from './config/toast'; import DefaultView from './views/DefaultView'; import FileView from './views/FileView'; import ImageView from './views/ImageView'; +import LinkView from './views/LinkView'; +import EventView from './views/EventView'; export default function install(config: ConfigType) { // Translation factory @@ -18,8 +20,11 @@ export default function install(config: ConfigType) { config.views.contentTypesViews = { File: FileView, Image: ImageView, + Link: LinkView, + Event: EventView, ...config.views.contentTypesViews, }; + config.views.layoutViews = { ...config.views.layoutViews }; installSettings(config); diff --git a/packages/layout/locales/de/common.json b/packages/layout/locales/de/common.json new file mode 100644 index 00000000000..a9282556fea --- /dev/null +++ b/packages/layout/locales/de/common.json @@ -0,0 +1,49 @@ +{ + "layout": { + "languageSwitcher": { + "switchTo": "Zu {{ lang }} wechseln" + }, + "contenttypes": { + "common": { + "size": "Größe:" + }, + "image": { + "download": "Klicke, um das Bild in voller Größe herunterzuladen" + }, + "file": { + "download": "Datei herunterladen" + } + }, + "toolbar": { + "label": "Werkzeugleiste" + }, + "views": { + "link": { + "linkLabel": "Die Linkadresse lautet:", + "externalLinkLabel": "Die externe Linkadresse lautet:" + }, + "event": { + "what": "Was", + "when": "Datum", + "time": { + "sameDayOpenEnd": "{{date}} ab {{fromTime}}", + "sameDayRange": "{{date}} von {{fromTime}} bis {{toTime}}", + "multiDayWholeDayOpen": "{{fromDate}}", + "multiDayWholeDay": "{{fromDate}} bis {{toDate}}", + "multiDayRangeOpen": "{{fromDate}} {{fromTime}}", + "multiDayRange": "{{fromDate}} {{fromTime}} bis {{toDate}} {{toTime}}" + }, + "allDates": "Alle Termine", + "where": "Ort", + "contact": { + "name": "Kontaktname", + "phone": "Kontakttelefon" + }, + "attendees": "Teilnehmer", + "website": "Webseite", + "visitWebsite": "Webseite besuchen", + "downloadEvent": "Event herunterladen" + } + } + } +} diff --git a/packages/layout/locales/en/common.json b/packages/layout/locales/en/common.json index 9e791c24e7f..015eedda630 100644 --- a/packages/layout/locales/en/common.json +++ b/packages/layout/locales/en/common.json @@ -16,6 +16,34 @@ }, "toolbar": { "label": "Toolbar" + }, + "views": { + "link": { + "linkLabel": "The link address is:", + "externalLinkLabel": "The external link address is:" + }, + "event": { + "what": "What", + "when": "When", + "time": { + "sameDayOpenEnd": "{{date}} from {{fromTime}}", + "sameDayRange": "{{date}} from {{fromTime}} to {{toTime}}", + "multiDayWholeDayOpen": "{{fromDate}}", + "multiDayWholeDay": "{{fromDate}} to {{toDate}}", + "multiDayRangeOpen": "{{fromDate}} {{fromTime}}", + "multiDayRange": "{{fromDate}} {{fromTime}} to {{toDate}} {{toTime}}" + }, + "allDates": "All dates", + "where": "Where", + "contact": { + "name": "Contact Name", + "phone": "Contact Phone" + }, + "attendees": "Attendees", + "website": "Website", + "visitWebsite": "Visit website", + "downloadEvent": "Download event" + } } } } diff --git a/packages/layout/locales/it/common.json b/packages/layout/locales/it/common.json index 551ea16fd64..6a5bf7a24e8 100644 --- a/packages/layout/locales/it/common.json +++ b/packages/layout/locales/it/common.json @@ -16,6 +16,34 @@ }, "toolbar": { "label": "Barra degli strumenti" + }, + "views": { + "link": { + "linkLabel": "L'indirizzo del link è:", + "externalLinkLabel": "L'indirizzo del link esterno è:" + }, + "event": { + "what": "Cosa", + "when": "Quando", + "time": { + "sameDayOpenEnd": "{{date}} dalle {{fromTime}}", + "sameDayRange": "{{date}} dalle {{fromTime}} alle {{toTime}}", + "multiDayWholeDayOpen": "{{fromDate}}", + "multiDayWholeDay": "Dal {{fromDate}} al {{toDate}}", + "multiDayRangeOpen": "{{fromDate}} {{fromTime}}", + "multiDayRange": "Dal {{fromDate}} {{fromTime}} al {{toDate}} {{toTime}}" + }, + "allDates": "Tutte le date", + "where": "Dove", + "contact": { + "name": "Nome del contatto", + "phone": "Telefono del contatto" + }, + "attendees": "Partecipanti", + "website": "Sito web", + "visitWebsite": "Visita il sito web", + "downloadEvent": "Scarica evento" + } } } } diff --git a/packages/layout/news/+contenttypes.breaking b/packages/layout/news/+contenttypes.breaking new file mode 100644 index 00000000000..dd2769fe3af --- /dev/null +++ b/packages/layout/news/+contenttypes.breaking @@ -0,0 +1 @@ +Refactored the `Content` type to properly match the basic Plone types and allow TypeScript to narrow this type automatically. @pnicolli \ No newline at end of file diff --git a/packages/layout/news/+route-error-toast.bugfix b/packages/layout/news/+route-error-toast.bugfix new file mode 100644 index 00000000000..b6cfc4ba62d --- /dev/null +++ b/packages/layout/news/+route-error-toast.bugfix @@ -0,0 +1 @@ +Improved route error toast typing and messages from React Router response data. @pnicolli @giuliaghisini diff --git a/packages/layout/news/+storybook.internal b/packages/layout/news/+storybook.internal new file mode 100644 index 00000000000..cc424aade8a --- /dev/null +++ b/packages/layout/news/+storybook.internal @@ -0,0 +1 @@ +Update to storybook 10. @sneridagh diff --git a/packages/layout/news/+unify-makefiles.internal b/packages/layout/news/+unify-makefiles.internal new file mode 100644 index 00000000000..5da674df4e4 --- /dev/null +++ b/packages/layout/news/+unify-makefiles.internal @@ -0,0 +1 @@ +Unify Makefile files across the packages. @ionlizarazu diff --git a/packages/layout/news/6649.feature b/packages/layout/news/6649.feature deleted file mode 100644 index f37df625361..00000000000 --- a/packages/layout/news/6649.feature +++ /dev/null @@ -1 +0,0 @@ -Added the left toolbar @pnicolli diff --git a/packages/layout/news/6691.feature b/packages/layout/news/6691.feature new file mode 100644 index 00000000000..af8744c454a --- /dev/null +++ b/packages/layout/news/6691.feature @@ -0,0 +1 @@ +Added `Component` component. @arybakov05 \ No newline at end of file diff --git a/packages/layout/news/6708.feature b/packages/layout/news/6708.feature new file mode 100644 index 00000000000..368400879f3 --- /dev/null +++ b/packages/layout/news/6708.feature @@ -0,0 +1 @@ +Added the view for the Event content type @arybakov05 \ No newline at end of file diff --git a/packages/layout/news/6710.feature b/packages/layout/news/6710.feature new file mode 100644 index 00000000000..a18b1562584 --- /dev/null +++ b/packages/layout/news/6710.feature @@ -0,0 +1 @@ +Added the view for the Link content type @arybakov05 \ No newline at end of file diff --git a/packages/layout/news/7921.feature b/packages/layout/news/7921.feature deleted file mode 100644 index 4d27c2992a1..00000000000 --- a/packages/layout/news/7921.feature +++ /dev/null @@ -1 +0,0 @@ -Somersault editor support (renderers). @sneridagh diff --git a/packages/layout/news/8018.bugfix b/packages/layout/news/8018.bugfix deleted file mode 100644 index 6e92b4a85e8..00000000000 --- a/packages/layout/news/8018.bugfix +++ /dev/null @@ -1 +0,0 @@ -Remove edit button from the preliminary tools. @sneridagh diff --git a/packages/layout/package.json b/packages/layout/package.json index 995fed674fd..d386ab63c7a 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -9,7 +9,7 @@ ], "funding": "https://github.com/sponsors/plone", "license": "MIT", - "version": "1.0.0-alpha.4", + "version": "1.0.0-alpha.8", "repository": { "type": "git", "url": "https://github.com/plone/volto.git", @@ -34,13 +34,17 @@ "main": "index.ts", "scripts": { "test": "vitest", - "check-ts": "tsc --project tsconfig.json", + "check:ts": "pnpm --filter seven run typegen && tsc --project tsconfig.json", "dry-release": "release-it --dry-run", "release": "release-it", "release-major-alpha": "release-it major --preRelease=alpha", "release-alpha": "release-it --preRelease=alpha", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", + "lint:fix": "eslint --max-warnings=0 './**/*.{js,jsx,ts,tsx}' --fix --no-error-on-unmatched-pattern", + "stylelint:fix": "sh -c 'if [ -f .stylelintrc ] || [ -f .stylelintrc.json ] || [ -f .stylelintrc.js ] || [ -f .stylelintrc.cjs ] || [ -f stylelint.config.js ] || [ -f stylelint.config.cjs ] || [ -f stylelint.config.mjs ]; then stylelint '''./**/*.{css,scss,less}''' --fix --allow-empty-input; else echo \"No local stylelint config, skipping\"; fi'", + "format": "pnpm prettier:fix && pnpm lint:fix && pnpm stylelint:fix" }, "peerDependencies": { "react": "^19.1.0", @@ -61,29 +65,29 @@ "pretty-bytes": "^7.1.0", "react-aria-components": "catalog:", "react-i18next": "catalog:", - "react-router": "catalog:" + "react-router": "catalog:", + "rrule": "^2.8.1" }, "devDependencies": { "@plone/types": "workspace:*", - "@storybook/addon-docs": "^9.1.7", - "@storybook/addon-links": "^9.1.7", - "@storybook/react-vite": "^9.1.7", + "@storybook/addon-docs": "^10.4.0", + "@storybook/addon-links": "^10.4.0", + "@storybook/react-vite": "^10.4.0", "@tailwindcss/vite": "catalog:", - "@testing-library/jest-dom": "6.4.2", + "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/jest-axe": "^3.5.7", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", - "eslint-plugin-storybook": "^9.1.7", + "eslint-plugin-storybook": "^10.4.0", "jest-axe": "^8.0.0", "release-it": "catalog:", - "storybook": "^9.1.7", + "storybook": "^10.4.0", "tsconfig": "workspace:*", "typescript": "catalog:", "vite": "catalog:", - "vite-tsconfig-paths": "^5.1.4", "vitest": "catalog:", "vitest-axe": "^0.1.0" } diff --git a/packages/layout/slots/ContentArea.tsx b/packages/layout/slots/ContentArea.tsx index 9074e54edc6..1644b6f0901 100644 --- a/packages/layout/slots/ContentArea.tsx +++ b/packages/layout/slots/ContentArea.tsx @@ -7,9 +7,7 @@ const ContentArea = (props: SlotComponentProps) => { const { content } = props; if ( - ['Document', 'Plone Site', 'LRF', 'News Item', 'Event'].includes( - content['@type'], - ) && + ['Document', 'Plone Site', 'LRF', 'News Item'].includes(content['@type']) && hasBlocksData(content) ) { return ( diff --git a/packages/layout/slots/LanguageSwitcher/LanguageSwitcher.tsx b/packages/layout/slots/LanguageSwitcher/LanguageSwitcher.tsx index a98bda7bb75..9d98617cfcd 100644 --- a/packages/layout/slots/LanguageSwitcher/LanguageSwitcher.tsx +++ b/packages/layout/slots/LanguageSwitcher/LanguageSwitcher.tsx @@ -27,13 +27,16 @@ const LanguageSwitcher = (props: LanguageSelectorProps) => { } const { site } = rootData; + if (!site) { + return null; + } const isMultilingual = site.features?.multilingual; const availableLanguages = site['plone.available_languages'] || []; const currentLang = site['plone.default_language'] || 'en'; return isMultilingual ? (
    - {availableLanguages.map((lang) => { + {availableLanguages.map((lang: string) => { return ( '; - } - } - - :root { - @property --block-width { - initial-value: 100%; - syntax: ''; - } - } +@property --block-width { + initial-value: 100%; + syntax: ''; +} +@layer custom { @import './header.css'; @import './content-area.css'; @import './footer.css'; diff --git a/packages/layout/tsconfig.json b/packages/layout/tsconfig.json index 82c7aaf4ab2..72d3c62c40b 100644 --- a/packages/layout/tsconfig.json +++ b/packages/layout/tsconfig.json @@ -1,17 +1,30 @@ { "extends": "tsconfig/react-library.json", - "include": ["**/*.ts", "**/*.tsx"], + "include": [ + "**/*.ts", + "**/*.tsx", + "../../apps/seven/.react-router/types/**/*" + ], "exclude": [ "node_modules", "build", "public", "coverage", + "**/*.stories.js", + "**/*.stories.jsx", + "**/*.stories.ts", + "**/*.stories.tsx", "src/**/*.test.{js,jsx,ts,tsx}", "src/**/*.spec.{js,jsx,ts,tsx}", "src/**/*.stories.{js,jsx,ts,tsx}" ], "compilerOptions": { "types": ["vite/client"], + "rootDirs": [ + ".", + "../../apps/seven/app", + "../../apps/seven/.react-router/types/app" + ], "paths": { "seven/*": ["../../apps/seven/*"] } diff --git a/packages/layout/views/EventView.module.css b/packages/layout/views/EventView.module.css new file mode 100644 index 00000000000..f0280ca8653 --- /dev/null +++ b/packages/layout/views/EventView.module.css @@ -0,0 +1,12 @@ +@layer custom { + .event-view { + display: grid; + width: 100%; + grid-template-columns: 70% 30%; + + @media (max-width: 768px) { + gap: 1rem; + grid-template-columns: 1fr; + } + } +} diff --git a/packages/layout/views/EventView.tsx b/packages/layout/views/EventView.tsx new file mode 100644 index 00000000000..c93428e32e9 --- /dev/null +++ b/packages/layout/views/EventView.tsx @@ -0,0 +1,41 @@ +import { hasBlocksData } from '@plone/helpers'; +import { useRouteLoaderData } from 'react-router'; +import type { RootLoader } from 'seven/app/root'; +import RenderBlocks from '../blocks/RenderBlocks'; +import EventDetails from '../components/EventDetails/EventDetails'; +import config from '@plone/registry'; +import { Container } from '@plone/components'; +import styles from './EventView.module.css'; + +export default function EventView() { + const rootData = useRouteLoaderData('root'); + + if (!rootData || rootData.content['@type'] !== 'Event') { + return null; + } + + const { content, locale } = rootData; + const hasBlocks = hasBlocksData(content); + + return ( + +
    + {hasBlocks ? ( + + ) : ( + <> +

    {content.title}

    + {Boolean(content.description) && ( +

    {content.description}

    + )} + + )} +
    + +
    + ); +} diff --git a/packages/layout/views/FileView.test.tsx b/packages/layout/views/FileView.test.tsx index a91e15caed9..8939d5720d2 100644 --- a/packages/layout/views/FileView.test.tsx +++ b/packages/layout/views/FileView.test.tsx @@ -13,6 +13,7 @@ vi.mock('react-router', () => ({ })), useRouteLoaderData: vi.fn(() => ({ content: { + '@type': 'File', title: 'My file', description: 'This is a file.', file: { diff --git a/packages/layout/views/FileView.tsx b/packages/layout/views/FileView.tsx index a534beebb4a..1f75a00e2fb 100644 --- a/packages/layout/views/FileView.tsx +++ b/packages/layout/views/FileView.tsx @@ -1,32 +1,14 @@ import { useRouteLoaderData } from 'react-router'; -import type { RootLoader } from 'seven/app/root'; import { useTranslation } from 'react-i18next'; import prettybytes from 'pretty-bytes'; import { Container, Link } from '@plone/components'; -import type { Content } from '@plone/types'; - -// TODO: move this to @plone/types in some way? -type FileContent = Content & { - '@type': 'File'; - file: { - 'content-type': string; - download: string; - filename: string; - size: number; - }; -}; - -type Loader = (args: Parameters) => Promise< - Awaited> & { - content: FileContent; - } ->; +import type { RootLoader } from 'seven/app/root'; export default function FileView() { - const rootData = useRouteLoaderData('root'); + const rootData = useRouteLoaderData('root'); const { t } = useTranslation(); - if (!rootData) { + if (!rootData || rootData.content['@type'] !== 'File') { return null; } diff --git a/packages/layout/views/LinkView.tsx b/packages/layout/views/LinkView.tsx new file mode 100644 index 00000000000..fd4f93bca30 --- /dev/null +++ b/packages/layout/views/LinkView.tsx @@ -0,0 +1,39 @@ +import { useRouteLoaderData } from 'react-router'; +import type { RootLoader } from 'seven/app/root'; +import { Container } from '@plone/components'; +import { Link } from '@plone/components/quanta'; +import { useTranslation } from 'react-i18next'; +import { isInternalURL } from '@plone/helpers'; +import clsx from 'clsx'; + +export default function LinkView() { + const rootData = useRouteLoaderData('root'); + const { t } = useTranslation(); + + if (!rootData || rootData.content['@type'] !== 'Link') { + return null; + } + + const { content } = rootData; + const isInternal = isInternalURL(content.remoteUrl); + + return ( + +

    {content.title}

    +

    {content.description}

    +

    + {isInternal + ? t('layout.views.link.linkLabel') + : t('layout.views.link.externalLinkLabel')} +   + + {content.remoteUrl} + +

    +
    + ); +} diff --git a/packages/layout/vite.config.ts b/packages/layout/vite.config.ts index 1c1bd7aac9f..ee1bff7795a 100644 --- a/packages/layout/vite.config.ts +++ b/packages/layout/vite.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; import { PloneSVGRVitePlugin } from '@plone/components/vite-plugin-svgr'; export default defineConfig({ - plugins: [tsconfigPaths(), tailwindcss(), PloneSVGRVitePlugin(), react()], + plugins: [tailwindcss(), PloneSVGRVitePlugin(), react()], + resolve: { + tsconfigPaths: true, + }, }); diff --git a/packages/plate/.release-it.json b/packages/plate/.release-it.json index 12205715a2d..12e5afdc24d 100644 --- a/packages/plate/.release-it.json +++ b/packages/plate/.release-it.json @@ -5,8 +5,7 @@ "hooks": { "after:bump": [ "pipx run towncrier build --draft --yes --version ${version} > .changelog.draft", - "pipx run towncrier build --yes --version ${version}", - "pnpm run generate:styles" + "pipx run towncrier build --yes --version ${version}" ], "after:release": "rm .changelog.draft" }, diff --git a/packages/plate/.storybook/main.ts b/packages/plate/.storybook/main.ts index ea17d852ff4..869fdaad03a 100644 --- a/packages/plate/.storybook/main.ts +++ b/packages/plate/.storybook/main.ts @@ -1,6 +1,5 @@ import type { StorybookConfig } from '@storybook/react-vite'; import { mergeConfig } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; const config: StorybookConfig = { // For some reason the property does not allow negation @@ -24,7 +23,9 @@ const config: StorybookConfig = { }, async viteFinal(config) { return mergeConfig(config, { - plugins: [tsconfigPaths()], + resolve: { + tsconfigPaths: true, + }, build: { minify: false, }, diff --git a/packages/plate/.storybook/preview.ts b/packages/plate/.storybook/preview.ts index 796b9e38d0a..e3556a0a35f 100644 --- a/packages/plate/.storybook/preview.ts +++ b/packages/plate/.storybook/preview.ts @@ -6,9 +6,7 @@ import installPlate from '../index'; installPlate(config); export const parameters = { - backgrounds: { - default: 'light', - }, + backgrounds: {}, options: { storySort: { order: [ @@ -28,3 +26,9 @@ export const parameters = { }, }, }; + +export const initialGlobals = { + backgrounds: { + value: 'light', + }, +}; diff --git a/packages/plate/AGENTS.md b/packages/plate/AGENTS.md new file mode 100644 index 00000000000..7bed89df03f --- /dev/null +++ b/packages/plate/AGENTS.md @@ -0,0 +1,44 @@ +# AGENTS.md + +This file applies only to `packages/plate` and its subdirectories. + +## What This Package Is + +- `@plone/plate` is the **block editor for Seven**, built on [Plate.js](https://platejs.org/). +- It will eventually **replace `@plone/volto-slate`** in the Seven context. +- It is consumed by `@plone/cmsui` to power the `add` and `edit` routes, and uses Edit components from `@plone/blocks` for individual block types. +- Block migration utilities live under `migrations/`. + +> [!WARNING] +> This package is experimental. Breaking changes may occur without notice. + +## Package Model + +- **Presets** (`config/presets/`) define composed editor configurations for different use cases: + - `block-editor` — full editing preset for the CMS UI + - `somersault-editor` / `somersault-renderer` — presets for the Somersault rendering pipeline + - `block-renderer` — read-only block rendering preset + - `full` — the most feature-complete preset +- **Components** (`components/`) contain Plate UI elements (nodes, resize handles, etc.) — follow Plate.js conventions for node and leaf components. +- **Migrations** (`migrations/`) handle data transformation from old block formats. +- Storybook is configured under `.storybook/`. + +## Editing Rules + +- When changing editor behavior, prefer adding or modifying a **preset** rather than changing core rendering logic. +- Follow Plate.js plugin and component conventions when adding new editor features. +- If a block needs both an edit and a view component, the edit component lives in `@plone/blocks`; the renderer preset wires it up here. +- Write migration tests for any data format changes. + +## Validation + +```sh +pnpm --filter @plone/plate test --run +pnpm --filter @plone/plate check:ts +``` + +For Storybook: + +```sh +pnpm --filter @plone/plate storybook +``` diff --git a/packages/plate/CHANGELOG.md b/packages/plate/CHANGELOG.md index c12eeed32ba..c4443bcfabf 100644 --- a/packages/plate/CHANGELOG.md +++ b/packages/plate/CHANGELOG.md @@ -8,6 +8,79 @@ +## 1.0.0-alpha.8 (2026-05-13) + +### Feature + +- Added first-class generic style field support while preserving `blockWidth` fallback for Plone blocks and explicit width handling for Plate-native blocks. @sneridagh + +## 1.0.0-alpha.7 (2026-05-08) + +### Bugfix + +- Fixed the floating toolbar width handler button, if there's only a single option, do not show. @sneridagh + +## 1.0.0-alpha.6 (2026-05-08) + +### Feature + +- Added a generic schema-driven style fields plugin for Plate and migrated `blockWidth` to use the shared style field runtime. @sneridagh + +## 1.0.0-alpha.5 (2026-05-07) + +### Bugfix + +- Prevent `input.css` import directly from the repo, since it's plain wrong. @sneridagh + +## 1.0.0-alpha.4 (2026-05-07) + +### Feature + +- Added shared Plate context state for comments and suggestion editing, including inline suggestion tracking and toolbar-driven suggestion mode for the new collaborative editor flow. @sneridagh + +### Bugfix + +- Kept fast add-view title typing synchronized in the title block and aligned the empty title placeholder with the constrained title container. + +### Internal + +- Added AGENTS.md file. @pnicolli +- Aligned Plate's editor typings and debounce dependency setup with the monorepo-wide typecheck cleanup. +- Switched Plate's local `@testing-library/jest-dom` dev dependency to the shared catalog entry during the monorepo dependency cleanup. + +## 1.0.0-alpha.3 (2026-04-16) + +### Breaking + +- Remove output.css from @plone/plate and generation command. @sneridagh + Remove Plate image plugin. @sneridagh [#8015](https://github.com/plone/volto/issues/8015) +- Refactored and re-thinked blockWidth feature. @sneridagh [#8053](https://github.com/plone/volto/issues/8053) +- Refactored and re-thinked slash menu plugin extensibility story. @sneridagh [#8054](https://github.com/plone/volto/issues/8054) +- Removed the h1 plugin from the general BasicBlocksKit configuration. @sneridagh [#8056](https://github.com/plone/volto/issues/8056) + +### Feature + +- Added detection of title block in Plate's table of contents plugin. @arybakov05 + Scroll to headings and highlight them after clicking a Plate table of contents entry in the static view. @arybakov05 [#6722](https://github.com/plone/volto/issues/6722) +- Somersault editor support. @sneridagh [#7921](https://github.com/plone/volto/issues/7921) +- Refactored runtime migrations to match the somersault editor, reorganize server config files. Fixed tests. @sneridagh [#8021](https://github.com/plone/volto/issues/8021) +- Add an always shown placeholder for the title if empty. @sneridagh [#8057](https://github.com/plone/volto/issues/8057) +- Added runtime migration for default blockWidths. @sneridagh [#8071](https://github.com/plone/volto/issues/8071) +- Improve implementation of the block widths, not normalizing on render every time in the block-width plugin, but tapping on block creation. @sneridagh [#8099](https://github.com/plone/volto/issues/8099) +- Fixed discussion kit render placement aboveNodes->belowNodes. @sneridagh [#8101](https://github.com/plone/volto/issues/8101) +- Fixed linting. @sneridagh [#8106](https://github.com/plone/volto/issues/8106) +- Added remaining block inner containers. @sneridagh + +### Bugfix + +- Fixed block inner container CSS for seven styling. @sneridagh [#8076](https://github.com/plone/volto/issues/8076) +- Fixed SOMERSAULT_KEY constant, it is centralized now. @sneridagh [#8078](https://github.com/plone/volto/issues/8078) + +### Internal + +- Removed `react-player` from Plate's editable video node to drop the `dash.js` build dependency and its noisy build warning output. @sneridagh +- Updated packages configuration for vite 8. @pnicolli + ## 1.0.0-alpha.2 (2026-02-03) ### Breaking diff --git a/packages/plate/Makefile b/packages/plate/Makefile new file mode 100644 index 00000000000..d141aebdf63 --- /dev/null +++ b/packages/plate/Makefile @@ -0,0 +1,25 @@ +# Project settings +include ../../variables.mk + +.PHONY: all +all: help + +.PHONY: help +help: ## This help message + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/$(CYAN)\1$(RESET):\2/' | column -c2 -t -s :)" + +.PHONY: install +install: ## Install dependencies + pnpm install + +.PHONY: build +build: ## Build the package + pnpm run --if-present build + +.PHONY: storybook-start +storybook-start: ## Start Storybook + pnpm run storybook + +.PHONY: storybook-build +storybook-build: ## Build Storybook + pnpm run build-storybook diff --git a/packages/plate/TODO.md b/packages/plate/TODO.md new file mode 100644 index 00000000000..804e98c2a3a --- /dev/null +++ b/packages/plate/TODO.md @@ -0,0 +1,298 @@ +# Comments And Suggestions Integration Notes + +This note documents the current integration model for comments and suggestions in `@plone/plate`, why it was changed, and how a host app such as Seven should consume it. + +## Summary + +Comments and suggestions no longer rely on hardcoded demo data inside `@plone/plate`. + +The library now expects the host app to provide: + +- `currentUser` +- `currentUserId` +- `users` +- `discussions` +- `setUsers` +- `setDiscussions` + +These values are exposed through [components/editor/plate-plugins-context.tsx](./components/editor/plate-plugins-context.tsx) and consumed with `usePlatePlugins()`. + +## What Changed + +### 1. Shared app-owned state + +`@plone/plate` now exposes a lightweight context: + +- [components/editor/plate-plugins-context.tsx](./components/editor/plate-plugins-context.tsx) + +This context is the app boundary for discussion/suggestion state. The editor package owns rendering and editor behavior, but the host app owns the actual data and current user. + +### 2. Comments use React context directly + +Comment flows were migrated fully to React-side data access. + +Relevant files: + +- [components/ui/comment.tsx](./components/ui/comment.tsx) +- [components/ui/block-discussion.tsx](./components/ui/block-discussion.tsx) +- [components/editor/use-chat.ts](./components/editor/use-chat.ts) + +Comment creation/update/resolve/remove now reads `currentUserId`, `users`, and `discussions` from `usePlatePlugins()`. Because of that, comments no longer need a bridge or sync step into plugin state. + +### 3. Suggestions use the same data source for display + +Suggestion rendering and discussion lookup also use the same context. + +Relevant files: + +- [components/ui/block-suggestion.tsx](./components/ui/block-suggestion.tsx) +- [components/ui/block-discussion.tsx](./components/ui/suggestion-node.tsx) + +Suggestion cards resolve author info from `users` in the shared context, and suggestion comments come from `discussions`. + +### 4. Suggestion creation still depends on plugin state + +Suggestion creation/edit behavior still lives in low-level editor transform overrides: + +- [components/editor/plugins/suggestion-core.ts](./components/editor/plugins/suggestion-core.ts) + +This file intercepts typing, deletion, insertion, breaks, fragments, etc. It still needs `currentUserId` when the editor operation happens so it can: + +- stamp new suggestion nodes with `userId` +- decide whether an existing suggestion belongs to the current user +- merge follow-up edits into the same suggestion when appropriate + +Because this code is not React, it cannot call `usePlatePlugins()`. + +## Current Model + +The current compromise is: + +- Comments: fully React/context-driven +- Suggestion display: React/context-driven +- Suggestion transform ownership: plugin-option-driven + +The relevant suggestion option is `currentUserId` on `SuggestionPlugin`. + +That option is not synced continuously anymore. Instead, it is set at the UI entry points that enable suggestion mode: + +- [components/ui/suggestion-toolbar-button.tsx](./components/ui/suggestion-toolbar-button.tsx) +- [components/ui/mode-toolbar-button.tsx](./components/ui/mode-toolbar-button.tsx) + +So the flow is now: + +1. Host app provides `currentUserId` via `PlatePluginsProvider`. +2. Suggestion mode is enabled from UI. +3. The UI writes `currentUserId` into `SuggestionPlugin` options. +4. `suggestion-core.ts` reads that option while handling editor transforms. + +This removed the explicit sync component while keeping suggestion creation working. + +## How A Host App Should Consume `@plone/plate` + +The host app should wrap the editor and renderer with `PlatePluginsProvider` from `@plone/plate` and provide a value object with: + +- `currentUser` +- `currentUserId` +- `users` +- `discussions` +- `setUsers` +- `setDiscussions` + +Minimum requirements: + +- `discussions` should include both comment threads and suggestion discussion threads. +- `users` should be keyed by user id. +- `currentUserId` should be stable for the logged-in editor session. + +Example shape: + +```ts +{ + currentUser: { id: 'admin', name: 'Admin' }, + currentUserId: 'admin', + discussions: [], + setDiscussions, + setUsers, + users: { + admin: { id: 'admin', name: 'Admin' }, + }, +} +``` + +## What Volto-Plate Does + +`volto-plate` currently provides the app-side adapter. + +Relevant files: + +- [../packages/volto-plate/src/plate/context/PlatePluginsProvider.tsx](../packages/volto-plate/src/plate/context/PlatePluginsProvider.tsx) +- [../packages/volto-plate/src/plate/discussion-data.ts](../packages/volto-plate/src/plate/discussion-data.ts) +- [../packages/volto-plate/src/components/PlateEditorForm/PlateEditorForm.tsx](../packages/volto-plate/src/components/PlateEditorForm/PlateEditorForm.tsx) +- [../packages/volto-plate/src/components/PlateEditorRenderer/PlateEditorRenderer.tsx](../packages/volto-plate/src/components/PlateEditorRenderer/PlateEditorRenderer.tsx) + +That adapter: + +- derives `currentUser` from the JWT token +- normalizes persisted `users` and `discussions` +- injects them into `@plone/plate` +- persists updated discussions back into the somersault block + +## How Hydration Is Wired In The Editor Instances + +The host app is responsible for hydrating Plate with persisted discussion data before rendering the editor instance. + +In `volto-plate`, that happens separately for edit mode and view mode. + +### Edit mode + +Relevant file: + +- [../packages/volto-plate/src/components/PlateEditorForm/PlateEditorForm.tsx](../packages/volto-plate/src/components/PlateEditorForm/PlateEditorForm.tsx) + +Flow: + +1. Read the persisted somersault block from content. +2. Normalize `discussions` with `normalizeDiscussions(...)`. +3. Normalize `users` with `normalizeUsers(...)`. +4. Wrap `PlateEditor` with the app-side `PlatePluginsProvider`. +5. Pass `initialDiscussions` and `initialUsers` into that provider. +6. On discussion changes, serialize them back with `serializeDiscussions(...)`. +7. Persist the updated value and serialized discussions back into the somersault block through `onChangeFormData(...)`. + +This means the editor instance is hydrated before the Plate UI mounts, and all comment/suggestion UI reads from already-normalized app state. + +### View mode + +Relevant file: + +- [../packages/volto-plate/src/components/PlateEditorRenderer/PlateEditorRenderer.tsx](../packages/volto-plate/src/components/PlateEditorRenderer/PlateEditorRenderer.tsx) + +Flow: + +1. Read persisted `discussions` and `users` from the somersault block. +2. Normalize both payloads. +3. Wrap `PlateRenderer` with `PlatePluginsProvider`. +4. Pass the normalized state into the provider. + +That is enough for persisted suggestions/comments to render correctly in view mode, because the renderer-side UI resolves authors and discussion threads from the same shared context contract. + +### Why this matters + +`@plone/plate` itself does not know where persisted comments/suggestions live. + +Hydration is therefore a host-app concern: + +- storage format is app-specific +- user/session source is app-specific +- persistence callbacks are app-specific + +The package only assumes that, by the time `PlateEditor` or `PlateRenderer` renders, the app has already transformed its stored data into the `PlatePluginsProvider` shape. + +### Minimal host-app example + +Pseudocode: + +```tsx +import { + PlatePluginsProvider, +} from '@plone/plate/components/editor/plate-plugins-context'; +import { PlateEditor, PlateRenderer } from '@plone/plate/components/editor'; + +function AppPlateEditor({ + currentUser, + persistedDiscussions, + persistedUsers, + value, + onChange, +}) { + const [discussions, setDiscussions] = React.useState( + normalizeDiscussions(persistedDiscussions), + ); + const [users, setUsers] = React.useState(normalizeUsers(persistedUsers)); + + const contextValue = React.useMemo( + () => ({ + currentUser, + currentUserId: currentUser?.id ?? null, + discussions, + setDiscussions, + setUsers, + users, + }), + [currentUser, discussions, users], + ); + + return ( + + { + onChange({ + value: nextValue, + discussions: serializeDiscussions(discussions), + }); + }} + /> + + ); +} + +function AppPlateRenderer({ + currentUser, + persistedDiscussions, + persistedUsers, + value, +}) { + const contextValue = { + currentUser, + currentUserId: currentUser?.id ?? null, + discussions: normalizeDiscussions(persistedDiscussions), + setDiscussions: () => {}, + setUsers: () => {}, + users: normalizeUsers(persistedUsers), + }; + + return ( + + + + ); +} +``` + +Important details: + +- In edit mode, `setDiscussions` must update app-owned state so comment/suggestion changes persist. +- In view mode, the setters can be inert because the renderer only needs read access. +- If the host app supports suggestion creation, it must expose a valid `currentUserId`. + +## What Seven Needs + +If Seven wants to consume comments/suggestions in the same way, it should provide the same provider contract around the Plate editor: + +- get the current user from the Seven session/app state +- normalize stored user/discussion data into the `@plone/plate` shape +- provide `setDiscussions` and `setUsers` +- persist updated discussion data in the app-specific storage layer + +The important point is: + +- `@plone/plate` is no longer the source of truth for users/discussions +- Seven (or any host app) must be the source of truth + +## Remaining Architectural Gap + +Suggestions are still not as clean as comments. + +Comments work entirely from React context because comment creation happens in React components. + +Suggestions still need `currentUserId` in plugin state because suggestion creation is implemented inside `suggestion-core.ts` transform overrides. + +If we want full parity with comments later, the deeper refactor would be: + +- move user-dependent suggestion creation/ownership logic out of `suggestion-core.ts` +- keep `suggestion-core.ts` generic +- let React-level commands provide user information directly + +That is not required for current usage, but it is the next cleanup direction if we want suggestions to match the comment model completely. diff --git a/packages/plate/components/editor/block-editor-base-kit.tsx b/packages/plate/components/editor/block-editor-base-kit.tsx index 7c6223fb067..680a6c1caf6 100644 --- a/packages/plate/components/editor/block-editor-base-kit.tsx +++ b/packages/plate/components/editor/block-editor-base-kit.tsx @@ -12,6 +12,7 @@ import { BaseListKit } from './plugins/list-base-kit'; import { BaseMediaKit } from './plugins/media-base-kit'; import { BaseMentionKit } from './plugins/mention-base-kit'; import { BaseBlockWidthKit } from './plugins/block-width-base-kit'; +import { BaseStyleFieldsKit } from './plugins/style-fields-base-kit'; import { BaseSuggestionKit } from './plugins/suggestion-base-kit'; import { BaseTableKit } from './plugins/table-base-kit'; import { BaseTocKit } from './plugins/toc-base-kit'; @@ -31,9 +32,10 @@ export const BlockBaseEditorKit = [ ...BaseBasicMarksKit, ...BaseFontKit, ...BaseListKit, + ...BaseStyleFieldsKit, + ...BaseBlockWidthKit, ...BaseAlignKit, ...BaseLineHeightKit, - ...BaseBlockWidthKit, ...BaseCommentKit, ...BaseSuggestionKit, ]; diff --git a/packages/plate/components/editor/block-editor-kit.tsx b/packages/plate/components/editor/block-editor-kit.tsx index 5716c13e0f5..ef19c7afeb1 100644 --- a/packages/plate/components/editor/block-editor-kit.tsx +++ b/packages/plate/components/editor/block-editor-kit.tsx @@ -1,7 +1,7 @@ import { type Value, TrailingBlockPlugin } from 'platejs'; import { type TPlateEditor, useEditorRef } from 'platejs/react'; -import { AIKit } from './plugins/ai-kit'; +// import { AIKit } from './plugins/ai-kit'; import { AlignKit } from './plugins/align-kit'; import { AutoformatKit } from './plugins/autoformat-kit'; import { BasicBlocksKit } from './plugins/basic-blocks-kit'; @@ -20,12 +20,12 @@ import { ExitBreakKit } from './plugins/exit-break-kit'; import { FloatingToolbarKit } from './plugins/floating-toolbar-kit'; import { FontKit } from './plugins/font-kit'; import { LineHeightKit } from './plugins/line-height-kit'; -import { LinkKit } from './plugins/link-kit'; import { ListKit } from './plugins/list-kit'; import { MarkdownKit } from './plugins/markdown-kit'; import { MediaKit } from './plugins/media-kit'; import { MentionKit } from './plugins/mention-kit'; import { BlockWidthKit } from './plugins/block-width-kit'; +import { StyleFieldsKit } from './plugins/style-fields-kit'; import { SlashKit } from './plugins/slash-kit'; import { SuggestionKit } from './plugins/suggestion-kit'; import { TableKit } from './plugins/table-kit'; @@ -34,7 +34,7 @@ import { ToggleKit } from './plugins/toggle-kit'; import { SplitHotkeyPlugin } from './plugins/split-hotkey'; export const BlockEditorKit = [ - ...AIKit, + // ...AIKit, ...BlockMenuKit, // Elements @@ -46,7 +46,6 @@ export const BlockEditorKit = [ ...MediaKit, ...CalloutKit, ...ColumnKit, - ...LinkKit, ...MentionKit, // Marks @@ -54,10 +53,11 @@ export const BlockEditorKit = [ ...FontKit, // Block Style + ...StyleFieldsKit, + ...BlockWidthKit, ...ListKit, ...AlignKit, ...LineHeightKit, - ...BlockWidthKit, // Collaboration ...DiscussionKit, diff --git a/packages/plate/components/editor/editor-base-kit.tsx b/packages/plate/components/editor/editor-base-kit.tsx index 6ed843bbd73..84a54fd8481 100644 --- a/packages/plate/components/editor/editor-base-kit.tsx +++ b/packages/plate/components/editor/editor-base-kit.tsx @@ -4,7 +4,7 @@ import { BaseBasicMarksKit } from './plugins/basic-marks-base-kit'; import { BaseCalloutKit } from './plugins/callout-base-kit'; import { BaseCodeBlockKit } from './plugins/code-block-base-kit'; import { BaseColumnKit } from './plugins/column-base-kit'; -import { BaseCommentKit } from './plugins/comment-base-kit'; +// import { BaseCommentKit } from './plugins/comment-base-kit'; import { BaseFontKit } from './plugins/font-base-kit'; import { BaseLineHeightKit } from './plugins/line-height-base-kit'; import { BaseLinkKit } from './plugins/link-base-kit'; @@ -13,7 +13,8 @@ import { MarkdownKit } from './plugins/markdown-kit'; import { BaseMediaKit } from './plugins/media-base-kit'; import { BaseMentionKit } from './plugins/mention-base-kit'; import { BaseBlockWidthKit } from './plugins/block-width-base-kit'; -import { BaseSuggestionKit } from './plugins/suggestion-base-kit'; +import { BaseStyleFieldsKit } from './plugins/style-fields-base-kit'; +// import { BaseSuggestionKit } from './plugins/suggestion-base-kit'; import { BaseTableKit } from './plugins/table-base-kit'; import { BaseTocKit } from './plugins/toc-base-kit'; import { BaseToggleKit } from './plugins/toggle-base-kit'; @@ -32,10 +33,11 @@ export const BaseEditorKit = [ ...BaseBasicMarksKit, ...BaseFontKit, ...BaseListKit, + ...BaseStyleFieldsKit, + ...BaseBlockWidthKit, ...BaseAlignKit, ...BaseLineHeightKit, - ...BaseBlockWidthKit, - ...BaseCommentKit, - ...BaseSuggestionKit, + // ...BaseCommentKit, + // ...BaseSuggestionKit, ...MarkdownKit, ]; diff --git a/packages/plate/components/editor/editor-kit.tsx b/packages/plate/components/editor/editor-kit.tsx index bdf5e013ab0..fcc22bc9e2a 100644 --- a/packages/plate/components/editor/editor-kit.tsx +++ b/packages/plate/components/editor/editor-kit.tsx @@ -13,7 +13,7 @@ import { CodeBlockKit } from './plugins/code-block-kit'; import { ColumnKit } from './plugins/column-kit'; import { CommentKit } from './plugins/comment-kit'; import { CursorOverlayKit } from './plugins/cursor-overlay-kit'; -import { DiscussionKit } from './plugins/discussion-kit'; +// import { DiscussionKit } from './plugins/discussion-kit'; import { DndKit } from './plugins/dnd-kit'; import { DocxKit } from './plugins/docx-kit'; import { ExitBreakKit } from './plugins/exit-break-kit'; @@ -27,8 +27,9 @@ import { MarkdownKit } from './plugins/markdown-kit'; import { MediaKit } from './plugins/media-kit'; import { MentionKit } from './plugins/mention-kit'; import { BlockWidthKit } from './plugins/block-width-kit'; +import { StyleFieldsKit } from './plugins/style-fields-kit'; import { SlashKit } from './plugins/slash-kit'; -import { SuggestionKit } from './plugins/suggestion-kit'; +// import { SuggestionKit } from './plugins/suggestion-kit'; import { TableKit } from './plugins/table-kit'; import { TocKit } from './plugins/toc-kit'; import { ToggleKit } from './plugins/toggle-kit'; @@ -54,15 +55,16 @@ export const EditorKit = [ ...FontKit, // Block Style + ...StyleFieldsKit, + ...BlockWidthKit, ...ListKit, ...AlignKit, ...LineHeightKit, - ...BlockWidthKit, // Collaboration - ...DiscussionKit, + // ...DiscussionKit, ...CommentKit, - ...SuggestionKit, + // ...SuggestionKit, // Editing ...SlashKit, diff --git a/packages/plate/components/editor/index.tsx b/packages/plate/components/editor/index.tsx index 7b1eeae72fe..881724c19f5 100644 --- a/packages/plate/components/editor/index.tsx +++ b/packages/plate/components/editor/index.tsx @@ -7,7 +7,6 @@ import { type TPlateEditor, type PlateViewProps, } from 'platejs/react'; -import { useMemo } from 'react'; import { Editor, @@ -16,7 +15,6 @@ import { editorVariants, } from '../ui/editor'; import type { VariantProps } from 'class-variance-authority'; -import { normalizeLegacyValue } from './plugins/normalize-legacy'; export function PlateEditor(props: { editorConfig: Parameters[0]; @@ -29,14 +27,9 @@ export function PlateEditor(props: { value: TElement[]; }) => void; }) { - const sanitizedValue = useMemo( - () => normalizeLegacyValue(props.value), - [props.value], - ); - const editor = usePlateEditor({ ...props.editorConfig, - value: sanitizedValue, + value: props.value, }); (editor as any).blocksApi = props.blocksApi; @@ -81,19 +74,18 @@ export function PlateRenderer( ) { const { editorConfig, ...rest } = props; - const sanitizedValue = useMemo( - () => normalizeLegacyValue(props.value), - [props.value], - ); - const editor = usePlateEditor({ ...editorConfig, - value: sanitizedValue, - }) as SlateEditor; // EditorView likes it more + value: props.value, + }) as unknown as TPlateEditor; return ( - + ); } diff --git a/packages/plate/components/editor/plate-plugins-context.tsx b/packages/plate/components/editor/plate-plugins-context.tsx new file mode 100644 index 00000000000..b8c7d9ec0a8 --- /dev/null +++ b/packages/plate/components/editor/plate-plugins-context.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import type { TDiscussion, TDiscussionUser } from './plugins/discussion-kit'; + +type PlatePluginsContextValue = { + currentUser: TDiscussionUser | null; + currentUserId: string | null; + discussions: TDiscussion[]; + setDiscussions: React.Dispatch>; + users: Record; +}; + +const PlatePluginsContext = + React.createContext(null); + +export const PlatePluginsProvider = ({ + children, + value, +}: React.PropsWithChildren<{ value: PlatePluginsContextValue }>) => { + return ( + + {children} + + ); +}; + +export const usePlatePlugins = () => { + const context = React.useContext(PlatePluginsContext); + + if (!context) { + throw new Error('PlatePluginsContext is missing'); + } + + return context; +}; diff --git a/packages/plate/components/editor/plugins/basic-blocks-base-kit.tsx b/packages/plate/components/editor/plugins/basic-blocks-base-kit.tsx index 00cd8a03474..ba597cee605 100644 --- a/packages/plate/components/editor/plugins/basic-blocks-base-kit.tsx +++ b/packages/plate/components/editor/plugins/basic-blocks-base-kit.tsx @@ -21,16 +21,10 @@ import { } from '../../ui/heading-node-static'; import { HrElementStatic } from '../../ui/hr-node-static'; import { ParagraphElementStatic } from '../../ui/paragraph-node-static'; -import { BLOCK_WIDTH_VALUES } from './block-width-plugin'; export const BaseBasicBlocksKit = [ BaseParagraphPlugin.configure({ node: { component: ParagraphElementStatic }, - options: { - blockWidth: { - defaultWidth: BLOCK_WIDTH_VALUES.narrow, - }, - }, }), BaseH1Plugin.withComponent(H1ElementStatic), BaseH2Plugin.withComponent(H2ElementStatic), diff --git a/packages/plate/components/editor/plugins/basic-blocks-kit.tsx b/packages/plate/components/editor/plugins/basic-blocks-kit.tsx index 79e4082ca26..000d6631272 100644 --- a/packages/plate/components/editor/plugins/basic-blocks-kit.tsx +++ b/packages/plate/components/editor/plugins/basic-blocks-kit.tsx @@ -1,6 +1,5 @@ import { BlockquotePlugin, - H1Plugin, H2Plugin, H3Plugin, H4Plugin, @@ -12,7 +11,6 @@ import { ParagraphPlugin } from 'platejs/react'; import { BlockquoteElement } from '../../ui/blockquote-node'; import { - H1Element, H2Element, H3Element, H4Element, @@ -21,26 +19,10 @@ import { } from '../../ui/heading-node'; import { HrElement } from '../../ui/hr-node'; import { ParagraphElement } from '../../ui/paragraph-node'; -import { BLOCK_WIDTH_VALUES } from './block-width-plugin'; export const BasicBlocksKit = [ ParagraphPlugin.configure({ node: { component: ParagraphElement }, - options: { - blockWidth: { - defaultWidth: BLOCK_WIDTH_VALUES.narrow, - widths: [BLOCK_WIDTH_VALUES.narrow], - }, - }, - }), - H1Plugin.configure({ - node: { - component: H1Element, - }, - rules: { - break: { empty: 'reset' }, - }, - shortcuts: { toggle: { keys: 'mod+alt+1' } }, }), H2Plugin.configure({ node: { diff --git a/packages/plate/components/editor/plugins/block-placeholder-kit.tsx b/packages/plate/components/editor/plugins/block-placeholder-kit.tsx index cca7cd9541a..fe538108104 100644 --- a/packages/plate/components/editor/plugins/block-placeholder-kit.tsx +++ b/packages/plate/components/editor/plugins/block-placeholder-kit.tsx @@ -5,7 +5,7 @@ export const BlockPlaceholderKit = [ BlockPlaceholderPlugin.configure({ options: { className: - 'before:absolute before:cursor-text before:text-muted-foreground/80 before:content-[attr(placeholder)]', + 'before:absolute before:left-1/2 before:block before:w-full before:max-w-(--block-width) before:-translate-x-1/2 before:cursor-text before:text-muted-foreground/80 before:content-[attr(placeholder)]', placeholders: { [KEYS.p]: 'Type something...', }, diff --git a/packages/plate/components/editor/plugins/block-width-plugin.test.ts b/packages/plate/components/editor/plugins/block-width-plugin.test.ts new file mode 100644 index 00000000000..2a05176dfbf --- /dev/null +++ b/packages/plate/components/editor/plugins/block-width-plugin.test.ts @@ -0,0 +1,460 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import config from '@plone/registry'; + +import { + BaseBlockWidthPlugin, + FALLBACK_BLOCK_WIDTH, + getBlockWidthConfig, + getDefaultBlockWidth, + getBlockWidthDefinitions, + getBlockWidthOptions, +} from './block-width-plugin'; +import { + BaseStyleFieldsPlugin, + resetStyleFieldOnEditor, + setStyleFieldOnEditor, +} from './style-fields-plugin'; + +type RegistryBlocksState = { + widths?: unknown; + plateBlocksConfig?: unknown; + blocksConfig?: unknown; + utilities?: unknown; +}; + +type TransformPropsArgs = { + editor?: unknown; + element?: Record; + props: { + style?: Record; + }; +}; + +type TransformPropsFn = (args: TransformPropsArgs) => { + style: Record; +}; + +const registryBlocks = config.blocks as Record; + +const snapshotRegistryState = (): RegistryBlocksState => ({ + widths: registryBlocks.widths, + plateBlocksConfig: registryBlocks.plateBlocksConfig, + blocksConfig: registryBlocks.blocksConfig, + utilities: config.utilities, +}); + +const restoreRegistryState = (state: RegistryBlocksState) => { + registryBlocks.widths = state.widths; + registryBlocks.plateBlocksConfig = state.plateBlocksConfig; + registryBlocks.blocksConfig = state.blocksConfig; + config.utilities = state.utilities as any; +}; + +const initialRegistryState = snapshotRegistryState(); + +const createEditor = (defaultWidths = ['default']) => + ({ + getOptions: vi.fn(() => ({ defaultWidths })), + }) as any; + +afterEach(() => { + restoreRegistryState(initialRegistryState); +}); + +beforeEach(() => { + config.registerUtility({ + type: 'styleFieldDefinition', + name: 'blockWidth', + method: () => (registryBlocks.widths as any) ?? [], + }); +}); + +describe('block width plugin', () => { + it('falls back to the default width definitions when config.blocks.widths is unset', () => { + registryBlocks.widths = undefined; + + expect(getBlockWidthDefinitions()).toEqual([ + { + style: { '--block-width': 'var(--narrow-container-width)' }, + name: 'narrow', + label: 'Narrow', + }, + { + style: { '--block-width': 'var(--default-container-width)' }, + name: 'default', + label: 'Default', + }, + { + style: { '--block-width': 'var(--layout-container-width)' }, + name: 'layout', + label: 'Layout', + }, + { + style: { '--block-width': '100%' }, + name: 'full', + label: 'Full Width', + }, + ]); + }); + + it('reads width definitions and toolbar options from config.blocks.widths', () => { + registryBlocks.widths = [ + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + { + name: 'cinema', + label: 'Cinema', + style: { '--block-width': '120ch' }, + }, + ]; + + expect(getBlockWidthDefinitions()).toEqual(registryBlocks.widths); + expect(getBlockWidthOptions()).toEqual([ + { label: 'Default', value: 'default' }, + { label: 'Cinema', value: 'cinema' }, + ]); + }); + + it('resolves plate block width config from config.blocks.plateBlocksConfig', () => { + registryBlocks.plateBlocksConfig = { + p: { + blockWidth: { + defaultWidth: 'narrow', + widths: ['narrow'], + }, + }, + }; + + const editor = createEditor(); + + expect( + getBlockWidthConfig(editor, { + type: 'p', + children: [{ text: 'Paragraph' }], + } as any), + ).toEqual({ + defaultWidth: 'narrow', + widths: ['narrow'], + }); + }); + + it('does not use blocksConfig.blockWidth for unknown blocks in BlockWidthPlugin', () => { + registryBlocks.widths = [ + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + { + name: 'layout', + label: 'Layout', + style: { '--block-width': 'var(--layout-container-width)' }, + }, + ]; + registryBlocks.blocksConfig = { + image: { + blockWidth: { + defaultWidth: 'layout', + widths: ['layout'], + }, + }, + }; + + const editor = createEditor(); + + expect( + getBlockWidthConfig(editor, { + type: 'unknown', + '@type': 'image', + children: [{ text: '' }], + } as any), + ).toEqual({ + defaultWidth: 'default', + widths: ['default', 'layout'], + }); + }); + + it('still uses blocksConfig.blockWidth as a fallback for unknown blocks in style fields', () => { + registryBlocks.widths = [ + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + { + name: 'layout', + label: 'Layout', + style: { '--block-width': 'var(--layout-container-width)' }, + }, + ]; + registryBlocks.blocksConfig = { + image: { + blockWidth: { + defaultWidth: 'layout', + widths: ['layout'], + }, + }, + }; + + const transformProps = (BaseStyleFieldsPlugin as any).inject.nodeProps + .transformProps as TransformPropsFn; + + expect( + transformProps({ + element: { + type: 'unknown', + '@type': 'image', + children: [{ text: '' }], + }, + props: { + style: { + color: 'red', + }, + }, + }), + ).toEqual({ + style: { + color: 'red', + '--block-width': 'var(--layout-container-width)', + }, + }); + }); + + it('adds the default width to the allowed list when the config omits it', () => { + registryBlocks.plateBlocksConfig = { + p: { + blockWidth: { + defaultWidth: 'default', + widths: ['narrow'], + }, + }, + }; + + const editor = createEditor(); + const result = getBlockWidthConfig(editor, { + type: 'p', + children: [{ text: 'Paragraph' }], + } as any); + + expect(result.defaultWidth).toBe('default'); + expect(result.widths).toEqual(['narrow', 'default']); + }); + + it('injects the resolved width style object into node props', () => { + registryBlocks.widths = [ + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + { + name: 'full', + label: 'Full Width', + style: { '--block-width': '100%' }, + }, + ]; + registryBlocks.plateBlocksConfig = { + p: { + blockWidth: { + defaultWidth: 'default', + widths: ['default', 'full'], + }, + }, + }; + + const transformProps = (BaseBlockWidthPlugin as any).inject.nodeProps + .transformProps as TransformPropsFn; + + expect( + transformProps({ + element: { + type: 'p', + blockWidth: 'full', + children: [{ text: 'Paragraph' }], + }, + props: { + style: { + color: 'red', + }, + }, + }), + ).toEqual({ + style: { + color: 'red', + '--block-width': '100%', + }, + }); + + expect(getDefaultBlockWidth()).toBe('default'); + expect(FALLBACK_BLOCK_WIDTH).toBe('default'); + }); + + it('uses the configured default width style when blockWidth is missing', () => { + registryBlocks.widths = [ + { + name: 'narrow', + label: 'Narrow', + style: { '--block-width': 'var(--narrow-container-width)' }, + }, + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + ]; + registryBlocks.plateBlocksConfig = { + p: { + blockWidth: { + defaultWidth: 'narrow', + widths: ['narrow'], + }, + }, + }; + + const editor = createEditor(); + const transformProps = (BaseBlockWidthPlugin as any).inject.nodeProps + .transformProps as TransformPropsFn; + + expect( + transformProps({ + editor, + element: { + type: 'p', + children: [{ text: 'Paragraph without width' }], + }, + props: { + style: { + color: 'red', + }, + }, + } as any), + ).toEqual({ + style: { + color: 'red', + '--block-width': 'var(--narrow-container-width)', + }, + }); + }); + + it('derives the fallback default width from the registry definitions', () => { + registryBlocks.widths = [ + { + name: 'cinema', + label: 'Cinema', + style: { '--block-width': '120ch' }, + }, + { + name: 'wide', + label: 'Wide', + style: { '--block-width': '90ch' }, + }, + ]; + + expect(getDefaultBlockWidth()).toBe('cinema'); + }); + + it('reads and writes nested path style fields through generic transforms', () => { + registryBlocks.blocksConfig = { + teaser: { + blockSchema: { + title: 'Teaser', + fieldsets: [], + required: [], + properties: { + theme: { + title: 'Theme', + default: 'default', + choices: [ + ['default', 'Default'], + ['sand', 'Sand'], + ], + styleField: { + path: 'styles.theme', + }, + }, + }, + }, + }, + }; + + config.registerUtility({ + type: 'styleFieldDefinition', + name: 'theme', + method: () => [ + { + name: 'default', + label: 'Default', + style: { '--theme-color': 'white' }, + }, + { + name: 'sand', + label: 'Sand', + style: { '--theme-color': 'wheat' }, + }, + ], + }); + + const setNodes = vi.fn(); + const block = { + type: 'unknown', + '@type': 'teaser', + styles: { + theme: 'sand', + }, + children: [{ text: '' }], + }; + const editor = { + api: { + block: vi.fn(() => [block, [0]]), + node: vi.fn(() => [block, [0]]), + blocks: vi.fn(() => [[block, [0]]]), + isBlock: vi.fn(() => true), + }, + tf: { + setNodes, + }, + } as any; + + const transformProps = (BaseStyleFieldsPlugin as any).inject.nodeProps + .transformProps as TransformPropsFn; + expect( + transformProps({ + editor, + element: block, + props: { style: {} }, + } as any), + ).toEqual({ + style: { + '--theme-color': 'wheat', + }, + }); + + setStyleFieldOnEditor(editor, 'theme', 'default'); + expect(setNodes).toHaveBeenCalledWith( + { + styles: { + theme: 'default', + }, + }, + { + at: [0], + }, + ); + + setNodes.mockClear(); + resetStyleFieldOnEditor(editor, 'theme'); + expect(setNodes).toHaveBeenCalledWith( + { + styles: { + theme: 'default', + }, + }, + { + at: [0], + }, + ); + }); +}); diff --git a/packages/plate/components/editor/plugins/block-width-plugin.ts b/packages/plate/components/editor/plugins/block-width-plugin.ts index 7c6b94673e7..63670b521df 100644 --- a/packages/plate/components/editor/plugins/block-width-plugin.ts +++ b/packages/plate/components/editor/plugins/block-width-plugin.ts @@ -1,37 +1,18 @@ import { + createSlatePlugin, + ElementApi, type SetNodesOptions, type SlateEditor, type TElement, - createSlatePlugin, - ElementApi, - getInjectMatch, - getPluginByType, } from 'platejs'; +import config from '@plone/registry'; +import type { StyleDefinition } from '@plone/types'; import { toPlatePlugin } from 'platejs/react'; export const BLOCK_WIDTH_KEY = 'blockWidth'; +export const FALLBACK_BLOCK_WIDTH = 'default'; -export const BLOCK_WIDTH_VALUES = { - layout: 'var(--layout-container-width)', - default: 'var(--default-container-width)', - narrow: 'var(--narrow-container-width)', -} as const; - -export const BLOCK_WIDTH_VALUE_LIST = [ - BLOCK_WIDTH_VALUES.layout, - BLOCK_WIDTH_VALUES.default, - BLOCK_WIDTH_VALUES.narrow, -] as const; - -export type BlockWidthValue = (typeof BLOCK_WIDTH_VALUE_LIST)[number]; - -export const BLOCK_WIDTH_OPTIONS = [ - { label: 'Layout', value: BLOCK_WIDTH_VALUES.layout }, - { label: 'Default', value: BLOCK_WIDTH_VALUES.default }, - { label: 'Narrow', value: BLOCK_WIDTH_VALUES.narrow }, -] as const; - -export const DEFAULT_BLOCK_WIDTH = BLOCK_WIDTH_VALUES.default; +export type BlockWidthValue = string; export type BlockWidthConfig = { defaultWidth?: BlockWidthValue; @@ -42,30 +23,128 @@ export type BlockWidthPluginOptions = { defaultWidths?: readonly BlockWidthValue[]; }; -const getBlockPluginWidthConfig = ( +const FALLBACK_WIDTH_DEFINITIONS: readonly StyleDefinition[] = [ + { + style: { + '--block-width': 'var(--narrow-container-width)', + }, + name: 'narrow', + label: 'Narrow', + }, + { + style: { + '--block-width': 'var(--default-container-width)', + }, + name: 'default', + label: 'Default', + }, + { + style: { + '--block-width': 'var(--layout-container-width)', + }, + name: 'layout', + label: 'Layout', + }, + { + style: { + '--block-width': '100%', + }, + name: 'full', + label: 'Full Width', + }, +] as const; + +export const getBlockWidthDefinitions = (): readonly StyleDefinition[] => { + const widths = config?.blocks?.widths as StyleDefinition[] | undefined; + + return widths?.length ? widths : FALLBACK_WIDTH_DEFINITIONS; +}; + +export const getBlockWidthValueList = (): BlockWidthValue[] => + getBlockWidthDefinitions() + .map((width) => width.name) + .filter((name): name is string => !!name); + +export const getDefaultBlockWidth = (): BlockWidthValue => { + const widthValues = getBlockWidthValueList(); + + if (!widthValues.length) return FALLBACK_BLOCK_WIDTH; + if (widthValues.includes(FALLBACK_BLOCK_WIDTH)) return FALLBACK_BLOCK_WIDTH; + + return widthValues[0]; +}; + +export const getBlockWidthOptions = () => + getBlockWidthDefinitions().map((width) => ({ + label: width.label, + value: width.name as BlockWidthValue, + })); + +const getBlockWidthStyle = (value?: string) => + getBlockWidthDefinitions().find((width) => width.name === value)?.style; + +const getPlateBlockRegistryWidthConfig = ( + element?: TElement | null, +): BlockWidthConfig => { + if (!element?.type) return {}; + + const plateBlocksConfig = config?.blocks?.plateBlocksConfig as + | Record + | undefined; + + return plateBlocksConfig?.[element.type]?.blockWidth ?? {}; +}; + +const getPloneBlockRegistryWidthConfig = ( + element?: TElement | null, +): BlockWidthConfig => { + const blockType = ( + element as (TElement & { '@type'?: unknown }) | null | undefined + )?.['@type']; + if (!blockType || typeof blockType !== 'string') return {}; + + const blocksConfig = config?.blocks?.blocksConfig as unknown as + | Record + | undefined; + + return blocksConfig?.[blockType]?.blockWidth ?? {}; +}; + +export const resolveBlockWidthConfig = ( editor: SlateEditor, element?: TElement | null, ): BlockWidthConfig => { - if (!element) return {}; + if (element?.type === 'unknown') { + return {}; + } - const plugin = getPluginByType(editor, element.type); + const registryConfig = + element?.type === 'unknown' + ? getPloneBlockRegistryWidthConfig(element) + : getPlateBlockRegistryWidthConfig(element); - return ( - (plugin?.options as { blockWidth?: BlockWidthConfig } | undefined) - ?.blockWidth ?? {} - ); + if (registryConfig.defaultWidth || registryConfig.widths?.length) { + return registryConfig; + } + + const pluginOptions = editor.getOptions(BaseBlockWidthPlugin) as + | BlockWidthPluginOptions + | undefined; + + return { + widths: pluginOptions?.defaultWidths, + }; }; export const getBlockWidthConfig = ( editor: SlateEditor, element?: TElement | null, ) => { - const pluginOptions = - editor.getOptions(BaseBlockWidthPlugin); - const blockConfig = getBlockPluginWidthConfig(editor, element); - const defaultWidth = blockConfig.defaultWidth ?? DEFAULT_BLOCK_WIDTH; + const blockConfig = resolveBlockWidthConfig(editor, element); + const defaultWidth = blockConfig.defaultWidth ?? getDefaultBlockWidth(); + const registryWidths = getBlockWidthValueList(); const widths = - blockConfig.widths ?? pluginOptions.defaultWidths ?? BLOCK_WIDTH_VALUE_LIST; + blockConfig.widths ?? (registryWidths.length ? registryWidths : []); return { defaultWidth, @@ -78,80 +157,201 @@ export const getBlockWidthConfig = ( const isAllowedWidth = (widths: readonly BlockWidthValue[], value: string) => widths.includes(value as BlockWidthValue); +type ValueElement = Record & { + type?: unknown; + children?: unknown[]; +}; + +export const applyBlockWidthDefaultsInValue = (value: unknown[]) => { + const fallbackWidths = getBlockWidthValueList(); + const fallbackDefaultWidth = getDefaultBlockWidth(); + + const visit = (node: unknown) => { + if (!node || typeof node !== 'object') return; + + const element = node as ValueElement; + if (typeof element.type !== 'string') return; + + if (element.type === 'unknown') { + if (Array.isArray(element.children)) { + element.children.forEach(visit); + } + return; + } + + const registryConfig = getPlateBlockRegistryWidthConfig( + element as TElement, + ); + const defaultWidth = registryConfig.defaultWidth ?? fallbackDefaultWidth; + const widths = registryConfig.widths?.length + ? registryConfig.widths + : fallbackWidths; + const currentWidth = element[BLOCK_WIDTH_KEY]; + + if (typeof currentWidth !== 'string' || !widths.includes(currentWidth)) { + element[BLOCK_WIDTH_KEY] = defaultWidth; + } + + if (Array.isArray(element.children)) { + element.children.forEach(visit); + } + }; + + value.forEach(visit); + return value; +}; + +export const getEffectiveBlockWidth = ( + editor: SlateEditor, + element?: TElement | null, +) => { + const current = element?.[BLOCK_WIDTH_KEY]; + const { defaultWidth, widths } = getBlockWidthConfig(editor, element); + + if (typeof current === 'string' && isAllowedWidth(widths, current)) { + return current; + } + + return defaultWidth; +}; + +export const withBlockWidthDefaults = ( + editor: SlateEditor, + element: T, +): T => { + if (element.type === 'unknown') { + return element; + } + + const width = getEffectiveBlockWidth(editor, element); + + if (element[BLOCK_WIDTH_KEY] === width) { + return element; + } + + return { + ...element, + [BLOCK_WIDTH_KEY]: width, + }; +}; + +const withInsertedBlockWidthDefaults = ( + editor: SlateEditor, + nodes: unknown, +): unknown => { + if (Array.isArray(nodes)) { + return nodes.map((node) => withInsertedBlockWidthDefaults(editor, node)); + } + + if (!ElementApi.isElement(nodes)) { + return nodes; + } + + const children: unknown[] | undefined = Array.isArray(nodes.children) + ? nodes.children.map((child: unknown) => + withInsertedBlockWidthDefaults(editor, child), + ) + : nodes.children; + const nextNode: TElement = + children === nodes.children ? nodes : ({ ...nodes, children } as TElement); + + if (!editor.api.isBlock(nextNode) || nextNode.type === 'unknown') { + return nextNode; + } + + return withBlockWidthDefaults(editor, nextNode); +}; + const setBlockWidth = ( editor: SlateEditor, value: string, setNodesOptions?: SetNodesOptions, ) => { - const { nodeKey } = editor.getInjectProps(BaseBlockWidthPlugin); - const match = getInjectMatch( - editor, - editor.getPlugin({ key: BLOCK_WIDTH_KEY }), - ); - - if (!nodeKey) return; - const matchesValue = (node: TElement) => { + if (node.type === 'unknown') return false; + const config = getBlockWidthConfig(editor, node); return isAllowedWidth(config.widths, value); }; editor.tf.setNodes( - { [nodeKey]: value }, + { [BLOCK_WIDTH_KEY]: value }, { - match: (node) => match(node) && matchesValue(node), + match: (node) => + ElementApi.isElement(node) && + editor.api.isBlock(node) && + matchesValue(node), ...setNodesOptions, }, ); }; -type BaseBlockWidthPluginOptions = BlockWidthPluginOptions; - -export const BaseBlockWidthPlugin = createSlatePlugin({ +export const BaseBlockWidthPlugin = createSlatePlugin({ key: BLOCK_WIDTH_KEY, + normalizeInitialValue: ({ value }) => { + applyBlockWidthDefaultsInValue(value); + }, inject: { isBlock: true, nodeProps: { nodeKey: BLOCK_WIDTH_KEY, - styleKey: 'maxWidth', - validNodeValues: BLOCK_WIDTH_VALUE_LIST, + transformProps: ({ editor, element, nodeValue, props }) => { + if ( + !element || + !ElementApi.isElement(element) || + element.type === 'unknown' + ) { + return props; + } + + const widthValue = getEffectiveBlockWidth(editor, element) ?? nodeValue; + const widthStyle = getBlockWidthStyle(widthValue); + + if (!widthStyle) return props; + + return { + ...props, + style: { + ...(props.style ?? {}), + ...widthStyle, + }, + }; + }, }, }, options: { - defaultWidths: BLOCK_WIDTH_VALUE_LIST, + defaultWidths: [], }, extendEditor: ({ editor }) => { - const { normalizeNode } = editor; - - editor.normalizeNode = (entry) => { - const [node, path] = entry; - - if (ElementApi.isElement(node) && editor.api.isBlock(node)) { - const config = getBlockWidthConfig(editor, node); - const current = (node as TElement)[BLOCK_WIDTH_KEY] as - | string - | undefined; - - if (!current || !isAllowedWidth(config.widths, current)) { - editor.tf.setNodes( - { [BLOCK_WIDTH_KEY]: config.defaultWidth }, - { at: path }, - ); - return; - } - } + const createBlock = editor.api.create.block.bind(editor.api.create); + const insertNodes = editor.tf.insertNodes.bind(editor.tf); + + editor.api.create.block = ((...args: any[]) => + withInsertedBlockWidthDefaults(editor, createBlock(...args))) as any; - normalizeNode(entry); - }; + editor.tf.insertNodes = ((nodes: any, options?: any) => + insertNodes( + withInsertedBlockWidthDefaults(editor, nodes) as any, + options, + )) as any; return editor; }, }).extendTransforms(({ editor }) => ({ - addMark: (value: string) => { - setBlockWidth(editor, value); + resetWidth: (options?: SetNodesOptions) => { + const blockEntry = editor.api.block(); + const block = + blockEntry && + ElementApi.isElement(blockEntry[0]) && + blockEntry[0].type !== 'unknown' + ? blockEntry[0] + : undefined; + const { defaultWidth } = getBlockWidthConfig(editor, block); + + setBlockWidth(editor, defaultWidth, options); }, - setNodes: (value: string, options?: SetNodesOptions) => { + setWidth: (value: string, options?: SetNodesOptions) => { setBlockWidth(editor, value, options); }, })); diff --git a/packages/plate/components/editor/plugins/discussion-kit.tsx b/packages/plate/components/editor/plugins/discussion-kit.tsx index cc73268100d..56746f16dde 100644 --- a/packages/plate/components/editor/plugins/discussion-kit.tsx +++ b/packages/plate/components/editor/plugins/discussion-kit.tsx @@ -1,7 +1,6 @@ import type { TComment } from '../../ui/comment'; import { createPlatePlugin } from 'platejs/react'; - import { BlockDiscussion } from '../../ui/block-discussion'; export interface TDiscussion { @@ -13,134 +12,17 @@ export interface TDiscussion { documentContent?: string; } -const discussionsData: TDiscussion[] = [ - { - id: 'discussion1', - comments: [ - { - id: 'comment1', - contentRich: [ - { - children: [ - { - text: 'Comments are a great way to provide feedback and discuss changes.', - }, - ], - type: 'p', - }, - ], - createdAt: new Date(Date.now() - 600_000), - discussionId: 'discussion1', - isEdited: false, - userId: 'charlie', - }, - { - id: 'comment2', - contentRich: [ - { - children: [ - { - text: 'Agreed! The link to the docs makes it easy to learn more.', - }, - ], - type: 'p', - }, - ], - createdAt: new Date(Date.now() - 500_000), - discussionId: 'discussion1', - isEdited: false, - userId: 'bob', - }, - ], - createdAt: new Date(), - documentContent: 'comments', - isResolved: false, - userId: 'charlie', - }, - { - id: 'discussion2', - comments: [ - { - id: 'comment1', - contentRich: [ - { - children: [ - { - text: 'Nice demonstration of overlapping annotations with both comments and suggestions!', - }, - ], - type: 'p', - }, - ], - createdAt: new Date(Date.now() - 300_000), - discussionId: 'discussion2', - isEdited: false, - userId: 'bob', - }, - { - id: 'comment2', - contentRich: [ - { - children: [ - { - text: 'This helps users understand how powerful the editor can be.', - }, - ], - type: 'p', - }, - ], - createdAt: new Date(Date.now() - 200_000), - discussionId: 'discussion2', - isEdited: false, - userId: 'charlie', - }, - ], - createdAt: new Date(), - documentContent: 'overlapping', - isResolved: false, - userId: 'bob', - }, -]; - -const avatarUrl = (seed: string) => - `https://api.dicebear.com/9.x/glass/svg?seed=${seed}`; - -const usersData: Record< - string, - { id: string; avatarUrl: string; name: string; hue?: number } -> = { - alice: { - id: 'alice', - avatarUrl: avatarUrl('alice6'), - name: 'Alice', - }, - bob: { - id: 'bob', - avatarUrl: avatarUrl('bob4'), - name: 'Bob', - }, - charlie: { - id: 'charlie', - avatarUrl: avatarUrl('charlie2'), - name: 'Charlie', - }, +export type TDiscussionUser = { + id: string; + avatarUrl?: string; + name: string; + hue?: number; }; -// This plugin is purely UI. It's only used to store the discussions and users data export const discussionPlugin = createPlatePlugin({ key: 'discussion', - options: { - currentUserId: 'alice', - discussions: discussionsData, - users: usersData, - }, -}) - .configure({ - render: { aboveNodes: BlockDiscussion }, - }) - .extendSelectors(({ getOption }) => ({ - currentUser: () => getOption('users')[getOption('currentUserId')], - user: (id: string) => getOption('users')[id], - })); +}).configure({ + render: { belowNodes: BlockDiscussion }, +}); export const DiscussionKit = [discussionPlugin]; diff --git a/packages/plate/components/editor/plugins/legacy-bold-plugin.ts b/packages/plate/components/editor/plugins/legacy-bold-plugin.ts index 904d01bf7c1..36ccde8eefc 100644 --- a/packages/plate/components/editor/plugins/legacy-bold-plugin.ts +++ b/packages/plate/components/editor/plugins/legacy-bold-plugin.ts @@ -1,12 +1,12 @@ import { ElementApi, TextApi, createSlatePlugin } from 'platejs'; -import type { Path, SlateEditor, Value } from 'platejs'; +import type { NodeEntry, Path, SlateEditor, Value } from 'platejs'; import { applyNormalizedValue, cloneValueToWritable } from './legacy-utils'; export type LegacyBoldNode = { type?: string; text?: string; bold?: boolean; - children?: LegacyBoldNode[]; + children?: Value; [key: string]: unknown; }; @@ -28,9 +28,9 @@ export const migrateLegacyBold = (editor: SlateEditor, path: Path) => { }; export const migrateLegacyBoldInValue = (nodes: Value) => { - const mutableNodes = cloneValueToWritable(nodes); + const mutableNodes = cloneValueToWritable(nodes) as any[]; - const visit = (node: LegacyBoldNode, isBold = false): LegacyBoldNode[] => { + const visit = (node: LegacyBoldNode, isBold = false): any[] => { const nextIsBold = isBold || node?.type === 'strong'; if (typeof node?.text === 'string') { @@ -44,8 +44,8 @@ export const migrateLegacyBoldInValue = (nodes: Value) => { return [node]; } - const normalizedChildren = node.children.flatMap((child: LegacyBoldNode) => - visit(child, nextIsBold), + const normalizedChildren = (node.children as any[]).flatMap((child: any) => + visit(child as LegacyBoldNode, nextIsBold), ); if (node.type === 'strong') { @@ -58,8 +58,8 @@ export const migrateLegacyBoldInValue = (nodes: Value) => { const normalized = mutableNodes.flatMap((node: any) => visit(node)); mutableNodes.splice(0, mutableNodes.length, ...normalized); - applyNormalizedValue(nodes, mutableNodes); - return mutableNodes; + applyNormalizedValue(nodes, mutableNodes as any); + return mutableNodes as any; }; /** @@ -80,7 +80,7 @@ export const LegacyBoldPlugin = [ extendEditor: ({ editor }) => { const { normalizeNode } = editor; - editor.normalizeNode = (entry) => { + editor.normalizeNode = (entry: NodeEntry) => { const [node, path] = entry; if (ElementApi.isElement(node) && node.type === 'strong') { @@ -88,7 +88,7 @@ export const LegacyBoldPlugin = [ return; } - normalizeNode(entry); + (normalizeNode as (entry: NodeEntry) => void)(entry); }; return editor; diff --git a/packages/plate/components/editor/plugins/legacy-italic-plugin.ts b/packages/plate/components/editor/plugins/legacy-italic-plugin.ts index 534ffdd9805..b2887592488 100644 --- a/packages/plate/components/editor/plugins/legacy-italic-plugin.ts +++ b/packages/plate/components/editor/plugins/legacy-italic-plugin.ts @@ -1,12 +1,12 @@ import { ElementApi, TextApi, createSlatePlugin } from 'platejs'; -import type { Path, SlateEditor, Value } from 'platejs'; +import type { NodeEntry, Path, SlateEditor, Value } from 'platejs'; import { applyNormalizedValue, cloneValueToWritable } from './legacy-utils'; export type LegacyItalicNode = { type?: string; text?: string; italic?: boolean; - children?: LegacyItalicNode[]; + children?: Value; [key: string]: unknown; }; @@ -28,12 +28,9 @@ export const migrateLegacyItalic = (editor: SlateEditor, path: Path) => { }; export const migrateLegacyItalicInValue = (nodes: Value) => { - const mutableNodes = cloneValueToWritable(nodes); + const mutableNodes = cloneValueToWritable(nodes) as any[]; - const visit = ( - node: LegacyItalicNode, - isItalic = false, - ): LegacyItalicNode[] => { + const visit = (node: LegacyItalicNode, isItalic = false): any[] => { const nextIsItalic = isItalic || node?.type === 'em'; if (typeof node?.text === 'string') { @@ -47,8 +44,8 @@ export const migrateLegacyItalicInValue = (nodes: Value) => { return [node]; } - const normalizedChildren = node.children.flatMap( - (child: LegacyItalicNode) => visit(child, nextIsItalic), + const normalizedChildren = (node.children as any[]).flatMap((child: any) => + visit(child as LegacyItalicNode, nextIsItalic), ); if (node.type === 'em') { @@ -59,12 +56,12 @@ export const migrateLegacyItalicInValue = (nodes: Value) => { return [node]; }; - const normalized = (mutableNodes as LegacyItalicNode[]).flatMap((node) => - visit(node), + const normalized = mutableNodes.flatMap((node: any) => + visit(node as LegacyItalicNode), ); mutableNodes.splice(0, mutableNodes.length, ...normalized); - applyNormalizedValue(nodes, mutableNodes); - return mutableNodes; + applyNormalizedValue(nodes, mutableNodes as any); + return mutableNodes as any; }; /** @@ -85,7 +82,7 @@ export const LegacyItalicPlugin = [ extendEditor: ({ editor }) => { const { normalizeNode } = editor; - editor.normalizeNode = (entry) => { + editor.normalizeNode = (entry: NodeEntry) => { const [node, path] = entry; if (ElementApi.isElement(node) && node.type === 'em') { @@ -93,7 +90,7 @@ export const LegacyItalicPlugin = [ return; } - normalizeNode(entry); + (normalizeNode as (entry: NodeEntry) => void)(entry); }; return editor; diff --git a/packages/plate/components/editor/plugins/legacy-link-plugin.ts b/packages/plate/components/editor/plugins/legacy-link-plugin.ts index 6126dc16c4a..03351d6041f 100644 --- a/packages/plate/components/editor/plugins/legacy-link-plugin.ts +++ b/packages/plate/components/editor/plugins/legacy-link-plugin.ts @@ -1,5 +1,5 @@ import { ElementApi, KEYS, createSlatePlugin } from 'platejs'; -import type { Path, SlateEditor, Value } from 'platejs'; +import type { NodeEntry, Path, SlateEditor, Value } from 'platejs'; import { applyNormalizedValue, cloneValueToWritable } from './legacy-utils'; export type LegacyLinkData = { @@ -52,28 +52,32 @@ export const migrateLegacyLinksInValueStatic = ( if (!ElementApi.isElement(node)) { return; } + const element = node as LegacyLinkElement; const legacyUrl = - typeof node?.data?.url === 'string' ? node.data.url : undefined; + typeof element.data?.url === 'string' ? element.data.url : undefined; if (typeof legacyUrl === 'string') { - node.type = linkType; - node.url = legacyUrl; - if (node?.data?.target) { - node.target = node.data.target; + element.type = linkType; + element.url = legacyUrl; + if (element.data?.target) { + element.target = element.data.target; } - delete node.data; - } else if (node.type === linkType && typeof node?.data?.url === 'string') { - node.url = node.data.url; - if (node?.data?.target) { - node.target = node.data.target; + delete element.data; + } else if ( + element.type === linkType && + typeof element.data?.url === 'string' + ) { + element.url = element.data.url; + if (element.data?.target) { + element.target = element.data.target; } - delete node.data; - } else if (node.type === 'link') { - node.type = linkType; + delete element.data; + } else if (element.type === 'link') { + element.type = linkType; } - if (Array.isArray(node.children)) { - node.children.forEach(visit); + if (Array.isArray(element.children)) { + element.children.forEach(visit); } }; @@ -93,31 +97,32 @@ export const migrateLegacyLinksInValue = ( if (!ElementApi.isElement(node)) { return; } + const element = node as LegacyLinkElement; const legacyUrl = - typeof node?.data?.url === 'string' ? node.data.url : undefined; + typeof element.data?.url === 'string' ? element.data.url : undefined; if (typeof legacyUrl === 'string') { - node.type = plateLinkType; - node.url = legacyUrl; - if (node?.data?.target) { - node.target = node.data.target; + element.type = plateLinkType; + element.url = legacyUrl; + if (element.data?.target) { + element.target = element.data.target; } - delete node.data; + delete element.data; } else if ( - node.type === plateLinkType && - typeof node?.data?.url === 'string' + element.type === plateLinkType && + typeof element.data?.url === 'string' ) { - node.url = node.data.url; - if (node?.data?.target) { - node.target = node.data.target; + element.url = element.data.url; + if (element.data?.target) { + element.target = element.data.target; } - delete node.data; - } else if (node.type === 'link') { - node.type = plateLinkType; + delete element.data; + } else if (element.type === 'link') { + element.type = plateLinkType; } - if (Array.isArray(node.children)) { - node.children.forEach(visit); + if (Array.isArray(element.children)) { + element.children.forEach(visit); } }; @@ -145,7 +150,7 @@ export const LegacyLinkPlugin = [ extendEditor: ({ editor }) => { const { normalizeNode } = editor; - editor.normalizeNode = (entry) => { + editor.normalizeNode = (entry: NodeEntry) => { const [node, path] = entry; if (ElementApi.isElement(node)) { @@ -173,7 +178,7 @@ export const LegacyLinkPlugin = [ } } - normalizeNode(entry); + (normalizeNode as (entry: NodeEntry) => void)(entry); }; return editor; diff --git a/packages/plate/components/editor/plugins/legacy-list-plugin.ts b/packages/plate/components/editor/plugins/legacy-list-plugin.ts index e48676b107c..2c985646478 100644 --- a/packages/plate/components/editor/plugins/legacy-list-plugin.ts +++ b/packages/plate/components/editor/plugins/legacy-list-plugin.ts @@ -1,11 +1,11 @@ import { ElementApi, KEYS, createSlatePlugin } from 'platejs'; -import type { Path, SlateEditor, Value } from 'platejs'; +import type { NodeEntry, Path, SlateEditor, Value } from 'platejs'; import { applyNormalizedValue, cloneValueToWritable } from './legacy-utils'; type LegacyListElement = { type?: string; - children?: LegacyListElement[]; + children?: Value; listStyleType?: string; indent?: number; listStart?: number; @@ -17,7 +17,7 @@ const listTypeMap: Record = { ol: KEYS.ol, // decimal }; -const flattenListNode = (node: LegacyListElement): LegacyListElement[] => { +const flattenListNode = (node: LegacyListElement): any[] => { const listStyleType = listTypeMap[node.type ?? ''] ?? KEYS.ul; const items = (node.children ?? []).filter((child) => ElementApi.isElement(child), @@ -40,9 +40,9 @@ const flattenListNode = (node: LegacyListElement): LegacyListElement[] => { }; export const migrateLegacyListsInValue = (nodes: Value): Value => { - const mutable = cloneValueToWritable(nodes); + const mutable = cloneValueToWritable(nodes) as any[]; - const visit = (node: LegacyListElement): LegacyListElement[] => { + const visit = (node: LegacyListElement): any[] => { const isList = node?.type === 'ul' || node?.type === 'ol'; if (isList) { @@ -53,26 +53,30 @@ export const migrateLegacyListsInValue = (nodes: Value): Value => { return [node]; } - const normalizedChildren = node.children.flatMap((child) => visit(child)); + const normalizedChildren = (node.children as any[]).flatMap((child: any) => + visit(child as LegacyListElement), + ); node.children = normalizedChildren; return [node]; }; - const normalized = mutable.flatMap((node) => visit(node)); + const normalized = mutable.flatMap((node: any) => + visit(node as LegacyListElement), + ); mutable.splice(0, mutable.length, ...normalized); - applyNormalizedValue(nodes, mutable); - return mutable; + applyNormalizedValue(nodes, mutable as any); + return mutable as any; }; const migrateListAtPath = (editor: SlateEditor, path: Path) => { const nodeEntry = editor.api.node(path); if (!nodeEntry) return; - const [node] = nodeEntry as [LegacyListElement]; + const [node] = nodeEntry as unknown as [LegacyListElement, Path]; const items = flattenListNode(node); editor.tf.withoutNormalizing(() => { editor.tf.removeNodes({ at: path }); - editor.tf.insertNodes(items, { at: path }); + editor.tf.insertNodes(items as any, { at: path }); }); }; @@ -94,7 +98,7 @@ export const LegacyListPlugin = [ extendEditor: ({ editor }) => { const { normalizeNode } = editor; - editor.normalizeNode = (entry) => { + editor.normalizeNode = (entry: NodeEntry) => { const [node, path] = entry; if ( @@ -105,7 +109,7 @@ export const LegacyListPlugin = [ return; } - normalizeNode(entry); + (normalizeNode as (entry: NodeEntry) => void)(entry); }; return editor; diff --git a/packages/plate/components/editor/plugins/legacy-migrations.test.ts b/packages/plate/components/editor/plugins/legacy-migrations.test.ts index a31a72f82d8..d5709fcdc73 100644 --- a/packages/plate/components/editor/plugins/legacy-migrations.test.ts +++ b/packages/plate/components/editor/plugins/legacy-migrations.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { KEYS } from 'platejs'; import type { SlateEditor, Value } from 'platejs'; @@ -20,7 +20,24 @@ import { migrateLegacyLinksInValue, migrateLegacyLinksInValueStatic, } from './legacy-link-plugin'; -import { normalizeLegacyValue } from './normalize-legacy'; +import { + migrateLegacyBlockWidthsInValue, + normalizeLegacyValue, +} from '../../../migrations'; +import config from '@plone/registry'; + +const registryBlocks = config.blocks as Record; +const initialRegistryState = { + widths: registryBlocks.widths, + plateBlocksConfig: registryBlocks.plateBlocksConfig, + blocksConfig: registryBlocks.blocksConfig, +}; + +afterEach(() => { + registryBlocks.widths = initialRegistryState.widths; + registryBlocks.plateBlocksConfig = initialRegistryState.plateBlocksConfig; + registryBlocks.blocksConfig = initialRegistryState.blocksConfig; +}); describe('legacy bold migration helpers', () => { it('calls transforms to bold text and unwrap strong elements', () => { @@ -598,3 +615,45 @@ describe('normalizeLegacyValue', () => { ]); }); }); + +describe('legacy block width migration helpers', () => { + it('injects configured default widths for block elements without blockWidth', () => { + registryBlocks.widths = [ + { + name: 'narrow', + label: 'Narrow', + style: { '--block-width': 'var(--narrow-container-width)' }, + }, + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + ]; + registryBlocks.plateBlocksConfig = { + p: { + blockWidth: { + defaultWidth: 'narrow', + widths: ['narrow'], + }, + }, + }; + + const value: Value = [ + { + type: 'p', + children: [{ text: 'Paragraph without width' }], + } as any, + ]; + + migrateLegacyBlockWidthsInValue(value); + + expect(value).toEqual([ + { + type: 'p', + blockWidth: 'narrow', + children: [{ text: 'Paragraph without width' }], + }, + ]); + }); +}); diff --git a/packages/plate/components/editor/plugins/legacy-strikethrough-plugin.ts b/packages/plate/components/editor/plugins/legacy-strikethrough-plugin.ts index 042e433ce02..407d161aeff 100644 --- a/packages/plate/components/editor/plugins/legacy-strikethrough-plugin.ts +++ b/packages/plate/components/editor/plugins/legacy-strikethrough-plugin.ts @@ -1,12 +1,12 @@ import { ElementApi, TextApi, createSlatePlugin } from 'platejs'; -import type { Path, SlateEditor, Value } from 'platejs'; +import type { NodeEntry, Path, SlateEditor, Value } from 'platejs'; import { applyNormalizedValue, cloneValueToWritable } from './legacy-utils'; export type LegacyStrikethroughNode = { type?: string; text?: string; strikethrough?: boolean; - children?: LegacyStrikethroughNode[]; + children?: Value; [key: string]: unknown; }; @@ -28,12 +28,9 @@ export const migrateLegacyStrikethrough = (editor: SlateEditor, path: Path) => { }; export const migrateLegacyStrikethroughInValue = (nodes: Value) => { - const mutableNodes = cloneValueToWritable(nodes); + const mutableNodes = cloneValueToWritable(nodes) as any[]; - const visit = ( - node: LegacyStrikethroughNode, - isStrike = false, - ): LegacyStrikethroughNode[] => { + const visit = (node: LegacyStrikethroughNode, isStrike = false): any[] => { const nextIsStrike = isStrike || node?.type === 'del'; if (typeof node?.text === 'string') { @@ -47,8 +44,8 @@ export const migrateLegacyStrikethroughInValue = (nodes: Value) => { return [node]; } - const normalizedChildren = node.children.flatMap( - (child: LegacyStrikethroughNode) => visit(child, nextIsStrike), + const normalizedChildren = (node.children as any[]).flatMap((child: any) => + visit(child as LegacyStrikethroughNode, nextIsStrike), ); if (node.type === 'del') { @@ -59,12 +56,12 @@ export const migrateLegacyStrikethroughInValue = (nodes: Value) => { return [node]; }; - const normalized = (mutableNodes as LegacyStrikethroughNode[]).flatMap( - (node) => visit(node), + const normalized = mutableNodes.flatMap((node: any) => + visit(node as LegacyStrikethroughNode), ); mutableNodes.splice(0, mutableNodes.length, ...normalized); - applyNormalizedValue(nodes, mutableNodes); - return mutableNodes; + applyNormalizedValue(nodes, mutableNodes as any); + return mutableNodes as any; }; /** @@ -85,7 +82,7 @@ export const LegacyStrikethroughPlugin = [ extendEditor: ({ editor }) => { const { normalizeNode } = editor; - editor.normalizeNode = (entry) => { + editor.normalizeNode = (entry: NodeEntry) => { const [node, path] = entry; if (ElementApi.isElement(node) && node.type === 'del') { @@ -93,7 +90,7 @@ export const LegacyStrikethroughPlugin = [ return; } - normalizeNode(entry); + (normalizeNode as (entry: NodeEntry) => void)(entry); }; return editor; diff --git a/packages/plate/components/editor/plugins/metadata-text-binding.test.ts b/packages/plate/components/editor/plugins/metadata-text-binding.test.ts new file mode 100644 index 00000000000..5712d5ef917 --- /dev/null +++ b/packages/plate/components/editor/plugins/metadata-text-binding.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { getMetadataTextSyncAction } from './metadata-text-binding'; + +describe('metadata text binding', () => { + it('does nothing when editor and field are already synchronized', () => { + expect( + getMetadataTextSyncAction({ + editorValue: 'Same', + fieldValue: 'Same', + isEditorActive: false, + lastAppliedFromEditor: null, + lastAppliedFromField: null, + }), + ).toBe('none'); + }); + + it('does nothing when the bound editor node does not exist', () => { + expect( + getMetadataTextSyncAction({ + editorValue: null, + fieldValue: 'Metadata title', + isEditorActive: false, + lastAppliedFromEditor: null, + lastAppliedFromField: null, + }), + ).toBe('none'); + }); + + it('prefers editor updates while the bound block is active', () => { + expect( + getMetadataTextSyncAction({ + editorValue: 'Typed in editor', + fieldValue: 'Old title', + isEditorActive: true, + lastAppliedFromEditor: null, + lastAppliedFromField: null, + }), + ).toBe('editor-to-field'); + }); + + it('prefers metadata updates while the bound block is inactive', () => { + expect( + getMetadataTextSyncAction({ + editorValue: 'Old title', + fieldValue: 'Updated metadata title', + isEditorActive: false, + lastAppliedFromEditor: null, + lastAppliedFromField: null, + }), + ).toBe('field-to-editor'); + }); + + it('ignores field echoes that originated from the editor', () => { + expect( + getMetadataTextSyncAction({ + editorValue: 'Typed in editor', + fieldValue: 'Typed in editor', + isEditorActive: true, + lastAppliedFromEditor: 'Typed in editor', + lastAppliedFromField: null, + }), + ).toBe('none'); + }); + + it('ignores editor echoes that originated from metadata', () => { + expect( + getMetadataTextSyncAction({ + editorValue: 'Updated metadata title', + fieldValue: 'Updated metadata title', + isEditorActive: false, + lastAppliedFromEditor: null, + lastAppliedFromField: 'Updated metadata title', + }), + ).toBe('none'); + }); + + it('keeps pushing the latest editor value while a stale field echo lags behind', () => { + expect( + getMetadataTextSyncAction({ + editorValue: 'Newer editor value', + fieldValue: 'Older editor value', + isEditorActive: true, + lastAppliedFromEditor: 'Newer editor value', + lastAppliedFromField: null, + }), + ).toBe('editor-to-field'); + }); +}); diff --git a/packages/plate/components/editor/plugins/metadata-text-binding.tsx b/packages/plate/components/editor/plugins/metadata-text-binding.tsx new file mode 100644 index 00000000000..7ebc1d24cb9 --- /dev/null +++ b/packages/plate/components/editor/plugins/metadata-text-binding.tsx @@ -0,0 +1,144 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { atom, type PrimitiveAtom } from 'jotai'; +import { useFieldFocusedAtom } from '@plone/helpers'; +import config from '@plone/registry'; +import { + useEditorRef, + useEditorSelector, + type TPlateEditor, +} from 'platejs/react'; + +type BindingState = { + isActive: boolean; + value: string | null; +}; + +type SyncAction = 'editor-to-field' | 'field-to-editor' | 'none'; + +type GetSyncActionArgs = { + editorValue: string | null; + fieldValue: string; + isEditorActive: boolean; + lastAppliedFromEditor: string | null; + lastAppliedFromField: string | null; +}; + +type MetadataTextBinding = { + field: string; + getState: (editor: TPlateEditor) => BindingState; + writeToEditor: (editor: TPlateEditor, value: string) => void; +}; + +const fallbackFormAtom = atom>({}); + +export function getMetadataTextSyncAction({ + editorValue, + fieldValue, + isEditorActive, + lastAppliedFromEditor, + lastAppliedFromField, +}: GetSyncActionArgs): SyncAction { + if (editorValue === null) return 'none'; + if (editorValue === fieldValue) return 'none'; + if (lastAppliedFromField !== null && editorValue === lastAppliedFromField) { + return 'none'; + } + if (lastAppliedFromEditor !== null && fieldValue === lastAppliedFromEditor) { + return 'none'; + } + + return isEditorActive ? 'editor-to-field' : 'field-to-editor'; +} + +export function useMetadataTextBinding(binding: MetadataTextBinding) { + const { field, getState, writeToEditor } = binding; + const editor = useEditorRef(); + const lastAppliedFromEditorRef = useRef(null); + const lastAppliedFromFieldRef = useRef(null); + + // Callers pass a fresh `binding` object literal on every render. Stash the + // callbacks in refs so the selector and effect deps below stay stable — + // otherwise this hook re-subscribes and re-fires every render, looping + // setState back into the editor / form atom. + const getStateRef = useRef(getState); + const writeToEditorRef = useRef(writeToEditor); + useEffect(() => { + getStateRef.current = getState; + writeToEditorRef.current = writeToEditor; + }); + + const formAtom = useMemo(() => { + try { + return config + .getUtility({ + name: 'formAtom', + type: 'atom', + }) + ?.method?.() as PrimitiveAtom> | undefined; + } catch { + return undefined; + } + }, []); + const [fieldValue, setFieldValue] = useFieldFocusedAtom< + Record, + any + >(formAtom ?? fallbackFormAtom, field as any); + const state = useEditorSelector( + (currentEditor) => getStateRef.current(currentEditor as TPlateEditor), + [], + ); + + const hasFormAtom = !!formAtom; + const normalizedFieldValue = typeof fieldValue === 'string' ? fieldValue : ''; + + useEffect(() => { + if (!hasFormAtom) { + lastAppliedFromEditorRef.current = null; + lastAppliedFromFieldRef.current = null; + return; + } + + if ( + lastAppliedFromEditorRef.current !== null && + normalizedFieldValue === lastAppliedFromEditorRef.current + ) { + lastAppliedFromEditorRef.current = null; + } + + if ( + lastAppliedFromFieldRef.current !== null && + state.value === lastAppliedFromFieldRef.current + ) { + lastAppliedFromFieldRef.current = null; + } + + const action = getMetadataTextSyncAction({ + editorValue: state.value, + fieldValue: normalizedFieldValue, + isEditorActive: state.isActive, + lastAppliedFromEditor: lastAppliedFromEditorRef.current, + lastAppliedFromField: lastAppliedFromFieldRef.current, + }); + + if (action === 'editor-to-field' && state.value !== null) { + lastAppliedFromEditorRef.current = state.value; + setFieldValue(state.value); + return; + } + + if (action === 'field-to-editor') { + lastAppliedFromFieldRef.current = normalizedFieldValue; + writeToEditorRef.current( + editor as unknown as TPlateEditor, + normalizedFieldValue, + ); + } + }, [ + editor, + hasFormAtom, + normalizedFieldValue, + setFieldValue, + state.isActive, + state.value, + ]); +} diff --git a/packages/plate/components/editor/plugins/plone-block-adapter-renderer.tsx b/packages/plate/components/editor/plugins/plone-block-adapter-renderer.tsx index 3affb74b35f..de4812e75f3 100644 --- a/packages/plate/components/editor/plugins/plone-block-adapter-renderer.tsx +++ b/packages/plate/components/editor/plugins/plone-block-adapter-renderer.tsx @@ -2,6 +2,7 @@ import React from 'react'; import config from '@plone/registry'; import { createSlatePlugin, type TElement } from 'platejs'; import { toPlatePlugin, type PlateElementProps } from 'platejs/react'; +import { BlockInnerContainer } from '../../ui/block-inner-container'; type NativeBlockElement = TElement & { '@type'?: string; @@ -53,7 +54,9 @@ function PloneBlockAdapterRendererElement( return (
    - + + +
    ); } diff --git a/packages/plate/components/editor/plugins/plone-block-adapter.tsx b/packages/plate/components/editor/plugins/plone-block-adapter.tsx index 2aceead1e72..0ec17b0a8a4 100644 --- a/packages/plate/components/editor/plugins/plone-block-adapter.tsx +++ b/packages/plate/components/editor/plugins/plone-block-adapter.tsx @@ -11,6 +11,7 @@ import { useSelected, type PlateElementProps, } from 'platejs/react'; +import { BlockInnerContainer } from '../../ui/block-inner-container'; type NativeBlockElement = TElement & { id?: string; @@ -150,22 +151,24 @@ function PloneBlockAdapterContent( element={element} className={className} > - {Edit ? ( - - ) : View ? ( - - ) : null} + + {Edit ? ( + + ) : View ? ( + + ) : null} + ); } diff --git a/packages/plate/components/editor/plugins/slash-kit.tsx b/packages/plate/components/editor/plugins/slash-kit.tsx index dca7278c36e..9d6ab13866f 100644 --- a/packages/plate/components/editor/plugins/slash-kit.tsx +++ b/packages/plate/components/editor/plugins/slash-kit.tsx @@ -1,16 +1,28 @@ +import type { PlateEditor } from 'platejs/react'; + import { SlashInputPlugin, SlashPlugin } from '@platejs/slash-command/react'; import { KEYS } from 'platejs'; import { SlashInputElement } from '../../ui/slash-node'; +import type { SlashMenuConfig } from './slash-menu'; + +export type SlashKitOptions = { + menu?: SlashMenuConfig; +}; -export const SlashKit = [ +const defaultTriggerQuery = (editor: PlateEditor) => + !editor.api.some({ + match: { type: editor.getType(KEYS.codeBlock) }, + }); + +export const createSlashKit = (options: SlashKitOptions = {}) => [ SlashPlugin.configure({ options: { - triggerQuery: (editor) => - !editor.api.some({ - match: { type: editor.getType(KEYS.codeBlock) }, - }), - }, + menu: options.menu, + triggerQuery: defaultTriggerQuery, + } as any, }), SlashInputPlugin.withComponent(SlashInputElement), ]; + +export const SlashKit = createSlashKit(); diff --git a/packages/plate/components/editor/plugins/slash-menu.tsx b/packages/plate/components/editor/plugins/slash-menu.tsx new file mode 100644 index 00000000000..d3fc6bf4f2d --- /dev/null +++ b/packages/plate/components/editor/plugins/slash-menu.tsx @@ -0,0 +1,309 @@ +import * as React from 'react'; + +import type { PlateEditor } from 'platejs/react'; + +import { AIChatPlugin } from '@platejs/ai/react'; +import config from '@plone/registry'; +import { + BookA, + ChevronRightIcon, + Code2, + Columns3Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + LightbulbIcon, + ListIcon, + ListOrdered, + PilcrowIcon, + Quote, + SparklesIcon, + Square, + Table, + TableOfContentsIcon, +} from 'lucide-react'; +import { KEYS, PathApi } from 'platejs'; + +import { insertBlock } from '../transforms'; +import { SuggestionPlugin } from './suggestion-kit'; +import { TITLE_BLOCK_TYPE } from './title'; + +export type SlashMenuItem = { + icon: React.ReactNode; + value: string; + onSelect: (editor: PlateEditor, value: string) => void; + className?: string; + description?: string; + focusEditor?: boolean; + keywords?: string[]; + label?: string; +}; + +export type SlashMenuGroup = { + group: string; + items: SlashMenuItem[]; +}; + +export type SlashMenuContext = { + hasTitleBlock: boolean; + translate?: (id: string) => string; +}; + +export type SlashMenuConfig = { + groups?: SlashMenuGroup[]; + getGroups?: ( + editor: PlateEditor, + context: SlashMenuContext, + ) => SlashMenuGroup[]; + extendGroups?: ( + groups: SlashMenuGroup[], + editor: PlateEditor, + context: SlashMenuContext, + ) => SlashMenuGroup[]; +}; + +const filteredBlocksConfig = (blocksConfig: Record) => + Object.entries(blocksConfig ?? {}).filter(([, block]) => { + const blockIsWellFormed = Boolean(block?.title && block?.id); + if (!blockIsWellFormed) return false; + if (typeof block?.restricted === 'boolean' && block.restricted) { + return false; + } + return true; + }); + +const insertSomersaultNativeBlock = ( + editor: PlateEditor, + nativeBlockType: string, +) => { + editor.tf.withoutNormalizing(() => { + const block = editor.api.block(); + if (!block) return; + + editor.tf.insertNodes( + editor.api.create.block({ + type: 'unknown', + '@type': nativeBlockType, + }), + { + at: PathApi.next(block[1]), + select: true, + }, + ); + + if (block[0].type !== 'unknown') { + editor.getApi(SuggestionPlugin).suggestion.withoutSuggestions(() => { + editor.tf.removeNodes({ previousEmptyBlock: true }); + }); + } + }); +}; + +const addGroupItem = ( + groups: SlashMenuGroup[], + groupName: SlashMenuGroup['group'], + item: SlashMenuItem, +) => + groups.map((group) => + group.group === groupName + ? { + ...group, + items: group.items.some((existing) => existing.value === item.value) + ? group.items + : [...group.items, item], + } + : group, + ); + +const createStaticGroups = (): SlashMenuGroup[] => [ + { + group: 'Actions', + items: [ + { + focusEditor: false, + icon: , + value: 'AI', + onSelect: (editor) => { + editor.getApi(AIChatPlugin).aiChat.show(); + }, + }, + ], + }, + { + group: 'Text blocks', + items: [ + { + icon: , + keywords: ['paragraph'], + label: 'Text', + value: KEYS.p, + }, + { + icon: , + keywords: ['subtitle', 'h2'], + label: 'Heading 2', + value: KEYS.h2, + }, + { + icon: , + keywords: ['subtitle', 'h3'], + label: 'Heading 3', + value: KEYS.h3, + }, + { + icon: , + keywords: ['subtitle', 'h4'], + label: 'Heading 4', + value: KEYS.h4, + }, + { + icon: , + keywords: ['unordered', 'ul', '-'], + label: 'Bulleted list', + value: KEYS.ul, + }, + { + icon: , + keywords: ['ordered', 'ol', '1'], + label: 'Numbered list', + value: KEYS.ol, + }, + { + icon: , + keywords: ['checklist', 'task', 'checkbox', '[]'], + label: 'To-do list', + value: KEYS.listTodo, + }, + { + icon: , + keywords: ['collapsible', 'expandable'], + label: 'Toggle', + value: KEYS.toggle, + }, + { + icon: , + keywords: ['```'], + label: 'Code Block', + value: KEYS.codeBlock, + }, + { + icon: , + label: 'Table', + value: KEYS.table, + }, + { + icon: , + keywords: ['citation', 'blockquote', 'quote', '>'], + label: 'Blockquote', + value: KEYS.blockquote, + }, + { + description: 'Insert a highlighted block.', + icon: , + keywords: ['note'], + label: 'Callout', + value: KEYS.callout, + }, + ].map((item) => ({ + ...item, + onSelect: (editor: PlateEditor, value: string) => { + insertBlock(editor, value); + }, + })), + }, + { + group: 'Advanced blocks', + items: [ + { + icon: , + keywords: ['toc'], + label: 'Table of contents', + value: KEYS.toc, + }, + { + icon: , + label: '3 columns', + value: 'action_three_columns', + }, + ].map((item) => ({ + ...item, + onSelect: (editor: PlateEditor, value: string) => { + insertBlock(editor, value); + }, + })), + }, +]; + +const createRegistryBlockItems = ( + translate?: SlashMenuContext['translate'], +): SlashMenuItem[] => { + const blocksConfig = config?.blocks?.blocksConfig; + if (!blocksConfig) return []; + + return filteredBlocksConfig(blocksConfig).map(([id, block]: any) => { + const label = + typeof block.title === 'string' + ? block.title + : typeof block.title?.id === 'string' + ? (translate?.(block.title.id) ?? + block.title.defaultMessage ?? + block.title.id) + : String(block.title); + const Icon = block.icon ? block.icon : Square; + + return { + icon: , + keywords: [id, label?.toString()?.toLowerCase?.()].filter(Boolean), + label, + value: `block_${id}`, + onSelect: (editor: PlateEditor) => { + insertSomersaultNativeBlock(editor, id); + }, + }; + }); +}; + +export const getDefaultSlashMenuGroups = ( + editor: PlateEditor, + context: SlashMenuContext, +): SlashMenuGroup[] => { + let groups = createStaticGroups(); + + if (!context.hasTitleBlock) { + groups = addGroupItem(groups, 'Text blocks', { + icon: , + keywords: ['title', 'page title', 'h1'], + label: 'Title', + value: TITLE_BLOCK_TYPE, + onSelect: (nextEditor: PlateEditor, value: string) => { + insertBlock(nextEditor, value); + }, + }); + } + + const blocks = createRegistryBlockItems(context.translate); + if (blocks.length) { + groups = [ + ...groups, + { + group: 'Blocks', + items: blocks, + }, + ]; + } + + return groups; +}; + +export const resolveSlashMenuGroups = ( + editor: PlateEditor, + config: SlashMenuConfig | undefined, + context: SlashMenuContext, +): SlashMenuGroup[] => { + const groups = + config?.getGroups?.(editor, context) ?? + config?.groups ?? + getDefaultSlashMenuGroups(editor, context); + + return config?.extendGroups?.(groups, editor, context) ?? groups; +}; diff --git a/packages/plate/components/editor/plugins/split-utils.ts b/packages/plate/components/editor/plugins/split-utils.ts index 9f221c6deb6..28442baeca1 100644 --- a/packages/plate/components/editor/plugins/split-utils.ts +++ b/packages/plate/components/editor/plugins/split-utils.ts @@ -22,7 +22,7 @@ export const splitEditorAtCursor = (editor: PlateEditor) => { editor.tf.removeNodes({ match: (node) => (node as any)?.type === slashType, }); - editor.tf.deleteBackward({ unit: 'character' }); + editor.tf.deleteBackward('character' as any); }); const block = editor.api.block(); diff --git a/packages/plate/components/editor/plugins/style-fields-base-kit.tsx b/packages/plate/components/editor/plugins/style-fields-base-kit.tsx new file mode 100644 index 00000000000..dffe1b0bc1c --- /dev/null +++ b/packages/plate/components/editor/plugins/style-fields-base-kit.tsx @@ -0,0 +1,3 @@ +import { BaseStyleFieldsPlugin } from './style-fields-plugin'; + +export const BaseStyleFieldsKit = [BaseStyleFieldsPlugin]; diff --git a/packages/plate/components/editor/plugins/style-fields-kit.tsx b/packages/plate/components/editor/plugins/style-fields-kit.tsx new file mode 100644 index 00000000000..7ed8a5226d8 --- /dev/null +++ b/packages/plate/components/editor/plugins/style-fields-kit.tsx @@ -0,0 +1,3 @@ +import { StyleFieldsPlugin } from './style-fields-plugin'; + +export const StyleFieldsKit = [StyleFieldsPlugin]; diff --git a/packages/plate/components/editor/plugins/style-fields-plugin.ts b/packages/plate/components/editor/plugins/style-fields-plugin.ts new file mode 100644 index 00000000000..82e9a211c4e --- /dev/null +++ b/packages/plate/components/editor/plugins/style-fields-plugin.ts @@ -0,0 +1,324 @@ +import { + applyStyleFieldDefaultsInData, + getStyleFieldsFromBlockSchema, + getStyleFieldDefinitionsFromRegistry, + resolveStyleFields, + setStyleFieldValue, +} from '@plone/helpers'; +import config from '@plone/registry'; +import type { BlockConfigBase, BlocksFormData } from '@plone/types'; +import { + createSlatePlugin, + ElementApi, + type SetNodesOptions, + type SlateEditor, + type TElement, +} from 'platejs'; +import { toPlatePlugin } from 'platejs/react'; + +export const STYLE_FIELDS_KEY = 'styleFields'; +type StyleFieldConfig = { + defaultValue?: string; + values?: readonly string[]; + path?: string; +}; + +type ValueElement = Record & { + type?: unknown; + children?: unknown[]; +}; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const getGlobalWidthValues = () => + ( + (config.blocks as Record).widths as + | Array<{ name?: string }> + | undefined + ) + ?.map((definition) => definition.name) + .filter((name): name is string => !!name) ?? []; + +const getGlobalDefaultWidth = () => { + const values = getGlobalWidthValues(); + + if (!values.length) return 'default'; + if (values.includes('default')) return 'default'; + + return values[0]; +}; + +const withBlockWidthFallback = ( + styleFields: Record, + blockWidthConfig?: { + defaultWidth?: string; + widths?: readonly string[]; + }, +) => { + if (!blockWidthConfig) { + return styleFields; + } + + styleFields.blockWidth = { + defaultValue: blockWidthConfig.defaultWidth ?? getGlobalDefaultWidth(), + values: blockWidthConfig.widths ?? getGlobalWidthValues(), + }; + + return styleFields; +}; + +const getElementStyleFieldConfigs = ( + element?: TElement | null, +): Record => { + if (!element) return {}; + + if (element.type === 'unknown') { + const blockType = (element as TElement & { '@type'?: unknown })['@type']; + + if (typeof blockType !== 'string') return {}; + + const blockConfig = (config.blocks as Record) + .blocksConfig as Record | undefined; + + const currentBlockConfig = blockConfig?.[blockType]; + + return withBlockWidthFallback( + getStyleFieldsFromBlockSchema( + currentBlockConfig, + element as unknown as BlocksFormData, + ), + currentBlockConfig?.blockWidth, + ); + } + + return {}; +}; + +const applyStyleFieldDefaultsToElement = ( + element: T, +): T => { + const fieldConfigs = getElementStyleFieldConfigs(element); + const nextElement = { ...element } as Record; + const nextStyles = nextElement['styles']; + + if (isRecord(nextStyles)) { + nextElement['styles'] = { ...nextStyles }; + } + + applyStyleFieldDefaultsInData({ + data: nextElement, + fieldConfigs, + container: undefined, + resolveDefinitions: getStyleFieldDefinitionsFromRegistry, + }); + + return nextElement as T; +}; + +const withInsertedStyleFieldDefaults = (nodes: unknown): unknown => { + if (Array.isArray(nodes)) { + return nodes.map((node) => withInsertedStyleFieldDefaults(node)); + } + + if (!ElementApi.isElement(nodes)) { + return nodes; + } + + const children: unknown[] | undefined = Array.isArray(nodes.children) + ? nodes.children.map((child: unknown) => + withInsertedStyleFieldDefaults(child), + ) + : nodes.children; + const nextNode: TElement = + children === nodes.children ? nodes : ({ ...nodes, children } as TElement); + + return applyStyleFieldDefaultsToElement(nextNode); +}; + +const applyStyleFieldDefaultsInValue = (value: unknown[]) => { + const visit = (node: unknown) => { + if (!node || typeof node !== 'object') return; + + const element = node as ValueElement; + if (typeof element.type !== 'string') return; + + if (isRecord(element.styles)) { + element.styles = { ...element.styles }; + } + + applyStyleFieldDefaultsInData({ + data: element, + fieldConfigs: getElementStyleFieldConfigs(element as TElement), + container: undefined, + resolveDefinitions: getStyleFieldDefinitionsFromRegistry, + }); + + if (Array.isArray(element.children)) { + element.children.forEach(visit); + } + }; + + value.forEach(visit); + return value; +}; + +const buildStyleFieldPatch = ( + node: TElement, + fieldName: string, + value: string, + fieldConfig?: StyleFieldConfig, +) => { + const nextNode = { ...node } as TElement & Record; + + if (isRecord(nextNode.styles)) { + nextNode.styles = { ...nextNode.styles }; + } + + setStyleFieldValue(nextNode, fieldName, value, fieldConfig); + + if ( + typeof fieldConfig?.path === 'string' && + fieldConfig.path.startsWith('styles.') + ) { + return { + styles: nextNode.styles, + }; + } + + if (!(fieldName in node) && isRecord(node.styles)) { + return { + styles: nextNode.styles, + }; + } + + return { + [fieldName]: nextNode[fieldName], + }; +}; + +export const setStyleFieldOnEditor = ( + editor: SlateEditor, + fieldName: string, + value: string, + setNodesOptions?: SetNodesOptions, +) => { + const entries = setNodesOptions?.at + ? [editor.api.node(setNodesOptions.at)].filter(Boolean) + : editor.api.blocks({ mode: 'lowest' }); + + entries.forEach((entry) => { + if (!entry) return; + + const [node, path] = entry; + + if (!ElementApi.isElement(node) || !editor.api.isBlock(node)) return; + + const fieldConfig = getElementStyleFieldConfigs(node)[fieldName]; + const definitions = getStyleFieldDefinitionsFromRegistry(fieldName, { + data: node as Record, + blockType: + (typeof node.type === 'string' && node.type !== 'unknown' + ? node.type + : (node as TElement & { '@type'?: string })['@type']) ?? undefined, + fieldName, + }); + + const allowedValues = fieldConfig?.values?.length + ? fieldConfig.values + : definitions + .map((definition: { name?: string }) => definition.name) + .filter((name: string | undefined): name is string => !!name); + + if (!allowedValues.includes(value)) return; + + editor.tf.setNodes( + buildStyleFieldPatch(node, fieldName, value, fieldConfig), + { + ...setNodesOptions, + at: path, + }, + ); + }); +}; + +export const resetStyleFieldOnEditor = ( + editor: SlateEditor, + fieldName: string, + options?: SetNodesOptions, +) => { + const blockEntry = editor.api.block(); + const block = + blockEntry && ElementApi.isElement(blockEntry[0]) + ? blockEntry[0] + : undefined; + const defaultValue = block + ? getElementStyleFieldConfigs(block)[fieldName]?.defaultValue + : undefined; + + if (!defaultValue) return; + + setStyleFieldOnEditor(editor, fieldName, defaultValue, options); +}; + +export const BaseStyleFieldsPlugin = createSlatePlugin({ + key: STYLE_FIELDS_KEY, + normalizeInitialValue: ({ value }) => { + applyStyleFieldDefaultsInValue(value); + }, + inject: { + isBlock: true, + nodeProps: { + transformProps: ({ element, props }) => { + if (!element || !ElementApi.isElement(element)) { + return props; + } + + const { style } = resolveStyleFields({ + data: element as Record, + fieldConfigs: getElementStyleFieldConfigs(element), + container: undefined, + resolveDefinitions: getStyleFieldDefinitionsFromRegistry, + }); + + if (!Object.keys(style).length) return props; + + return { + ...props, + style: { + ...(props.style ?? {}), + ...style, + }, + }; + }, + }, + }, + extendEditor: ({ editor }) => { + const createBlock = editor.api.create.block.bind(editor.api.create); + const insertNodes = editor.tf.insertNodes.bind(editor.tf); + + editor.api.create.block = ((...args: any[]) => + withInsertedStyleFieldDefaults(createBlock(...args))) as any; + + editor.tf.insertNodes = ((nodes: any, options?: any) => + insertNodes( + withInsertedStyleFieldDefaults(nodes) as any, + options, + )) as any; + + return editor; + }, +}).extendTransforms(({ editor }) => ({ + setStyleField: ( + fieldName: string, + value: string, + options?: SetNodesOptions, + ) => { + setStyleFieldOnEditor(editor, fieldName, value, options); + }, + resetStyleField: (fieldName: string, options?: SetNodesOptions) => { + resetStyleFieldOnEditor(editor, fieldName, options); + }, +})); + +export const StyleFieldsPlugin = toPlatePlugin(BaseStyleFieldsPlugin); diff --git a/packages/plate/components/editor/plugins/suggestion-base-kit.tsx b/packages/plate/components/editor/plugins/suggestion-base-kit.tsx index 38aecf4e21e..cea0f33464a 100644 --- a/packages/plate/components/editor/plugins/suggestion-base-kit.tsx +++ b/packages/plate/components/editor/plugins/suggestion-base-kit.tsx @@ -1,6 +1,5 @@ -import { BaseSuggestionPlugin } from '@platejs/suggestion'; - import { SuggestionLeafStatic } from '../../ui/suggestion-node-static'; +import { BaseSuggestionPlugin } from './suggestion-kit'; export const BaseSuggestionKit = [ BaseSuggestionPlugin.withComponent(SuggestionLeafStatic), diff --git a/packages/plate/components/editor/plugins/suggestion-core.ts b/packages/plate/components/editor/plugins/suggestion-core.ts new file mode 100644 index 00000000000..77a08eda5a4 --- /dev/null +++ b/packages/plate/components/editor/plugins/suggestion-core.ts @@ -0,0 +1,860 @@ +import { + createTSlatePlugin, + ElementApi, + getAt, + KEYS, + nanoid, + PathApi, + PointApi, + TextApi, +} from 'platejs'; +import { toPlatePlugin } from 'platejs/react'; + +const getCurrentUserId = (editor: any): string | null => { + return editor.getOption(SuggestionPlugin, 'currentUserId') ?? null; +}; + +const getSuggestionKeyId = (node: any) => { + const ids = Object.keys(node).filter((key) => { + return key.startsWith(`${KEYS.suggestion}_`); + }); + + return ids.at(-1); +}; + +const getInlineSuggestionData = (node: any) => { + const keyId = getSuggestionKeyId(node); + + if (!keyId) return; + + return node[keyId]; +}; + +export const getSuggestionKey = (id = '0'): string => + `${KEYS.suggestion}_${id}`; + +const isSuggestionKey = (key: string) => key.startsWith(`${KEYS.suggestion}_`); + +const getSuggestionKeys = (node: any) => { + const keys: string[] = []; + + Object.keys(node).forEach((key) => { + if (isSuggestionKey(key)) keys.push(key); + }); + + return keys; +}; + +const isCurrentUserSuggestion = (editor: any, node: any) => { + return getInlineSuggestionData(node)?.userId === getCurrentUserId(editor); +}; + +const getTransientSuggestionKey = () => `${KEYS.suggestion}Transient`; + +const findSuggestionProps = (editor: any, { at, type }: any) => { + const defaultProps = { + id: nanoid(), + createdAt: Date.now(), + }; + + const api = editor.getApi(BaseSuggestionPlugin); + let entry = api.suggestion.node({ + at, + isText: true, + }); + + if (!entry) { + let start; + let end; + + try { + [start, end] = editor.api.edges(at); + } catch { + return defaultProps; + } + + const nextPoint = editor.api.after(end); + + if (nextPoint) { + entry = api.suggestion.node({ + at: nextPoint, + isText: true, + }); + + if (!entry) { + const prevPoint = editor.api.before(start); + + if (prevPoint) { + entry = api.suggestion.node({ + at: prevPoint, + isText: true, + }); + } + + if (!entry && editor.api.isStart(start, at)) { + const fallbackAt = prevPoint ?? at; + const lineBreak = editor.api.above({ at: fallbackAt }); + const lineBreakData = lineBreak?.[0].suggestion; + + if (lineBreakData?.isLineBreak) { + return { + createdAt: lineBreakData.createdAt ?? Date.now(), + id: lineBreakData.id ?? nanoid(), + }; + } + } + } + } + } + + if ( + entry && + getInlineSuggestionData(entry[0])?.type === type && + isCurrentUserSuggestion(editor, entry[0]) + ) { + return { + createdAt: getInlineSuggestionData(entry[0])?.createdAt ?? Date.now(), + id: api.suggestion.nodeId(entry[0]) ?? nanoid(), + }; + } + + return defaultProps; +}; + +const setSuggestionNodes = (editor: any, options?: any) => { + const at = getAt(editor, options?.at) ?? editor.selection; + + if (!at) return; + + const { suggestionId = nanoid() } = options ?? {}; + const nodeEntries = [ + ...editor.api.nodes({ + match: (node: any) => + ElementApi.isElement(node) && editor.api.isInline(node), + ...options, + }), + ]; + + editor.tf.withoutNormalizing(() => { + const data = { + createdAt: options?.createdAt ?? Date.now(), + id: suggestionId, + type: 'remove', + userId: getCurrentUserId(editor), + }; + const props = { + [getSuggestionKey(suggestionId)]: data, + [KEYS.suggestion]: true, + }; + + editor.tf.setNodes(props, { + at, + marks: true, + }); + + nodeEntries.forEach(([, path]: any) => { + editor.tf.setNodes(props, { + at: path, + match: (node: any) => + ElementApi.isElement(node) && editor.api.isInline(node), + ...options, + }); + }); + }); +}; + +const deleteSuggestion = (editor: any, at: any, { reverse }: any = {}) => { + let resultId; + + editor.tf.withoutNormalizing(() => { + const { anchor: from, focus: to } = at; + const { createdAt, id } = findSuggestionProps(editor, { + at: from, + type: 'remove', + }); + + resultId = id; + + const toRef = editor.api.pointRef(to); + let pointCurrent; + + while (true) { + pointCurrent = editor.selection?.anchor; + + if (!pointCurrent) break; + + const pointTarget = toRef.current; + + if (!pointTarget) break; + + if ( + !editor.api.isAt({ + at: { anchor: pointCurrent, focus: pointTarget }, + blocks: true, + }) + ) { + const text = editor.api.string( + reverse + ? { anchor: pointTarget, focus: pointCurrent } + : { anchor: pointCurrent, focus: pointTarget }, + ); + + if (text.length === 0) break; + } + + const getPoint = reverse ? editor.api.before : editor.api.after; + const pointNext = getPoint(pointCurrent, { unit: 'character' }); + + if (!pointNext) break; + + let range = reverse + ? { anchor: pointNext, focus: pointCurrent } + : { anchor: pointCurrent, focus: pointNext }; + + range = editor.api.unhangRange(range, { character: true }); + + const entryBlock = editor.api.node({ + at: pointCurrent, + block: true, + match: (node: any) => + node[KEYS.suggestion] && + TextApi.isText(node) && + getInlineSuggestionData(node)?.type === 'insert' && + isCurrentUserSuggestion(editor, node), + }); + + if ( + entryBlock && + editor.api.isStart(pointCurrent, entryBlock[1]) && + editor.api.isEmpty(entryBlock[0]) + ) { + editor.tf.removeNodes({ + at: entryBlock[1], + }); + + continue; + } + + if (editor.api.isAt({ at: range, blocks: true })) { + const previousAboveNode = editor.api.above({ at: range.anchor }); + + if (previousAboveNode && ElementApi.isElement(previousAboveNode[0])) { + const isBlockSuggestion = editor + .getApi(BaseSuggestionPlugin) + .suggestion.isBlockSuggestion(previousAboveNode[0]); + + if (isBlockSuggestion) { + const node = previousAboveNode[0] as any; + + if (node.suggestion.type === 'insert') { + editor + .getApi(BaseSuggestionPlugin) + .suggestion.withoutSuggestions(() => { + editor.tf.unsetNodes([KEYS.suggestion], { + at: previousAboveNode[1], + }); + editor.tf.mergeNodes({ + at: PathApi.next(previousAboveNode[1]), + }); + }); + } + + if (node.suggestion.type === 'remove') { + editor.tf.move({ + reverse, + unit: 'character', + }); + } + + break; + } + + editor.tf.setNodes( + { + [KEYS.suggestion]: { + createdAt, + id, + type: 'remove', + userId: getCurrentUserId(editor), + }, + }, + { at: previousAboveNode[1] }, + ); + editor.tf.move({ + reverse, + unit: 'character', + }); + + break; + } + + break; + } + + if (PointApi.equals(pointCurrent, editor.selection.anchor)) { + editor.tf.move({ + reverse, + unit: 'character', + }); + } + + const entryText = editor.getApi(BaseSuggestionPlugin).suggestion.node({ + at: range, + isText: true, + match: (node: any) => + TextApi.isText(node) && + getInlineSuggestionData(node)?.type === 'insert' && + isCurrentUserSuggestion(editor, node), + }); + + if (entryText) { + editor.tf.delete({ at: range, unit: 'character' }); + continue; + } + + setSuggestionNodes(editor, { + at: range, + createdAt, + suggestionDeletion: true, + suggestionId: id, + }); + } + }); + + return resultId; +}; + +const deleteFragmentSuggestion = (editor: any, { reverse }: any = {}) => { + let resultId; + + editor.tf.withoutNormalizing(() => { + const selection = editor.selection; + const [start, end] = editor.api.edges(selection); + + if (reverse) { + editor.tf.collapse({ edge: 'end' }); + resultId = deleteSuggestion( + editor, + { anchor: end, focus: start }, + { reverse: true }, + ); + } else { + editor.tf.collapse({ edge: 'start' }); + resultId = deleteSuggestion(editor, { anchor: start, focus: end }); + } + }); + + return resultId; +}; + +const insertFragmentSuggestion = ( + editor: any, + fragment: any[], + { insertFragment = editor.tf.insertFragment }: any = {}, +) => { + editor.tf.withoutNormalizing(() => { + deleteFragmentSuggestion(editor); + + const { createdAt, id } = findSuggestionProps(editor, { + at: editor.selection, + type: 'insert', + }); + + fragment.forEach((node) => { + if (TextApi.isText(node)) { + if (!node[KEYS.suggestion]) { + node[KEYS.suggestion] = true; + } + + getSuggestionKeys(node).forEach((key) => { + delete node[key]; + }); + + node[getSuggestionKey(id)] = { + createdAt, + id, + type: 'insert', + userId: getCurrentUserId(editor), + }; + } else { + node[KEYS.suggestion] = { + createdAt, + id, + type: 'insert', + userId: getCurrentUserId(editor), + }; + } + }); + + editor.getApi(BaseSuggestionPlugin).suggestion.withoutSuggestions(() => { + insertFragment(fragment); + }); + }); +}; + +const insertTextSuggestion = (editor: any, text: string) => { + editor.tf.withoutNormalizing(() => { + let resultId: string | undefined; + + const { createdAt, id } = findSuggestionProps(editor, { + at: editor.selection, + type: 'insert', + }); + + if (editor.api.isExpanded()) { + resultId = deleteFragmentSuggestion(editor); + } + + editor.getApi(BaseSuggestionPlugin).suggestion.withoutSuggestions(() => { + editor.tf.insertNodes( + { + [getSuggestionKey(resultId ?? id)]: { + createdAt, + id: resultId ?? id, + type: 'insert', + userId: getCurrentUserId(editor), + }, + suggestion: true, + text, + }, + { + at: editor.selection, + select: true, + }, + ); + }); + }); +}; + +const removeMarkSuggestion = (editor: any, key: string) => { + editor.getApi(BaseSuggestionPlugin).suggestion.withoutSuggestions(() => { + const createdAt = Date.now(); + const id = nanoid(); + const match = (node: any) => { + if (!TextApi.isText(node)) return false; + + if (node[KEYS.suggestion]) { + return getInlineSuggestionData(node)?.type === 'update'; + } + + return true; + }; + + editor.tf.unsetNodes(key, { match }); + editor.tf.setNodes( + { + [getSuggestionKey(id)]: { + createdAt, + id, + properties: { + [key]: undefined, + }, + type: 'update', + userId: getCurrentUserId(editor), + }, + [KEYS.suggestion]: true, + }, + { + match, + }, + ); + }); +}; + +const removeNodesSuggestion = (editor: any, nodes: any[]) => { + if (nodes.length === 0) return; + + const { createdAt, id } = findSuggestionProps(editor, { + at: editor.selection, + type: 'remove', + }); + + nodes.forEach(([, blockPath]) => { + editor.tf.setNodes( + { + [KEYS.suggestion]: { + createdAt, + id, + type: 'remove', + userId: getCurrentUserId(editor), + }, + }, + { at: blockPath }, + ); + }); +}; + +const addMarkSuggestion = (editor: any, key: string, value: any) => { + editor.getApi(BaseSuggestionPlugin).suggestion.withoutSuggestions(() => { + const createdAt = Date.now(); + const id = nanoid(); + const match = (node: any) => { + if (!TextApi.isText(node)) return false; + + if (node[KEYS.suggestion]) { + return getInlineSuggestionData(node)?.type === 'update'; + } + + return true; + }; + + editor.tf.setNodes( + { + [key]: value, + [getSuggestionKey(id)]: { + createdAt, + id, + newProperties: { + [key]: value, + }, + type: 'update', + userId: getCurrentUserId(editor), + }, + [KEYS.suggestion]: true, + }, + { + match, + split: true, + }, + ); + }); +}; + +const withSuggestion = ({ + api, + editor, + getOptions, + tf: { + addMark, + apply, + deleteBackward, + deleteForward, + deleteFragment, + insertBreak, + insertFragment, + insertNodes, + insertText, + normalizeNode, + removeMark, + removeNodes, + }, +}: any) => ({ + transforms: { + addMark(key: string, value: any) { + if (getOptions().isSuggesting && api.isExpanded()) { + return addMarkSuggestion(editor, key, value); + } + + return addMark(key, value); + }, + apply(operation: any) { + return apply(operation); + }, + deleteBackward(unit: any) { + const selection = editor.selection; + const pointTarget = editor.api.before(selection, { unit }); + + if (getOptions().isSuggesting) { + const node = editor.api.above(); + + if (node?.[0][KEYS.suggestion] && !node?.[0].suggestion.isLineBreak) { + return deleteBackward(unit); + } + + if (!pointTarget) return; + + deleteSuggestion( + editor, + { anchor: selection.anchor, focus: pointTarget }, + { reverse: true }, + ); + + return; + } + + if (pointTarget) { + const isCrossBlock = editor.api.isAt({ + at: { anchor: selection.anchor, focus: pointTarget }, + blocks: true, + }); + + if (isCrossBlock) { + editor.tf.unsetNodes([KEYS.suggestion], { + at: pointTarget, + }); + } + } + + deleteBackward(unit); + }, + deleteForward(unit: any) { + if (getOptions().isSuggesting) { + const selection = editor.selection; + const pointTarget = editor.api.after(selection, { unit }); + + if (!pointTarget) return; + + deleteSuggestion(editor, { + anchor: selection.anchor, + focus: pointTarget, + }); + + return; + } + + deleteForward(unit); + }, + deleteFragment(direction: any) { + if (getOptions().isSuggesting) { + deleteFragmentSuggestion(editor, { reverse: true }); + return; + } + + deleteFragment(direction); + }, + insertBreak() { + if (getOptions().isSuggesting) { + const [node, path] = editor.api.above(); + + if (path.length > 1 || node.type !== editor.getType(KEYS.p)) { + return insertTextSuggestion(editor, '\n'); + } + + const { createdAt, id } = findSuggestionProps(editor, { + at: editor.selection, + type: 'insert', + }); + + insertBreak(); + editor.tf.withoutMerging(() => { + editor.tf.setNodes( + { + [KEYS.suggestion]: { + createdAt, + id, + isLineBreak: true, + type: 'insert', + userId: getCurrentUserId(editor), + }, + }, + { at: path }, + ); + }); + + return; + } + + insertBreak(); + }, + insertFragment(fragment: any) { + if (getOptions().isSuggesting) { + insertFragmentSuggestion(editor, fragment, { insertFragment }); + return; + } + + insertFragment(fragment); + }, + insertNodes(nodes: any, options: any) { + if (getOptions().isSuggesting) { + const nodesArray = Array.isArray(nodes) ? nodes : [nodes]; + + if (nodesArray.some((node) => node.type === 'slash_input')) { + api.suggestion.withoutSuggestions(() => { + insertNodes(nodes, options); + }); + return; + } + + const suggestionNodes = nodesArray.map((node) => ({ + ...node, + [KEYS.suggestion]: { + createdAt: Date.now(), + id: nanoid(), + type: 'insert', + userId: getCurrentUserId(editor), + }, + })); + + return insertNodes(suggestionNodes, options); + } + + return insertNodes(nodes, options); + }, + insertText(text: string, options: any) { + if (getOptions().isSuggesting) { + const node = editor.api.above(); + + if (node?.[0][KEYS.suggestion] && !node?.[0].suggestion.isLineBreak) { + return insertText(text, options); + } + + insertTextSuggestion(editor, text); + return; + } + + insertText(text, options); + }, + normalizeNode(entry: any) { + api.suggestion.withoutSuggestions(() => { + const [node, path] = entry; + const inlineSuggestion = + (ElementApi.isElement(node) && editor.api.isInline(node)) || + TextApi.isText(node); + + if ( + node[KEYS.suggestion] && + inlineSuggestion && + !getSuggestionKeyId(node) + ) { + editor.tf.unsetNodes([KEYS.suggestion, 'suggestionData'], { + at: path, + }); + return; + } + + if ( + node[KEYS.suggestion] && + inlineSuggestion && + !getInlineSuggestionData(node)?.userId + ) { + if (getInlineSuggestionData(node)?.type === 'remove') { + editor.tf.unsetNodes([KEYS.suggestion, getSuggestionKeyId(node)], { + at: path, + }); + } else { + editor.tf.removeNodes({ at: path }); + } + + return; + } + + normalizeNode(entry); + }); + }, + removeMark(key: string) { + if (getOptions().isSuggesting && api.isExpanded()) { + return removeMarkSuggestion(editor, key); + } + + return removeMark(key); + }, + removeNodes(options: any) { + if (getOptions().isSuggesting) { + const nodes = [...editor.api.nodes(options)]; + + if (nodes.some(([node]) => node.type === 'slash_input')) { + api.suggestion.withoutSuggestions(() => { + removeNodes(options); + }); + return; + } + + return removeNodesSuggestion(editor, nodes); + } + + return removeNodes(options); + }, + }, +}); + +export const BaseSuggestionPlugin = createTSlatePlugin({ + key: KEYS.suggestion, + node: { isLeaf: true }, + options: { + currentUserId: null, + isSuggesting: false, + }, + rules: { selection: { affinity: 'outward' } }, +}) + .overrideEditor(withSuggestion as any) + .extendApi(({ api, editor, getOption, setOption, type }: any) => ({ + dataList: (node: any) => + Object.keys(node) + .filter((key) => key.startsWith(`${KEYS.suggestion}_`)) + .map((key) => node[key]), + isBlockSuggestion: (node: any) => + ElementApi.isElement(node) && + !editor.api.isInline(node) && + 'suggestion' in node, + node: (options: any = {}) => { + const { id, isText, ...rest } = options; + + return editor.api.node({ + match: (node: any) => { + if (!node[type]) return false; + if (isText && !TextApi.isText(node)) return false; + + if (id) { + if (TextApi.isText(node)) { + return !!node[getSuggestionKey(id)]; + } + + if ( + ElementApi.isElement(node) && + api.suggestion.isBlockSuggestion(node) + ) { + return (node as any).suggestion.id === id; + } + } + + return true; + }, + ...rest, + }); + }, + nodeId: (node: any) => { + if ( + TextApi.isText(node) || + (ElementApi.isElement(node) && editor.api.isInline(node)) + ) { + const keyId = getSuggestionKeyId(node); + + if (!keyId) return; + + return keyId.replace(`${type}_`, ''); + } + + if (api.suggestion.isBlockSuggestion(node)) { + return node.suggestion.id; + } + }, + nodes: (options: any = {}) => { + const { transient } = options; + const at = getAt(editor, options.at) ?? []; + + return [ + ...editor.api.nodes({ + ...options, + at, + mode: 'all', + match: (node: any) => + node[type] && + (transient ? node[getTransientSuggestionKey()] : true), + }), + ]; + }, + suggestionData: (node: any) => { + if ( + TextApi.isText(node) || + (ElementApi.isElement(node) && editor.api.isInline(node)) + ) { + const keyId = getSuggestionKeyId(node); + + if (!keyId) return; + + return node[keyId]; + } + + if (api.suggestion.isBlockSuggestion(node)) { + return node.suggestion; + } + }, + withoutSuggestions: (fn: () => void) => { + const previous = getOption('isSuggesting'); + setOption('isSuggesting', false); + fn(); + setOption('isSuggesting', previous); + }, + })); + +export const SuggestionPlugin = toPlatePlugin(BaseSuggestionPlugin); diff --git a/packages/plate/components/editor/plugins/suggestion-kit.tsx b/packages/plate/components/editor/plugins/suggestion-kit.tsx index 3dd1e5caf67..2a2c4d28532 100644 --- a/packages/plate/components/editor/plugins/suggestion-kit.tsx +++ b/packages/plate/components/editor/plugins/suggestion-kit.tsx @@ -1,7 +1,4 @@ -import { - type BaseSuggestionConfig, - BaseSuggestionPlugin, -} from '@platejs/suggestion'; +import type { BaseSuggestionConfig } from '@platejs/suggestion'; import { type ExtendConfig, type Path, @@ -13,13 +10,13 @@ import { toTPlatePlugin } from 'platejs/react'; import { BlockSuggestion } from '../../ui/block-suggestion'; import { SuggestionLeaf, SuggestionLineBreak } from '../../ui/suggestion-node'; - -import { discussionPlugin } from './discussion-kit'; +import { BaseSuggestionPlugin, SuggestionPlugin } from './suggestion-core'; export type SuggestionConfig = ExtendConfig< BaseSuggestionConfig, { activeId: string | null; + currentUserId: string | null; hoverId: string | null; uniquePathMap: Map; } @@ -27,10 +24,10 @@ export type SuggestionConfig = ExtendConfig< export const suggestionPlugin = toTPlatePlugin( BaseSuggestionPlugin, - ({ editor }) => ({ + () => ({ options: { activeId: null, - currentUserId: editor.getOption(discussionPlugin, 'currentUserId'), + currentUserId: null, hoverId: null, uniquePathMap: new Map(), }, @@ -90,4 +87,5 @@ export const suggestionPlugin = toTPlatePlugin( }, }); +export { BaseSuggestionPlugin, SuggestionPlugin }; export const SuggestionKit = [suggestionPlugin]; diff --git a/packages/plate/components/editor/plugins/title-block.test.ts b/packages/plate/components/editor/plugins/title-block.test.ts index 1924776ce89..d027ef84c8c 100644 --- a/packages/plate/components/editor/plugins/title-block.test.ts +++ b/packages/plate/components/editor/plugins/title-block.test.ts @@ -1,71 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { - TITLE_BLOCK_TYPE, - getTitleSyncAction, - BaseTitleBlockPlugin, -} from './title'; +import { TITLE_BLOCK_TYPE, BaseTitleBlockPlugin } from './title'; describe('title block plugin', () => { it('exposes the expected title block type key', () => { expect(TITLE_BLOCK_TYPE).toBe('title'); expect(BaseTitleBlockPlugin.key).toBe(TITLE_BLOCK_TYPE); }); - - describe('sync direction', () => { - it('returns none when no title block exists in editor', () => { - expect( - getTitleSyncAction({ - previousAtomTitle: 'Metadata title', - previousEditorTitle: null, - atomTitle: 'Metadata title', - editorTitle: null, - }), - ).toBe('none'); - }); - - it('initializes a new empty title block from existing metadata title', () => { - expect( - getTitleSyncAction({ - previousAtomTitle: 'Metadata title', - previousEditorTitle: null, - atomTitle: 'Metadata title', - editorTitle: '', - }), - ).toBe('atom-to-editor'); - }); - - it('syncs metadata form updates into the title block', () => { - expect( - getTitleSyncAction({ - previousAtomTitle: 'Old metadata title', - previousEditorTitle: 'Old metadata title', - atomTitle: 'Updated metadata title', - editorTitle: 'Old metadata title', - }), - ).toBe('atom-to-editor'); - }); - - it('syncs title block typing back into metadata', () => { - expect( - getTitleSyncAction({ - previousAtomTitle: 'Old title', - previousEditorTitle: 'Old title', - atomTitle: 'Old title', - editorTitle: 'Typed in editor', - }), - ).toBe('editor-to-atom'); - }); - - it('returns none when title block and metadata are already synchronized', () => { - expect( - getTitleSyncAction({ - previousAtomTitle: 'Same', - previousEditorTitle: 'Same', - atomTitle: 'Same', - editorTitle: 'Same', - }), - ).toBe('none'); - }); - }); }); diff --git a/packages/plate/components/editor/plugins/title-renderer.tsx b/packages/plate/components/editor/plugins/title-renderer.tsx index 534b102f9a5..8e4ae0bff99 100644 --- a/packages/plate/components/editor/plugins/title-renderer.tsx +++ b/packages/plate/components/editor/plugins/title-renderer.tsx @@ -1,5 +1,6 @@ import { createSlatePlugin } from 'platejs'; import { toPlatePlugin, type PlateElementProps } from 'platejs/react'; +import { BlockInnerContainer } from '../../ui/block-inner-container'; export const TITLE_BLOCK_TYPE = 'title'; @@ -9,7 +10,7 @@ function TitleRendererElement(props: PlateElementProps) { {...props.attributes} className="font-heading mt-[1.6em] pb-1 text-4xl font-bold" > - {props.children} + {props.children} ); } diff --git a/packages/plate/components/editor/plugins/title.tsx b/packages/plate/components/editor/plugins/title.tsx index aa3bf5173d0..f4e6fda771b 100644 --- a/packages/plate/components/editor/plugins/title.tsx +++ b/packages/plate/components/editor/plugins/title.tsx @@ -1,24 +1,16 @@ -import { useEffect, useMemo, useRef } from 'react'; -import { atom, type PrimitiveAtom } from 'jotai'; -import { useFieldFocusedAtom } from '@plone/helpers'; -import config from '@plone/registry'; -import { createSlatePlugin, ElementApi, PathApi } from 'platejs'; +import { ElementApi, PathApi } from 'platejs'; import { PlateElement, type PlateElementProps, + type TPlateEditor, + createPlatePlugin, toPlatePlugin, - useEditorRef, - useEditorSelector, } from 'platejs/react'; -import { BLOCK_WIDTH_VALUES } from './block-width-plugin'; +import { BlockInnerContainer } from '../../ui/block-inner-container'; +import { useMetadataTextBinding } from './metadata-text-binding'; export const TITLE_BLOCK_TYPE = 'title'; - -type TitleData = { - title?: string; -}; - -const fallbackFormAtom = atom({ title: '' }); +const TITLE_PLACEHOLDER = 'Type the title...'; const isTitleNode = (node: unknown) => ElementApi.isElement(node) && node.type === TITLE_BLOCK_TYPE; @@ -44,119 +36,97 @@ const getNodeText = (node: unknown): string => { return node.children.map((child) => getNodeText(child)).join(''); }; -type SyncAction = 'none' | 'atom-to-editor' | 'editor-to-atom'; - -export function getTitleSyncAction({ - previousAtomTitle, - previousEditorTitle, - atomTitle, - editorTitle, -}: { - previousAtomTitle: string; - previousEditorTitle: string | null; - atomTitle: string; - editorTitle: string | null; -}): SyncAction { - if (editorTitle === null) return 'none'; - - const atomChanged = previousAtomTitle !== atomTitle; - const editorChanged = previousEditorTitle !== editorTitle; - const titleNodeJustAppeared = - previousEditorTitle === null && editorTitle !== null; - const shouldInitializeFromAtom = - titleNodeJustAppeared && editorTitle === '' && atomTitle !== ''; - - if (shouldInitializeFromAtom) return 'atom-to-editor'; - if (atomChanged && editorTitle !== atomTitle) return 'atom-to-editor'; - if (editorChanged && !atomChanged && editorTitle !== atomTitle) { - return 'editor-to-atom'; - } +const isPathInside = (path: number[], ancestorPath: number[]) => + ancestorPath.every((segment, index) => path[index] === segment); - return 'none'; -} +const isSelectionInside = (selection: any, path: number[]) => { + if (!selection) return false; -function TitleMetadataSync() { - const editor = useEditorRef(); - const previousEditorTitleRef = useRef(null); - const previousAtomTitleRef = useRef(''); - const formAtom = useMemo(() => { - try { - return config - .getUtility({ - name: 'formAtom', - type: 'atom', - }) - ?.method?.() as PrimitiveAtom | undefined; - } catch { - return undefined; - } - }, []); - const [titleValue, setTitleValue] = useFieldFocusedAtom( - formAtom ?? fallbackFormAtom, - 'title', + return ( + isPathInside(selection.anchor.path, path) && + isPathInside(selection.focus.path, path) ); - const editorTitle = useEditorSelector((editor) => { - const titleEntry = getTitleNodeEntry(editor.children as unknown[]); - return titleEntry ? getNodeText(titleEntry.node) : null; - }, []); - - const hasFormAtom = !!formAtom; - const normalizedTitle = titleValue ?? ''; - - useEffect(() => { - if (!hasFormAtom) { - previousEditorTitleRef.current = editorTitle; - previousAtomTitleRef.current = normalizedTitle; - return; - } +}; - const action = getTitleSyncAction({ - previousAtomTitle: previousAtomTitleRef.current, - previousEditorTitle: previousEditorTitleRef.current, - atomTitle: normalizedTitle, - editorTitle, - }); +const setTitleNodeText = ( + editor: TPlateEditor, + titlePath: number[], + titleNode: unknown, + value: string, +) => { + editor.tf.replaceNodes( + { + ...(titleNode as object), + children: [{ text: value }], + } as any, + { at: titlePath }, + ); +}; - if (action === 'atom-to-editor') { +function TitleMetadataSync() { + useMetadataTextBinding({ + field: 'title', + getState: (editor) => { const titleEntry = getTitleNodeEntry(editor.children as unknown[]); - if (titleEntry) { - editor.tf.replaceNodes( - { - ...(titleEntry.node as object), - children: [{ text: normalizedTitle }], - }, - { at: [titleEntry.index] }, - ); + + if (!titleEntry) { + return { + isActive: false, + value: null, + }; } - } - if (action === 'editor-to-atom' && editorTitle !== null) { - setTitleValue(editorTitle); - } + const titlePath = [titleEntry.index]; + + return { + isActive: isSelectionInside(editor.selection, titlePath), + value: getNodeText(titleEntry.node), + }; + }, + writeToEditor: (editor, value) => { + const titleEntry = getTitleNodeEntry(editor.children as unknown[]); - previousEditorTitleRef.current = editorTitle; - previousAtomTitleRef.current = normalizedTitle; - }, [editor, editorTitle, hasFormAtom, normalizedTitle, setTitleValue]); + if (!titleEntry) return; + + setTitleNodeText(editor, [titleEntry.index], titleEntry.node, value); + }, + }); return null; } export function TitleBlockElement(props: PlateElementProps) { + const showPlaceholder = getNodeText(props.element) === ''; + return ( - {props.children} + + {showPlaceholder ? ( + + ) : null} + {props.children} + ); } -export const BaseTitleBlockPlugin = createSlatePlugin({ +export const BaseTitleBlockPlugin = createPlatePlugin({ key: TITLE_BLOCK_TYPE, handlers: { - onKeyDown: ({ editor, event }) => { + onKeyDown: ({ editor, event }: any) => { const nativeEvent = (event as any)?.nativeEvent ?? event; if (!nativeEvent || nativeEvent.key !== 'Enter') return; if (!editor.selection || !editor.api.isCollapsed()) return; @@ -177,7 +147,7 @@ export const BaseTitleBlockPlugin = createSlatePlugin({ editor.api.create.block({ type: 'p', children: [{ text: '' }], - }), + }) as any, { at: PathApi.next(currentPath as number[]), select: true, @@ -190,12 +160,6 @@ export const BaseTitleBlockPlugin = createSlatePlugin({ isElement: true, type: TITLE_BLOCK_TYPE, }, - options: { - blockWidth: { - defaultWidth: BLOCK_WIDTH_VALUES.default, - widths: [BLOCK_WIDTH_VALUES.default], - }, - }, extendEditor: ({ editor }) => { const insertBreak = editor.tf.insertBreak; const normalizeNode = editor.normalizeNode as (entry: any) => void; @@ -209,7 +173,7 @@ export const BaseTitleBlockPlugin = createSlatePlugin({ editor.api.create.block({ type: 'p', children: [{ text: '' }], - }), + }) as any, { at: PathApi.next(path), select: true, @@ -222,7 +186,7 @@ export const BaseTitleBlockPlugin = createSlatePlugin({ insertBreak(); }; - editor.normalizeNode = (entry) => { + editor.normalizeNode = (entry: any) => { const [, path] = entry; if (path.length === 0) { @@ -259,7 +223,7 @@ export const BaseTitleBlockPlugin = createSlatePlugin({ }, }); -export const TitleBlock = toPlatePlugin(BaseTitleBlockPlugin).configure({ +export const TitleBlock = toPlatePlugin(BaseTitleBlockPlugin as any).configure({ render: { afterEditable: TitleMetadataSync, }, diff --git a/packages/plate/components/editor/plugins/toc-base-kit.tsx b/packages/plate/components/editor/plugins/toc-base-kit.tsx index c16389bbeab..72b317e9c17 100644 --- a/packages/plate/components/editor/plugins/toc-base-kit.tsx +++ b/packages/plate/components/editor/plugins/toc-base-kit.tsx @@ -1,16 +1,12 @@ import { BaseTocPlugin } from '@platejs/toc'; import { TocElementStatic } from '../../ui/toc-node-static'; -import { BLOCK_WIDTH_VALUES } from './block-width-plugin'; import { queryHeadingWithTitle } from './toc-query-heading'; export const BaseTocKit = [ BaseTocPlugin.configure({ options: { queryHeading: queryHeadingWithTitle, - blockWidth: { - defaultWidth: BLOCK_WIDTH_VALUES.default, - }, }, }).withComponent(TocElementStatic), ]; diff --git a/packages/plate/components/editor/plugins/toc-kit.tsx b/packages/plate/components/editor/plugins/toc-kit.tsx index 87bce99b20c..5812397f747 100644 --- a/packages/plate/components/editor/plugins/toc-kit.tsx +++ b/packages/plate/components/editor/plugins/toc-kit.tsx @@ -1,7 +1,6 @@ import { TocPlugin } from '@platejs/toc/react'; import { TocElement } from '../../ui/toc-node'; -import { BLOCK_WIDTH_VALUES } from './block-width-plugin'; import { queryHeadingWithTitle } from './toc-query-heading'; export const TocKit = [ @@ -12,9 +11,6 @@ export const TocKit = [ isScroll: false, topOffset: 80, queryHeading: queryHeadingWithTitle, - blockWidth: { - defaultWidth: BLOCK_WIDTH_VALUES.default, - }, }, }).withComponent(TocElement), ]; diff --git a/packages/plate/components/editor/transforms.ts b/packages/plate/components/editor/transforms.ts index fef519e01e3..8b59efd7f40 100644 --- a/packages/plate/components/editor/transforms.ts +++ b/packages/plate/components/editor/transforms.ts @@ -10,7 +10,6 @@ import { insertMedia, insertVideoPlaceholder, } from '@platejs/media'; -import { SuggestionPlugin } from '@platejs/suggestion/react'; import { TablePlugin } from '@platejs/table/react'; import { insertToc } from '@platejs/toc'; import { @@ -20,6 +19,8 @@ import { KEYS, PathApi, } from 'platejs'; +import { SuggestionPlugin } from './plugins/suggestion-kit'; +import { withBlockWidthDefaults } from './plugins/block-width-plugin'; const ACTION_THREE_COLUMNS = 'action_three_columns'; @@ -141,7 +142,9 @@ export const setBlockType = ( return setBlockMap[type](editor, type, entry); } if (node.type !== type) { - editor.tf.setNodes({ type }, { at: path }); + editor.tf.setNodes(withBlockWidthDefaults(editor, { ...node, type }), { + at: path, + }); } }; diff --git a/packages/plate/components/editor/use-chat.ts b/packages/plate/components/editor/use-chat.ts index b6bf3dbc509..13183db4ab2 100644 --- a/packages/plate/components/editor/use-chat.ts +++ b/packages/plate/components/editor/use-chat.ts @@ -10,8 +10,7 @@ import { type TNode, KEYS, nanoid, NodeApi, TextApi } from 'platejs'; import { type PlateEditor, useEditorRef, usePluginOption } from 'platejs/react'; import { aiChatPlugin } from '../../components/editor/plugins/ai-kit'; - -import { discussionPlugin } from './plugins/discussion-kit'; +import { usePlatePlugins } from './plate-plugins-context'; export type ToolName = 'comment' | 'edit' | 'generate'; @@ -53,6 +52,7 @@ const loremWordChunk = (minWords = 1, maxWords = 3) => { export const useChat = () => { const editor = useEditorRef(); const options = usePluginOption(aiChatPlugin, 'chatOptions'); + const { currentUserId, discussions, setDiscussions } = usePlatePlugins(); // remove when you implement the route /api/ai/command const abortControllerRef = React.useRef(null); @@ -124,9 +124,7 @@ export const useChat = () => { // eslint-disable-next-line no-console if (!range) return console.warn('No range found for AI comment'); - - const discussions = - editor.getOption(discussionPlugin, 'discussions') || []; + if (!currentUserId) return; // Generate a new discussion ID const discussionId = nanoid(); @@ -138,7 +136,7 @@ export const useChat = () => { createdAt: new Date(), discussionId, isEdited: false, - userId: editor.getOption(discussionPlugin, 'currentUserId'), + userId: currentUserId, }; // Create a new discussion @@ -150,12 +148,12 @@ export const useChat = () => { .map((node: TNode) => NodeApi.string(node)) .join('\n'), isResolved: false, - userId: editor.getOption(discussionPlugin, 'currentUserId'), + userId: currentUserId, }; // Update discussions const updatedDiscussions = [...discussions, newDiscussion]; - editor.setOption(discussionPlugin, 'discussions', updatedDiscussions); + setDiscussions(updatedDiscussions); // Apply comment marks to the editor editor.tf.withMerging(() => { diff --git a/packages/plate/components/ui/block-discussion.tsx b/packages/plate/components/ui/block-discussion.tsx index 4ba26faf11e..87448bb0e10 100644 --- a/packages/plate/components/ui/block-discussion.tsx +++ b/packages/plate/components/ui/block-discussion.tsx @@ -5,7 +5,6 @@ import type { PlateElementProps, RenderNodeWrapper } from 'platejs/react'; import { getDraftCommentKey } from '@platejs/comment'; import { CommentPlugin } from '@platejs/comment/react'; import { getTransientSuggestionKey } from '@platejs/suggestion'; -import { SuggestionPlugin } from '@platejs/suggestion/react'; import { MessageSquareTextIcon, MessagesSquareIcon, @@ -30,12 +29,13 @@ import { PopoverContent, PopoverTrigger, } from './popover'; +import { usePlatePlugins } from '../editor/plate-plugins-context'; import { commentPlugin } from '../editor/plugins/comment-kit'; +import { type TDiscussion } from '../editor/plugins/discussion-kit'; import { - type TDiscussion, - discussionPlugin, -} from '../editor/plugins/discussion-kit'; -import { suggestionPlugin } from '../editor/plugins/suggestion-kit'; + SuggestionPlugin, + suggestionPlugin, +} from '../editor/plugins/suggestion-kit'; import { BlockSuggestionCard, @@ -324,8 +324,7 @@ const useResolvedDiscussion = ( blockPath: Path, ) => { const { api, getOption, setOption } = useEditorPlugin(commentPlugin); - - const discussions = usePluginOption(discussionPlugin, 'discussions'); + const { discussions } = usePlatePlugins(); commentNodes.forEach(([node]) => { const id = api.comment.nodeId(node); diff --git a/packages/plate/components/ui/block-inner-container.test.tsx b/packages/plate/components/ui/block-inner-container.test.tsx new file mode 100644 index 00000000000..c1a476e49b7 --- /dev/null +++ b/packages/plate/components/ui/block-inner-container.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { ParagraphElement } from './paragraph-node'; + +describe('block inner container', () => { + it('wraps paragraph children in a block-inner-container', () => { + const element = ParagraphElement({ + attributes: { 'data-slate-node': 'element' }, + children: 'Paragraph body', + } as any); + + expect(element.props.children.type.name).toBe('BlockInnerContainer'); + expect(element.props.children.props.children).toBe('Paragraph body'); + }); +}); diff --git a/packages/plate/components/ui/block-inner-container.tsx b/packages/plate/components/ui/block-inner-container.tsx new file mode 100644 index 00000000000..81fd034c971 --- /dev/null +++ b/packages/plate/components/ui/block-inner-container.tsx @@ -0,0 +1,20 @@ +import type { CSSProperties, PropsWithChildren } from 'react'; + +import { cn } from '../../lib/utils'; + +type BlockInnerContainerProps = PropsWithChildren<{ + className?: string; + style?: CSSProperties; +}>; + +export function BlockInnerContainer({ + children, + className, + style, +}: BlockInnerContainerProps) { + return ( +
    + {children} +
    + ); +} diff --git a/packages/plate/components/ui/block-suggestion.tsx b/packages/plate/components/ui/block-suggestion.tsx index 58610862f14..a1703a38e6a 100644 --- a/packages/plate/components/ui/block-suggestion.tsx +++ b/packages/plate/components/ui/block-suggestion.tsx @@ -8,7 +8,6 @@ import { keyId2SuggestionId, rejectSuggestion, } from '@platejs/suggestion'; -import { SuggestionPlugin } from '@platejs/suggestion/react'; import { CheckIcon, XIcon } from 'lucide-react'; import { type NodeEntry, @@ -21,16 +20,17 @@ import { PathApi, TextApi, } from 'platejs'; -import { useEditorPlugin, usePluginOption } from 'platejs/react'; +import { useEditorPlugin } from 'platejs/react'; import { Avatar, AvatarFallback, AvatarImage } from './avatar'; import { Button } from './button'; import { cn } from '../../lib/utils'; +import { usePlatePlugins } from '../editor/plate-plugins-context'; +import { type TDiscussion } from '../editor/plugins/discussion-kit'; import { - type TDiscussion, - discussionPlugin, -} from '../editor/plugins/discussion-kit'; -import { suggestionPlugin } from '../editor/plugins/suggestion-kit'; + SuggestionPlugin, + suggestionPlugin, +} from '../editor/plugins/suggestion-kit'; import { type TComment, @@ -102,8 +102,8 @@ export function BlockSuggestionCard({ suggestion: ResolvedSuggestion; }) { const { api, editor } = useEditorPlugin(SuggestionPlugin); - - const userInfo = usePluginOption(discussionPlugin, 'user', suggestion.userId); + const { users } = usePlatePlugins(); + const userInfo = users[suggestion.userId]; const accept = (suggestion: ResolvedSuggestion) => { api.suggestion.withoutSuggestions(() => { @@ -279,7 +279,7 @@ export const useResolveSuggestion = ( suggestionNodes: NodeEntry[], blockPath: Path, ) => { - const discussions = usePluginOption(discussionPlugin, 'discussions'); + const { discussions } = usePlatePlugins(); const { api, editor, getOption, setOption } = useEditorPlugin(suggestionPlugin); diff --git a/packages/plate/components/ui/block-width-toolbar-button.test.tsx b/packages/plate/components/ui/block-width-toolbar-button.test.tsx new file mode 100644 index 00000000000..d597186a4de --- /dev/null +++ b/packages/plate/components/ui/block-width-toolbar-button.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { BlockWidthToolbarButton } from './block-width-toolbar-button'; + +vi.mock('platejs/react', () => ({ + useEditorPlugin: () => ({ + editor: { + api: { + block: () => [{ type: 'p' }], + }, + tf: { + focus: vi.fn(), + }, + }, + tf: { + styleFields: { + resetStyleField: vi.fn(), + setStyleField: vi.fn(), + }, + }, + }), + useSelectionFragmentProp: () => 'default', +})); + +vi.mock('@plone/components/Icons', () => ({ + WidthDefaultIcon: () => default-icon, + WidthFullIcon: () => full-icon, + WidthLayoutIcon: () => layout-icon, + WidthNarrowIcon: () => narrow-icon, +})); + +vi.mock('../editor/plugins/block-width-plugin', () => ({ + BlockWidthPlugin: {}, + BLOCK_WIDTH_KEY: 'blockWidth', + getDefaultBlockWidth: () => 'default', + getBlockWidthConfig: () => ({ + defaultWidth: 'default', + widths: ['default'], + }), + getBlockWidthOptions: () => [ + { + label: 'Default', + value: 'default', + }, + ], +})); + +describe('BlockWidthToolbarButton', () => { + it('does not render when there is only a single allowed width option', () => { + render(); + + expect(screen.queryByText('default-icon')).toBeNull(); + }); +}); diff --git a/packages/plate/components/ui/block-width-toolbar-button.tsx b/packages/plate/components/ui/block-width-toolbar-button.tsx index 794e4b1666a..662c7847a6b 100644 --- a/packages/plate/components/ui/block-width-toolbar-button.tsx +++ b/packages/plate/components/ui/block-width-toolbar-button.tsx @@ -7,16 +7,16 @@ import { CheckIcon } from 'lucide-react'; import { useEditorPlugin, useSelectionFragmentProp } from 'platejs/react'; import { WidthDefaultIcon, + WidthFullIcon, WidthLayoutIcon, WidthNarrowIcon, } from '@plone/components/Icons'; import { - DEFAULT_BLOCK_WIDTH, BlockWidthPlugin, - BLOCK_WIDTH_VALUES, - BLOCK_WIDTH_OPTIONS, + getDefaultBlockWidth, getBlockWidthConfig, + getBlockWidthOptions, } from '../editor/plugins/block-width-plugin'; import { @@ -35,14 +35,19 @@ import { export function BlockWidthToolbarButton(props: DropdownMenuProps) { const { editor, tf } = useEditorPlugin(BlockWidthPlugin); + const blockWidthTransforms = tf.blockWidth as { + resetWidth: () => void; + setWidth: (value: string) => void; + }; const [open, setOpen] = React.useState(false); const activeBlock = editor.api.block()?.[0]; const config = getBlockWidthConfig(editor, activeBlock); - const baseValue = config.defaultWidth ?? DEFAULT_BLOCK_WIDTH; + const baseValue = config.defaultWidth ?? getDefaultBlockWidth(); const widthOptions = React.useMemo(() => { const allowed = new Set(config.widths ?? []); + const options = getBlockWidthOptions(); - return BLOCK_WIDTH_OPTIONS.filter((option) => allowed.has(option.value)); + return options.filter((option) => allowed.has(option.value)); }, [config.widths]); const value = useSelectionFragmentProp({ @@ -50,12 +55,16 @@ export function BlockWidthToolbarButton(props: DropdownMenuProps) { getProp: (node) => node.blockWidth, }); + if (widthOptions.length <= 1) { + return null; + } + return ( { - tf.blockWidth.setNodes(baseValue); + blockWidthTransforms.resetWidth(); editor.tf.focus(); }} data-state={value !== baseValue ? 'on' : 'off'} @@ -72,17 +81,19 @@ export function BlockWidthToolbarButton(props: DropdownMenuProps) { { - tf.blockWidth.setNodes(newValue); + blockWidthTransforms.setWidth(newValue); editor.tf.focus(); }} > {widthOptions.map(({ label, value }) => { const Icon = - value === BLOCK_WIDTH_VALUES.layout - ? WidthLayoutIcon - : value === BLOCK_WIDTH_VALUES.narrow - ? WidthNarrowIcon - : WidthDefaultIcon; + value === 'full' + ? WidthFullIcon + : value === 'layout' + ? WidthLayoutIcon + : value === 'narrow' + ? WidthNarrowIcon + : WidthDefaultIcon; return ( + + + {props.children} + + ); } diff --git a/packages/plate/components/ui/blockquote-node.tsx b/packages/plate/components/ui/blockquote-node.tsx index b84aab0acbd..0749a124ce1 100644 --- a/packages/plate/components/ui/blockquote-node.tsx +++ b/packages/plate/components/ui/blockquote-node.tsx @@ -1,11 +1,13 @@ import { type PlateElementProps, PlateElement } from 'platejs/react'; +import { BlockInnerContainer } from './block-inner-container'; + export function BlockquoteElement(props: PlateElementProps) { return ( - + + + {props.children} + + ); } diff --git a/packages/plate/components/ui/callout-node-static.tsx b/packages/plate/components/ui/callout-node-static.tsx index a657959af4e..a880d78a68f 100644 --- a/packages/plate/components/ui/callout-node-static.tsx +++ b/packages/plate/components/ui/callout-node-static.tsx @@ -2,6 +2,7 @@ import type { SlateElementProps } from 'platejs'; import { SlateElement } from 'platejs'; +import { BlockInnerContainer } from './block-inner-container'; import { cn } from '../../lib/utils'; export function CalloutElementStatic({ @@ -10,27 +11,28 @@ export function CalloutElementStatic({ ...props }: SlateElementProps) { return ( - -
    -
    - - {(props.element.icon as any) || '💡'} - + + +
    +
    + + {(props.element.icon as any) || '💡'} + +
    +
    {children}
    -
    {children}
    -
    + ); } diff --git a/packages/plate/components/ui/callout-node.tsx b/packages/plate/components/ui/callout-node.tsx index 00945eff373..b22629852a3 100644 --- a/packages/plate/components/ui/callout-node.tsx +++ b/packages/plate/components/ui/callout-node.tsx @@ -4,6 +4,7 @@ import { useCalloutEmojiPicker } from '@platejs/callout/react'; import { useEmojiDropdownMenuState } from '@platejs/emoji/react'; import { PlateElement } from 'platejs/react'; +import { BlockInnerContainer } from './block-inner-container'; import { cn } from '../../lib/utils'; export function CalloutElement({ @@ -26,20 +27,22 @@ export function CalloutElement({ return ( -
    - {/* ToDo: Replace the dependency on @platejs/emoji and @emoji-mart/data */} - {/* with something more lightweight and sane */} - {/* +
    + {/* ToDo: Replace the dependency on @platejs/emoji and @emoji-mart/data */} + {/* with something more lightweight and sane */} + {/* */} -
    {children}
    -
    +
    {children}
    +
    +
    ); } diff --git a/packages/plate/components/ui/code-block-node-static.tsx b/packages/plate/components/ui/code-block-node-static.tsx index 1106c298abd..63552d307de 100644 --- a/packages/plate/components/ui/code-block-node-static.tsx +++ b/packages/plate/components/ui/code-block-node-static.tsx @@ -6,6 +6,8 @@ import { SlateLeaf, } from 'platejs'; +import { BlockInnerContainer } from './block-inner-container'; + export function CodeBlockElementStatic( props: SlateElementProps, ) { @@ -38,17 +40,19 @@ export function CodeBlockElementStatic( `} {...props} > -
    -
    -          {props.children}
    -        
    -
    + +
    +
    +            {props.children}
    +          
    +
    +
    ); } diff --git a/packages/plate/components/ui/code-block-node.tsx b/packages/plate/components/ui/code-block-node.tsx index b62b4134aee..6d93bc7b3c1 100644 --- a/packages/plate/components/ui/code-block-node.tsx +++ b/packages/plate/components/ui/code-block-node.tsx @@ -11,6 +11,7 @@ import { } from 'platejs/react'; import { useEditorRef, useElement, useReadOnly } from 'platejs/react'; +import { BlockInnerContainer } from './block-inner-container'; import { Button } from './button'; import { Command, @@ -55,43 +56,45 @@ export function CodeBlockElement(props: PlateElementProps) { `} {...props} > -
    -
    -          {props.children}
    -        
    + +
    +
    +            {props.children}
    +          
    -
    - {isLangSupported(element.lang) && ( - - )} +
    + {isLangSupported(element.lang) && ( + + )} - + - NodeApi.string(element)} - /> + NodeApi.string(element)} + /> +
    -
    +
    ); } diff --git a/packages/plate/components/ui/column-node-static.tsx b/packages/plate/components/ui/column-node-static.tsx index edabb70cb40..2d9f7b2b69f 100644 --- a/packages/plate/components/ui/column-node-static.tsx +++ b/packages/plate/components/ui/column-node-static.tsx @@ -2,6 +2,8 @@ import type { SlateElementProps, TColumnElement } from 'platejs'; import { SlateElement } from 'platejs'; +import { BlockInnerContainer } from './block-inner-container'; + export function ColumnElementStatic(props: SlateElementProps) { const { width } = props.element; @@ -25,8 +27,10 @@ export function ColumnElementStatic(props: SlateElementProps) { export function ColumnGroupElementStatic(props: SlateElementProps) { return ( - -
    {props.children}
    + + +
    {props.children}
    +
    ); } diff --git a/packages/plate/components/ui/column-node.tsx b/packages/plate/components/ui/column-node.tsx index 1520c275b0d..cfe5e6c622f 100644 --- a/packages/plate/components/ui/column-node.tsx +++ b/packages/plate/components/ui/column-node.tsx @@ -33,6 +33,7 @@ import { TooltipProvider, TooltipTrigger, } from './tooltip'; +import { BlockInnerContainer } from './block-inner-container'; import { cn } from '../../lib/utils'; export const ColumnElement = withHOC( @@ -149,9 +150,11 @@ function DropLine() { export function ColumnGroupElement(props: PlateElementProps) { return ( - + -
    {props.children}
    + +
    {props.children}
    +
    ); diff --git a/packages/plate/components/ui/comment.tsx b/packages/plate/components/ui/comment.tsx index 3cae7964903..df43cade94e 100644 --- a/packages/plate/components/ui/comment.tsx +++ b/packages/plate/components/ui/comment.tsx @@ -24,7 +24,6 @@ import { useEditorPlugin, useEditorRef, usePlateEditor, - usePluginOption, } from 'platejs/react'; import { Avatar, AvatarFallback, AvatarImage } from './avatar'; @@ -37,11 +36,9 @@ import { DropdownMenuTrigger, } from './dropdown-menu'; import { cn } from '../../lib/utils'; +import { usePlatePlugins } from '../editor/plate-plugins-context'; import { BasicMarksKit } from '../editor/plugins/basic-marks-kit'; -import { - type TDiscussion, - discussionPlugin, -} from '../editor/plugins/discussion-kit'; +import { type TDiscussion } from '../editor/plugins/discussion-kit'; import { Editor, EditorContainer } from './editor'; @@ -75,27 +72,25 @@ export function Comment(props: { onEditorClick, } = props; - const editor = useEditorRef(); - const userInfo = usePluginOption(discussionPlugin, 'user', comment.userId); - const currentUserId = usePluginOption(discussionPlugin, 'currentUserId'); + const { discussions, setDiscussions, users, currentUserId } = + usePlatePlugins(); + const userInfo = users[comment.userId]; const resolveDiscussion = async (id: string) => { - const updatedDiscussions = editor - .getOption(discussionPlugin, 'discussions') - .map((discussion) => { - if (discussion.id === id) { - return { ...discussion, isResolved: true }; - } - return discussion; - }); - editor.setOption(discussionPlugin, 'discussions', updatedDiscussions); + const updatedDiscussions = discussions.map((discussion) => { + if (discussion.id === id) { + return { ...discussion, isResolved: true }; + } + return discussion; + }); + setDiscussions(updatedDiscussions); }; const removeDiscussion = async (id: string) => { - const updatedDiscussions = editor - .getOption(discussionPlugin, 'discussions') - .filter((discussion) => discussion.id !== id); - editor.setOption(discussionPlugin, 'discussions', updatedDiscussions); + const updatedDiscussions = discussions.filter( + (discussion) => discussion.id !== id, + ); + setDiscussions(updatedDiscussions); }; const updateComment = async (input: { @@ -104,26 +99,24 @@ export function Comment(props: { discussionId: string; isEdited: boolean; }) => { - const updatedDiscussions = editor - .getOption(discussionPlugin, 'discussions') - .map((discussion) => { - if (discussion.id === input.discussionId) { - const updatedComments = discussion.comments.map((comment) => { - if (comment.id === input.id) { - return { - ...comment, - contentRich: input.contentRich, - isEdited: true, - updatedAt: new Date(), - }; - } - return comment; - }); - return { ...discussion, comments: updatedComments }; - } - return discussion; - }); - editor.setOption(discussionPlugin, 'discussions', updatedDiscussions); + const updatedDiscussions = discussions.map((discussion) => { + if (discussion.id === input.discussionId) { + const updatedComments = discussion.comments.map((comment) => { + if (comment.id === input.id) { + return { + ...comment, + contentRich: input.contentRich, + isEdited: true, + updatedAt: new Date(), + }; + } + return comment; + }); + return { ...discussion, comments: updatedComments }; + } + return discussion; + }); + setDiscussions(updatedDiscussions); }; const { tf } = useEditorPlugin(CommentPlugin); @@ -311,7 +304,7 @@ function CommentMoreDropdown(props: { onRemoveComment, } = props; - const editor = useEditorRef(); + const { discussions, setDiscussions } = usePlatePlugins(); const selectedEditCommentRef = React.useRef(false); @@ -321,33 +314,36 @@ function CommentMoreDropdown(props: { return alert('You are operating too quickly, please try again later.'); // Find and update the discussion - const updatedDiscussions = editor - .getOption(discussionPlugin, 'discussions') - .map((discussion) => { - if (discussion.id !== comment.discussionId) { - return discussion; - } - - const commentIndex = discussion.comments.findIndex( - (c) => c.id === comment.id, - ); - if (commentIndex === -1) { - return discussion; - } - - return { - ...discussion, - comments: [ - ...discussion.comments.slice(0, commentIndex), - ...discussion.comments.slice(commentIndex + 1), - ], - }; - }); + const updatedDiscussions = discussions.map((discussion) => { + if (discussion.id !== comment.discussionId) { + return discussion; + } - // Save back to session storage - editor.setOption(discussionPlugin, 'discussions', updatedDiscussions); + const commentIndex = discussion.comments.findIndex( + (c) => c.id === comment.id, + ); + if (commentIndex === -1) { + return discussion; + } + + return { + ...discussion, + comments: [ + ...discussion.comments.slice(0, commentIndex), + ...discussion.comments.slice(commentIndex + 1), + ], + }; + }); + + setDiscussions(updatedDiscussions); onRemoveComment?.(); - }, [comment.discussionId, comment.id, editor, onRemoveComment]); + }, [ + comment.discussionId, + comment.id, + discussions, + onRemoveComment, + setDiscussions, + ]); const onEditComment = React.useCallback(() => { selectedEditCommentRef.current = true; @@ -424,13 +420,12 @@ export function CommentCreateForm({ discussionId?: string; focusOnMount?: boolean; }) { - const discussions = usePluginOption(discussionPlugin, 'discussions'); - const editor = useEditorRef(); const commentId = useCommentId(); const discussionId = discussionIdProp ?? commentId; - - const userInfo = usePluginOption(discussionPlugin, 'currentUser'); + const { currentUser, currentUserId, discussions, setDiscussions } = + usePlatePlugins(); + const userInfo = currentUser ?? undefined; const [commentValue, setCommentValue] = React.useState(); const commentContent = React.useMemo( () => @@ -448,7 +443,7 @@ export function CommentCreateForm({ }, [commentEditor, focusOnMount]); const onAddComment = React.useCallback(async () => { - if (!commentValue) return; + if (!commentValue || !currentUserId) return; commentEditor.tf.reset(); @@ -466,18 +461,15 @@ export function CommentCreateForm({ createdAt: new Date(), discussionId, isEdited: false, - userId: editor.getOption(discussionPlugin, 'currentUserId'), + userId: currentUserId, }, ], createdAt: new Date(), isResolved: false, - userId: editor.getOption(discussionPlugin, 'currentUserId'), + userId: currentUserId, }; - editor.setOption(discussionPlugin, 'discussions', [ - ...discussions, - newDiscussion, - ]); + setDiscussions([...discussions, newDiscussion]); return; } @@ -488,7 +480,7 @@ export function CommentCreateForm({ createdAt: new Date(), discussionId, isEdited: false, - userId: editor.getOption(discussionPlugin, 'currentUserId'), + userId: currentUserId, }; // Add reply to discussion comments @@ -502,7 +494,7 @@ export function CommentCreateForm({ .filter((d) => d.id !== discussionId) .concat(updatedDiscussion); - editor.setOption(discussionPlugin, 'discussions', updatedDiscussions); + setDiscussions(updatedDiscussions); return; } @@ -528,19 +520,16 @@ export function CommentCreateForm({ createdAt: new Date(), discussionId: _discussionId, isEdited: false, - userId: editor.getOption(discussionPlugin, 'currentUserId'), + userId: currentUserId, }, ], createdAt: new Date(), documentContent, isResolved: false, - userId: editor.getOption(discussionPlugin, 'currentUserId'), + userId: currentUserId, }; - editor.setOption(discussionPlugin, 'discussions', [ - ...discussions, - newDiscussion, - ]); + setDiscussions([...discussions, newDiscussion]); const id = newDiscussion.id; @@ -553,7 +542,15 @@ export function CommentCreateForm({ ); editor.tf.unsetNodes([getDraftCommentKey()], { at: path }); }); - }, [commentValue, commentEditor.tf, discussionId, editor, discussions]); + }, [ + commentValue, + commentEditor.tf, + currentUserId, + discussionId, + editor, + discussions, + setDiscussions, + ]); return (
    diff --git a/packages/plate/components/ui/editor-static.tsx b/packages/plate/components/ui/editor-static.tsx index 2714f01ca89..db3d87bd29c 100644 --- a/packages/plate/components/ui/editor-static.tsx +++ b/packages/plate/components/ui/editor-static.tsx @@ -19,7 +19,6 @@ export const editorVariants = cva( **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100! `, '[&_[data-slate-node="element"]:not([data-slate-inline="true"])]:mx-auto', - '[&_.slate-p]:mx-auto [&_.slate-p]:max-w-(--narrow-container-width)', '[&_strong]:font-bold', ), { diff --git a/packages/plate/components/ui/editor.tsx b/packages/plate/components/ui/editor.tsx index cd01978913f..b0b7e7a499a 100644 --- a/packages/plate/components/ui/editor.tsx +++ b/packages/plate/components/ui/editor.tsx @@ -82,7 +82,6 @@ export const editorVariants = cva( **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100! `, '[&_[data-slate-node="element"]:not([data-slate-inline="true"])]:mx-auto', - '[&_.slate-p]:mx-auto [&_.slate-p]:max-w-(--narrow-container-width)', '[&_strong]:font-bold', ), { diff --git a/packages/plate/components/ui/font-color-toolbar-button.tsx b/packages/plate/components/ui/font-color-toolbar-button.tsx index 00e0d90fe7f..d50b02c0a95 100644 --- a/packages/plate/components/ui/font-color-toolbar-button.tsx +++ b/packages/plate/components/ui/font-color-toolbar-button.tsx @@ -6,7 +6,7 @@ import type { } from '@radix-ui/react-dropdown-menu'; import { useComposedRef } from '@udecode/cn'; -import debounce from 'lodash/debounce.js'; +import debounce from 'lodash.debounce'; import { EraserIcon, PlusIcon } from 'lucide-react'; import { useEditorRef, useEditorSelector } from 'platejs/react'; diff --git a/packages/plate/components/ui/heading-node-static.tsx b/packages/plate/components/ui/heading-node-static.tsx index 7c63691e979..bd21f31cabf 100644 --- a/packages/plate/components/ui/heading-node-static.tsx +++ b/packages/plate/components/ui/heading-node-static.tsx @@ -5,6 +5,8 @@ import type { SlateElementProps } from 'platejs'; import { type VariantProps, cva } from 'class-variance-authority'; import { SlateElement } from 'platejs'; +import { BlockInnerContainer } from './block-inner-container'; + const headingVariants = cva('relative mb-1', { variants: { variant: { @@ -28,7 +30,7 @@ export function HeadingElementStatic({ className={headingVariants({ variant })} {...props} > - {props.children} + {props.children} ); } diff --git a/packages/plate/components/ui/heading-node.tsx b/packages/plate/components/ui/heading-node.tsx index 3b3333bb4ad..44ffc139673 100644 --- a/packages/plate/components/ui/heading-node.tsx +++ b/packages/plate/components/ui/heading-node.tsx @@ -3,6 +3,8 @@ import type { PlateElementProps } from 'platejs/react'; import { type VariantProps, cva } from 'class-variance-authority'; import { PlateElement } from 'platejs/react'; +import { BlockInnerContainer } from './block-inner-container'; + const headingVariants = cva('relative mb-1', { variants: { variant: { @@ -26,7 +28,7 @@ export function HeadingElement({ className={headingVariants({ variant })} {...props} > - {props.children} + {props.children} ); } diff --git a/packages/plate/components/ui/link-node-static.tsx b/packages/plate/components/ui/link-node-static.tsx index 6dd8f06276e..7f039f80217 100644 --- a/packages/plate/components/ui/link-node-static.tsx +++ b/packages/plate/components/ui/link-node-static.tsx @@ -1,20 +1,23 @@ import type { SlateElementProps, TLinkElement } from 'platejs'; -import { getLinkAttributes } from '@platejs/link'; +import { Link } from '@plone/components'; import { SlateElement } from 'platejs'; export function LinkElementStatic(props: SlateElementProps) { return ( - {props.children} + + {props.children} + ); } diff --git a/packages/plate/components/ui/link-node.tsx b/packages/plate/components/ui/link-node.tsx index 668929deac0..3bd145a68fd 100644 --- a/packages/plate/components/ui/link-node.tsx +++ b/packages/plate/components/ui/link-node.tsx @@ -1,11 +1,11 @@ import type { TInlineSuggestionData, TLinkElement } from 'platejs'; import type { PlateElementProps } from 'platejs/react'; -import { getLinkAttributes } from '@platejs/link'; -import { SuggestionPlugin } from '@platejs/suggestion/react'; +import { Link } from '@plone/components'; import { PlateElement } from 'platejs/react'; import { cn } from '../../lib/utils'; +import { SuggestionPlugin } from '../editor/plugins/suggestion-kit'; export function LinkElement(props: PlateElementProps) { const suggestionData = props.editor @@ -17,21 +17,32 @@ export function LinkElement(props: PlateElementProps) { return ( { - e.stopPropagation(); - }, - }} + attributes={props.attributes} > - {props.children} + { + e.preventDefault(); + e.stopPropagation(); + }} + onAuxClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onMouseOver={(e) => { + e.stopPropagation(); + }} + > + {props.children} + ); } diff --git a/packages/plate/components/ui/link-toolbar.tsx b/packages/plate/components/ui/link-toolbar.tsx index 1ea3f803531..3a7cf622685 100644 --- a/packages/plate/components/ui/link-toolbar.tsx +++ b/packages/plate/components/ui/link-toolbar.tsx @@ -101,7 +101,7 @@ export function LinkFloatingToolbar({ if (hidden) return null; const input = ( -
    +
    @@ -161,11 +161,19 @@ export function LinkFloatingToolbar({ return ( <> -
    +
    {input}
    -
    +
    {editContent}
    diff --git a/packages/plate/components/ui/media-toolbar.tsx b/packages/plate/components/ui/media-toolbar.tsx index 0cc04dd63ab..01057273243 100644 --- a/packages/plate/components/ui/media-toolbar.tsx +++ b/packages/plate/components/ui/media-toolbar.tsx @@ -70,6 +70,7 @@ export function MediaToolbar({ const anchorElement = React.useMemo(() => { try { const domElement = editor.api.toDOMNode(element); + if (!domElement) return null; const figure = domElement.querySelector('figure'); return figure ?? domElement; diff --git a/packages/plate/components/ui/media-video-node.tsx b/packages/plate/components/ui/media-video-node.tsx index b5874cc9fde..f9d506575aa 100644 --- a/packages/plate/components/ui/media-video-node.tsx +++ b/packages/plate/components/ui/media-video-node.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import LiteYouTubeEmbed from 'react-lite-youtube-embed'; import type { TResizableProps, TVideoElement } from 'platejs'; @@ -19,8 +18,6 @@ import { ResizeHandle, } from './resize-handle'; -const LazyReactPlayer = React.lazy(() => import('react-player')); - export const VideoElement = withHOC( ResizableProvider, function VideoElement( @@ -136,24 +133,13 @@ export const VideoElement = withHOC( {isUpload && isEditorMounted && (
    - - } - > - - + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +
    )}
    diff --git a/packages/plate/components/ui/mode-toolbar-button.tsx b/packages/plate/components/ui/mode-toolbar-button.tsx index c1638e39e02..ccd3cc0d119 100644 --- a/packages/plate/components/ui/mode-toolbar-button.tsx +++ b/packages/plate/components/ui/mode-toolbar-button.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; -import { SuggestionPlugin } from '@platejs/suggestion/react'; import { type DropdownMenuProps, DropdownMenuItemIndicator, @@ -16,12 +15,15 @@ import { DropdownMenuTrigger, } from './dropdown-menu'; +import { usePlatePlugins } from '../editor/plate-plugins-context'; +import { SuggestionPlugin } from '../editor/plugins/suggestion-kit'; import { ToolbarButton } from './toolbar'; export function ModeToolbarButton(props: DropdownMenuProps) { const editor = useEditorRef(); const [readOnly, setReadOnly] = usePlateState('readOnly'); const [open, setOpen] = React.useState(false); + const { currentUserId } = usePlatePlugins(); const isSuggesting = usePluginOption(SuggestionPlugin, 'isSuggesting'); @@ -75,10 +77,16 @@ export function ModeToolbarButton(props: DropdownMenuProps) { } if (newValue === 'suggestion') { + editor.setOption( + SuggestionPlugin, + 'currentUserId', + currentUserId, + ); editor.setOption(SuggestionPlugin, 'isSuggesting', true); return; } else { + editor.setOption(SuggestionPlugin, 'currentUserId', null); editor.setOption(SuggestionPlugin, 'isSuggesting', false); } diff --git a/packages/plate/components/ui/paragraph-node-static.tsx b/packages/plate/components/ui/paragraph-node-static.tsx index 6392352f181..875b9faace0 100644 --- a/packages/plate/components/ui/paragraph-node-static.tsx +++ b/packages/plate/components/ui/paragraph-node-static.tsx @@ -2,12 +2,13 @@ import type { SlateElementProps } from 'platejs'; import { SlateElement } from 'platejs'; +import { BlockInnerContainer } from './block-inner-container'; import { cn } from '../../lib/utils'; export function ParagraphElementStatic(props: SlateElementProps) { return ( - {props.children} + {props.children} ); } diff --git a/packages/plate/components/ui/paragraph-node.tsx b/packages/plate/components/ui/paragraph-node.tsx index ca46c2bc916..5832f3da86e 100644 --- a/packages/plate/components/ui/paragraph-node.tsx +++ b/packages/plate/components/ui/paragraph-node.tsx @@ -2,12 +2,13 @@ import type { PlateElementProps } from 'platejs/react'; import { PlateElement } from 'platejs/react'; +import { BlockInnerContainer } from './block-inner-container'; import { cn } from '../../lib/utils'; export function ParagraphElement(props: PlateElementProps) { return ( - {props.children} + {props.children} ); } diff --git a/packages/plate/components/ui/slash-node.tsx b/packages/plate/components/ui/slash-node.tsx index d3e832462ca..87b32cc6aba 100644 --- a/packages/plate/components/ui/slash-node.tsx +++ b/packages/plate/components/ui/slash-node.tsx @@ -1,35 +1,16 @@ import * as React from 'react'; -import type { PlateEditor, PlateElementProps } from 'platejs/react'; +import type { PlateElementProps } from 'platejs/react'; -import { AIChatPlugin } from '@platejs/ai/react'; -import { SuggestionPlugin } from '@platejs/suggestion/react'; -import config from '@plone/registry'; - -import { - BookA, - ChevronRightIcon, - Code2, - Columns3Icon, - Heading1Icon, - Heading2Icon, - Heading3Icon, - ImageIcon, - LightbulbIcon, - ListIcon, - ListOrdered, - PilcrowIcon, - Quote, - SparklesIcon, - Square, - Table, - TableOfContentsIcon, -} from 'lucide-react'; -import { type TComboboxInputElement, ElementApi, KEYS, PathApi } from 'platejs'; +import { SlashPlugin } from '@platejs/slash-command/react'; +import { type TComboboxInputElement, ElementApi } from 'platejs'; import { PlateElement } from 'platejs/react'; -import { insertBlock } from '../editor/transforms'; import { getIntl } from '../editor/plugins/split-utils'; import { TITLE_BLOCK_TYPE } from '../editor/plugins/title'; +import { + resolveSlashMenuGroups, + type SlashMenuConfig, +} from '../editor/plugins/slash-menu'; import { InlineCombobox, @@ -41,268 +22,39 @@ import { InlineComboboxItem, } from './inline-combobox'; -type Group = { - group: string; - items: { - icon: React.ReactNode; - value: string; - onSelect: (editor: PlateEditor, value: string) => void; - className?: string; - focusEditor?: boolean; - keywords?: string[]; - label?: string; - }[]; -}; - -const baseGroups: Group[] = [ - { - group: 'Actions', - items: [ - { - focusEditor: false, - icon: , - value: 'AI', - onSelect: (editor) => { - editor.getApi(AIChatPlugin).aiChat.show(); - }, - }, - ], - }, - { - group: 'Text blocks', - items: [ - { - icon: , - keywords: ['paragraph'], - label: 'Text', - value: KEYS.p, - }, - { - icon: , - keywords: ['img', 'picture', 'photo'], - label: 'Image', - value: KEYS.img, - onSelect: (editor: PlateEditor, value: string) => { - insertBlock(editor, value); - }, - }, - { - icon: , - keywords: ['title', 'h1'], - label: 'Heading 1', - value: KEYS.h1, - }, - { - icon: , - keywords: ['subtitle', 'h2'], - label: 'Heading 2', - value: KEYS.h2, - }, - { - icon: , - keywords: ['subtitle', 'h3'], - label: 'Heading 3', - value: KEYS.h3, - }, - { - icon: , - keywords: ['unordered', 'ul', '-'], - label: 'Bulleted list', - value: KEYS.ul, - }, - { - icon: , - keywords: ['ordered', 'ol', '1'], - label: 'Numbered list', - value: KEYS.ol, - }, - { - icon: , - keywords: ['checklist', 'task', 'checkbox', '[]'], - label: 'To-do list', - value: KEYS.listTodo, - }, - { - icon: , - keywords: ['collapsible', 'expandable'], - label: 'Toggle', - value: KEYS.toggle, - }, - { - icon: , - keywords: ['```'], - label: 'Code Block', - value: KEYS.codeBlock, - }, - { - icon:
    , - label: 'Table', - value: KEYS.table, - }, - { - icon: , - keywords: ['citation', 'blockquote', 'quote', '>'], - label: 'Blockquote', - value: KEYS.blockquote, - }, - { - description: 'Insert a highlighted block.', - icon: , - keywords: ['note'], - label: 'Callout', - value: KEYS.callout, - }, - ].map((item) => ({ - ...item, - onSelect: (editor, value) => { - insertBlock(editor, value); - }, - })), - }, - { - group: 'Advanced blocks', - items: [ - { - icon: , - keywords: ['toc'], - label: 'Table of contents', - value: KEYS.toc, - }, - { - icon: , - label: '3 columns', - value: 'action_three_columns', - }, - ].map((item) => ({ - ...item, - onSelect: (editor, value) => { - insertBlock(editor, value); - }, - })), - }, -]; - -const filteredBlocksConfig = (blocksConfig: Record) => - Object.entries(blocksConfig ?? {}).filter(([, block]) => { - // Check if the block is well formed (has at least id and title) - const blockIsWellFormed = Boolean(block?.title && block?.id); - if (!blockIsWellFormed) return false; - if (typeof block?.restricted === 'boolean' && block.restricted) - return false; - return true; - }); - -const insertSomersaultNativeBlock = ( - editor: PlateEditor, - nativeBlockType: string, -) => { - editor.tf.withoutNormalizing(() => { - const block = editor.api.block(); - if (!block) return; - - editor.tf.insertNodes( - editor.api.create.block({ - type: 'unknown', - '@type': nativeBlockType, - }), - { - at: PathApi.next(block[1]), - select: true, - }, - ); - - if (block[0].type !== 'unknown') { - editor.getApi(SuggestionPlugin).suggestion.withoutSuggestions(() => { - editor.tf.removeNodes({ previousEmptyBlock: true }); - }); - } - }); -}; - export function SlashInputElement( props: PlateElementProps, ) { const { editor, element } = props; - const intl = React.useMemo(() => getIntl(editor), [editor]); - const blocks = React.useMemo(() => { - const blocksConfig = config?.blocks?.blocksConfig; - if (!blocksConfig) return []; - - return filteredBlocksConfig(blocksConfig).map(([id, block]: any) => { - const format = - intl?.formatMessage?.bind(intl) || - ((msg: any) => msg?.defaultMessage ?? msg?.id ?? String(msg)); + const menuConfig = ( + editor.getOptions(SlashPlugin as any) as + | { menu?: SlashMenuConfig } + | undefined + )?.menu; + const translate = React.useMemo(() => { + const intl = getIntl(editor); + + if (!intl?.formatMessage) { + return (id: string) => id; + } - const label = - typeof block.title === 'string' ? block.title : format(block.title); - // const iconNode = block.icon ? ( - // - // ) : ( - // - // ); - const Icon = block.icon ? block.icon : Square; - return { - icon: , - keywords: [id, label?.toString()?.toLowerCase?.()].filter(Boolean), - label, - value: `block_${id}`, - onSelect: (plateEditor: PlateEditor) => { - insertSomersaultNativeBlock(plateEditor, id); - }, - }; - }); - }, [intl]); + return (id: string) => + intl.formatMessage({ + defaultMessage: id, + id, + }); + }, [editor]); const hasTitleBlock = editor.children.some( (child) => ElementApi.isElement(child) && child.type === TITLE_BLOCK_TYPE, ); const groups = React.useMemo(() => { - const addGroupItem = ( - groups: Group[], - groupName: Group['group'], - item: { value: string } & Group['items'][number], - ) => - groups.map((group) => - group.group === groupName - ? { - ...group, - items: group.items.some( - (existing) => existing.value === item.value, - ) - ? group.items - : [...group.items, item], - } - : group, - ); - - let nextGroups = baseGroups; - if (!hasTitleBlock) { - const titleBlockItem = { - icon: , - keywords: ['title', 'page title', 'h1'], - label: 'Title', - value: TITLE_BLOCK_TYPE, - onSelect: (editor: PlateEditor, value: string) => { - insertBlock(editor, value); - }, - }; - - nextGroups = addGroupItem(nextGroups, 'Text blocks', titleBlockItem); - } - - if (blocks.length) { - nextGroups = [ - ...nextGroups, - { - group: 'Blocks', - items: blocks, - }, - ]; - } - - return nextGroups; - }, [hasTitleBlock, blocks]); + return resolveSlashMenuGroups(editor, menuConfig, { + hasTitleBlock, + translate, + }); + }, [editor, hasTitleBlock, menuConfig, translate]); return ( diff --git a/packages/plate/components/ui/suggestion-node-static.tsx b/packages/plate/components/ui/suggestion-node-static.tsx index aee65fdb878..addbd243919 100644 --- a/packages/plate/components/ui/suggestion-node-static.tsx +++ b/packages/plate/components/ui/suggestion-node-static.tsx @@ -1,9 +1,9 @@ import type { SlateLeafProps, TSuggestionText } from 'platejs'; -import { BaseSuggestionPlugin } from '@platejs/suggestion'; import { SlateLeaf } from 'platejs'; import { cn } from '../../lib/utils'; +import { BaseSuggestionPlugin } from '../editor/plugins/suggestion-kit'; export function SuggestionLeafStatic(props: SlateLeafProps) { const { editor, leaf } = props; @@ -11,7 +11,7 @@ export function SuggestionLeafStatic(props: SlateLeafProps) { const dataList = editor .getApi(BaseSuggestionPlugin) .suggestion.dataList(leaf); - const hasRemove = dataList.some((data) => data.type === 'remove'); + const hasRemove = dataList.some((data: any) => data.type === 'remove'); const diffOperation = { type: hasRemove ? 'delete' : 'insert' } as const; const Component = ({ delete: 'del', insert: 'ins', update: 'span' } as const)[ diff --git a/packages/plate/components/ui/suggestion-toolbar-button.tsx b/packages/plate/components/ui/suggestion-toolbar-button.tsx index 090bc65283b..2cf53518fe5 100644 --- a/packages/plate/components/ui/suggestion-toolbar-button.tsx +++ b/packages/plate/components/ui/suggestion-toolbar-button.tsx @@ -1,14 +1,16 @@ -import { SuggestionPlugin } from '@platejs/suggestion/react'; import { PencilLineIcon } from 'lucide-react'; import { useEditorPlugin, usePluginOption } from 'platejs/react'; import { cn } from '../../lib/utils'; +import { usePlatePlugins } from '../editor/plate-plugins-context'; +import { SuggestionPlugin } from '../editor/plugins/suggestion-kit'; import { ToolbarButton } from './toolbar'; export function SuggestionToolbarButton() { const { setOption } = useEditorPlugin(SuggestionPlugin); const isSuggesting = usePluginOption(SuggestionPlugin, 'isSuggesting'); + const { currentUserId } = usePlatePlugins(); return ( setOption('isSuggesting', !isSuggesting)} + onClick={() => { + const nextIsSuggesting = !isSuggesting; + + setOption('currentUserId', nextIsSuggesting ? currentUserId : null); + setOption('isSuggesting', nextIsSuggesting); + }} onMouseDown={(e) => e.preventDefault()} tooltip={isSuggesting ? 'Turn off suggesting' : 'Suggestion edits'} > diff --git a/packages/plate/components/ui/table-node-static.tsx b/packages/plate/components/ui/table-node-static.tsx index 7c414c1e5b0..def8bb1104e 100644 --- a/packages/plate/components/ui/table-node-static.tsx +++ b/packages/plate/components/ui/table-node-static.tsx @@ -9,6 +9,7 @@ import type { import { BaseTablePlugin } from '@platejs/table'; import { SlateElement } from 'platejs'; +import { BlockInnerContainer } from './block-inner-container'; import { cn } from '../../lib/utils'; export function TableElementStatic({ @@ -19,16 +20,19 @@ export function TableElementStatic({ const marginLeft = disableMarginLeft ? 0 : props.element.marginLeft; return ( - -
    -
    - {children} -
    -
    + + +
    +
    + + {children} +
    +
    +
    +
    ); } diff --git a/packages/plate/components/ui/table-node.tsx b/packages/plate/components/ui/table-node.tsx index 77198c7f904..3bb254430bf 100644 --- a/packages/plate/components/ui/table-node.tsx +++ b/packages/plate/components/ui/table-node.tsx @@ -59,6 +59,7 @@ import { import { useElementSelector } from 'platejs/react'; import { Button } from './button'; +import { BlockInnerContainer } from './block-inner-container'; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -112,33 +113,39 @@ export const TableElement = withHOC( const isSelectingTable = useBlockSelected(props.element.id as string); const content = ( - -
    - + +
    -
    {children} -
    +
    + + {children} +
    - {isSelectingTable && ( -
    - )} -
    + {isSelectingTable && ( +
    + )} +
    +
    + ); diff --git a/packages/plate/components/ui/toc-node-static.tsx b/packages/plate/components/ui/toc-node-static.tsx index 80e3caf0065..09b8b0c89f8 100644 --- a/packages/plate/components/ui/toc-node-static.tsx +++ b/packages/plate/components/ui/toc-node-static.tsx @@ -4,6 +4,7 @@ import { type Heading, BaseTocPlugin, isHeading } from '@platejs/toc'; import { cva } from 'class-variance-authority'; import { NodeApi, SlateElement } from 'platejs'; +import { BlockInnerContainer } from './block-inner-container'; import { Button } from './button'; const headingItemVariants = cva( @@ -28,46 +29,48 @@ export function TocElementStatic(props: SlateElementProps) { const headingList = getHeadingList(editor); return ( - -
    - {headingList.length > 0 ? ( - headingList.map((item) => ( - - )) - ) : ( -
    - Create a heading to display the table of contents. -
    - )} -
    - {props.children} + + +
    + {headingList.length > 0 ? ( + headingList.map((item) => ( + + )) + ) : ( +
    + Create a heading to display the table of contents. +
    + )} +
    + {props.children} +
    ); } diff --git a/packages/plate/components/ui/toc-node.tsx b/packages/plate/components/ui/toc-node.tsx index 9a49741d379..cdda1e460b7 100644 --- a/packages/plate/components/ui/toc-node.tsx +++ b/packages/plate/components/ui/toc-node.tsx @@ -4,6 +4,7 @@ import { useTocElement, useTocElementState } from '@platejs/toc/react'; import { cva } from 'class-variance-authority'; import { PlateElement } from 'platejs/react'; +import { BlockInnerContainer } from './block-inner-container'; import { Button } from './button'; const headingItemVariants = cva( @@ -29,29 +30,31 @@ export function TocElement(props: PlateElementProps) { const { headingList } = state; return ( - -
    - {headingList.length > 0 ? ( - headingList.map((item) => ( - - )) - ) : ( -
    - Create a heading to display the table of contents. -
    - )} -
    - {props.children} + + +
    + {headingList.length > 0 ? ( + headingList.map((item) => ( + + )) + ) : ( +
    + Create a heading to display the table of contents. +
    + )} +
    + {props.children} +
    ); } diff --git a/packages/plate/components/ui/toggle-node-static.tsx b/packages/plate/components/ui/toggle-node-static.tsx index 7dc9c3b0ae8..70d05c60f29 100644 --- a/packages/plate/components/ui/toggle-node-static.tsx +++ b/packages/plate/components/ui/toggle-node-static.tsx @@ -3,21 +3,25 @@ import type { SlateElementProps } from 'platejs'; import { ChevronRight } from 'lucide-react'; import { SlateElement } from 'platejs'; +import { BlockInnerContainer } from './block-inner-container'; + export function ToggleElementStatic(props: SlateElementProps) { return ( - -
    - -
    - {props.children} + + +
    + +
    + {props.children} +
    ); } diff --git a/packages/plate/components/ui/toggle-node.tsx b/packages/plate/components/ui/toggle-node.tsx index 60e5b2b6f93..abc08f7843a 100644 --- a/packages/plate/components/ui/toggle-node.tsx +++ b/packages/plate/components/ui/toggle-node.tsx @@ -4,6 +4,7 @@ import { useToggleButton, useToggleButtonState } from '@platejs/toggle/react'; import { ChevronRight } from 'lucide-react'; import { PlateElement } from 'platejs/react'; +import { BlockInnerContainer } from './block-inner-container'; import { Button } from './button'; export function ToggleElement(props: PlateElementProps) { @@ -12,28 +13,30 @@ export function ToggleElement(props: PlateElementProps) { const { buttonProps, open } = useToggleButton(state); return ( - - - {props.children} + + + + {props.children} + ); } diff --git a/packages/plate/components/ui/toolbar.tsx b/packages/plate/components/ui/toolbar.tsx index 5e04e438a3f..ec078f3b6ac 100644 --- a/packages/plate/components/ui/toolbar.tsx +++ b/packages/plate/components/ui/toolbar.tsx @@ -351,46 +351,46 @@ type TooltipProps = { } & React.ComponentPropsWithoutRef; function withTooltip(Component: T) { - const ComponentWithTooltip = React.forwardRef< - React.ElementRef, - TooltipProps - >(function ExtendComponent( - { - tooltip, - tooltipContentProps, - tooltipProps, - tooltipTriggerProps, - ...props - }, - ref, - ) { - const [mounted, setMounted] = React.useState(false); + const ComponentAny = Component as any; + const ComponentWithTooltip = React.forwardRef>( + function ExtendComponent( + { + tooltip, + tooltipContentProps, + tooltipProps, + tooltipTriggerProps, + ...props + }, + ref, + ) { + const [mounted, setMounted] = React.useState(false); - React.useEffect(() => { - setMounted(true); - }, []); + React.useEffect(() => { + setMounted(true); + }, []); - const component = ( - )} - ref={ref as React.ComponentPropsWithRef['ref']} - /> - ); + const component = ( + )} + ref={ref as any} + /> + ); - if (tooltip && mounted) { - return ( - - - {component} - + if (tooltip && mounted) { + return ( + + + {component} + - {tooltip} - - ); - } + {tooltip} + + ); + } - return component; - }); + return component; + }, + ); ComponentWithTooltip.displayName = `WithTooltip(${getDisplayName(Component)})`; diff --git a/packages/plate/components/ui/turn-into-toolbar-button.tsx b/packages/plate/components/ui/turn-into-toolbar-button.tsx index 0b8c727ee51..da7722c06c5 100644 --- a/packages/plate/components/ui/turn-into-toolbar-button.tsx +++ b/packages/plate/components/ui/turn-into-toolbar-button.tsx @@ -9,12 +9,9 @@ import { ChevronRightIcon, Columns3Icon, FileCodeIcon, - Heading1Icon, Heading2Icon, Heading3Icon, Heading4Icon, - Heading5Icon, - Heading6Icon, ListIcon, ListOrderedIcon, PilcrowIcon, @@ -41,12 +38,6 @@ export const turnIntoItems = [ label: 'Text', value: KEYS.p, }, - { - icon: , - keywords: ['title', 'h1'], - label: 'Heading 1', - value: 'h1', - }, { icon: , keywords: ['subtitle', 'h2'], @@ -65,18 +56,6 @@ export const turnIntoItems = [ label: 'Heading 4', value: 'h4', }, - { - icon: , - keywords: ['subtitle', 'h5'], - label: 'Heading 5', - value: 'h5', - }, - { - icon: , - keywords: ['subtitle', 'h6'], - label: 'Heading 6', - value: 'h6', - }, { icon: , keywords: ['unordered', 'ul', '-'], diff --git a/packages/plate/constants.ts b/packages/plate/constants.ts new file mode 100644 index 00000000000..0272a93d298 --- /dev/null +++ b/packages/plate/constants.ts @@ -0,0 +1 @@ +export const SOMERSAULT_KEY = '__somersault__'; diff --git a/packages/plate/helpers/conversions.test.ts b/packages/plate/helpers/conversions.test.ts deleted file mode 100644 index 460a8447c57..00000000000 --- a/packages/plate/helpers/conversions.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { blocksToPlate, plateToBlocks } from './conversions'; -import type { Content } from '@plone/types'; - -describe('Blocks to Plate conversions', () => { - it('should handle an empty content object', () => { - const content: Partial = { - blocks: {}, - blocks_layout: { items: [] }, - title: '', - '@id': '', - '@type': '', - }; - // @ts-expect-error - const plate = blocksToPlate(content); - expect(plate).toEqual([]); - - const blocks = plateToBlocks(plate); - expect(blocks).toEqual({ - blocks: {}, - blocks_layout: { items: [] }, - }); - }); - - it('should convert a slate block to a plate paragraph', () => { - const content: Partial = { - blocks: { - 'block-1': { - '@type': 'slate', - value: [{ children: [{ text: 'Hello, world!' }], type: 'p' }], - }, - }, - blocks_layout: { items: ['block-1'] }, - }; - // @ts-expect-error - const plate = blocksToPlate(content); - expect(plate).toEqual([ - { - type: 'p', - children: [{ text: 'Hello, world!' }], - id: 'block-1', - }, - ]); - - const blocks = plateToBlocks(plate); - expect(blocks).toEqual(content); - }); - - it('should convert a title block to a plate title', () => { - const content: Partial = { - blocks: { - 'block-2': { - '@type': 'title', - }, - }, - blocks_layout: { items: ['block-2'] }, - title: 'My Title', - }; - // @ts-expect-error - const plate = blocksToPlate(content); - expect(plate).toEqual([ - { - type: 'title', - children: [{ text: 'My Title' }], - id: 'block-2', - '@type': 'title', - }, - ]); - - const blocks = plateToBlocks(plate); - expect(blocks).toEqual({ - blocks: content.blocks, - blocks_layout: content.blocks_layout, - }); - }); - - it('should handle an unknown block type as Slate unknown elements', () => { - const content: Partial = { - blocks: { - 'block-3': { - '@type': 'image', - align: 'left', - caption: 'My Image', - url: 'https://example.com/image.jpg', - }, - }, - blocks_layout: { items: ['block-3'] }, - }; - // @ts-expect-error - const plate = blocksToPlate(content); - expect(plate).toEqual([ - { - type: 'unknown', - children: [ - { - text: '', - }, - ], - align: 'left', - caption: 'My Image', - url: 'https://example.com/image.jpg', - id: 'block-3', - '@type': 'image', - }, - ]); - - const blocks = plateToBlocks(plate); - expect(blocks).toEqual(content); - }); - - it('should handle multiple blocks of different types', () => { - const content: Partial = { - blocks: { - 'block-1': { - '@type': 'slate', - value: [{ children: [{ text: 'Hello, world!' }], type: 'p' }], - }, - 'block-2': { - '@type': 'title', - }, - 'block-3': { - '@type': 'image', - }, - }, - blocks_layout: { items: ['block-1', 'block-2', 'block-3'] }, - title: 'My Title', - '@id': '', - '@type': '', - }; - // @ts-expect-error - const plate = blocksToPlate(content); - expect(plate).toEqual([ - { - type: 'p', - children: [{ text: 'Hello, world!' }], - id: 'block-1', - }, - { - type: 'title', - children: [{ text: 'My Title' }], - id: 'block-2', - '@type': 'title', - }, - { - type: 'unknown', - children: [ - { - text: '', - }, - ], - id: 'block-3', - '@type': 'image', - }, - ]); - - const blocks = plateToBlocks(plate); - expect(blocks).toEqual({ - blocks: content.blocks, - blocks_layout: content.blocks_layout, - }); - }); -}); diff --git a/packages/plate/helpers/conversions.ts b/packages/plate/helpers/conversions.ts deleted file mode 100644 index 58deada1907..00000000000 --- a/packages/plate/helpers/conversions.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { BlocksData, Content } from '@plone/types'; -import { nanoid, type TElement } from 'platejs'; - -type SlateNode = TElement; -type ExtendedSlateNode = SlateNode & { - id: string; - '@type'?: string; - value?: SlateNode[]; -}; - -export function blocksToPlate(content: Content) { - const { blocks, blocks_layout } = content; - - const plateData: Array = blocks_layout.items.map( - (blockId) => { - const block = blocks[blockId]; - - if (block['@type'] === 'slate') { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { '@type': blockType, value, plaintext, ...blockValue } = block; - return { - ...value[0], - id: blockId, - ...blockValue, - }; - } else if (block['@type'] === 'title') { - return { - type: 'title', - children: [ - { - text: content.title, - }, - ], - id: blockId, - ...block, - }; - } - - return { - type: 'unknown', - // We need not to render anything - children: [{ text: '' }], - id: blockId, - ...block, - }; - }, - ); - - return plateData.filter((block) => block !== undefined); -} - -export function plateToBlocks(plateData: Array) { - const blocks: BlocksData['blocks'] = {}; - const blocks_layout: BlocksData['blocks_layout'] = { items: [] }; - // console.log('plateData', plateData); - plateData.forEach((node) => { - const id = node.id || nanoid(10); - if (node['@type']) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, type, children, ...nodeValue } = node; - blocks[id] = { - ...nodeValue, - }; - } else { - const { id, ...nodeValue } = node; - blocks[id] = { - '@type': 'slate', - value: [nodeValue], - }; - } - blocks_layout.items.push(id); - }); - - return { blocks, blocks_layout }; -} diff --git a/packages/plate/hooks/use-why-did-you-update.ts b/packages/plate/hooks/use-why-did-you-update.ts index 26c4afb6090..91b484290fb 100644 --- a/packages/plate/hooks/use-why-did-you-update.ts +++ b/packages/plate/hooks/use-why-did-you-update.ts @@ -5,7 +5,7 @@ export function useWhyDidYouUpdate>( name: string, props: T, ) { - const previousProps = React.useRef(); + const previousProps = React.useRef(undefined); React.useEffect(() => { if (previousProps.current) { diff --git a/packages/plate/input.css b/packages/plate/input.css new file mode 100644 index 00000000000..81ae36717ad --- /dev/null +++ b/packages/plate/input.css @@ -0,0 +1,15 @@ +/* Support input.css file for loading @plone/plate from outside Seven */ +@import 'tailwindcss'; + +/* Plate’s own code */ +@source './components/**/*.{ts,tsx,js,jsx}'; +@source './stories/**/*.{ts,tsx,js,jsx}'; +@source './lib/**/*.{ts,tsx,js,jsx}'; + +/* Dependencies that contain Tailwind classes */ +@source './node_modules/@platejs/**/*.{js,jsx,ts,tsx}'; +@source './node_modules/@radix-ui/**/*.{js,jsx,ts,tsx}'; +@source './node_modules/sonner/**/*.{js,jsx,ts,tsx}'; + +/* Optional: keep Tailwind from walking into nested deps */ +@source not './node_modules/**/node_modules/**'; diff --git a/packages/plate/legacy/Icon.tsx b/packages/plate/legacy/Icon.tsx deleted file mode 100644 index 8def5cda15a..00000000000 --- a/packages/plate/legacy/Icon.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Volto legacy Icon component for render Volto icons in Plate - * @module components/theme/Icon/Icon - */ -import React from 'react'; -import clsx from 'clsx'; - -const defaultSize = '36px'; - -type IconName = { - attributes: { - xmlns?: string; - viewBox?: string; - }; - content: string; -}; - -type IconProps = { - name: IconName; - size?: string; - color?: string; - className?: string; - title?: string; - onClick?: React.MouseEventHandler; - style?: React.CSSProperties; - id?: string; - ariaHidden?: boolean; -}; - -/** - * Component to display an SVG as Icon. - * Use: - * - drop icon to the icons folder ("src/icons") - * - import svg into the file - * - import this Icon component - * - add icon component with name = your imported svg - */ -const Icon: React.FC = ({ - name, - size = defaultSize, - color = null, - className = null, - title = null, - onClick = null, - style = {}, - id, - ariaHidden, -}) => ( - ${title}${name.content}` : name.content, - }} - /> -); - -export default Icon; diff --git a/packages/plate/migrations/block-width.test.ts b/packages/plate/migrations/block-width.test.ts new file mode 100644 index 00000000000..2f194c415c0 --- /dev/null +++ b/packages/plate/migrations/block-width.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import config from '@plone/registry'; +import type { Value } from 'platejs'; +import { migrateLegacyBlockWidthsInValue } from './block-width'; + +describe('migrateLegacyBlockWidthsInValue', () => { + afterEach(() => { + config.blocks.plateBlocksConfig = {} as any; + config.blocks.blocksConfig = {} as any; + config.blocks.widths = [] as any; + }); + + it('injects the configured default block width when blockWidth is missing', () => { + config.blocks.widths = [ + { + name: 'narrow', + label: 'Narrow', + style: { '--block-width': 'var(--narrow-container-width)' }, + }, + { + name: 'default', + label: 'Default', + style: { '--block-width': 'var(--default-container-width)' }, + }, + ] as any; + config.blocks.plateBlocksConfig = { + p: { + blockWidth: { + defaultWidth: 'narrow', + widths: ['narrow'], + }, + }, + } as any; + + const value: Value = [ + { + type: 'p', + children: [{ text: 'Legacy paragraph without width' }], + } as any, + ]; + + migrateLegacyBlockWidthsInValue(value); + + expect(value).toEqual([ + { + type: 'p', + blockWidth: 'narrow', + children: [{ text: 'Legacy paragraph without width' }], + }, + ]); + }); +}); diff --git a/packages/plate/migrations/block-width.ts b/packages/plate/migrations/block-width.ts new file mode 100644 index 00000000000..86645c5828d --- /dev/null +++ b/packages/plate/migrations/block-width.ts @@ -0,0 +1,7 @@ +import type { Value } from 'platejs'; +import { applyBlockWidthDefaultsInValue } from '../components/editor/plugins/block-width-plugin'; + +export const migrateLegacyBlockWidthsInValue = (value: Value) => { + applyBlockWidthDefaultsInValue(value); + return value; +}; diff --git a/packages/plate/components/editor/plugins/normalize-legacy.ts b/packages/plate/migrations/index.ts similarity index 54% rename from packages/plate/components/editor/plugins/normalize-legacy.ts rename to packages/plate/migrations/index.ts index 57450f9a83f..29092c5f98b 100644 --- a/packages/plate/components/editor/plugins/normalize-legacy.ts +++ b/packages/plate/migrations/index.ts @@ -1,32 +1,43 @@ import type { Value } from 'platejs'; - import { KEYS } from 'platejs'; + import { - migrateLegacyBoldInValue, migrateLegacyBold, -} from './legacy-bold-plugin'; + migrateLegacyBoldInValue, +} from '../components/editor/plugins/legacy-bold-plugin'; import { migrateLegacyItalic, migrateLegacyItalicInValue, -} from './legacy-italic-plugin'; -import { migrateLegacyLinksInValueStatic } from './legacy-link-plugin'; +} from '../components/editor/plugins/legacy-italic-plugin'; +import { migrateLegacyLinksInValueStatic } from '../components/editor/plugins/legacy-link-plugin'; +import { + migrateLegacyStrikethrough, + migrateLegacyStrikethroughInValue, +} from '../components/editor/plugins/legacy-strikethrough-plugin'; +import { migrateLegacyListsInValue } from '../components/editor/plugins/legacy-list-plugin'; import { + applyNormalizedValue, + cloneValueToWritable, +} from '../components/editor/plugins/legacy-utils'; +import { migrateLegacyBlockWidthsInValue } from './block-width'; + +export { + migrateLegacyBold, + migrateLegacyBoldInValue, + migrateLegacyBlockWidthsInValue, + migrateLegacyItalic, + migrateLegacyItalicInValue, + migrateLegacyLinksInValueStatic, migrateLegacyStrikethrough, migrateLegacyStrikethroughInValue, -} from './legacy-strikethrough-plugin'; -import { migrateLegacyListsInValue } from './legacy-list-plugin'; -import { applyNormalizedValue, cloneValueToWritable } from './legacy-utils'; + migrateLegacyListsInValue, +}; -/** - * Run legacy migrations on a value synchronously (useful for SSR). - * Mutates the provided value; returns the same reference for convenience. - */ export const normalizeLegacyValue = (value?: Value, linkType = KEYS.link) => { if (!value) return value; let mutableValue = cloneValueToWritable(value); - // These functions are idempotent and safe to run multiple times. mutableValue = migrateLegacyBoldInValue(mutableValue); mutableValue = migrateLegacyItalicInValue(mutableValue); mutableValue = migrateLegacyStrikethroughInValue(mutableValue); @@ -44,6 +55,8 @@ export const legacyMigrations = { migrateLegacyItalicInValue, migrateLegacyStrikethrough, migrateLegacyStrikethroughInValue, + migrateLegacyBlockWidthsInValue, migrateLegacyLinksInValueStatic, + migrateLegacyListsInValue, normalizeLegacyValue, }; diff --git a/packages/plate/news/+max-update-path-exceeded-error.bugfix b/packages/plate/news/+max-update-path-exceeded-error.bugfix new file mode 100644 index 00000000000..4336366204b --- /dev/null +++ b/packages/plate/news/+max-update-path-exceeded-error.bugfix @@ -0,0 +1 @@ +fix 'Maximum update depth exceeded' errors on @@edit. @frapell \ No newline at end of file diff --git a/packages/plate/news/+removeimageblockfromtextgroup.bugfix b/packages/plate/news/+removeimageblockfromtextgroup.bugfix new file mode 100644 index 00000000000..1c99ee8d015 --- /dev/null +++ b/packages/plate/news/+removeimageblockfromtextgroup.bugfix @@ -0,0 +1 @@ +Remove the native Plate Image block from the slash command "Text blocks" group in favor of the custom Image block available in the "Blocks" group. @iFlameing diff --git a/packages/plate/news/+storybook.internal b/packages/plate/news/+storybook.internal new file mode 100644 index 00000000000..cc424aade8a --- /dev/null +++ b/packages/plate/news/+storybook.internal @@ -0,0 +1 @@ +Update to storybook 10. @sneridagh diff --git a/packages/plate/news/+unify-makefiles.internal b/packages/plate/news/+unify-makefiles.internal new file mode 100644 index 00000000000..5da674df4e4 --- /dev/null +++ b/packages/plate/news/+unify-makefiles.internal @@ -0,0 +1 @@ +Unify Makefile files across the packages. @ionlizarazu diff --git a/packages/plate/news/6722.feature b/packages/plate/news/6722.feature deleted file mode 100644 index 2a71c57b4e2..00000000000 --- a/packages/plate/news/6722.feature +++ /dev/null @@ -1,2 +0,0 @@ -Added detection of title block in Plate's table of contents plugin. @arybakov05 -Scroll to headings and highlight them after clicking a Plate table of contents entry in the static view. @arybakov05 \ No newline at end of file diff --git a/packages/plate/news/7921.feature b/packages/plate/news/7921.feature deleted file mode 100644 index bed35ce169d..00000000000 --- a/packages/plate/news/7921.feature +++ /dev/null @@ -1 +0,0 @@ -Somersault editor support. @sneridagh diff --git a/packages/plate/news/8015.breaking b/packages/plate/news/8015.breaking deleted file mode 100644 index e7601793ede..00000000000 --- a/packages/plate/news/8015.breaking +++ /dev/null @@ -1,2 +0,0 @@ -Remove output.css from @plone/plate and generation command. @sneridagh -Remove Plate image plugin. @sneridagh diff --git a/packages/plate/news/8246.feature b/packages/plate/news/8246.feature new file mode 100644 index 00000000000..6e29be5534a --- /dev/null +++ b/packages/plate/news/8246.feature @@ -0,0 +1 @@ +Integrate links with ObjectBrowser. @sneridagh diff --git a/packages/plate/package.json b/packages/plate/package.json index 7674ee076da..aedad7f4e7a 100644 --- a/packages/plate/package.json +++ b/packages/plate/package.json @@ -9,7 +9,7 @@ ], "funding": "https://github.com/sponsors/plone", "license": "MIT", - "version": "1.0.0-alpha.2", + "version": "1.0.0-alpha.8", "repository": { "type": "git", "url": "https://github.com/plone/volto.git", @@ -35,13 +35,17 @@ "scripts": { "test": "vitest", "test:debug": "vitest --inspect-brk --watch", - "check-ts": "tsc --project tsconfig.json", + "check:ts": "tsc --project tsconfig.json", "dry-release": "release-it --dry-run", "release": "release-it", "release-major-alpha": "release-it major --preRelease=alpha", "release-alpha": "release-it --preRelease=alpha", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", + "lint:fix": "eslint --max-warnings=0 './**/*.{js,jsx,ts,tsx}' --fix --no-error-on-unmatched-pattern", + "stylelint:fix": "sh -c 'if [ -f .stylelintrc ] || [ -f .stylelintrc.json ] || [ -f .stylelintrc.js ] || [ -f .stylelintrc.cjs ] || [ -f stylelint.config.js ] || [ -f stylelint.config.cjs ] || [ -f stylelint.config.mjs ]; then stylelint '''./**/*.{css,scss,less}''' --fix --allow-empty-input; else echo \"No local stylelint config, skipping\"; fi'", + "format": "pnpm prettier:fix && pnpm lint:fix && pnpm stylelint:fix" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", @@ -78,6 +82,7 @@ "@platejs/markdown": "^50.2.0", "@platejs/media": "^49.0.0", "@platejs/mention": "^49.0.0", + "@platejs/playwright": "catalog:", "@platejs/resizable": "^49.0.0", "@platejs/selection": "^50.3.4", "@platejs/slash-command": "^49.0.0", @@ -106,7 +111,7 @@ "cmdk": "^1.1.1", "html2canvas-pro": "^1.5.12", "jotai": "^2.15.0", - "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", "lowlight": "^3.3.0", "lucide-react": "^0.544.0", "pdf-lib": "^1.17.1", @@ -115,7 +120,6 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-lite-youtube-embed": "^2.5.6", - "react-player": "3.3.1", "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", @@ -130,26 +134,26 @@ }, "devDependencies": { "@plone/types": "workspace:*", - "@storybook/addon-docs": "^9.1.15", - "@storybook/addon-links": "^9.1.15", - "@storybook/react-vite": "^9.1.15", + "@storybook/addon-docs": "^10.4.0", + "@storybook/addon-links": "^10.4.0", + "@storybook/react-vite": "^10.4.0", "@tailwindcss/vite": "catalog:", - "@testing-library/jest-dom": "6.4.2", + "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/jest-axe": "^3.5.9", + "@types/lodash.debounce": "^4.0.9", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", - "eslint-plugin-storybook": "^9.1.15", + "eslint-plugin-storybook": "^10.4.0", "jest-axe": "^8.0.0", "jotai-devtools": "^0.11.0", "release-it": "catalog:", - "storybook": "^9.1.15", + "storybook": "^10.4.0", "tsconfig": "workspace:*", "typescript": "catalog:", "vite": "catalog:", - "vite-tsconfig-paths": "^5.1.4", "vitest": "catalog:", "vitest-axe": "^0.1.0" } diff --git a/packages/plate/styles/cmsui.css b/packages/plate/styles/cmsui.css index 44f52a58284..31d31c2f59e 100644 --- a/packages/plate/styles/cmsui.css +++ b/packages/plate/styles/cmsui.css @@ -1,2 +1,9 @@ /* Do not remove, it's necessary for the registry to detect Tailwind and load the styles for @plone/plate in the cmsui */ + +.block-inner-container { + display: block; + width: 100%; + max-width: var(--block-width); + margin-inline: auto; +} diff --git a/packages/plate/styles/publicui.css b/packages/plate/styles/publicui.css index b4338c8811e..2c5369070f4 100644 --- a/packages/plate/styles/publicui.css +++ b/packages/plate/styles/publicui.css @@ -1,2 +1,9 @@ /* Do not remove, it's necessary for the registry to detect Tailwind and load the styles for @plone/plate in the publicui */ + +.block-inner-container { + width: 100%; + max-width: var(--block-width); + justify-items: start; + margin-inline: auto; +} diff --git a/packages/plate/tsconfig.json b/packages/plate/tsconfig.json index 76a9fa732c8..9f40ab4f192 100644 --- a/packages/plate/tsconfig.json +++ b/packages/plate/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "tsconfig/react-library.json", - "include": ["**/*.ts", "**/*.tsx", "../components/src/icons.d.ts"], + "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "../components/src/icons.d.ts"], "exclude": [ "node_modules", "build", diff --git a/packages/plate/vite.config.ts b/packages/plate/vite.config.ts index 8b15c81f624..c1cdd6f5441 100644 --- a/packages/plate/vite.config.ts +++ b/packages/plate/vite.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; // import { PloneSVGRVitePlugin } from '@plone/components/vite-plugin-svgr'; export default defineConfig({ - plugins: [tsconfigPaths(), tailwindcss(), react()], + plugins: [tailwindcss(), react()], + resolve: { + tsconfigPaths: true, + }, }); diff --git a/packages/providers/CHANGELOG.md b/packages/providers/CHANGELOG.md deleted file mode 100644 index 74a0773bf41..00000000000 --- a/packages/providers/CHANGELOG.md +++ /dev/null @@ -1,80 +0,0 @@ -# @plone/providers Release Notes - - - - - -## 1.0.0-alpha.9 (2025-09-29) - -### Internal - -- Use ESlint 9, fix code. @sneridagh [#6775](https://github.com/plone/volto/issues/6775) -- Added vitest config to not fail if no test is present. @sneridagh [#6916](https://github.com/plone/volto/issues/6916) - -## 1.0.0-alpha.8 (2025-02-08) - -### Internal - -- Update internal `peerDependencies` to include React 19. - Update TS version. @sneridagh [#6641](https://github.com/plone/volto/issues/6641) -- Remove no longer required dependencies. @sneridagh [#6728](https://github.com/plone/volto/issues/6728) - -## 1.0.0-alpha.7 (2025-01-24) - -### Internal - -- Centralize `tsconfig`. @sneridagh [#6536](https://github.com/plone/volto/issues/6536) - -## 1.0.0-alpha.6 (2024-11-21) - -### Feature - -- Update RAC to 1.5.0 @sneridagh [#6498](https://github.com/plone/volto/issues/6498) - -## 1.0.0-alpha.5 (2024-11-05) - -### Internal - -- Improve packaging. @sneridagh - -## 1.0.0-alpha.4 (2024-11-05) - -### Internal - -- Bump local `typescript` version. @sneridagh [#6461](https://github.com/plone/volto/issues/6461) -- Replace `parcel` with `tsup`. Better types, better tsconfig. Move to ESM. @sneridagh [#6468](https://github.com/plone/volto/issues/6468) - -## 1.0.0-alpha.3 (2024-10-18) - -## 1.0.0-alpha.2 (2024-10-18) - -### Breaking - -- Improve and group providers. @sneridagh - Breaking: - - The interface of the providers has changed. Please check the new one, and adapt your apps accordingly. [#6069](https://github.com/plone/volto/issues/6069) - -### Internal - -- Update typescript and vitest everywhere @sneridagh [#6407](https://github.com/plone/volto/issues/6407) - -## 1.0.0-alpha.1 (2024-05-23) - -### Internal - -- Cleanup imports in RouterLocation provider @pnicolli [#6029](https://github.com/plone/volto/issues/6029) - -## 1.0.0-alpha.0 (2024-05-13) - -### Feature - -- Initial implementation @sneridagh [#5887](https://github.com/plone/volto/issues/5887) - -### Internal - -- Improvements to the monorepo setup with utilities, especially ESLint. Build cached option to speedup operations. @sneridagh [#5969](https://github.com/plone/volto/issues/5969) -- Saner defaults for building deps, switch default to cached, add `build:force` command @sneridagh [#5980](https://github.com/plone/volto/issues/5980) diff --git a/packages/providers/README.md b/packages/providers/README.md deleted file mode 100644 index 75ba6475a99..00000000000 --- a/packages/providers/README.md +++ /dev/null @@ -1,188 +0,0 @@ -# `@plone/providers` - -This package contains utility providers for Plone React components. -The main purpose is to provide dependency injection of common required artifacts needed by any app. -These artifacts include: -- Router related -- Plone Client -- URL handling methods - -> [!WARNING] -> This package or app is experimental. -> The community offers no support whatsoever for it. -> Breaking changes may occur without notice. - -## `PloneProvider` - -It provides all the necessary artifacts that an app can need grouped in a single provider. - -```ts -interface PloneProvider { - ploneClient: InstanceType; - queryClient: QueryClient; - useLocation: () => Location | undefined; - useParams: (opts?: any) => Record; - navigate: (path: string) => void; - useHref: (to: string, options?: any) => string; - flattenToAppURL: (path: string | undefined) => string | undefined; -} -``` - -It should be instantiated at the top of your app. -You have to provide the required props depending on the framework and the router used. -This is the example for a Next.js app. -Please refer to the {file}`apps` folder of the Volto repository for more examples of the usage of `PloneProvider` in different React frameworks. - -```tsx -'use client'; -import React from 'react'; -import { - useRouter, - usePathname, - useSearchParams, - useParams, -} from 'next/navigation'; -import { QueryClient } from '@tanstack/react-query'; -import { PloneProvider } from '@plone/providers'; -import PloneClient from '@plone/client'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { flattenToAppURL } from './utils'; -import config from './config'; - -// Custom hook to unify the location object between NextJS and Plone -function useLocation() { - let pathname = usePathname(); - let search = useSearchParams(); - - return { - pathname, - search, - searchStr: '', - hash: (typeof window !== 'undefined' && window.location.hash) || '', - href: (typeof window !== 'undefined' && window.location.href) || '', - }; -} - -const Providers: React.FC<{ - children?: React.ReactNode; -}> = ({ children }) => { - // Creating the clients at the file root level makes the cache shared - // between all requests and means _all_ data gets passed to _all_ users. - // Besides being bad for performance, this also leaks any sensitive data. - // We use this pattern to ensure that every client gets its own clients - const [queryClient] = React.useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - // With SSR, we usually want to set some default staleTime - // above 0 to avoid refetching immediately on the client - staleTime: 60 * 1000, - }, - }, - }), - ); - - const [ploneClient] = React.useState(() => - PloneClient.initialize({ - apiPath: config.settings.apiPath, - }), - ); - - const router = useRouter(); - - return ( - { - router.push(to); - }} - useParams={useParams} - useHref={(to) => flattenToAppURL(to)} - flattenToAppURL={flattenToAppURL} - > - {children} - - - ); -}; - -export default Providers; - -``` - -You can use it anywhere in your app by using the hook `usePloneProvider`. - -```tsx -import { usePloneProvider } from '@plone/providers'; - -const { ploneClient } = usePloneProvider() -``` - -Alternatively, you can use it in any other context property. - -```tsx -const { navigate } = usePloneProvider() -``` - -## `PloneClientProvider` - -`PloneProvider` in a group of other smaller providers. -You can also instantiate and use them as standalone providers. -However, you should do this only if the framework has some limitation on using the bulk `PloneClientProvider`. - -The following snippets show its usage. -First, instantiate the provider. - -```ts -export type PloneClientProviderProps = { - client: InstanceType; - queryClient: QueryClient; - children?: React.ReactNode; -}; -``` - -Second, use its related hook through either of the following examples. - -```tsx -import { usePloneClient } from '@plone/providers'; - -const client = usePloneClient() -``` - -or - -```tsx -const { getContentQuery } = usePloneClient() -``` - -## `AppRouterProvider` - -This provider is included also in `PloneProvider`. -You can also instantiate and use it as a standalone provider. -However, you should do this only if the framework has some limitation on using the bulk `PloneClientProvider`. - -The following code example shows its usage. - -```ts -interface AppRouterProps { - useLocation: () => Location | undefined; - useParams: (opts?: any) => Record; - navigate: (path: string) => void; - useHref?: (to: string, options?: any) => string; - flattenToAppURL: (path: string | undefined) => string | undefined; - children: ReactNode; -} -``` - -The following code sample shows its related hook. - -```tsx -import { useAppRouter } from '@plone/providers'; - -const { useLocation } = useAppRouter() -``` diff --git a/packages/providers/package.json b/packages/providers/package.json deleted file mode 100644 index c6e8de16a5f..00000000000 --- a/packages/providers/package.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "name": "@plone/providers", - "description": "Plone core providers", - "maintainers": [ - { - "name": "Plone Foundation", - "url": "https://plone.org" - } - ], - "funding": "https://github.com/sponsors/plone", - "license": "MIT", - "version": "1.0.0-alpha.9", - "repository": { - "type": "git", - "url": "https://github.com/plone/volto.git" - }, - "bugs": { - "url": "https://github.com/plone/volto/issues" - }, - "homepage": "https://plone.org", - "keywords": [ - "volto", - "plone", - "plone6", - "react", - "helpers" - ], - "publishConfig": { - "access": "public" - }, - "type": "module", - "files": [ - "dist", - "README.md" - ], - "main": "./dist/index.js", - "exports": { - "./package.json": "./package.json", - ".": { - "import": "./dist/index.js", - "default": "./dist/index.cjs" - } - }, - "scripts": { - "build": "tsup", - "build:force": "tsup", - "check:exports": "attw --pack .", - "test": "vitest", - "dry-release": "release-it --dry-run", - "release": "release-it", - "release-major-alpha": "release-it major --preRelease=alpha", - "release-alpha": "release-it --preRelease=alpha" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - }, - "dependencies": { - "@plone/client": "workspace:*", - "@plone/registry": "workspace:*", - "@tanstack/react-query": "catalog:", - "react-aria-components": "catalog:" - }, - "devDependencies": { - "@arethetypeswrong/cli": "^0.16.4", - "@plone/types": "workspace:*", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "release-it": "catalog:", - "tsconfig": "workspace:*", - "tsup": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/packages/providers/src/AppRouter.tsx b/packages/providers/src/AppRouter.tsx deleted file mode 100644 index 3e2db26aa50..00000000000 --- a/packages/providers/src/AppRouter.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { - createContext, - type ReactNode, - useContext, - useMemo, -} from 'react'; -import { RouterProvider } from 'react-aria-components'; -import { flattenToAppURL as defaultFlattenToAppURL } from './utils'; - -export type AnySearchSchema = {}; -export interface Location { - href?: string; // TSR - searchStr?: string; // TSR - pathname: string; // TSR, RR and Remix - search: TSearchObj; // TSR, RR and Remix - hash: string; // TSR, RR and Remix - state?: any; // TSR, RR and Remix - key?: string; // RR and Remix -} - -interface AppRouter { - useLocation: () => Location | undefined; - useParams: (opts?: any) => Record; - navigate: (path: string) => void; - useHref?: (to: string, options?: any) => string; - flattenToAppURL: (path: string | undefined) => string | undefined; -} - -const AppRouterContext = createContext({ - useLocation: () => ({ - href: '', - pathname: '', - search: {}, - searchStr: '', - hash: '', - }), - useParams: () => ({}), - navigate: () => {}, - useHref: () => '', - flattenToAppURL: defaultFlattenToAppURL, -}); - -interface AppRouterProps { - useLocation: () => Location | undefined; - useParams: (opts?: any) => Record; - navigate: (path: string) => void; - useHref?: (to: string, options?: any) => string; - flattenToAppURL: (path: string | undefined) => string | undefined; - children: ReactNode; -} - -export function AppRouterProvider(props: AppRouterProps) { - const { children, navigate, useLocation, useParams, useHref } = props; - - let { flattenToAppURL } = props; - - if (!flattenToAppURL) { - flattenToAppURL = defaultFlattenToAppURL; - } - - const ctx = useMemo( - () => ({ - useLocation, - useParams, - navigate, - useHref, - flattenToAppURL, - }), - [useLocation, useParams, navigate, useHref, flattenToAppURL], - ); - - return ( - - - {children} - - - ); -} - -export function useAppRouter() { - return useContext(AppRouterContext); -} diff --git a/packages/providers/src/PloneClient.tsx b/packages/providers/src/PloneClient.tsx deleted file mode 100644 index 3b92caab08f..00000000000 --- a/packages/providers/src/PloneClient.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import type { JSX } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -import PloneClient from '@plone/client'; - -export const PloneClientContext = React.createContext< - InstanceType | undefined ->(undefined); - -export const usePloneClient = () => { - const client = React.useContext(PloneClientContext); - - if (!client) { - throw new Error('No PloneClient set, use PloneClientProvider to set one'); - } - - return client; -}; - -export type PloneClientProviderProps = { - client: InstanceType; - queryClient: QueryClient; - children?: React.ReactNode; -}; - -export const PloneClientProvider = ({ - client, - queryClient, - children, -}: PloneClientProviderProps): JSX.Element => { - return ( - - {children} - - ); -}; diff --git a/packages/providers/src/PloneProvider.tsx b/packages/providers/src/PloneProvider.tsx deleted file mode 100644 index 6696b80d49b..00000000000 --- a/packages/providers/src/PloneProvider.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { - createContext, - type ReactNode, - useContext, - useMemo, -} from 'react'; -import { QueryClient } from '@tanstack/react-query'; -import { AppRouterProvider, type Location } from './AppRouter'; -import { PloneClientProvider } from './PloneClient'; -import PloneClient from '@plone/client'; -import { flattenToAppURL as defaultFlattenToAppURL } from './utils'; - -interface PloneProvider { - ploneClient?: InstanceType; - queryClient?: QueryClient; - useLocation: () => Location | undefined; - useParams: (opts?: any) => Record; - navigate: (path: string) => void; - useHref?: (to: string, options?: any) => string; - flattenToAppURL: (path: string | undefined) => string | undefined; -} - -const PloneProviderContext = createContext({ - useLocation: () => ({ - href: '', - pathname: '', - search: {}, - searchStr: '', - hash: '', - }), - useParams: () => ({}), - navigate: () => {}, - useHref: () => '', - flattenToAppURL: defaultFlattenToAppURL, -}); - -interface PloneProviderProps { - ploneClient: InstanceType; - queryClient: QueryClient; - useLocation: () => Location | undefined; - useParams: (opts?: any) => Record; - navigate: (path: string) => void; - useHref?: (to: string, options?: any) => string; - flattenToAppURL?: (path: string | undefined) => string | undefined; - children: ReactNode; -} - -export function PloneProvider(props: PloneProviderProps) { - const { - children, - navigate, - useLocation, - useParams, - useHref, - ploneClient, - queryClient, - } = props; - - let { flattenToAppURL } = props; - - if (!flattenToAppURL) { - flattenToAppURL = defaultFlattenToAppURL; - } - - const ctx = useMemo( - () => ({ - ploneClient, - queryClient, - useLocation, - useParams, - navigate, - useHref, - flattenToAppURL, - }), - [ - ploneClient, - queryClient, - useLocation, - useParams, - navigate, - useHref, - flattenToAppURL, - ], - ); - - return ( - - - - {children} - - - - ); -} - -export function usePloneProvider() { - return useContext(PloneProviderContext); -} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts deleted file mode 100644 index 5978c8c8214..00000000000 --- a/packages/providers/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './AppRouter'; -export * from './PloneClient'; -export * from './PloneProvider'; - -// Proxying RouterProvider from react-aria-components -export { RouterProvider } from 'react-aria-components'; diff --git a/packages/providers/src/utils.ts b/packages/providers/src/utils.ts deleted file mode 100644 index 17bd56168f0..00000000000 --- a/packages/providers/src/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import config from '@plone/registry'; - -/** - * Flatten to app server URL - Given a URL if it starts with the API server URL - * this method flattens it (removes) the server part - * TODO: Update it when implementing non-root based app location (on a - * directory other than /, eg. /myapp) - * @method flattenToAppURL - */ -export function flattenToAppURL(url: string | undefined) { - const { settings } = config; - return (url && url.replace(settings.apiPath, '')) || '/'; -} diff --git a/packages/providers/tsup.config.ts b/packages/providers/tsup.config.ts deleted file mode 100644 index 82b88a425c9..00000000000 --- a/packages/providers/tsup.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'tsup'; - -export default defineConfig({ - entryPoints: ['src/index.ts'], - format: ['cjs', 'esm'], - dts: true, - outDir: 'dist', - clean: true, -}); diff --git a/packages/publicui/AGENTS.md b/packages/publicui/AGENTS.md new file mode 100644 index 00000000000..135d0161eed --- /dev/null +++ b/packages/publicui/AGENTS.md @@ -0,0 +1,37 @@ +# AGENTS.md + +This file applies only to `packages/publicui` and its subdirectories. + +## What This Package Is + +- `@plone/publicui` provides the **public-facing routes and page rendering for Seven**. +- It is the surface that anonymous visitors and authenticated users see when browsing the site. +- It covers content views, search results, and the sitemap. +- It consumes `@plone/layout` for structural page elements and `@plone/blocks` for block rendering. + +> [!WARNING] +> This package is experimental. Breaking changes may occur without notice. + +## Package Model + +- **Routes** (`routes/`) define the public URL surface: + - `content.tsx` — renders a content item by resolving its blocks through `@plone/layout`'s `RenderBlocks` + - `search.tsx` — search results page + - `sitemap.tsx` — sitemap view + - `index.tsx` — route index / layout wrapper + - `layers.css.tsx` — CSS layer declarations injected via React +- **Styles** (`styles/publicui.css`) — public-facing page styles. + +## Editing Rules + +- Route components should stay **thin**: resolve data from the REST API (via `@plone/client`) and delegate rendering to `@plone/layout` and `@plone/blocks`. +- Do not add editor or admin UI here — that belongs in `@plone/cmsui`. +- When adding a new public route, register it in `routes/index.tsx`. +- Keep CSS in `styles/` — do not inline significant styles into route components. + +## Validation + +```sh +pnpm --filter @plone/publicui test --run +pnpm --filter @plone/publicui check:ts +``` diff --git a/packages/publicui/CHANGELOG.md b/packages/publicui/CHANGELOG.md index 2e6cd44daed..2181bbd67b6 100644 --- a/packages/publicui/CHANGELOG.md +++ b/packages/publicui/CHANGELOG.md @@ -8,6 +8,26 @@ +## 1.0.0-alpha.3 (2026-05-07) + +### Internal + +- Added AGENTS.md file. @pnicolli +- Aligned PublicUI's app-aware TypeScript project setup and route typing with the monorepo-wide typecheck cleanup. +- Switched PublicUI's local `@testing-library/jest-dom` dev dependency to the shared catalog entry to keep test tooling aligned with the monorepo dependency refresh. + +## 1.0.0-alpha.2 (2026-04-16) + +### Feature + +- Added the left toolbar @pnicolli [#6649](https://github.com/plone/volto/issues/6649) +- Moved the initialize client to the middleware from the config. @sneridagh [#8108](https://github.com/plone/volto/issues/8108) +- Moved basic data fetching to a middleware to allow all loaders and actions to use it @pnicolli + +### Internal + +- Refactored to use context in all loaders and actions. @pnicolli + ## 1.0.0-alpha.1 (2025-12-23) ### Feature diff --git a/packages/publicui/Makefile b/packages/publicui/Makefile new file mode 100644 index 00000000000..6dffbcd8592 --- /dev/null +++ b/packages/publicui/Makefile @@ -0,0 +1,25 @@ +# Project settings +include ../../variables.mk + +.PHONY: all +all: help + +.PHONY: help +help: ## This help message + @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/$(CYAN)\1$(RESET):\2/' | column -c2 -t -s :)" + +.PHONY: install +install: ## Install dependencies + pnpm install + +.PHONY: build +build: ## Build the package + pnpm run --if-present build + +# .PHONY: storybook-start +# storybook-start: ## Start Storybook +# pnpm run storybook + +# .PHONY: storybook-build +# storybook-build: ## Build Storybook +# pnpm run build-storybook diff --git a/packages/publicui/news/+contents-toolbar-button.feature b/packages/publicui/news/+contents-toolbar-button.feature new file mode 100644 index 00000000000..660073d5086 --- /dev/null +++ b/packages/publicui/news/+contents-toolbar-button.feature @@ -0,0 +1 @@ +Added a toolbar button that opens the contents view for the current public page. @pnicolli @giuliaghisini diff --git a/packages/publicui/news/+fix-edit-button-stale-url.bugfix b/packages/publicui/news/+fix-edit-button-stale-url.bugfix new file mode 100644 index 00000000000..6406812aa82 --- /dev/null +++ b/packages/publicui/news/+fix-edit-button-stale-url.bugfix @@ -0,0 +1 @@ +Fix toolbar edit button pointing to stale URL on client-side navigation. @iFlameing diff --git a/packages/publicui/news/+unify-makefiles.internal b/packages/publicui/news/+unify-makefiles.internal new file mode 100644 index 00000000000..5da674df4e4 --- /dev/null +++ b/packages/publicui/news/+unify-makefiles.internal @@ -0,0 +1 @@ +Unify Makefile files across the packages. @ionlizarazu diff --git a/packages/publicui/news/6649.feature b/packages/publicui/news/6649.feature deleted file mode 100644 index f37df625361..00000000000 --- a/packages/publicui/news/6649.feature +++ /dev/null @@ -1 +0,0 @@ -Added the left toolbar @pnicolli diff --git a/packages/publicui/package.json b/packages/publicui/package.json index 988d7ec59f9..9ccccbe920c 100644 --- a/packages/publicui/package.json +++ b/packages/publicui/package.json @@ -9,7 +9,7 @@ ], "funding": "https://github.com/sponsors/plone", "license": "MIT", - "version": "1.0.0-alpha.1", + "version": "1.0.0-alpha.3", "repository": { "type": "git", "url": "https://github.com/plone/volto.git", @@ -38,11 +38,15 @@ ], "scripts": { "test": "vitest", - "check-ts": "tsc --project tsconfig.json", + "check:ts": "pnpm --filter seven run typegen && tsc --project tsconfig.json", "dry-release": "release-it --dry-run", "release": "release-it", "release-major-alpha": "release-it major --preRelease=alpha", - "release-alpha": "release-it --preRelease=alpha" + "release-alpha": "release-it --preRelease=alpha", + "prettier:fix": "prettier --write '**/*.{js,jsx,ts,tsx}'", + "lint:fix": "eslint --max-warnings=0 './**/*.{js,jsx,ts,tsx}' --fix --no-error-on-unmatched-pattern", + "stylelint:fix": "sh -c 'if [ -f .stylelintrc ] || [ -f .stylelintrc.json ] || [ -f .stylelintrc.js ] || [ -f .stylelintrc.cjs ] || [ -f stylelint.config.js ] || [ -f stylelint.config.cjs ] || [ -f stylelint.config.mjs ]; then stylelint '''./**/*.{css,scss,less}''' --fix --allow-empty-input; else echo \"No local stylelint config, skipping\"; fi'", + "format": "pnpm prettier:fix && pnpm lint:fix && pnpm stylelint:fix" }, "peerDependencies": { "react": "^19.1.0", @@ -68,7 +72,7 @@ }, "devDependencies": { "@plone/types": "workspace:*", - "@testing-library/jest-dom": "6.4.2", + "@testing-library/jest-dom": "catalog:", "@testing-library/react": "catalog:", "@types/jest-axe": "^3.5.7", "@types/node": "catalog:", diff --git a/packages/publicui/routes/content.tsx b/packages/publicui/routes/content.tsx index a79a9e59f7e..54ee97755a7 100644 --- a/packages/publicui/routes/content.tsx +++ b/packages/publicui/routes/content.tsx @@ -1,15 +1,22 @@ -import { useLocation, useRouteLoaderData } from 'react-router'; +import { + RouterContextProvider, + useLoaderData, + useLocation, + type LoaderFunctionArgs, +} from 'react-router'; import SlotRenderer from '@plone/layout/slots/SlotRenderer'; -import type { RootLoader } from 'seven/app/root'; +import { ploneContentContext } from 'seven/app/middleware.server'; + +export async function loader({ + context, +}: LoaderFunctionArgs) { + const content = context.get(ploneContentContext); + return { content }; +} export default function Content() { - const contentData = useRouteLoaderData('root'); + const { content } = useLoaderData(); const location = useLocation(); - if (!contentData) { - return null; - } - const { content } = contentData; - return ; } diff --git a/packages/publicui/routes/index.tsx b/packages/publicui/routes/index.tsx index ca94b3cdab9..3da83023ed6 100644 --- a/packages/publicui/routes/index.tsx +++ b/packages/publicui/routes/index.tsx @@ -7,20 +7,25 @@ import { Outlet, Scripts, ScrollRestoration, + useLoaderData, useLocation, useMatches, useNavigate, - useRouteLoaderData, type UIMatch, type LinksFunction, type MetaFunction, + type LoaderFunctionArgs, + RouterContextProvider, } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Link, RouterProvider as RACRouterProvider, } from 'react-aria-components'; +import i18next from 'seven/app/i18next.server'; +import { ploneContentContext } from 'seven/app/middleware.server'; import type { RootLoader } from 'seven/app/root'; +import { FolderIcon } from '@plone/components/Icons'; import Pencil from '@plone/components/icons/pencil.svg?react'; import SlotRenderer from '@plone/layout/slots/SlotRenderer'; import Toolbar from '@plone/layout/components/Toolbar/Toolbar'; @@ -72,27 +77,35 @@ export const links: LinksFunction = () => [ }, ]; -export async function loader() { - return { cssLayers: config.settings.cssLayers }; +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + const locale = await i18next.getLocale(request); + const content = context.get(ploneContentContext); + return { + content, + cssLayers: config.settings.cssLayers, + locale, + }; } export default function Index() { const location = useLocation(); - const rootData = useRouteLoaderData('root'); + const { content, locale } = useLoaderData(); const { i18n } = useTranslation(); const navigate = useNavigate(); const matches = useMatches() as UIMatch[]; const routesBodyClasses = matches .filter((match) => match.handle?.bodyClass) .map((match) => match.handle?.bodyClass); + const contentLanguage = (content.language as { token?: string } | undefined) + ?.token; - if (!rootData) { - return null; - } - const { content, locale } = rootData; + const showToolbar = shouldShowToolbar(content); return ( - + @@ -102,14 +115,19 @@ export default function Index() { {/* We pre-define here the @layer before tailwind does, adding our own layers in a React 19 managed tag */} - + - + + + + + + {showToolbar && }
    -