diff --git a/.agents/skills/strapi-plugin-maintenance/SKILL.md b/.agents/skills/strapi-plugin-maintenance/SKILL.md index 07b0b77..94c721c 100644 --- a/.agents/skills/strapi-plugin-maintenance/SKILL.md +++ b/.agents/skills/strapi-plugin-maintenance/SKILL.md @@ -1,12 +1,16 @@ --- name: strapi-plugin-maintenance -description: Maintain Magicmark Strapi 5 bookmarks, sharing, license UI and conditional access with reproducible checks. +description: Maintain Magic Mark (Magicmark), the free Strapi 5 bookmarks and advanced filters plugin, with reproducible checks. --- -# Magicmark maintenance +# Magic Mark maintenance -Use Node 22. Read local rules, query current Strapi 5 plugin/route/permission and Design System docs through Context7, and compare newest compatible 5.x with the workbench pin. Do not store credentials or license keys in skills, traces or Git; never push without an explicit request. +Use Node 22 or 24. Read local rules and the workbench skills (docs-first, lint-required, strapi-plugin-api, strapi-plugin-security, strapi-admin-ui-ux, strapi-design-system). Never store credentials in skills, traces or Git; never push without an explicit request. -Trace both `server/src/routes/admin.ts` and `server/routes/admin.js`, the controller/services and `admin/src/permissions.ts`. Routes and menu links currently use empty `policies`/`permissions`; do not assume this means unauthenticated access, but prove the admin auth scope and role/permission boundary at runtime. Make a matrix for anonymous, admin lacking permission, allowed admin, bookmark owner and another admin. Check create/reorder/pin/share/delete ownership, role visibility, license key writes and limits server-side. Preserve the intentional share semantics while denying cross-user modification. +The plugin is free: there is no license system and there must never be outbound requests, timers or tracking. `tests/source-contract.test.cjs` enforces this. -Use Strapi 5 Design System, permission-backed menu and accessible bookmark actions, empty/error/loading states and responsive query-builder layout. Run `npm run build`, `npm run verify`, `npm run test:ts:front`, `npm run test:ts:back`, plus focused owner/deny tests; then shared app build/boot and Playwright CLI navigation. Do not present successful typechecks as proof of authorization. +Server: `server/src/routes/admin.ts` (every route has `admin::isAuthenticatedAdmin` plus `admin::hasPermissions`), `controllers/bookmarks.ts`, `services/bookmarks.ts` (access rules, sharing rows, per-user pins), `services/maintenance.ts` (3.x migrations, deletion cleanup), `validation.ts` (zod). Keep the route permission matrix in `tests/server-routes.test.cjs` and the README in sync. A bookmark the caller cannot see answers 404; a visible one owned by someone else answers 403 on writes. + +Admin: Design System components and `@strapi/strapi/admin` APIs only, no custom styled components or colour literals. One injection in `listView.actions` (Advanced filters + Bookmarks), the bookmarks page, and the homepage widget. Every string uses `msg()` and exists in en, de, fr, es and pt (`tests/admin-i18n.test.cjs`); German copy does not address the reader. + +Gates before a commit: `npm run lint`, `npm run format:check`, `npm test`, `npm run test:ts:front`, `npm run test:ts:back`, `npm run build`, `npm run verify`, `npm run verify:runtime`, `npm pack --dry-run`. For UI or permission changes also run the isolated host acceptance (own port, SQLite, synthetic data, egress blocked) with an RBAC matrix (anonymous, admin without permission, owner, other admin, manager) and the Playwright viewport matrix in light and dark mode. diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index de4d1f0..0000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -dist -node_modules diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index e4683a5..74c8e5f 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -7,7 +7,7 @@ body: attributes: value: | ## Thanks for taking the time to report a bug! - + - type: textarea id: bug-description attributes: @@ -15,21 +15,21 @@ body: description: A clear and concise description of what the bug is validations: required: true - + - type: textarea id: expected-behavior attributes: label: βœ… Expected Behavior validations: required: true - + - type: textarea id: actual-behavior attributes: label: ❌ Actual Behavior validations: required: true - + - type: textarea id: reproduction-steps attributes: @@ -41,7 +41,7 @@ body: 4. See error... validations: required: true - + - type: dropdown id: feature-area attributes: @@ -58,7 +58,7 @@ body: - Other validations: required: true - + - type: dropdown id: content-type attributes: @@ -72,7 +72,7 @@ body: - Other validations: required: false - + - type: input id: plugin-version attributes: @@ -80,7 +80,7 @@ body: placeholder: e.g. 3.0.2 validations: required: true - + - type: input id: strapi-version attributes: @@ -88,7 +88,7 @@ body: placeholder: e.g. 5.29.0 validations: required: true - + - type: input id: node-version attributes: @@ -96,7 +96,7 @@ body: placeholder: e.g. 20.19.4 validations: required: true - + - type: dropdown id: database attributes: @@ -109,7 +109,7 @@ body: - Other validations: required: false - + - type: textarea id: error-logs attributes: @@ -117,7 +117,7 @@ body: render: shell validations: required: false - + - type: textarea id: filter-query attributes: @@ -126,7 +126,7 @@ body: render: json validations: required: false - + - type: checkboxes id: terms attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index e27c955..6840858 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -7,7 +7,7 @@ body: attributes: value: | ## Thanks for suggesting a feature! - + - type: dropdown id: feature-area attributes: @@ -27,21 +27,21 @@ body: - Other validations: required: true - + - type: textarea id: problem attributes: label: πŸ€” Problem / Use Case validations: required: true - + - type: textarea id: solution attributes: label: πŸ’‘ Proposed Solution validations: required: true - + - type: textarea id: example attributes: @@ -49,7 +49,7 @@ body: description: Show example queries or UI mockups validations: required: false - + - type: checkboxes id: terms attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 86594f2..93f8baa 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,20 +1,21 @@ - ## πŸ“ Description - - + ## πŸ”— Related Issue(s) + - Closes # - Related to # ## 🎯 Type of Change + - [ ] πŸ› Bug fix (non-breaking change that fixes an issue) @@ -27,6 +28,7 @@ Please fill out this template to help us review your PR. - [ ] ⚑ Performance improvement ## πŸ” Feature Area + - [ ] Query Builder UI @@ -43,6 +45,7 @@ Please fill out this template to help us review your PR. - [ ] Documentation ## πŸ§ͺ Testing + - [ ] I have tested these changes locally @@ -53,13 +56,14 @@ Please fill out this template to help us review your PR. ### Test Environment -- **Plugin Version**: -- **Strapi Version**: -- **Node Version**: -- **Database**: -- **OS**: +- **Plugin Version**: +- **Strapi Version**: +- **Node Version**: +- **Database**: +- **OS**: ### Filter Testing + - [ ] Tested with simple filters @@ -69,11 +73,11 @@ Please fill out this template to help us review your PR. - [ ] Tested bookmark sharing ## πŸ“Έ Screenshots/Videos - - + ## πŸ“‹ Checklist + - [ ] My code follows the project's code style @@ -85,17 +89,17 @@ Please fill out this template to help us review your PR. - [ ] I have tested query generation and execution ## πŸ”„ Breaking Changes - - + ## πŸ“š Additional Context - - + --- + **PR Title Format**: Please use conventional commits format: + - `feat: add date range filter operator` - `fix: resolve relation filter query generation` - `docs: update advanced filter examples` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f4cc537 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,56 @@ +# Automatic dependency updates. Patch and minor updates are grouped into one weekly PR +# and merged automatically by .github/workflows/dependency-auto-merge.yml when lint, tests, +# type checks, build and verify all pass. Major updates get their own PR and wait for review. +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: '06:00' + timezone: Europe/Berlin + open-pull-requests-limit: 5 + versioning-strategy: increase-if-necessary + commit-message: + prefix: 'chore(deps)' + prefix-development: 'chore(deps-dev)' + labels: [dependencies] + groups: + minor-and-patch: + update-types: [minor, patch] + ignore: + # These versions are set by the Strapi 5 admin that hosts the plugin. + # A major bump here would break the plugin inside Strapi, so it is never proposed. + - dependency-name: 'react' + update-types: ['version-update:semver-major'] + - dependency-name: 'react-dom' + update-types: ['version-update:semver-major'] + - dependency-name: '@types/react' + update-types: ['version-update:semver-major'] + - dependency-name: '@types/react-dom' + update-types: ['version-update:semver-major'] + - dependency-name: 'react-intl' + update-types: ['version-update:semver-major'] + - dependency-name: 'react-router-dom' + update-types: ['version-update:semver-major'] + - dependency-name: 'styled-components' + update-types: ['version-update:semver-major'] + - dependency-name: '@strapi/*' + update-types: ['version-update:semver-major'] + # @strapi/sdk-plugin 6.1.1 and typescript-eslint 8 require TypeScript 5.x (< 6.1). + - dependency-name: 'typescript' + update-types: ['version-update:semver-major'] + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: '06:00' + timezone: Europe/Berlin + commit-message: + prefix: 'ci' + labels: [dependencies] + groups: + actions: + update-types: [minor, patch] diff --git a/.github/workflows/dependency-auto-merge.yml b/.github/workflows/dependency-auto-merge.yml new file mode 100644 index 0000000..0763d14 --- /dev/null +++ b/.github/workflows/dependency-auto-merge.yml @@ -0,0 +1,82 @@ +# Verifies every Dependabot PR and merges it automatically when it is safe: +# - patch/minor updates: merged after lint, format check, tests, type checks, build and verify pass; +# - major updates: never merged automatically; the PR is labelled and left for review. +# Merges made with GITHUB_TOKEN do not trigger release.yml, so no npm release is published +# automatically. Updates ship with the next regular release. +name: Dependency auto-merge + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: dependency-auto-merge-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + verify: + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '22' + cache: npm + - name: Install (no lifecycle scripts) + run: npm ci --legacy-peer-deps --ignore-scripts + - name: Lint + run: npm run --if-present lint + - name: Check formatting + run: npm run --if-present format:check + - name: Tests + run: npm test + - name: Type checks + run: | + npm run --if-present test:ts:front + npm run --if-present test:ts:back + - name: Build + run: npm run build + - name: Verify package + run: | + npm run --if-present verify + npm run --if-present verify:runtime + - name: Production audit (high and critical) + run: npm audit --omit=dev --audit-level=critical + + auto-merge: + needs: verify + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Read update metadata + id: meta + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Merge patch and minor updates + if: steps.meta.outputs.update-type != 'version-update:semver-major' + run: gh pr merge "$PR_URL" --squash --delete-branch + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Flag major updates for review + if: steps.meta.outputs.update-type == 'version-update:semver-major' + run: | + gh pr edit "$PR_URL" --add-label "major-update" || true + gh pr comment "$PR_URL" --body "Major update: all checks passed, but this PR is not merged automatically. Review the changelog of ${DEPS} for breaking changes before merging." + env: + PR_URL: ${{ github.event.pull_request.html_url }} + DEPS: ${{ steps.meta.outputs.dependency-names }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 604c728..ebf99ab 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,4 +1,4 @@ -name: Deploy GitHub Pages +name: Deploy Pages on: push: @@ -8,8 +8,6 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: pages @@ -17,19 +15,25 @@ concurrency: jobs: deploy: + permissions: + contents: read + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Configure GitHub Pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload site artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: docs/site - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3408ab6..fabb0e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,47 +1,67 @@ -name: Release to npm +# Publishes a release with semantic-release when main changes. +# Hardening: +# - every action is pinned to a full commit SHA (Dependabot keeps them current); +# - the workflow token is read-only by default; only the release job may write +# contents (tag, GitHub release, CHANGELOG commit) and request an OIDC token; +# - npm auth: trusted publishing (OIDC) first; until a trusted publisher is set up +# on npmjs.com the exchange returns 404, so the NPM_TOKEN secret is the fallback +# (same setup as Magic Session Manager); provenance stays on; +# - dependencies install without lifecycle scripts and without a cache. +name: Release on: push: branches: - main +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest + timeout-minutes: 30 permissions: - contents: write - issues: write - pull-requests: write - + contents: write # tag, GitHub release and the CHANGELOG commit + id-token: write # npm trusted publishing and provenance steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '22' - registry-url: 'https://registry.npmjs.org' - cache: 'npm' - - - name: Install dependencies - run: npm ci --legacy-peer-deps - - - name: Run tests + registry-url: https://registry.npmjs.org + package-manager-cache: false + - name: Install OIDC-capable npm CLI + run: npm install --global npm@12.2.0 --ignore-scripts --no-audit --no-fund + - name: Install dependencies (no lifecycle scripts) + run: npm ci --legacy-peer-deps --ignore-scripts + - name: Lint + run: npm run lint + - name: Format check + run: npm run format:check + - name: Tests run: npm test - - - name: Build plugin + - name: Build run: npm run build - - - name: Verify plugin - run: npm run verify - + - name: Verify package + run: | + npm run verify + npm run verify:runtime + - name: Production audit (critical) + run: npm audit --omit=dev --audit-level=critical - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npx semantic-release + NPM_CONFIG_PROVENANCE: 'true' + run: npx --no-install semantic-release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b415b9..5ec4af8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,35 +8,53 @@ on: branches: - main +permissions: + contents: read + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + jobs: test: - name: Test & Verify Build + name: Test & Verify Build (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest - + timeout-minutes: 30 + strategy: + fail-fast: false matrix: - node-version: [22.x] - + # package.json engines: ^22.22.2 || >=24.15.0 + node-version: ['22', '24'] + steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} - cache: 'npm' + cache: npm - name: Install dependencies run: npm ci --legacy-peer-deps - - name: Type check (Frontend) - run: npm run test:ts:front - continue-on-error: true + - name: Lint + run: npm run lint - - name: Type check (Backend) - run: npm run test:ts:back - continue-on-error: true + - name: Check formatting + run: npm run format:check + + - name: Run regression test suite + run: npm test + + - name: Type checks + run: | + npm run test:ts:front + npm run test:ts:back - name: Build plugin run: npm run build @@ -44,43 +62,21 @@ jobs: - name: Verify plugin integrity run: npm run verify + - name: Verify server runtime bundle + run: npm run verify:runtime + - name: Verify build output run: | - echo "Checking admin build..." - test -f "dist/admin/index.js" && echo "βœ“ Admin JS built" || exit 1 - test -f "dist/admin/index.mjs" && echo "βœ“ Admin MJS built" || exit 1 - - echo "Checking server build..." - test -f "dist/server/index.js" && echo "βœ“ Server JS built" || exit 1 - test -f "dist/server/index.mjs" && echo "βœ“ Server MJS built" || exit 1 - - echo "βœ“ All build artifacts present" - - lint: - name: Code Quality - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - cache: 'npm' - - - name: Install dependencies - run: npm ci --legacy-peer-deps + test -f dist/admin/index.js + test -f dist/admin/index.mjs + test -f dist/server/index.js + test -f dist/server/index.mjs - name: Check file structure run: | - echo "Verifying plugin structure..." - test -d "admin/src" && echo "βœ“ Admin src found" || exit 1 - test -d "server/src" && echo "βœ“ Server src found" || exit 1 - test -f "package.json" && echo "βœ“ package.json found" || exit 1 - test -f ".releaserc.json" && echo "βœ“ .releaserc.json found" || exit 1 - test -f "README.md" && echo "βœ“ README.md found" || exit 1 - test -f "LICENSE" && echo "βœ“ LICENSE found" || exit 1 - echo "βœ“ All required files present" - + test -d admin/src + test -d server/src + test -f package.json + test -f .releaserc.json + test -f README.md + test -f LICENSE diff --git a/.prettierignore b/.prettierignore index 009af54..10ebc51 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,14 @@ +# Build output and coverage dist coverage + +# Binary and generated assets +pics +docs/site/img + +# Lockfiles and files written by tooling +package-lock.json +CHANGELOG.md + +# Agent rules synced from the workbench templates +.agents diff --git a/.releaserc.json b/.releaserc.json index b23f122..302427e 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -5,7 +5,10 @@ "@semantic-release/release-notes-generator", "@semantic-release/changelog", "@semantic-release/npm", - ["@semantic-release/github", { "labels": false, "successComment": false }], + [ + "@semantic-release/github", + { "labels": false, "successComment": false, "failCommentCondition": false } + ], [ "@semantic-release/git", { @@ -15,4 +18,3 @@ ] ] } - diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b753de..d487326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,71 @@ +## [Unreleased] + +Magic Mark is now free, rebuilt on the Strapi Design System and secured on the server. + +### Free + +- Removed the license system: license guard, license routes and settings pages, upgrade page, + feature gates, the 10/50 bookmark limit, the keep-alive heartbeat and every call to the license + server. No telemetry, no timers, no outbound requests. +- The stored `licenseKey` and `lastValidated` plugin store keys are deleted on the first start. +- Removed `COPYRIGHT_NOTICE.txt`; the plugin is MIT licensed. + +### Security and fixes + +- Every admin route now requires `admin::isAuthenticatedAdmin` and `admin::hasPermissions`. Before, + any authenticated admin could call every route, including the admin user list, because the + admin strategy ignores `auth.scope`. +- Request validation with strict zod schemas; paths must be Content Manager list views and queries + are normalised with `qs` (no prototype keys, no `page`). +- Sharing with individual users works (3.x compared numeric ids with documentIds). +- Lists are filtered, searched and paginated in the database; owner names load in one query. +- The user search is paginated and shows email only to admins with `admin::users.read`. +- Applying filters no longer reloads the page; "all conditions" filters use the native `$and` + shape and show as native filter tags. +- Opening a bookmark replaces the Content Manager's remembered list settings for that content type. +- Bookmark paths work with a custom admin URL; saved views no longer include the page number. +- Advanced filters read fields from the Content Manager, so roles without Content-Type Builder + access can use them. Internal component keys (`id`, `documentId`) are not offered; a condition + on a component `documentId` made the list request fail. +- Closing a changed bookmark dialog asks first (Cancel keeps the edits). The row menu closes with + Escape, row dialogs return focus to the row menu, and deleting a bookmark keeps keyboard focus in + the page. The Shared badge has enough contrast in dark mode, and below 1080 px the bookmarks + page shows cards so the row actions never scroll out of view. +- Saved 3.x queries are normalised on the first start like new ones (the page number and unknown + keys are dropped), and duplicating a bookmark never copies an unnormalised query. +- A search text of up to 1000 characters can be saved. When a view still cannot be saved, the + dialog says why instead of showing a generic error. +- The Bookmarks popover requests pinned bookmarks separately, so a pin is never cut off, and + links to the Magic Mark page when a content type has more than 100 bookmarks. + +### Features + +- Rebuilt admin UI with Design System components only: Bookmarks popover and Advanced filters + button in the list view toolbar, bookmarks page with search, scope, content type filter, sorting + and pagination, create and edit dialog with sharing, delete confirmation, dark mode, 320 px + layouts, five languages. +- Per-user pins (also for shared bookmarks), duplicate, "update with current view", copy link. +- "Pinned bookmarks" homepage widget. +- New permissions: Read, Create, Edit own, Delete own, Share and Manage shared bookmarks of + others. Managers see every public or shared bookmark under **Show > All shared bookmarks**, + also when they are not a recipient and when the owner was deleted; private bookmarks of other + users stay private. +- Admin user and role deletion clean up shares, pins and private bookmarks. + +### Migration notes + +- Roles keep their access: 3.x `settings.read` maps to Read bookmarks; `settings.update` maps to + Read, Create, Edit own, Delete own and Share bookmarks. Give Manage shared bookmarks of others + to roles that should tidy up other users' shared bookmarks. +- 3.x shares (JSON columns) and global pins are migrated on the first start; pins become the + owner's personal pin. The custom manual order is no longer used. +- The plugin-internal admin routes changed (`/license/*`, `/roles`, `/users` and + `/bookmarks/reorder` were removed; sharing lookups moved to `/sharing/roles` and `/sharing/users`). + The plugin's own admin UI is their only consumer. +- Peer dependencies: `@strapi/design-system`, `@strapi/icons` and `@strapi/utils` are now peers; + `@heroicons/react` is no longer installed. `@strapi/strapi` and `@strapi/utils` need 5.57 or + newer 5.x. + # [3.4.0](https://github.com/Schero94/Magicmark/compare/v3.3.2...v3.4.0) (2026-02-08) diff --git a/COPYRIGHT_NOTICE.txt b/COPYRIGHT_NOTICE.txt deleted file mode 100644 index cd0100c..0000000 --- a/COPYRIGHT_NOTICE.txt +++ /dev/null @@ -1,20 +0,0 @@ -/** - * MagicMark - Advanced Query Builder for Strapi - * - * Copyright (c) 2025 Schero D. - * - * Licensed under the MIT License - * - * This plugin is free to use for personal and commercial projects. - * - * IMPORTANT RESTRICTION: - * The license validation system (license-guard.ts and related components) - * must remain intact and functional. Removing or bypassing this system - * is strictly prohibited. - * - * See LICENSE file for full terms. - * - * Repository: https://github.com/Schero94/Magicmark - * Issues: https://github.com/Schero94/Magicmark/issues - */ - diff --git a/README.md b/README.md index 5770d70..c710182 100644 --- a/README.md +++ b/README.md @@ -1,597 +1,337 @@ -# MagicMark - Advanced Query Builder for Strapi v5 πŸ”– +# Magic Mark -**Save and apply complex Content Manager queries with one click.** Professional bookmark management with role-based sharing, advanced filtering, and drag-and-drop organization for Strapi v5. +Bookmarks and advanced filters for the Strapi 5 Content Manager. Filter a list, save the view as +a bookmark, open it again with one click and share it with roles or individual admin users. -[GitHub Pages documentation](https://schero94.github.io/Magicmark/) +Free and open source (MIT). No license key, no limits, no tracking, no calls to external +servers. -Requires Strapi `^5.57.0`. +[![npm](https://img.shields.io/npm/v/strapi-plugin-magic-mark.svg)](https://www.npmjs.com/package/strapi-plugin-magic-mark) +[![Test & Verify](https://github.com/Schero94/Magicmark/actions/workflows/test.yml/badge.svg)](https://github.com/Schero94/Magicmark/actions/workflows/test.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![npm version](https://badge.fury.io/js/strapi-plugin-magic-mark.svg)](https://www.npmjs.com/package/strapi-plugin-magic-mark) -[![GitHub release](https://img.shields.io/github/v/release/Schero94/Magicmark.svg)](https://github.com/Schero94/Magicmark/releases) +Documentation site: [https://schero94.github.io/Magicmark/](https://schero94.github.io/Magicmark/) ---- +![Bookmarks popover in the Content Manager list view](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/list-view-bookmarks.png) -## πŸ“Έ Screenshots +## Features -### MagicMark Dashboard -Professional interface for managing query bookmarks with statistics and easy access. +- **Save view** in the list view toolbar keeps the content type, filters, sort order, page size, + search text and locale of the list you are looking at. +- One click on a bookmark opens the list exactly like that again, without a page reload. +- Share a bookmark with roles, with single admin users or with every admin. Recipients can open, + pin and copy it but not change it. +- Pins are per user. Pinned bookmarks come first in the toolbar and on the Strapi homepage widget. +- **Advanced filters** add "any condition", "is one of", "is none of", "is between", relation + fields and component fields to the native filters. +- A Magic Mark page with search, scope, content type filter, sorting and pagination for all your + bookmarks. +- Six role permissions, checked on the server for every request. +- Built only with Strapi Design System components: light and dark mode, layouts down to 320 px, + keyboard access, five languages. -![MagicMark Dashboard](pics/dashboard.png) - -### Save as MagicMark -Create bookmarks with emoji, name, description, and sharing options directly from Content Manager. - -![Save Bookmark](pics/saveAsMagicMark.png) - -### Advanced Filter Builder -Build complex queries with the advanced filter modal for precise content filtering. - -![Advanced Filters](pics/advancedFilters.png) - -### Quick Access in Content Manager -Seamless integration - MagicMark button right in the Content Manager toolbar for instant bookmark access. - -![Quick Access](pics/quickaccesinContentmanager.png) - ---- - -## πŸ“‹ Table of Contents +## Contents - [Features](#features) -- [Quick Start](#quick-start) -- [How It Works](#how-it-works) -- [Screenshots in Action](#screenshots-in-action) -- [API Routes](#api-routes) +- [Quick start](#quick-start) +- [Screenshots](#screenshots) +- [Core concepts](#core-concepts) +- [Screens](#screens) +- [Compatibility](#compatibility) +- [Examples](#examples) - [Configuration](#configuration) -- [Security](#security) -- [Use Cases](#use-cases) -- [Testing](#testing) -- [Releases](#releases) +- [Admin API and permissions](#admin-api-and-permissions) +- [Security and privacy](#security-and-privacy) +- [Performance and several instances](#performance-and-several-instances) - [Troubleshooting](#troubleshooting) -- [Development](#development) - ---- - -## ✨ Features - -### Core Bookmark Management -βœ… **Query Bookmarks** - Save complex Content Manager filters, sorts, and pagination -βœ… **One-Click Apply** - Instantly restore entire query states -βœ… **Emoji Identification** - Visual icons for quick bookmark recognition -βœ… **Pin to Top** - Keep frequently used bookmarks always accessible -βœ… **Drag & Drop** - Reorder bookmarks effortlessly -βœ… **Mobile Optimized** - Fully responsive design - -### Advanced Sharing -βœ… **Role-Based Sharing** - Share with specific admin roles -βœ… **User Sharing** - Share with individual admin users -βœ… **Public Bookmarks** - Make bookmarks visible to all users -βœ… **Permission Control** - Fine-grained access management - -### User Experience -βœ… **Quick Access** - Bookmark button in Content Manager toolbar -βœ… **Filter Preview** - See exactly what's saved in each bookmark -βœ… **Descriptions** - Add notes to remember bookmark purpose -βœ… **Context Aware** - Automatically detects current content type -βœ… **Instant Save** - Capture current query state in one click -βœ… **Professional UI** - Beautiful gradient design with smooth animations - -### Multi-Language Support -βœ… **5 Languages** - English, Deutsch, FranΓ§ais, EspaΓ±ol, PortuguΓͺs -βœ… **Auto-Detection** - Follows your Strapi admin language -βœ… **Complete i18n** - All UI elements translated +- [Upgrading from 3.x](#upgrading-from-3x) +- [FAQ](#faq) +- [Development and tests](#development-and-tests) +- [Contributing](#contributing) +- [Maintenance status](#maintenance-status) +- [Languages](#languages) +- [License](#license) ---- +## Quick start -## πŸš€ Quick Start - -### 1. Install Plugin +Install the package, enable it, rebuild the admin and start Strapi: ```bash npm install strapi-plugin-magic-mark -# or -yarn add strapi-plugin-magic-mark ``` -### 2. Register in Config - -Add to `src/config/plugins.ts` (or `plugins.js`): - -```typescript -export default () => ({ - 'magic-mark': { - enabled: true, - }, +```js +// config/plugins.js (or .ts) +module.exports = () => ({ + 'magic-mark': { enabled: true }, }); ``` -### 3. Build & Run - ```bash -# Rebuild admin panel npm run build - -# Start Strapi npm run develop ``` -### 4. Activate License (First Use) +Then give your roles the permissions they need under **Settings > Roles > Plugins > Magic +Mark**. Super Admins have all of them. -- Go to Admin β†’ **MagicMark** -- Click **"Activate License"** -- Choose: Auto-create, Manual entry, or Existing key -- Done! Plugin is ready +To save your first bookmark, open any collection type in the Content Manager, set a filter, open +the star button next to **View settings** and choose **Save view**. Name it and save. The bookmark +is now in the same star menu, on the Magic Mark page and, once pinned, in the homepage widget. -### 5. Save Your First Bookmark +## Screenshots -1. Navigate to any Content Manager collection -2. Apply filters, sorting, or search -3. Click the **MagicMark** button in the toolbar (see screenshot above) -4. Click **"Save Bookmark"** -5. Add: - - Name (e.g., "Published Articles") - - Emoji (e.g., πŸ“°) - - Description (optional) - - Sharing settings (optional) -6. Click **Save** +All screenshots use synthetic data on an isolated Strapi 5.61 host. -### 6. Use Bookmarks +### Bookmarks in the Content Manager -- Click **MagicMark** β†’ Select bookmark β†’ Done! -- All filters are instantly applied -- Share with team members +![Bookmarks popover next to View settings in the Content Manager list view](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/list-view-bookmarks.png) ---- +### Advanced filters -## πŸ”„ How It Works +![Advanced filters dialog with two conditions](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/advanced-filters.png) -### Bookmark Save Flow +### Save view as bookmark -``` -User in Content Manager - ↓ -Applies: Filters, Sort, Search, Pagination - ↓ -[Click MagicMark Button] (See "Quick Access" screenshot) - ↓ -"Save Bookmark" Modal opens (See "Save as MagicMark" screenshot) - ↓ -User enters: Name, Emoji, Description, Sharing - ↓ -[Save] - ↓ -Query parameters captured: - - Path: /content-manager/collection-types/api::article.article - - Query: filters[$and][0][publishedAt][$notNull]=true&sort[0]=createdAt:desc - - Pagination: page=1&pageSize=25 - ↓ -Bookmark stored in database - ↓ -Success! Bookmark appears in Dashboard (See "Dashboard" screenshot) -``` +![Save view dialog with the captured filters and sharing options](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/save-view.png) -### Bookmark Apply Flow +### Bookmarks page -``` -User clicks MagicMark button - ↓ -List of available bookmarks appears - (filtered by user permissions) - ↓ -[User selects bookmark] - ↓ -Navigation to content type happens - ↓ -Query parameters applied: - - Filters restored (See "Advanced Filters" screenshot) - - Sort order set - - Pagination reset - - Search cleared - ↓ -Content Manager displays filtered results -``` +![Bookmarks page with table, search and filters](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/bookmarks.png) ---- - -## 🎬 Screenshots in Action - -### 1. Dashboard View -The main **MagicMark Dashboard** (see first screenshot above) shows: -- All your bookmarks with emoji icons -- Pinned bookmarks at the top -- Quick actions: Edit, Delete, Share -- Search and filter capabilities -- Beautiful gradient design - -### 2. Quick Access Button -The **Quick Access** button (see screenshot above) is integrated directly into: -- Content Manager toolbar -- Appears on all collection pages -- One-click access to bookmarks -- Context-aware (shows relevant bookmarks) - -### 3. Save Bookmark Modal -The **Save Bookmark Modal** (see screenshot above) includes: -- Name field with emoji picker -- Description for notes -- Sharing options (Roles, Users, Public) -- Preview of current query -- Visual feedback - -### 4. Advanced Filters -The **Advanced Filter Builder** (see screenshot above) lets you: -- Build complex filter conditions -- Combine AND/OR logic -- Save multi-level queries -- Preview filter results -- Edit existing filters - ---- - -## πŸ“‘ API Routes - -MagicMark registers these under Strapi's authenticated admin API at `/magic-mark`. They are not Content API routes. Each route requires the `plugin::magic-mark.settings.read` permission for `GET` or `plugin::magic-mark.settings.update` for writes. Bookmark changes also check ownership in the service. - -| Method | Path | Purpose | -| --- | --- | --- | -| `GET` | `/magic-mark/bookmarks` | List bookmarks visible to the signed-in admin | -| `POST` | `/magic-mark/bookmarks` | Create a bookmark | -| `PUT` | `/magic-mark/bookmarks/:id` | Update an owned bookmark | -| `DELETE` | `/magic-mark/bookmarks/:id` | Delete an owned bookmark | -| `POST` | `/magic-mark/bookmarks/:id/pin` | Pin or unpin an owned bookmark | -| `POST` | `/magic-mark/bookmarks/reorder` | Reorder bookmarks owned by the signed-in admin | -| `GET` | `/magic-mark/roles` | List admin roles for sharing | -| `GET` | `/magic-mark/users` | List admin users for sharing | -| `GET` | `/magic-mark/license/status` | Read license status | -| `POST` | `/magic-mark/license/auto-create` | Create and activate a license automatically | -| `POST` | `/magic-mark/license/create` | Create and activate a license from submitted details | -| `POST` | `/magic-mark/license/ping` | Check the stored license with the license service | -| `POST` | `/magic-mark/license/store-key` | Store a submitted license key | -| `GET` | `/magic-mark/license/limits` | Read current license limits | - ---- - -## βš™οΈ Configuration - -### Basic Setup (Default) - -The plugin works out of the box with zero configuration! - -```typescript -// src/config/plugins.ts -export default () => ({ - 'magic-mark': { - enabled: true, - }, -}); -``` +![Bookmarks page in dark mode](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/bookmarks-dark.png) -The plugin has no configurable bookmark-limit, history, or cleanup options. Its server config currently exposes only `debug`, which defaults to `false`. +### Homepage widget -## πŸ” Security +![Pinned bookmarks widget on the Strapi homepage](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/homepage-widget.png) -Admin routes use registered Strapi admin permission scopes. Bookmark controllers reject requests without an authenticated admin, and update, delete, pin, and reorder operations check that the acting admin owns the affected bookmark. Sharing changes are also owner-only. Public bookmarks are visible through the authenticated admin workflow; they do not make these routes unauthenticated. +### Mobile (390 px) ---- +![Bookmarks page at 390 px width](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/mobile-bookmarks.png) -## πŸ’‘ Use Cases +## Core concepts -### Content Management +| Term | Meaning | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Bookmark | A saved Content Manager list view: content type, filters, sort order, page size, search text and locale. The page number is not saved, so a bookmark always opens on page 1. | +| Owner | The admin user who created the bookmark. Only the owner, or a user with **Manage shared bookmarks of others**, can edit or delete it. | +| Visibility | **Only me**, **Specific roles and users** or **All admin users**. Recipients can open, pin and duplicate a bookmark, but not change it. | +| Pin | Per user. Pinning a shared bookmark changes only your own list and your homepage widget. | +| Advanced filters | A filter editor for the list view with "all conditions" or "any condition", extra operators (is one of, is none of, is between), relation fields and component fields. | -**Published Articles** πŸ“° -``` -Filter: publishedAt is not empty -Sort: createdAt DESC -Result: Quick access to all published content -``` +## Screens -**Draft Posts** πŸ“ -``` -Filter: publishedAt is empty AND status = "draft" -Sort: updatedAt DESC -Result: Focus on work in progress -``` +| Screen | Where | Who | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| **Advanced filters** button | Content Manager list view toolbar, next to View settings | Every admin who can open the list | +| **Bookmarks** button (popover) | Same toolbar. Lists the bookmarks of this content type, pinned first (up to 100; a link opens the rest on the Magic Mark page). **Save view** captures the current list; the refresh icon saves the current view into an existing bookmark. | Read bookmarks (saving: Create bookmarks) | +| **Magic Mark** page | Main menu. Search, show "created by me", "shared with me" or "pinned", filter by content type, sort and paginate. Row actions: pin, open, edit, duplicate, copy link, delete. | Read bookmarks | +| **Pinned bookmarks** widget | Strapi homepage (Add widget) | Read bookmarks | -**Recent Updates** πŸ•’ -``` -Filter: updatedAt >= 7 days ago -Sort: updatedAt DESC -Result: See what changed recently -``` +Opening a bookmark is a normal in-app navigation (no page reload). The Content Manager's +remembered list settings for that content type are replaced by the bookmark's, so filters from +an earlier visit do not mix in. -**My Content** πŸ‘€ -``` -Filter: createdBy = current user -Sort: createdAt DESC -Result: See all your creations -``` +With **All conditions** the filters use the same `$and` shape as Strapi's own filters, so they +appear as native filter tags and can be removed one by one. With **Any condition** the list is +filtered correctly, but Strapi does not show those conditions as tags; open Advanced filters to +change or clear them. -### Team Collaboration +## Compatibility -**Shared Queries** πŸ‘₯ -- Team members access same filtered views -- No need to re-apply complex filters -- Everyone sees consistent data +| Plugin | Strapi | Node.js | Notes | +| -------------------------- | ----------------- | --------------------- | ----------------------------------------------------------------------------------------------------------- | +| Next release (this branch) | 5.57 or newer 5.x | ^22.22.2 or >=24.15.0 | Acceptance host: Strapi 5.61.0 with SQLite. Free; 3.x data and permissions are migrated on the first start. | +| 3.x | 5.x | 22 | Contained the license system; no longer maintained. | -**Role-Based Access** πŸ” -- Editors see different bookmarks than Authors -- Managers see different views than Contributors -- Automatic based on admin roles +React 18, React Router 6, styled-components 6 and react-intl 6 are peer dependencies because the +Strapi 5 admin ships these majors. TypeScript stays on 5.9 for development because +`@strapi/sdk-plugin` 6.1.1 builds with TypeScript 5.9.3 and typescript-eslint 8 supports +TypeScript below 6.1. -**Public Bookmarks** 🌍 -- Common queries for entire team -- New members immediately see them -- Save time explaining filters +## Configuration -### Power Users +Magic Mark has no configuration options and reads no environment variables. Everything is +controlled with role permissions. -**Complex Filters** 🎯 -``` -Save multi-condition queries: - - Status = "approved" - - Category IN ["tech", "news"] - - Priority >= "high" - - CreatedAt >= "2024-01-01" -``` +## Examples -**Sorted Views** πŸ“Š -``` -Different sort orders for different needs: - - By date (newest first) - - By title (A-Z) - - By popularity (most viewed) -``` +**A review queue for editors.** Filter Articles by `stage` is `review`, sort by `updatedAt`, save +the view as "Waiting for review" and share it with the Editor role. Every editor sees it in the +star menu of Articles and can pin it for the homepage. ---- +**Your own working set.** Use Advanced filters with **Any condition** to combine "stage is one of +draft, review" with "featured is true", save it as a private bookmark and pin it. -## πŸ§ͺ Testing +**Keep a shared bookmark current.** Change the filters in the list, open the star menu and use +the refresh icon next to the bookmark. It now saves the current view; the name and sharing stay. -### 1. Install & Setup +**List bookmarks from a script.** The admin API takes an admin JWT, like every Strapi admin route: ```bash -# Install in your Strapi project -npm install strapi-plugin-magic-mark - -# Add to config/plugins.ts -# Rebuild admin -npm run build +curl -s "http://localhost:1337/magic-mark/bookmarks?contentType=api::article.article&scope=pinned" \ + -H "Authorization: Bearer $ADMIN_JWT" ``` -### 2. Test Basic Bookmark - -1. Go to Content Manager β†’ Any collection -2. Apply some filters -3. Click **MagicMark** button (see screenshot) -4. Save with name "Test Bookmark" and emoji πŸ§ͺ -5. Verify it appears in dashboard - -### 3. Test Bookmark Apply - -1. Clear all filters -2. Click **MagicMark** β†’ Select "Test Bookmark" -3. Verify filters are restored correctly - -### 4. Test Sharing - -1. Create bookmark -2. Share with specific role -3. Login as user with that role -4. Verify bookmark is visible - -### 5. Test Advanced Filters - -1. Use **Advanced Filter Builder** (see screenshot) -2. Create complex multi-condition filter -3. Save as bookmark -4. Apply and verify all conditions work - ---- - -## πŸ› Troubleshooting - -### MagicMark Button Not Appearing - -**Problem:** No MagicMark button in Content Manager toolbar (see screenshot for where it should be). - -**Solutions:** -1. Rebuild admin: `npm run build` -2. Clear browser cache (Cmd+Shift+R / Ctrl+Shift+R) -3. Check plugin is enabled in config -4. Check browser console for errors (F12) - -### Bookmarks Not Saving - -**Problem:** Click "Save Bookmark" but bookmark doesn't appear in dashboard. - -**Solutions:** -1. Check Strapi logs for errors -2. Verify license is activated -3. Check database is writable -4. Try creating simpler bookmark first - -### Bookmarks Not Applying - -**Problem:** Select bookmark but filters don't apply. - -**Solutions:** -1. Refresh page and try again -2. Check Content Manager path matches bookmark -3. Try creating new bookmark -4. Clear browser localStorage - -### Advanced Filters Not Working - -**Problem:** Complex filters from advanced builder don't apply correctly. - -**Solutions:** -1. Check filter syntax in bookmark -2. Try simpler filter first -3. Verify content type supports the filters -4. Check browser console for errors - ---- - -## πŸ› οΈ Development - -### Local Development - -```bash -# Watch mode - rebuilds on file changes -npm run watch - -# Link to local Strapi -npm run watch:link - -# Type checking -npm run test:ts:front # Frontend TypeScript -npm run test:ts:back # Backend TypeScript - -# Verify plugin -npm run verify -``` - -## 🚒 Releases - -Releases use semantic-release from the `main` branch. Use Conventional Commits so semantic-release can determine the next version. The release workflow runs `npm ci --legacy-peer-deps`, tests, the plugin build, and plugin verification before publishing to npm. - -- [GitHub releases](https://github.com/Schero94/Magicmark/releases) -- [npm package](https://www.npmjs.com/package/strapi-plugin-magic-mark) - -### Plugin Structure - -``` -magic-mark/ -β”œβ”€β”€ admin/ # React admin UI -β”‚ └── src/ -β”‚ β”œβ”€β”€ components/ -β”‚ β”‚ β”œβ”€β”€ BookmarkList.tsx -β”‚ β”‚ β”œβ”€β”€ SaveBookmarkModal.tsx -β”‚ β”‚ β”œβ”€β”€ BookmarkCard.tsx -β”‚ β”‚ └── AdvancedFilterBuilder.tsx -β”‚ β”œβ”€β”€ pages/ -β”‚ β”‚ β”œβ”€β”€ App.tsx -β”‚ β”‚ β”œβ”€β”€ HomePage.tsx (Dashboard - see screenshot) -β”‚ β”‚ └── LicensePage.tsx -β”‚ β”œβ”€β”€ hooks/ -β”‚ β”œβ”€β”€ translations/ # i18n (5 languages) -β”‚ └── index.ts -β”œβ”€β”€ server/ # Backend (TypeScript) -β”‚ └── src/ -β”‚ β”œβ”€β”€ bootstrap.ts -β”‚ β”œβ”€β”€ controllers/ -β”‚ β”œβ”€β”€ services/ -β”‚ β”œβ”€β”€ routes/ -β”‚ └── content-types/ -β”œβ”€β”€ pics/ # Screenshots -β”‚ β”œβ”€β”€ dashboard.png -β”‚ β”œβ”€β”€ saveAsMagicMark.png -β”‚ β”œβ”€β”€ advancedFilters.png -β”‚ └── quickaccesinContentmanager.png -β”œβ”€β”€ .github/workflows/ # CI/CD -β”œβ”€β”€ package.json -β”œβ”€β”€ .releaserc.json -└── README.md -``` - -### Build & Release +## Admin API and permissions + +All routes are admin routes under `/magic-mark`. Each one requires an authenticated admin +(`admin::isAuthenticatedAdmin`) and every listed action (`admin::hasPermissions`). Owner checks +run in the service: a bookmark you cannot see answers `404`, a bookmark you can see but do not +own answers `403`. + +| Permission (Settings > Roles > Plugins) | Action id | +| --------------------------------------------- | ------------------------------------- | +| Read bookmarks | `plugin::magic-mark.bookmarks.read` | +| Create bookmarks | `plugin::magic-mark.bookmarks.create` | +| Edit own bookmarks | `plugin::magic-mark.bookmarks.update` | +| Delete own bookmarks | `plugin::magic-mark.bookmarks.delete` | +| Share bookmarks | `plugin::magic-mark.bookmarks.share` | +| Manage shared bookmarks of others (see below) | `plugin::magic-mark.bookmarks.manage` | + +**Manage shared bookmarks of others** is meant for the people who keep the bookmark list tidy. +It covers every bookmark that is not private: public ones, ones shared with any role or user +(also when you are not a recipient) and ones whose owner was deleted. They are listed on the +Magic Mark page under **Show > All shared bookmarks**. Private bookmarks of other users stay +private, also for managers and Super Admins. Editing and deleting still need Edit own and +Delete own bookmarks. + +| Method and path | Required actions | Notes | +| ------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET /bookmarks` | read | Query: `page`, `pageSize` (max 100), `q`, `scope` (`all`, `mine`, `shared`, `pinned`, `managed`), `sort` (`name:asc`, `name:desc`, `updatedAt:desc`, `updatedAt:asc`), `contentType` | +| `POST /bookmarks` | read, create | Body: `name`, `path`, `query`, `description`, `icon`, `visibility`, `roles`, `users`. Sharing also needs share. | +| `GET /bookmarks/:id` | read | Owners and managers also get the sharing lists | +| `PUT /bookmarks/:id` | read, update | Owner or manage | +| `DELETE /bookmarks/:id` | read, delete | Owner or manage | +| `PUT /bookmarks/:id/view` | read, update | Replaces the saved query ("update with current view") | +| `POST /bookmarks/:id/duplicate` | read, create | Private copy owned by the caller | +| `PUT /bookmarks/:id/pin` | read | Body `{ "pinned": true }`; per user | +| `GET /sharing/roles` | share | `documentId` and name | +| `GET /sharing/users` | share | Search `q`, `page`; 20 per page; email only with `admin::users.read` | + +Errors follow Strapi's format `{ data: null, error: { status, name, message, details } }`. +Invalid input answers `400 ValidationError` with field details. + +There are no Content API routes. + +## Security and privacy + +- No license system, no outbound requests, no timers and no telemetry. On the first start the + 3.x license keys are deleted from the plugin store. +- Every route checks permissions on the server; the menu and buttons only follow them. +- `path` must be a Content Manager list view (`/content-manager/collection-types/`), so a + shared bookmark cannot send anyone to another admin page or off-site. Queries are parsed with + `qs` with prototype keys disabled, limited in size and depth, and stored without `page`. +- Request bodies are validated with strict zod schemas; unknown fields are rejected. +- Recipients never see who else a bookmark is shared with. The user search returns names only, + plus email for admins who may read admin users. +- Deleting an admin user removes their pins, the shares that point at them and their private + bookmarks. Their public or shared bookmarks stay and show "Deleted user"; someone with + **Manage shared bookmarks of others** finds them under **All shared bookmarks** and can edit or + delete them. Deleting a role removes its shares. +- Logs contain no keys, emails or request bodies. +- Several Strapi processes can share one database: the plugin keeps no state in memory, lists + are filtered and paginated in the database, and migrations claim each row with a guarded update + inside a transaction. + +## Performance and several instances + +- Lists are filtered, searched, sorted and paginated in the database (at most 100 rows per page). + Owner names, pins and share flags of a page are loaded with one query each, so a page costs + the same number of queries for 10 or 100 bookmarks. +- The plugin keeps no state in memory and starts no timers, so several replicas and workers + (for example 2 replicas x 4 pm2 workers) can share one database. Parallel requests were tested + on SQLite; a run on Postgres with several real processes is still to do. +- The 3.x migration runs on every process at start-up. Each row is claimed with a guarded update + inside a transaction, so shares and pins are written once even when processes start together. +- Pinning is idempotent: pinning twice, from two tabs or two processes, leaves the bookmark + pinned. + +## Troubleshooting + +| Symptom | Cause and fix | +| --------------------------------------------- | -------------------------------------------------------------------------------------- | +| No Magic Mark menu entry or Bookmarks button | The role lacks **Read bookmarks**. | +| Save view is missing | The role lacks **Create bookmarks**. | +| No sharing options in the dialog | The role lacks **Share bookmarks**. | +| Users are listed without email | Email is shown only to admins who can read admin users. | +| Advanced filters show only a few fields | The role can read only those fields of the content type in the Content Manager. | +| "This bookmark does not point to a list view" | The bookmark was saved by 3.x with an unusual path. Delete it and save the view again. | + +## Upgrading from 3.x + +No manual steps. On the first start after the upgrade: + +- Roles with the 3.x permission **Read** (`settings.read`) get **Read bookmarks**. Roles with + **Edit** (`settings.update`) get Read, Create, Edit own, Delete own and Share bookmarks. Strapi + then removes the old permissions. +- Bookmarks shared with users or roles move to the new share table. Shares to users that no + longer exist are dropped. Relative 3.x paths are fixed. +- A bookmark pinned in 3.x stays pinned for its owner. Pins are per user from now on. +- The custom order (move up and down) is gone; lists are sorted by name or by last update. +- The license key and its validation date are deleted. The Upgrade and License settings pages are + gone; every feature is available to everyone. +- The 3.x icon ids are kept and shown with Strapi icons. + +## FAQ + +**Is Magic Mark free?** Yes. The license system of 3.x is gone; every feature is available to +everyone under the MIT license. + +**Does the plugin send data anywhere?** No. It makes no outbound requests and has no telemetry. + +**Can I see the private bookmarks of other admins?** No, also not as Super Admin. Managers see +public and shared bookmarks of everyone under **Show > All shared bookmarks**. + +**What happens to bookmarks when an admin user is deleted?** Their private bookmarks, pins and the +shares that point at them are removed. Their public and shared bookmarks stay and show "Deleted +user" until a manager removes them. + +**Does a bookmark open on the page I saved it on?** No. The page number is not saved, so a +bookmark always opens on page 1 with its filters, sort, page size, search and locale. + +**Why do "Any condition" filters not show as filter tags?** Strapi shows only `$and` filters as +tags. The list is still filtered correctly; open Advanced filters to change or clear them. + +**Are the columns of the list view saved?** No. Columns are a per-user Content Manager setting +and stay as they are. + +## Development and tests ```bash -# Build plugin +npm ci --legacy-peer-deps +npm run lint +npm run format:check +npm test +npm run test:ts:front +npm run test:ts:back npm run build - -# Verify integrity npm run verify - -# Release (automatic via GitHub Actions) -# Just use semantic commits: -git commit -m "feat: add new feature" # β†’ MINOR version -git commit -m "fix: fix bug" # β†’ PATCH version -git commit -m "feat!: breaking change" # β†’ MAJOR version +npm run verify:runtime ``` ---- - -## πŸ“¦ NPM Release Process - -This plugin uses **semantic-release** for automated versioning. - -### Commit Message Format - -```bash -# PATCH version (bug fix) -git commit -m "fix: correct bookmark save" - -# MINOR version (new feature) -git commit -m "feat: add bookmark export" - -# MAJOR version (breaking change) -git commit -m "feat!: change API format" -``` - -### Automatic Release - -GitHub Actions automatically: -- Analyzes commits -- Bumps version -- Updates CHANGELOG -- Publishes to NPM -- Creates GitHub release - ---- - -## 🀝 Contributing - -Contributions are welcome! - -1. Fork: https://github.com/Schero94/Magicmark -2. Create branch: `git checkout -b feature/amazing` -3. Commit: `git commit -m "feat: add amazing feature"` -4. Push: `git push origin feature/amazing` -5. Open Pull Request - ---- - -## πŸ“š Resources - -- **NPM Package:** https://www.npmjs.com/package/strapi-plugin-magic-mark -- **GitHub:** https://github.com/Schero94/Magicmark -- **Issues:** https://github.com/Schero94/Magicmark/issues -- **Changelog:** [CHANGELOG.md](./CHANGELOG.md) - ---- - -## πŸ“„ License - -**MIT License** - Free for personal & commercial use - -**Copyright (c) 2025 Schero D.** - -See [LICENSE](./LICENSE) for full terms - ---- - -## 🌐 Supported Languages +`npm test` runs the Node test runner over the server services, validation, routes (with the real +`admin::hasPermissions` policy), lifecycles and migrations, the admin filter and list view +helpers, translation completeness and source rules. Releases use semantic-release from `main`; +commit messages follow Conventional Commits. -- πŸ‡¬πŸ‡§ **English** - Global standard -- πŸ‡©πŸ‡ͺ **Deutsch** - German (DACH region) -- πŸ‡«πŸ‡· **FranΓ§ais** - French -- πŸ‡ͺπŸ‡Έ **EspaΓ±ol** - Spanish (Spain & Latin America) -- πŸ‡΅πŸ‡Ή **PortuguΓͺs** - Portuguese (Brazil & Portugal) +## Contributing -Language automatically follows your Strapi admin interface setting. +Issues and pull requests are welcome at +[github.com/Schero94/Magicmark](https://github.com/Schero94/Magicmark/issues). Before you open a +pull request, run the commands from [Development and tests](#development-and-tests); CI runs the +same ones. Use Conventional Commits (`fix:`, `feat:`, `docs:`), because the release version is +derived from them. Screenshots must use synthetic data only. ---- +## Maintenance status -## πŸ™ Acknowledgments +Actively maintained. Dependabot proposes updates weekly; patch and minor updates are merged +after lint, tests, type checks, build and verify pass. Strapi peer majors are updated by hand. -Built for the Strapi community. +## Languages -Special thanks to: -- Strapi team for the amazing CMS -- Community contributors -- All users providing feedback +English, German, French, Spanish and Portuguese. ---- +## License -**Made with πŸ”– by Schero D.** +[MIT](./LICENSE) diff --git a/admin/custom.d.ts b/admin/custom.d.ts index 7aa3609..1ff429d 100644 --- a/admin/custom.d.ts +++ b/admin/custom.d.ts @@ -1,5 +1,4 @@ -// Type declarations for translations declare module '*.json' { - const content: Record; + const content: Record; export default content; } diff --git a/admin/src/components/AdvancedFilterButton.tsx b/admin/src/components/AdvancedFilterButton.tsx deleted file mode 100644 index 4a5cefb..0000000 --- a/admin/src/components/AdvancedFilterButton.tsx +++ /dev/null @@ -1,323 +0,0 @@ -// @ts-nocheck -import React, { useState, useEffect } from 'react'; -import { Filter, Cross } from '@strapi/icons'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { useFetchClient } from '@strapi/strapi/admin'; -import styled from 'styled-components'; -import StrapiStyleFilterModal from './StrapiStyleFilterModal'; -import { useLicenseInfo } from '../hooks/useFeatureGate'; - -// ================ STYLED COMPONENTS ================ -const FilterButtonGroup = styled.div` - display: flex; - align-items: center; - gap: 6px; -`; - -const FilterButton = styled.button<{ $isActive?: boolean }>` - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 36px; - padding: 0 14px; - font-size: 13px; - font-weight: 500; - border: 1px solid ${props => props.$isActive ? '#4945FF' : 'rgba(128, 128, 128, 0.25)'}; - border-radius: 4px; - background: ${props => props.$isActive - ? 'rgba(73, 69, 255, 0.08)' - : props.theme.colors.neutral0}; - color: ${props => props.$isActive ? '#4945FF' : props.theme.colors.neutral800}; - cursor: pointer; - transition: all 0.15s ease; - white-space: nowrap; - - &:hover { - background: ${props => props.$isActive ? 'rgba(73, 69, 255, 0.12)' : props.theme.colors.neutral100}; - border-color: ${props => props.$isActive ? '#4945FF' : 'rgba(128, 128, 128, 0.3)'}; - } - - &:active { - transform: scale(0.98); - } - - svg { - width: 16px; - height: 16px; - flex-shrink: 0; - } -`; - -const ClearButton = styled.button` - display: inline-flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - padding: 0; - border: 1px solid rgba(239, 68, 68, 0.3); - border-radius: 4px; - background: rgba(239, 68, 68, 0.06); - color: #dc2626; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background: rgba(239, 68, 68, 0.12); - border-color: rgba(239, 68, 68, 0.4); - } - - &:active { - transform: scale(0.98); - } - - svg { - width: 16px; - height: 16px; - } -`; - -const ActiveDot = styled.span` - width: 6px; - height: 6px; - background: #22c55e; - border-radius: 50%; - margin-left: 2px; -`; - -const AdvancedFilterButton: React.FC = () => { - const [showModal, setShowModal] = useState(false); - const navigate = useNavigate(); - const location = useLocation(); - const { get } = useFetchClient(); - const { isPremium, isAdvanced } = useLicenseInfo(); - const [availableFields, setAvailableFields] = useState>([]); - const [availableRelations, setAvailableRelations] = useState>([]); - const [hasActiveFilters, setHasActiveFilters] = useState(false); - - // Check if URL has filters - useEffect(() => { - const params = new URLSearchParams(location.search); - let hasFilters = false; - params.forEach((value, key) => { - if (key.startsWith('filters[')) { - hasFilters = true; - } - }); - setHasActiveFilters(hasFilters); - }, [location.search]); - - // Extract content type UID from URL - // /admin/content-manager/collection-types/plugin::users-permissions.user -> plugin::users-permissions.user - const extractContentTypeUid = (): string | null => { - const match = location.pathname.match(/collection-types\/([^/?]+)/); - return match ? match[1] : null; - }; - - /** - * Fetches schema for current content type and extracts fields + relations - */ - useEffect(() => { - const fetchSchema = async () => { - const uid = extractContentTypeUid(); - if (!uid) { - console.log('[AdvancedFilter] No content type UID found in URL'); - return; - } - - try { - console.log('[AdvancedFilter] Fetching schema for:', uid); - - // Step 1: Get the REAL schema from content-type-builder (has correct attribute types) - let realAttributes: Record = {}; - try { - const schemaResponse = await get(`/content-type-builder/content-types/${uid}`); - const schemaInfo = schemaResponse.data?.data?.schema || schemaResponse.data?.schema || {}; - realAttributes = schemaInfo.attributes || {}; - console.log('[AdvancedFilter] Real schema attributes:', realAttributes); - } catch (e) { - console.log('[AdvancedFilter] Content-type-builder endpoint failed:', e); - } - - // Step 2: Also get configuration for field metadata (labels, etc.) - let metadatas: Record = {}; - try { - const configResponse = await get(`/content-manager/content-types/${uid}/configuration`); - const contentTypeData = configResponse.data?.data?.contentType || configResponse.data?.contentType || {}; - metadatas = contentTypeData.metadatas || {}; - console.log('[AdvancedFilter] Configuration metadatas:', metadatas); - } catch (e) { - console.log('[AdvancedFilter] Configuration endpoint failed:', e); - } - - // Step 3: Extract fields and relations from REAL attributes - const fields: Array<{ name: string; type: string }> = []; - const relations: Array<{ name: string; target: string }> = []; - const fieldNames = new Set(); - - // Process real attributes (this has correct types!) - Object.keys(realAttributes).forEach((key) => { - const attr = realAttributes[key]; - const attrType = attr.type; - - // Skip internal fields - if (['createdBy', 'updatedBy', 'localizations', 'locale'].includes(key)) { - return; - } - - // Check if it's a relation - if (attrType === 'relation') { - relations.push({ name: key, target: attr.target }); - console.log('[AdvancedFilter] Found relation:', key, '-> target:', attr.target); - } else if (attrType === 'component' || attrType === 'dynamiczone') { - // Skip components and dynamic zones for filtering - console.log('[AdvancedFilter] Skipping component/dynamiczone:', key); - } else if (attrType === 'media') { - // Skip media for now (complex filtering) - console.log('[AdvancedFilter] Skipping media:', key); - } else if (!fieldNames.has(key)) { - // Regular field - fieldNames.add(key); - fields.push({ - name: key, - type: attrType || 'string' - }); - } - }); - - // Add default fields if not already present - ['id', 'documentId', 'createdAt', 'updatedAt'].forEach(defaultField => { - if (!fieldNames.has(defaultField)) { - let type = 'string'; - if (defaultField === 'id') type = 'integer'; - if (defaultField === 'createdAt' || defaultField === 'updatedAt') type = 'datetime'; - fields.unshift({ name: defaultField, type }); - } - }); - - setAvailableFields(fields); - setAvailableRelations(relations); - - console.log('[AdvancedFilter] Final fields:', fields); - console.log('[AdvancedFilter] Final relations:', relations); - - } catch (error) { - console.error('[AdvancedFilter] Error fetching schema:', error); - // Fallback to basic fields - setAvailableFields([ - { name: 'id', type: 'integer' }, - { name: 'documentId', type: 'string' }, - { name: 'createdAt', type: 'datetime' }, - { name: 'updatedAt', type: 'datetime' }, - ]); - } - }; - - fetchSchema(); - }, [location.pathname]); - - const handleApplyFilters = (queryString: string) => { - console.log('[AdvancedFilter] Applying filters:', queryString); - - // Get current path - const currentPath = location.pathname; - - // Merge with existing query (keep pagination, sorting if present) - const currentParams = new URLSearchParams(location.search); - const newParams = new URLSearchParams(queryString); - - // Remove old filters - const cleanParams = new URLSearchParams(); - currentParams.forEach((value, key) => { - if (!key.startsWith('filters[') && !key.startsWith('populate[')) { - cleanParams.set(key, value); - } - }); - - // Add new filters - newParams.forEach((value, key) => { - cleanParams.set(key, value); - }); - - // Reset to page 1 when applying filters - cleanParams.set('page', '1'); - - const finalUrl = `${currentPath}?${cleanParams.toString()}`; - console.log('[AdvancedFilter] Final URL:', finalUrl); - - // Navigate first, then reload to ensure Strapi CM picks up the filters - navigate(finalUrl); - - // Small delay then reload to ensure the URL is updated before reload - setTimeout(() => { - window.location.reload(); - }, 100); - }; - - const handleClearFilters = () => { - const currentPath = location.pathname; - const currentParams = new URLSearchParams(location.search); - - // Keep only non-filter params (like page, pageSize, sort) - const cleanParams = new URLSearchParams(); - currentParams.forEach((value, key) => { - if (!key.startsWith('filters[') && !key.startsWith('populate[')) { - cleanParams.set(key, value); - } - }); - - const finalUrl = `${currentPath}${cleanParams.toString() ? '?' + cleanParams.toString() : ''}`; - navigate(finalUrl); - - // Small delay then reload - setTimeout(() => { - window.location.reload(); - }, 100); - }; - - // Extract current filters from URL - const getCurrentFilters = () => { - const params = new URLSearchParams(location.search); - const currentQuery = params.toString(); - console.log('[AdvancedFilter] Current query from URL:', currentQuery); - return currentQuery; - }; - - return ( - - setShowModal(true)} - title="Open advanced filter builder" - > - - Advanced Filter - {hasActiveFilters && } - - - {hasActiveFilters && ( - - - - )} - - {showModal && ( - setShowModal(false)} - onApply={handleApplyFilters} - availableFields={availableFields} - availableRelations={availableRelations} - currentQuery={getCurrentFilters()} - enableNestedGroups={isPremium || isAdvanced} - /> - )} - - ); -}; - -export default AdvancedFilterButton; - diff --git a/admin/src/components/AdvancedFilterModal.tsx b/admin/src/components/AdvancedFilterModal.tsx deleted file mode 100644 index f7870cb..0000000 --- a/admin/src/components/AdvancedFilterModal.tsx +++ /dev/null @@ -1,449 +0,0 @@ -// @ts-nocheck -import React, { useState } from 'react'; -import { useIntl } from 'react-intl'; -import { - Box, - Button, - Flex, - TextInput, - Typography, -} from '@strapi/design-system'; -import { Cross, Plus, Trash, Search, Link as LinkIcon, Check, Stack, Lightbulb } from '@strapi/icons'; -import styled from 'styled-components'; - -const ModalOverlay = styled.div` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 999; -`; - -const ModalContent = styled(Box)` - background: ${(p) => p.theme.colors.neutral0}; - border-radius: 8px; - max-height: 90vh; - overflow: auto; - max-width: 700px; - width: 90%; -`; - -const FilterRow = styled(Flex)` - padding: 12px; - background: ${(p) => p.theme.colors.neutral100}; - border-radius: 4px; - margin-bottom: 8px; -`; - -const StyledSelect = styled.select` - width: 100%; - padding: 8px 12px; - border: 1px solid rgba(128, 128, 128, 0.25); - border-radius: 4px; - background: ${(p) => p.theme.colors.neutral0}; - font-size: 14px; - font-family: inherit; - cursor: pointer; - - &:focus { - outline: none; - border-color: #4945ff; - } - - &:disabled { - background: ${(p) => p.theme.colors.neutral100}; - cursor: not-allowed; - } -`; - -interface FilterCondition { - id: string; - field: string; - operator: string; - value: string; -} - -interface PopulateField { - name: string; - enabled: boolean; - deep: boolean; -} - -interface AdvancedFilterModalProps { - onClose: () => void; - onApply: (queryString: string) => void; - availableFields: Array<{ name: string; type: string }>; - availableRelations?: Array<{ name: string }>; - currentFilters?: FilterCondition[]; -} - -const OPERATORS = [ - { value: 'eq', label: '=' }, - { value: 'ne', label: 'β‰ ' }, - { value: 'lt', label: '<' }, - { value: 'lte', label: '≀' }, - { value: 'gt', label: '>' }, - { value: 'gte', label: 'β‰₯' }, - { value: 'contains', label: 'contains' }, - { value: 'notContains', label: 'not contains' }, - { value: 'startsWith', label: 'starts with' }, - { value: 'endsWith', label: 'ends with' }, -]; - -const AdvancedFilterModal: React.FC = ({ - onClose, - onApply, - availableFields, - availableRelations = [], - currentFilters = [], -}) => { - const { formatMessage } = useIntl(); - const [useOr, setUseOr] = useState(false); - const [filters, setFilters] = useState( - currentFilters.length > 0 - ? currentFilters - : [{ id: '1', field: '', operator: 'eq', value: '' }] - ); - const [populateFields, setPopulateFields] = useState([]); - - // Update populateFields when availableRelations changes (async fetch) - React.useEffect(() => { - if (availableRelations.length > 0) { - console.log('[AdvancedFilter] Updating populateFields with relations:', availableRelations); - setPopulateFields( - availableRelations.map(rel => ({ - name: rel.name, - enabled: false, - deep: false, - })) - ); - } - }, [availableRelations]); - - const addFilter = () => { - setFilters([ - ...filters, - { id: Date.now().toString(), field: '', operator: 'eq', value: '' }, - ]); - }; - - const removeFilter = (id: string) => { - setFilters(filters.filter(f => f.id !== id)); - }; - - const updateFilter = (id: string, key: keyof FilterCondition, value: string) => { - setFilters(filters.map(f => (f.id === id ? { ...f, [key]: value } : f))); - }; - - /** - * Cycles through populate states: OFF -> ON -> DEEP -> OFF - */ - const cyclePopulateState = (name: string) => { - setPopulateFields(populateFields.map(p => { - if (p.name !== name) return p; - - // Cycle: OFF -> ON -> DEEP -> OFF - if (!p.enabled && !p.deep) { - // OFF -> ON - return { ...p, enabled: true, deep: false }; - } else if (p.enabled && !p.deep) { - // ON -> DEEP - return { ...p, enabled: true, deep: true }; - } else { - // DEEP -> OFF - return { ...p, enabled: false, deep: false }; - } - })); - }; - - const togglePopulate = (name: string) => { - setPopulateFields(populateFields.map(p => - p.name === name ? { ...p, enabled: !p.enabled, deep: false } : p - )); - }; - - const toggleDeepPopulate = (name: string) => { - setPopulateFields(populateFields.map(p => - p.name === name ? { ...p, deep: !p.deep, enabled: true } : p - )); - }; - - const generateQueryString = (): string => { - const logic = useOr ? '$or' : '$and'; - const parts: string[] = []; - - // Add filters - filters.forEach((filter, index) => { - if (filter.field && filter.value) { - const key = `filters[${logic}][${index}][${filter.field}][$${filter.operator}]`; - parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(filter.value)}`); - } - }); - - // Add populate - populateFields.forEach(p => { - if (p.enabled) { - if (p.deep) { - // Deep populate: populate[relation][populate]=* - parts.push(`populate[${p.name}][populate]=%2A`); // %2A = * - } else { - // Simple populate - parts.push(`populate[${p.name}]=true`); - } - } - }); - - return parts.join('&'); - }; - - const handleApply = () => { - const queryString = generateQueryString(); - console.log('[AdvancedFilter] Generated query:', queryString); - onApply(queryString); - onClose(); - }; - - return ( - - e.stopPropagation()}> - {/* Header */} - - - - Advanced Filters - - - - - {/* Logic Toggle */} - - - - Filter Logic: - - - - - - - - {useOr - ? '🟠 OR: Any condition can match (at least one must be true)' - : 'πŸ”΅ AND: All conditions must match (all must be true)'} - - - - {/* Filters */} - - - Filter Conditions: - - - {filters.map((filter, index) => ( - - {/* Logic Badge */} - {index > 0 && ( - - - {useOr ? 'OR' : 'AND'} - - - )} - - {/* Field */} - - - Field - - updateFilter(filter.id, 'field', e.target.value)} - > - - {availableFields.map((field) => ( - - ))} - - - - {/* Operator */} - - - Operator - - updateFilter(filter.id, 'operator', e.target.value)} - > - {OPERATORS.map((op) => ( - - ))} - - - - {/* Value */} - - - Value - - updateFilter(filter.id, 'value', e.target.value)} - placeholder="Enter value" - /> - - - {/* Delete */} - - - ))} - - - - - {/* Population Section - Only show if there are relations */} - {populateFields.length > 0 && ( - - - - Load Related Data - - {populateFields.filter(f => f.enabled || f.deep).length} of {populateFields.length} selected - - - - {/* Relation Cards Grid */} - - {populateFields.map((field) => ( - cyclePopulateState(field.name)} - style={{ - background: field.deep - ? 'linear-gradient(135deg, #DBEAFE 0%, #BFDBFE 100%)' - : field.enabled - ? 'linear-gradient(135deg, #D1FAE5 0%, #A7F3D0 100%)' - : 'transparent', - borderRadius: '10px', - border: field.deep - ? '2px solid #3B82F6' - : field.enabled - ? '2px solid #10B981' - : '2px solid rgba(128, 128, 128, 0.2)', - cursor: 'pointer', - transition: 'all 0.2s ease', - textAlign: 'center', - }} - > - {/* Icon based on state */} - - {field.deep ? : field.enabled ? : } - - - {/* Field Name */} - - {field.name} - - - {/* Status Text */} - - {field.deep ? 'DEEP LOAD' : field.enabled ? 'LOADED' : 'Click to load'} - - - ))} - - - {/* Helper Text */} - - Click once = Load direct data | Click twice = Load all nested data | Click again = Turn off - - - )} - - {/* Preview */} - - - Generated Query: - - - {generateQueryString() || 'No filters defined'} - - - - {/* Footer */} - - - - - - - ); -}; - -export default AdvancedFilterModal; - diff --git a/admin/src/components/AdvancedFiltersModal.tsx b/admin/src/components/AdvancedFiltersModal.tsx new file mode 100644 index 0000000..9d28c28 --- /dev/null +++ b/admin/src/components/AdvancedFiltersModal.tsx @@ -0,0 +1,503 @@ +/** + * Advanced filters for a Content Manager list view. + * + * One "match" choice (all / any) and a flat list of conditions. Applying writes the filters + * into the URL with the admin's `useQueryParams`, the same way the native filters do, so the + * list refetches without a page reload. With "all" the conditions use the native `$and` + * shape and appear as native filter tags. + */ +import { + Alert, + Box, + Button, + Combobox, + ComboboxOption, + Field, + Flex, + Grid, + IconButton, + Loader, + Modal, + MultiSelect, + MultiSelectOption, + SingleSelect, + SingleSelectOption, + TextButton, + TextInput, +} from '@strapi/design-system'; +import { Plus, Trash } from '@strapi/icons'; +import { deepEncodeQueryValues, useQueryParams, withEncodedUserParams } from '@strapi/strapi/admin'; +import { useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useFilterFields } from '../hooks/useSchemas'; +import { + LIST_OPERATORS, + NO_VALUE_OPERATORS, + RANGE_OPERATORS, + type FieldOption, + type FilterRow, + type MatchMode, + buildFilters, + fitsAdminQueryDepth, + nextRowId, + operatorsForType, + parseFilters, + splitList, + validateRow, +} from '../utils/filters'; +import { msg, operatorLabel } from '../utils/translations'; + +interface AdvancedFiltersModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + uid: string; +} + +const INPUT_TYPES: Record = { + integer: 'number', + biginteger: 'text', + float: 'number', + decimal: 'number', + date: 'date', + time: 'time', + datetime: 'datetime-local', + email: 'text', +}; + +/** ISO date-time -> value for in the user's time zone. */ +const isoToLocalInput = (value: string) => { + const date = new Date(value); + if (!value.includes('T') || Number.isNaN(date.getTime())) return value; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +/** value -> ISO date-time (UTC). */ +const localInputToIso = (value: string) => { + const date = new Date(value); + return value && !Number.isNaN(date.getTime()) ? date.toISOString() : value; +}; + +const emptyRow = (): FilterRow => ({ id: nextRowId(), field: '', operator: '$eq', value: '' }); + +/** + * @param props - `open`, `onOpenChange` and the content type `uid`. + */ +export const AdvancedFiltersModal = ({ open, onOpenChange, uid }: AdvancedFiltersModalProps) => { + const { formatMessage } = useIntl(); + const [{ query }, setQuery] = useQueryParams<{ filters?: unknown; _q?: string }>(); + const { fields, isLoading, error, retry } = useFilterFields(open ? uid : null); + const fieldMap = useMemo(() => new Map(fields.map((field) => [field.path, field])), [fields]); + + // The modal is mounted when it opens, so the draft starts from the current URL filters. + const [initial] = useState(() => parseFilters(query.filters)); + const [match, setMatch] = useState(initial.match); + const [rows, setRows] = useState(() => + initial.rows.length + ? initial.rows.map((row) => ({ + ...row, + value: isoToLocalInput(row.value), + valueTo: row.valueTo && isoToLocalInput(row.valueTo), + })) + : [emptyRow()] + ); + const [unsupported, setUnsupported] = useState(initial.unsupported); + const [showErrors, setShowErrors] = useState(false); + + const updateRow = (id: string, patch: Partial) => + setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row))); + + const activeRows = rows.filter((row) => row.field || row.value); + const rowErrors = new Map(activeRows.map((row) => [row.id, validateRow(row, fieldMap)])); + const hasErrors = [...rowErrors.values()].some(Boolean); + + const apply = () => { + setShowErrors(true); + if (hasErrors) return; + const prepared = activeRows.map((row) => + fieldMap.get(row.field)?.type === 'datetime' + ? { + ...row, + value: localInputToIso(row.value), + valueTo: row.valueTo && localInputToIso(row.valueTo), + } + : row + ); + const filters = buildFilters(match, prepared); + if (filters && !fitsAdminQueryDepth(filters)) return; + setQuery( + { + ...withEncodedUserParams(query, { page: 1 }), + filters: filters ? deepEncodeQueryValues(filters) : undefined, + }, + 'push', + true + ); + onOpenChange(false); + }; + + const clearAll = () => { + setQuery({ ...withEncodedUserParams(query, { page: 1 }), filters: undefined }, 'push', true); + onOpenChange(false); + }; + + const errorText = (row: FilterRow, part: string) => { + if (!showErrors || rowErrors.get(row.id) !== part) return undefined; + if (part === 'field') return formatMessage(msg('filters.errorField', 'Choose a field.')); + if (part === 'operator') + return formatMessage(msg('filters.errorOperator', 'Choose a condition.')); + return formatMessage(msg('filters.errorValue', 'Enter a value.')); + }; + + return ( + + + + {formatMessage(msg('filters.title', 'Advanced filters'))} + + + {isLoading ? ( + + {formatMessage(msg('state.loading', 'Loading'))} + + ) : error ? ( + + {formatMessage(msg('state.retry', 'Try again'))} + + } + > + {formatMessage( + msg('filters.loadError', 'The fields of this content type could not be loaded.') + )} + + ) : ( + + {unsupported ? ( + setUnsupported(false)} + > + {formatMessage( + msg( + 'filters.unsupported', + 'The list has filters this editor cannot show. Applying replaces them.' + ) + )} + + ) : null} + + + + {formatMessage(msg('filters.match', 'Show entries that match'))} + + setMatch(value as MatchMode)} + > + + {formatMessage(msg('filters.matchAll', 'All conditions'))} + + + {formatMessage(msg('filters.matchAny', 'Any condition'))} + + + + + + + {rows.map((row, index) => ( + + updateRow(row.id, patch)} + onRemove={() => + setRows((prev) => { + const next = prev.filter((r) => r.id !== row.id); + return next.length ? next : [emptyRow()]; + }) + } + errorText={(part) => errorText(row, part)} + /> + + ))} + + + } + onClick={() => setRows((prev) => [...prev, emptyRow()])} + > + {formatMessage(msg('filters.add', 'Add condition'))} + + + + )} + + + + + + + + + + + + + ); +}; + +interface FilterRowEditorProps { + row: FilterRow; + index: number; + fields: FieldOption[]; + field?: FieldOption; + onChange: (patch: Partial) => void; + onRemove: () => void; + errorText: (part: string) => string | undefined; +} + +/** + * One condition: field, operator and value, with a remove button. + */ +const FilterRowEditor = ({ + row, + index, + fields, + field, + onChange, + onRemove, + errorText, +}: FilterRowEditorProps) => { + const { formatMessage } = useIntl(); + const operators = field ? operatorsForType(field.type, row.field.includes('.')) : []; + const needsValue = !NO_VALUE_OPERATORS.includes(row.operator); + const position = { number: index + 1 }; + + return ( + + + + {formatMessage(msg('filters.field', 'Field'))} + { + const next = fields.find((f) => f.path === value); + const ops = next ? operatorsForType(next.type, (value ?? '').includes('.')) : []; + onChange({ + field: value ?? '', + operator: ops.includes(row.operator) ? row.operator : (ops[0] ?? '$eq'), + value: '', + valueTo: undefined, + }); + }} + placeholder={formatMessage(msg('filters.fieldPlaceholder', 'Choose a field'))} + noOptionsMessage={() => formatMessage(msg('filters.noFields', 'No matching fields'))} + > + {fields.map((option) => ( + + {option.label} + + ))} + + + + + + + {formatMessage(msg('filters.operator', 'Condition'))} + onChange({ operator: String(value) })} + > + {operators.map((op) => ( + + {formatMessage(operatorLabel(op))} + + ))} + + + + + + {needsValue ? ( + + ) : null} + + + + + + + + ); +}; + +interface ValueInputProps { + row: FilterRow; + field?: FieldOption; + onChange: (patch: Partial) => void; + errorText: (part: string) => string | undefined; +} + +/** + * Value input that matches the field type and the operator. + */ +const ValueInput = ({ row, field, onChange, errorText }: ValueInputProps) => { + const { formatMessage } = useIntl(); + const valueLabel = formatMessage(msg('filters.value', 'Value')); + const isList = LIST_OPERATORS.includes(row.operator); + const isRange = RANGE_OPERATORS.includes(row.operator); + const type = field?.type ?? 'string'; + + if (type === 'boolean') { + return ( + + {valueLabel} + onChange({ value: String(value) })} + > + + {formatMessage(msg('filters.true', 'true'))} + + + {formatMessage(msg('filters.false', 'false'))} + + + + + ); + } + + if (type === 'enumeration' && field?.enum) { + return isList ? ( + + {valueLabel} + onChange({ value: values.join(', ') })} + > + {field.enum.map((option) => ( + + {option} + + ))} + + + + ) : ( + + {valueLabel} + onChange({ value: String(value) })} + > + {field.enum.map((option) => ( + + {option} + + ))} + + + + ); + } + + const inputType = isList ? 'text' : (INPUT_TYPES[type] ?? 'text'); + + if (isRange) { + return ( + + + {formatMessage(msg('filters.from', 'From'))} + ) => + onChange({ value: event.target.value }) + } + /> + + + + {formatMessage(msg('filters.to', 'To'))} + ) => + onChange({ valueTo: event.target.value }) + } + /> + + + + ); + } + + return ( + + {valueLabel} + ) => + onChange({ value: event.target.value }) + } + /> + + + + ); +}; + +export default AdvancedFiltersModal; diff --git a/admin/src/components/BookmarkActions.tsx b/admin/src/components/BookmarkActions.tsx new file mode 100644 index 0000000..058b858 --- /dev/null +++ b/admin/src/components/BookmarkActions.tsx @@ -0,0 +1,124 @@ +/** + * Row actions of a bookmark: a pin toggle and a "More actions" menu (open, edit, duplicate, + * copy link, delete). Actions the user may not perform are not rendered. + * + * In the table each part goes in its own cell (`parts`), like the single "Row actions" button + * of the native Content Manager list: a Design System table cell that holds more than one + * button calls preventDefault on every key event, including the Escape that bubbles (through + * React) from the portalled menu, so the menu would not close with Escape. + */ +import { Flex, IconButton, Menu } from '@strapi/design-system'; +import { Duplicate, ExternalLink, Link as LinkIcon, More, Pencil, Pin, Trash } from '@strapi/icons'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import type { Bookmark } from '../types'; +import { msg } from '../utils/translations'; + +/** + * The IconButton tooltip of the menu trigger stays open while the modal menu is open and covers + * the last item (Delete) when the menu flips above the button. Menu.Trigger forwards its props + * to IconButton at runtime, but its types do not list `withTooltip`, hence the spread. The + * label stays the accessible name. + */ +const TRIGGER_WITHOUT_TOOLTIP: Record = { withTooltip: false }; + +export interface BookmarkPermissions { + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; +} + +interface BookmarkActionsProps { + bookmark: Bookmark; + permissions: BookmarkPermissions; + busy: boolean; + onOpen: () => void; + onPin: () => void; + onEdit: () => void; + onDuplicate: () => void; + onCopyLink: () => void; + onDelete: () => void; + /** Which controls to render. Default: both. */ + parts?: 'all' | 'pin' | 'menu'; +} + +/** + * @param props - The bookmark, the user's permissions and the action callbacks. + */ +export const BookmarkActions = ({ + bookmark, + permissions, + busy, + onOpen, + onPin, + onEdit, + onDuplicate, + onCopyLink, + onDelete, + parts = 'all', +}: BookmarkActionsProps) => { + const { formatMessage } = useIntl(); + const name = { name: bookmark.name }; + const canEdit = permissions.canUpdate && bookmark.canEdit; + const canDelete = permissions.canDelete && bookmark.canEdit; + // Controlled like the native Content Manager row menu (DocumentActionsMenu). + const [menuOpen, setMenuOpen] = useState(false); + + return ( + + {parts !== 'menu' ? ( + + + + ) : null} + {parts !== 'pin' ? ( + + } + label={formatMessage(msg('actions.moreNamed', 'More actions for {name}'), name)} + variant="ghost" + disabled={busy} + data-bookmark-menu={bookmark.documentId} + {...TRIGGER_WITHOUT_TOOLTIP} + /> + {/* No max height, like the native row menu: all five actions stay visible. */} + + } onSelect={onOpen} disabled={!bookmark.path}> + {formatMessage(msg('actions.open', 'Open'))} + + {canEdit ? ( + } onSelect={onEdit}> + {formatMessage(msg('actions.edit', 'Edit'))} + + ) : null} + {permissions.canCreate ? ( + } onSelect={onDuplicate} disabled={!bookmark.path}> + {formatMessage(msg('actions.duplicate', 'Duplicate'))} + + ) : null} + } onSelect={onCopyLink} disabled={!bookmark.path}> + {formatMessage(msg('actions.copyLink', 'Copy link'))} + + {canDelete ? ( + } variant="danger" onSelect={onDelete}> + {formatMessage(msg('actions.delete', 'Delete'))} + + ) : null} + + + ) : null} + + ); +}; diff --git a/admin/src/components/BookmarkFormModal.tsx b/admin/src/components/BookmarkFormModal.tsx new file mode 100644 index 0000000..b9f800c --- /dev/null +++ b/admin/src/components/BookmarkFormModal.tsx @@ -0,0 +1,378 @@ +/** + * Create or edit a bookmark in a Design System modal. + * + * Create: the list view (content type, filters, sort) is captured from the Content Manager + * and shown read-only at the top, then name, description, icon and, with the share + * permission, who can see it. Edit: the same form without the capture; the saved view is + * changed with "Update with current view" in the Content Manager. + * + * Closing a changed form (Escape, Close, Cancel) asks first, like the native Content Manager + * Blocker: a ConfirmDialog whose Cancel keeps the edits. + */ +import { + Alert, + Box, + Button, + Dialog, + Field, + Flex, + Loader, + Modal, + SingleSelect, + SingleSelectOption, + TextInput, + Textarea, + Typography, +} from '@strapi/design-system'; +import { ConfirmDialog, useNotification } from '@strapi/strapi/admin'; +import { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useBookmarksApi } from '../hooks/useBookmarksApi'; +import { useContentTypeNames } from '../hooks/useSchemas'; +import type { Bookmark } from '../types'; +import { fieldErrors } from '../utils/errors'; +import { ICON_OPTIONS } from '../utils/icons'; +import { msg } from '../utils/translations'; +import { ICON_LABELS } from '../utils/iconLabels'; +import { FilterSummary } from './FilterSummary'; +import { SharingFields, type SharingValue } from './SharingFields'; + +export interface CapturedView { + path: string; + query: string; + contentType: string; + contentTypeName: string; +} + +interface BookmarkFormModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Create mode: the captured list view. */ + view?: CapturedView; + /** Edit mode: the bookmark's documentId. */ + bookmarkId?: string; + canShare: boolean; + onSaved: (bookmark: Bookmark) => void; +} + +const NAME_MAX = 100; + +/** Comparable snapshot of the editable fields, used to detect unsaved changes. */ +const snapshot = (name: string, description: string, icon: string, sharing: SharingValue) => + JSON.stringify([ + name, + description, + icon, + sharing.visibility, + [...sharing.roles].sort(), + sharing.users.map((user) => user.documentId).sort(), + ]); +const DESCRIPTION_MAX = 500; + +/** + * @param props - See BookmarkFormModalProps. + */ +export const BookmarkFormModal = ({ + open, + onOpenChange, + view, + bookmarkId, + canShare, + onSaved, +}: BookmarkFormModalProps) => { + const { formatMessage } = useIntl(); + const { toggleNotification } = useNotification(); + const api = useBookmarksApi(); + const { names } = useContentTypeNames(); + const isEdit = !!bookmarkId; + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [icon, setIcon] = useState('bookmark'); + const [sharing, setSharing] = useState({ + visibility: 'private', + roles: [], + users: [], + }); + const [loaded, setLoaded] = useState(null); + const [loading, setLoading] = useState(!!bookmarkId); + const [loadFailed, setLoadFailed] = useState(false); + const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState>({}); + // Set when the server rejects the captured list view itself (search or filter too long); + // no form field can fix that, so it is shown as an Alert above the form. + const [queryRejected, setQueryRejected] = useState(false); + const [initial, setInitial] = useState(() => + snapshot('', '', 'bookmark', { visibility: 'private', roles: [], users: [] }) + ); + const [confirmDiscard, setConfirmDiscard] = useState(false); + + // The modal is mounted when it opens; in edit mode it loads the bookmark once. + useEffect(() => { + if (!bookmarkId) return undefined; + let active = true; + api + .get(bookmarkId) + .then((bookmark) => { + if (!active) return; + const loadedSharing: SharingValue = { + visibility: bookmark.visibility, + roles: bookmark.sharing?.roles.map((role) => role.documentId) ?? [], + users: bookmark.sharing?.users ?? [], + }; + setLoaded(bookmark); + setName(bookmark.name); + setDescription(bookmark.description ?? ''); + setIcon(bookmark.icon); + setSharing(loadedSharing); + setInitial( + snapshot(bookmark.name, bookmark.description ?? '', bookmark.icon, loadedSharing) + ); + }) + .catch(() => active && setLoadFailed(true)) + .finally(() => active && setLoading(false)); + return () => { + active = false; + }; + }, [bookmarkId, api]); + + const validate = () => { + const next: Record = {}; + if (!name.trim()) next.name = formatMessage(msg('form.nameRequired', 'Enter a name.')); + else if (name.trim().length > NAME_MAX) { + next.name = formatMessage(msg('form.nameTooLong', 'Use at most {max} characters.'), { + max: NAME_MAX, + }); + } + if (description.length > DESCRIPTION_MAX) { + next.description = formatMessage( + msg('form.descriptionTooLong', 'Use at most {max} characters.'), + { + max: DESCRIPTION_MAX, + } + ); + } + if ( + canShare && + sharing.visibility === 'shared' && + !sharing.roles.length && + !sharing.users.length + ) { + next.roles = formatMessage(msg('form.targetsRequired', 'Choose at least one role or user.')); + } + setErrors(next); + return Object.keys(next).length === 0; + }; + + const submit = async () => { + if (!validate()) return; + setSaving(true); + const sharingInput = canShare + ? { + visibility: sharing.visibility, + roles: sharing.visibility === 'shared' ? sharing.roles : [], + users: sharing.visibility === 'shared' ? sharing.users.map((u) => u.documentId) : [], + } + : {}; + try { + const saved = isEdit + ? await api.update(bookmarkId as string, { + name: name.trim(), + description: description.trim(), + icon, + ...sharingInput, + }) + : await api.create({ + name: name.trim(), + description: description.trim(), + icon, + path: view?.path, + query: view?.query ?? '', + ...sharingInput, + }); + toggleNotification({ + type: 'success', + message: formatMessage( + isEdit + ? msg('notification.updated', 'Bookmark saved.') + : msg('notification.created', 'Bookmark created.') + ), + }); + onSaved(saved); + onOpenChange(false); + } catch (error) { + const fields = fieldErrors(error); + setQueryRejected(!!fields.query); + if (Object.keys(fields).length) { + setErrors({ + ...(fields.name + ? { name: formatMessage(msg('form.nameInvalid', 'Check the name.')) } + : {}), + ...(fields.roles || fields.users + ? { + roles: formatMessage( + msg('form.targetsInvalid', 'One of the roles or users no longer exists.') + ), + } + : {}), + }); + } + if (fields.query) return; + toggleNotification({ + type: 'danger', + message: formatMessage( + msg( + 'notification.saveError', + 'The bookmark could not be saved. Your changes are still in the form.' + ) + ), + }); + } finally { + setSaving(false); + } + }; + + const isDirty = !loading && !loadFailed && snapshot(name, description, icon, sharing) !== initial; + + /** Closing a changed form asks first; saving closes directly through `onOpenChange`. */ + const handleOpenChange = (next: boolean) => { + if (!next && isDirty && !saving) { + setConfirmDiscard(true); + return; + } + onOpenChange(next); + }; + + const title = isEdit + ? formatMessage(msg('form.editTitle', 'Edit bookmark')) + : formatMessage(msg('form.createTitle', 'Save view as bookmark')); + const contentType = view?.contentType ?? loaded?.contentType ?? null; + const query = view?.query ?? loaded?.query ?? ''; + + return ( + <> + + + + {title} + + + {loading ? ( + + {formatMessage(msg('state.loading', 'Loading'))} + + ) : loadFailed ? ( + + {formatMessage(msg('form.loadError', 'The bookmark could not be loaded.'))} + + ) : ( + + {queryRejected ? ( + setQueryRejected(false)} + > + {formatMessage( + msg( + 'form.queryTooLong', + 'This view cannot be saved: the search text or a filter value is too long. Shorten it in the list and try again.' + ) + )} + + ) : null} + + + + {formatMessage(msg('form.savedView', 'Saved view'))} + + + {view?.contentTypeName ?? + (contentType ? (names.get(contentType) ?? contentType) : '')} + + + + + + + {formatMessage(msg('form.name', 'Name'))} + ) => + setName(event.target.value) + } + placeholder={formatMessage( + msg('form.namePlaceholder', 'e.g. Drafts waiting for review') + )} + /> + + + + + {formatMessage(msg('form.description', 'Description'))} +