diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..c97a91b6fa5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +; top-most EditorConfig file +root = true + +; Unix-style newlines +[*] +charset = utf-8 +end_of_line = LF +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{php,html,twig}] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..097cb99cf7e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +/.yarn/** linguist-vendored +/.yarn/releases/* binary +/.yarn/plugins/**/* binary +/.pnp.* binary linguist-generated diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..7e3e86a1ccf --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +src/LazyImage @Kocal +src/Map @Kocal +src/Toolkit @Kocal +src/Translator @Kocal diff --git a/.github/ISSUE_TEMPLATE/1-bug_report.md b/.github/ISSUE_TEMPLATE/1-bug_report.md index 03391625afb..36713b354f1 100644 --- a/.github/ISSUE_TEMPLATE/1-bug_report.md +++ b/.github/ISSUE_TEMPLATE/1-bug_report.md @@ -2,7 +2,31 @@ name: '🐞 Bug Report' about: Report a bug in existing features title: '' -labels: 'bug' +labels: ['Bug'] assignees: '' --- + + + + + diff --git a/.github/ISSUE_TEMPLATE/2-feature_request.md b/.github/ISSUE_TEMPLATE/2-feature_request.md index a6e92dc1a1a..ca32d4ee663 100644 --- a/.github/ISSUE_TEMPLATE/2-feature_request.md +++ b/.github/ISSUE_TEMPLATE/2-feature_request.md @@ -2,7 +2,7 @@ name: '🚀 Feature Request' about: Suggest ideas for new features or enhancements title: '' -labels: 'RFC' +labels: ['RFC'] assignees: '' --- diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8542b440d25..c2c2fed169c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,7 @@ | ------------- | --- | Bug fix? | yes/no | New feature? | yes/no +| Docs? | yes/no | Issues | Fix #... | License | MIT @@ -13,6 +14,7 @@ Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - For new features, provide some code snippets to help understand usage. - Features and deprecations must be submitted against branch main. + - Update/add documentation as required (we can help!) - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry - Never break backward compatibility (see https://symfony.com/bc). --> diff --git a/.github/build-packages.php b/.github/build-packages.php index 65bf0c27c32..95d40759d20 100644 --- a/.github/build-packages.php +++ b/.github/build-packages.php @@ -9,12 +9,12 @@ use Symfony\Component\Finder\Finder; $finder = (new Finder()) - ->in([__DIR__.'/../src/*/', __DIR__.'/../src/*/src/Bridge/*/', __DIR__.'/../ux.symfony.com/']) + ->in([__DIR__.'/../src/*/', __DIR__.'/../src/*/src/Bridge/*/', __DIR__.'/../ux.symfony.com/', __DIR__.'/../test_apps/*/']) ->depth(0) ->name('composer.json') ; -// 1. Find all UX packages +// 1. Find all UX packages $uxPackages = []; foreach ($finder as $composerFile) { $json = file_get_contents($composerFile->getPathname()); @@ -54,7 +54,7 @@ $packageData[$key][$packageName] = '@dev'; } } - + if ($repositories) { $packageData['repositories'] = $repositories; } diff --git a/.github/generate-dist-files-size-diff.mjs b/.github/generate-dist-files-size-diff.mjs new file mode 100644 index 00000000000..6dba85c86e0 --- /dev/null +++ b/.github/generate-dist-files-size-diff.mjs @@ -0,0 +1,177 @@ +/** + * Generate a markdown table with the difference in size of the dist files between the base and the PR. + */ + +/* +Usage: +```shell +BASE_DIST_FILES='{"src/Autocomplete/assets/dist/controller.js":{"size":15382,"size_gz":3716},"src/Chartjs/assets/dist/controller.js":{"size":2281,"size_gz":771},"src/Cropperjs/assets/dist/controller.js":{"size":1044,"size_gz":475}}' \ +HEAD_DIST_FILES='{"src/Chartjs/assets/dist/controller.js":{"size":1281,"size_gz":171},"src/Cropperjs/assets/dist/controller.js":{"size":1044,"size_gz":475},"src/Cropperjs/assets/dist/style.min.css":{"size":32,"size_gz":66},"src/Dropzone/assets/dist/controller.js":{"size":3199,"size_gz":816},"src/Map/src/Bridge/Google/assets/dist/foo.js":{"size":3199,"size_gz":816}}' \ +HEAD_REPO_NAME='kocal/symfony-ux' \ +HEAD_REF='my-branch-name' \ + node .github/generate-dist-files-size-diff.mjs +``` + */ + +if (!process.env.BASE_DIST_FILES) { + throw new Error('Missing or invalid "BASE_DIST_FILES" env variable.'); +} + +if (!process.env.HEAD_DIST_FILES) { + throw new Error('Missing or invalid "HEAD_DIST_FILES" env variable.'); +} + +if (!process.env.HEAD_REPO_NAME) { + throw new Error('Missing or invalid "HEAD_REPO_NAME" env variable.'); +} + +if (!process.env.HEAD_REF) { + throw new Error('Missing or invalid "HEAD_REF" env variable.'); +} + +/** + * Adapted from https://gist.github.com/zentala/1e6f72438796d74531803cc3833c039c?permalink_comment_id=4455218#gistcomment-4455218 + * @param {number} bytes + * @param {number} digits + * @returns {string} + */ +function formatBytes(bytes, digits = 2) { + if (bytes === 0) { + return '0 B'; + } + const sizes = [`B`, 'kB', 'MB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + return parseFloat((bytes / Math.pow(1024, i)).toFixed(digits)) + ' ' + sizes[i]; +} + +/** + * @param {number} from + * @param {number} to + * @returns {number} + */ +function computeDiffPercent(from, to) { + if (from === to) { + return 0; + } + + return Math.round((from - to) / from * -100); +} + +/** + * @param {number} percent + * @returns {string} + */ +function formatDiffPercent(percent) { + return percent > 0 ? `+${percent}% 📈` : percent < 0 ? `${percent}% 📉` : `${percent}%`; +} + +export function main() { + const repoUrl = `https://github.com/${process.env.HEAD_REPO_NAME}`; + /** @type {Record} */ + const base = JSON.parse(process.env.BASE_DIST_FILES); + /** @type {Record} */ + const pr = JSON.parse(process.env.HEAD_DIST_FILES); + let output = '

📊 Packages dist files size difference

\n\n'; + + /** + * @type {Map + * }>} + */ + const packagesFiles = [...new Set([...Object.keys(pr), ...Object.keys(base)])] + .sort() + .reduce((acc, file) => { + const beforeSize = base[file]?.size || 0; + const afterSize = pr[file]?.size || 0; + const beforeSizeGz = base[file]?.size_gz || 0; + const afterSizeGz = pr[file]?.size_gz || 0; + + if (beforeSize !== afterSize) { + const isBridge = file.includes('src/Bridge'); // we assume that's enough for now + const packageName = file.split('/')[1]; + const bridgeName = isBridge ? file.split('/')[4] : ''; + const key = isBridge ? `${packageName} (Bridge ${bridgeName})` : packageName; + if (!acc.has(key)) { + acc.set(key, { + meta: { + packageName, + bridgeName, + url: isBridge ? `${repoUrl}/tree/${process.env.HEAD_REF}/src/${packageName}/src/Bridge/${bridgeName}/assets/dist` : `${repoUrl}/tree/${process.env.HEAD_REF}/src/${packageName}/assets/dist`, + }, files: new Set(), + }); + } + + const added = !base[file] && pr[file]; + const removed = base[file] && !pr[file]; + + acc.get(key).files.add({ + state: added ? 'added' : (removed ? 'removed' : 'changed'), + before: { size: beforeSize, sizeGz: beforeSizeGz }, + after: { size: afterSize, sizeGz: afterSizeGz }, + diffPercent: { + size: removed ? -100 : (added ? 100 : computeDiffPercent(beforeSize, afterSize)), + sizeGz: removed ? -100 : (added ? 100 : computeDiffPercent(beforeSizeGz, afterSizeGz)), + }, + meta: { + fileNameShort: file.replace(isBridge ? `src/${file.split('/')[1]}/src/Bridge/${file.split('/')[4]}/assets/dist/` : `src/${file.split('/')[1]}/assets/dist/`, ''), + fileNameUrl: `${repoUrl}/blob/${process.env.HEAD_REF}/${file}`, + }, + }); + } + + return acc; + }, new Map); + + if (packagesFiles.size === 0) { + output += 'â„šī¸ No difference in dist packagesFiles.\n'; + return output; + } + + output += 'Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.\n'; + output += 'Please review the changes and make sure they are expected.\n\n'; + output += ` + + `; + for (const [pkgKey, pkg] of packagesFiles.entries()) { + output += ``; + for (const file of pkg.files) { + output += ` + + `; + output += file.state === 'added' + ? `` + : ``; + output += file.state === 'removed' + ? `` + : ``; + output += ``; + } + } + output += ` +
FileBefore (Size / Gzip)After (Size / Gzip)
${pkgKey}
${file.meta.fileNameShort}Added + ${formatBytes(file.before.size)} + / ${formatBytes(file.before.sizeGz)} + Removed + ${formatBytes(file.after.size)}${file.state === 'changed' ? `${formatDiffPercent(file.diffPercent.size)}` : ''} + / ${formatBytes(file.after.sizeGz)}${file.state === 'changed' ? `${formatDiffPercent(file.diffPercent.sizeGz)}` : ''} +
+`; + + return output; +} + +if (!process.env.CI) { + console.log(main()); +} diff --git a/.github/workflows/.utils.sh b/.github/workflows/.utils.sh new file mode 100644 index 00000000000..e4f14caf089 --- /dev/null +++ b/.github/workflows/.utils.sh @@ -0,0 +1,37 @@ +_run_task() { + local ok=0 + local title="$1" + local start=$(date -u +%s) + OUTPUT=$(bash -xc "$2" 2>&1) || ok=$? + local end=$(date -u +%s) + + if [[ $ok -ne 0 ]]; then + printf "\n%-70s%10s\n" $title $(($end-$start))s + echo "$OUTPUT" + echo "Job exited with: $ok" + echo -e "\n::error::KO $title\\n" + else + printf "::group::%-68s%10s\n" $title $(($end-$start))s + echo "$OUTPUT" + echo -e "\n\\e[32mOK\\e[0m $title\\n\\n::endgroup::" + fi + + exit $ok +} +export -f _run_task + +install_property_info_for_version() { + local php_version="$1" + local min_stability="$2" + + if [ "$php_version" = "8.2" ]; then + composer require symfony/property-info:7.1.* symfony/type-info:7.2.* + elif [ "$php_version" = "8.3" ]; then + composer require symfony/property-info:7.2.* symfony/type-info:7.2.* + elif [ "$php_version" = "8.4" ] && [ "$min_stability" = "stable" ]; then + composer require symfony/property-info:7.3.* symfony/type-info:7.3.* + elif [ "$php_version" = "8.4" ] && [ "$min_stability" = "dev" ]; then + composer require symfony/property-info:>=7.3 symfony/type-info:>=7.3 + fi +} +export -f install_property_info_for_version diff --git a/.github/workflows/app-tests.yaml b/.github/workflows/app-tests.yaml new file mode 100644 index 00000000000..b139c113d31 --- /dev/null +++ b/.github/workflows/app-tests.yaml @@ -0,0 +1,103 @@ +name: App Tests + +on: + push: + paths-ignore: + - 'src/*/doc/**' + - 'src/**/*.md' + - 'ux.symfony.com/**' + pull_request: + paths-ignore: + - 'src/*/doc/**' + - 'src/**/*.md' + - 'ux.symfony.com/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + encore-app: + name: "Encore (${{ matrix.name}})" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Internal, from "vendor/" + ux-packages-source: php-vendor + - name: External, from "npm add" + ux-packages-source: js-packages + steps: + - uses: actions/checkout@v4 + + - run: npm i -g corepack && corepack enable + + - uses: actions/setup-node@v4 + with: + cache: 'yarn' + cache-dependency-path: | + yarn.lock + package.json + src/**/package.json + test_apps/encore-app/package.json + + - uses: shivammathur/setup-php@v2 + + - name: Install root dependencies + run: composer install + + - name: Build root packages + run: php .github/build-packages.php + + # We always install PHP deps because we of the UX Translator, which requires `var/translations` to exists + - name: Install App dependenies + run: composer update + working-directory: test_apps/encore-app + + - if: matrix.ux-packages-source == 'php-vendor' + name: Refresh dependencies from vendor/ + working-directory: test_apps/encore-app + run: yarn + env: + YARN_ENABLE_HARDENED_MODE: 0 + YARN_ENABLE_IMMUTABLE_INSTALLS: 0 + + - if: matrix.ux-packages-source == 'js-packages' + name: Install UX JS packages with a JS package manager + working-directory: test_apps/encore-app + run: | + PACKAGES_TO_INSTALL='' + for PACKAGE in $(cd ../..; yarn workspaces list --no-private --json); do + PACKAGE_DIR=../../$(echo $PACKAGE | jq -r '.location') + PACKAGES_TO_INSTALL="$PACKAGES_TO_INSTALL $PACKAGE_DIR" + done + echo "Installing packages: $PACKAGES_TO_INSTALL" + yarn add --dev $PACKAGES_TO_INSTALL + + - name: Ensure UX packages are installed from "${{ matrix.ux-packages-source == 'php-vendor' && 'vendor/symfony/ux-...' || '../../../src/**/assets' }}" + working-directory: test_apps/encore-app + run: | + for PACKAGE in $(cat package.json | jq -c '(.dependencies // {}) + (.devDependencies // {}) | to_entries[] | select(.key | startswith("@symfony/ux-")) | {name: .key, version: .value}'); do + PACKAGE_NAME=$(echo $PACKAGE | jq -r '.name') + PACKAGE_VERSION=$(echo $PACKAGE | jq -r '.version') + + echo -n "Checking $PACKAGE_NAME@$PACKAGE_VERSION..." + if [[ $PACKAGE_VERSION == $EXPECTED_PATTERN* ]]; then + echo " OK" + else + echo " KO" + echo "The package version of $PACKAGE_NAME must starts with the pattern (e.g.: $EXPECTED_PATTERN), got $PACKAGE_VERSION instead." + exit 1 + fi + done; + env: + EXPECTED_PATTERN: ${{ matrix.ux-packages-source == 'php-vendor' && 'file:vendor/symfony/*' || '../../src/*' }} + + - name: Run Encore (dev) + working-directory: test_apps/encore-app + run: yarn encore dev + + - name: Run Encore (prod) + working-directory: test_apps/encore-app + run: yarn encore production diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml new file mode 100644 index 00000000000..94cfde8462d --- /dev/null +++ b/.github/workflows/code-quality.yaml @@ -0,0 +1,94 @@ +name: Code Quality + +on: + push: + paths-ignore: + - 'src/*/doc/**' + - 'src/**/*.md' + - 'ux.symfony.com/**' + pull_request: + paths-ignore: + - 'src/*/doc/**' + - 'src/**/*.md' + - 'ux.symfony.com/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + coding-style-js: + name: JavaScript Coding Style + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm i -g corepack && corepack enable + - uses: actions/setup-node@v4 + with: + cache: 'yarn' + - run: yarn --immutable + - run: yarn ci + + coding-style-php: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + - run: composer install + - name: php-cs-fixer + run: ./vendor/bin/php-cs-fixer fix --dry-run --diff + + phpstan: + name: PHPStan + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: [ '8.1', '8.2', '8.3', '8.4'] + dependency-version: [''] + symfony-version: [''] + minimum-stability: ['stable'] + include: + # lowest deps + - php-version: '8.1' + dependency-version: 'lowest' + # LTS version of Symfony + - php-version: '8.1' + symfony-version: '6.4.*' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure environment + run: | + echo COLUMNS=120 >> $GITHUB_ENV + echo COMPOSER_MIN_STAB='composer config minimum-stability ${{ matrix.minimum-stability || 'stable' }} --ansi' >> $GITHUB_ENV + echo COMPOSER_UP='composer update ${{ matrix.dependency-version == 'lowest' && '--prefer-lowest' || '' }} --no-progress --no-interaction --ansi' >> $GITHUB_ENV + echo PHPUNIT_INSTALL='vendor/bin/simple-phpunit install' >> $GITHUB_ENV + echo PHPSTAN='vendor/bin/phpstan' >> $GITHUB_ENV + + # TODO: Only Turbo has PHPStan configuration, let's improve this later :) + PACKAGES=Turbo + #PACKAGES=$(find src/ -mindepth 2 -type f -name composer.json -not -path "*/vendor/*" -printf '%h\n' | sed 's/^src\///' | sort | tr '\n' ' ') + echo "Packages: $PACKAGES" + echo "PACKAGES=$PACKAGES" >> $GITHUB_ENV + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: flex + + - name: Install root dependencies + run: composer install + + - name: Build root packages + run: php .github/build-packages.php + + - name: Run PHPStan on packages + run: | + source .github/workflows/.utils.sh + + echo "$PACKAGES" | xargs -n1 | parallel -j +3 "_run_task {} '(cd src/{} && $COMPOSER_MIN_STAB && $COMPOSER_UP && $PHPUNIT_INSTALL && $PHPSTAN)'" diff --git a/.github/workflows/dist-files-size-diff-comment.yaml b/.github/workflows/dist-files-size-diff-comment.yaml new file mode 100644 index 00000000000..95134499699 --- /dev/null +++ b/.github/workflows/dist-files-size-diff-comment.yaml @@ -0,0 +1,29 @@ +name: Dist Files Size Diff (Comment) + +on: + workflow_run: + workflows: ["Dist Files Size Diff"] + types: + - completed + +jobs: + dist-files-size-diff: + runs-on: ubuntu-latest + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist-size-diff + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Read pr-number artifact to env var + id: read-pr-number + run: | + echo "pr-number=$(cat ./pr-number)" >> $GITHUB_OUTPUT + + - name: Comment on the pull request (if success) + uses: marocchino/sticky-pull-request-comment@v2 + with: + number: ${{ steps.read-pr-number.outputs.pr-number }} + path: ./diff.md diff --git a/.github/workflows/dist-files-size-diff.yaml b/.github/workflows/dist-files-size-diff.yaml new file mode 100644 index 00000000000..e7d1aed6199 --- /dev/null +++ b/.github/workflows/dist-files-size-diff.yaml @@ -0,0 +1,76 @@ +name: Dist Files Size Diff + +on: + pull_request: + types: [opened, synchronize] + paths: + - 'src/*/assets/dist/**' + - 'src/*/src/Bridge/*/assets/dist/**' + +jobs: + dist-files-size-diff: + runs-on: ubuntu-latest + steps: + - name: Configure git + run: | + git config --global user.email "" + git config --global user.name "github-action[bot]" + + - uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + + - name: Get dist files size (from base branch) + id: base-dist-files + run: | + set -e + + FILES=$(find src -mindepth 2 -type f -path '*/assets/dist/*' -not \( -path '*/tests/*' -o -path '*/public/*' -o -path '*/vendor/*' \) | sort | while read -r file; do + echo "{\"$file\": {\"size\": $(wc -c < "$file"), \"size_gz\": $(gzip -c "$file" | wc -c)}}" + done | jq -s 'add' -c) + + echo "files=$FILES" >> $GITHUB_OUTPUT + + - uses: actions/checkout@v4 + + - name: Get dist files size (from pull request) + id: pr-dist-files + run: | + set -e + + FILES=$(find src -mindepth 2 -type f -path '*/assets/dist/*' -not \( -path '*/tests/*' -o -path '*/public/*' -o -path '*/vendor/*' \) | sort | while read -r file; do + echo "{\"$file\": {\"size\": $(wc -c < "$file"), \"size_gz\": $(gzip -c "$file" | wc -c)}}" + done | jq -s 'add' -c) + + echo "files=$FILES" >> $GITHUB_OUTPUT + + - name: Generate the diff + id: diff + uses: actions/github-script@v7 + env: + BASE_DIST_FILES: ${{ steps.base-dist-files.outputs.files }} + HEAD_DIST_FILES: ${{ steps.pr-dist-files.outputs.files }} + HEAD_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + with: + result-encoding: string + script: | + const fs = require('fs') + const { main } = await import('${{ github.workspace }}/.github/generate-dist-files-size-diff.mjs') + + const diff = await main() + console.log(diff); + + fs.writeFileSync(process.env.GITHUB_WORKSPACE + '/diff.md', diff) + + - name: Save PR number + run: | + echo "${{ github.event.number }}" > pr-number + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist-size-diff + path: | + ./diff.md + ./pr-number diff --git a/.github/workflows/dist-files-unbuilt.yaml b/.github/workflows/dist-files-unbuilt.yaml new file mode 100644 index 00000000000..cd76bdaf489 --- /dev/null +++ b/.github/workflows/dist-files-unbuilt.yaml @@ -0,0 +1,42 @@ +name: Dist Files Unbuilt + +on: + push: + paths-ignore: + - 'src/*/doc/**' + - 'src/**/*.md' + - 'ux.symfony.com/**' + pull_request: + paths-ignore: + - 'src/*/doc/**' + - 'src/**/*.md' + - 'ux.symfony.com/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm i -g corepack && corepack enable + - uses: actions/setup-node@v4 + with: + cache: 'yarn' + cache-dependency-path: | + yarn.lock + **/package.json + - run: yarn --immutable && yarn build + + - name: Check if JS dist files are current + run: | + if ! git diff --quiet; then + echo "The Git workspace is unclean! Changes detected:" + git status --porcelain + git diff + exit 1 + else + echo "The Git workspace is clean. No changes detected." + fi diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml new file mode 100644 index 00000000000..c4963af0a0e --- /dev/null +++ b/.github/workflows/functional-tests.yml @@ -0,0 +1,81 @@ +name: Functional Tests + +on: + push: + paths: + - '.github/workflows/functional-tests.yml' + - 'src/Turbo/**' + pull_request: + paths: + - '.github/workflows/functional-tests.yml' + - 'src/Turbo/**' + +jobs: + turbo-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.2', '8.3', '8.4'] + dependency-version: [''] + symfony-version: [''] + minimum-stability: ['stable'] + include: + # dev packages (probably not needed to have multiple such jobs) + - minimum-stability: 'dev' + php-version: '8.4' + # lowest deps + - php-version: '8.1' + dependency-version: 'lowest' + # LTS version of Symfony + - php-version: '8.1' + symfony-version: '6.4.*' + + env: + SYMFONY_REQUIRE: ${{ matrix.symfony-version || '>=5.4' }} + services: + mercure: + image: dunglas/mercure + env: + SERVER_NAME: :3000 + MERCURE_PUBLISHER_JWT_KEY: '!ChangeMe!' + MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeMe!' + MERCURE_EXTRA_DIRECTIVES: | + anonymous + cors_origins * + ports: + - 3000:3000 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: flex + + - name: Install dependencies with composer + working-directory: src/Turbo + run: | + composer config minimum-stability ${{ matrix.minimum-stability || 'stable' }} + composer update ${{ matrix.dependency-version == 'lowest' && '--prefer-lowest' || '' }} --no-progress --no-interaction + + - name: Install JavaScript dependencies + working-directory: src/Turbo/tests/app + run: | + php public/index.php importmap:install + php public/index.php asset-map:compile + + - name: Create DB + working-directory: src/Turbo/tests/app + run: php public/index.php doctrine:schema:create + + - name: Run tests + working-directory: src/Turbo + run: | + [ 'lowest' = '${{ matrix.dependency-version }}' ] && export SYMFONY_DEPRECATIONS_HELPER=weak + vendor/bin/simple-phpunit + env: + SYMFONY_DEPRECATIONS_HELPER: 'max[self]=1' diff --git a/.github/workflows/release-on-npm.yaml b/.github/workflows/release-on-npm.yaml new file mode 100644 index 00000000000..11fc7d79f2f --- /dev/null +++ b/.github/workflows/release-on-npm.yaml @@ -0,0 +1,54 @@ +name: Release on NPM + +on: + push: + tags: + - 'v2.*.*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: 2.x + + - name: Configure Git + run: | + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + + - run: npm i -g corepack && corepack enable + - uses: actions/setup-node@v4 + with: + cache: 'yarn' + cache-dependency-path: | + yarn.lock + package.json + src/**/package.json + - run: yarn --immutable + + - name: Update version of JS packages + run: yarn workspaces foreach -pA exec "npm version ${{ env.VERSION }} --no-git-tag-version --no-workspaces-update" + + - name: Commit changes + run: | + git add . + git commit -m "Update versions to ${{ env.VERSION }}" + + - name: Replace local "workspace:*" occurrences + run: | + yarn workspaces foreach -pA exec "sed -i 's/\"workspace:\*\"/\"${{ env.VERSION }}\"/g' package.json" + + - name: Publish on NPM + env: + NPM_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} + run: yarn workspaces foreach -A --no-private npm publish --access public --tolerate-republish + + - name: Push changes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: git push origin 2.x diff --git a/.github/workflows/test-turbo.yml b/.github/workflows/test-turbo.yml deleted file mode 100644 index 9d5185e5062..00000000000 --- a/.github/workflows/test-turbo.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Symfony UX Turbo - -on: - push: - paths: - - 'src/Turbo/**' - pull_request: - paths: - - 'src/Turbo/**' - -jobs: - phpstan: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - extensions: zip - - - uses: ramsey/composer-install@v3 - with: - working-directory: src/Turbo - - - name: Install PHPUnit dependencies - working-directory: src/Turbo - run: vendor/bin/simple-phpunit --version - - - name: PHPStan - working-directory: src/Turbo - run: vendor/bin/phpstan analyse --no-progress - - tests: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php-version: ['8.1', '8.3'] - include: - - php-version: '8.1' - dependency-version: 'lowest' - - php-version: '8.3' - dependency-version: 'highest' - - services: - mercure: - image: dunglas/mercure - env: - SERVER_NAME: :3000 - MERCURE_PUBLISHER_JWT_KEY: '!ChangeMe!' - MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeMe!' - MERCURE_EXTRA_DIRECTIVES: | - anonymous - cors_origins * - ports: - - 3000:3000 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - - - name: Install Turbo packages - uses: ramsey/composer-install@v3 - with: - working-directory: src/Turbo - dependency-versions: ${{ matrix.dependency-version }} - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install JavaScript dependencies - working-directory: src/Turbo/tests/app - run: yarn install - - - name: Build JavaScript - working-directory: src/Turbo/tests/app - run: yarn build - - - name: Create DB - working-directory: src/Turbo/tests/app - run: php public/index.php doctrine:schema:create - - - name: Run tests - working-directory: src/Turbo - run: vendor/bin/simple-phpunit - env: - SYMFONY_DEPRECATIONS_HELPER: 'max[self]=1' diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index 49a8f3aa397..00000000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,147 +0,0 @@ -name: Symfony UX - -on: - push: - paths-ignore: - - 'src/*/doc/**' - - 'ux.symfony.com/**' - pull_request: - paths-ignore: - - 'src/*/doc/**' - - 'ux.symfony.com/**' - -jobs: - coding-style-js: - name: JavaScript Coding Style - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }} - restore-keys: | - ${{ runner.os }}-yarn- - - run: yarn --frozen-lockfile - - run: yarn check-lint - - run: yarn check-format - - js-dist-current: - name: Check for UnBuilt JS Dist Files - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }} - restore-keys: | - ${{ runner.os }}-yarn- - - run: yarn --frozen-lockfile && yarn build - - name: Check if js dist files are current - id: changes - run: | - echo "STATUS=$(git status --porcelain)" >> $GITHUB_OUTPUT - - - name: No changes found - if: steps.changes.outputs.STATUS == '' - run: | - echo "git status is clean" - - name: Changes were found - if: steps.changes.outputs.STATUS != '' - run: | - echo "JS dist files need to be rebuilt" - echo "${{ steps.changes.outputs.STATUS }}" - exit 1 - - tests-php-components: - runs-on: ubuntu-latest - outputs: - components: ${{ steps.components.outputs.components }} - steps: - - uses: actions/checkout@v4 - - - id: components - run: | - components=$(find src/ -mindepth 2 -type f -name composer.json -not -path "*/vendor/*" -printf '%h\n' | jq -R -s -c 'split("\n")[:-1] | map(. | sub("^src/";"")) | sort') - echo "$components" - echo "components=$components" >> $GITHUB_OUTPUT - - tests-php: - runs-on: ubuntu-latest - needs: tests-php-components - strategy: - fail-fast: false - matrix: - php-version: ['8.1', '8.3', '8.4'] - include: - - php-version: '8.1' - dependency-version: 'lowest' - - php-version: '8.3' - dependency-version: 'highest' - - php-version: '8.4' - dependency-version: 'highest' - component: ${{ fromJson(needs.tests-php-components.outputs.components )}} - exclude: - - component: Map # does not support PHP 8.1 - php-version: '8.1' - - component: Map/src/Bridge/Google # does not support PHP 8.1 - php-version: '8.1' - - component: Map/src/Bridge/Leaflet # does not support PHP 8.1 - php-version: '8.1' - - component: Swup # has no tests - - component: Turbo # has its own workflow (test-turbo.yml) - - component: Typed # has no tests - - steps: - - uses: actions/checkout@v4 - - - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - - - name: Install root packages - uses: ramsey/composer-install@v3 - with: - working-directory: ${{ github.workspace }} - dependency-versions: ${{ matrix.dependency-version }} - - - name: Build root packages - run: php .github/build-packages.php - working-directory: ${{ github.workspace }} - - - name: Install ${{ matrix.component }} packages - uses: ramsey/composer-install@v3 - with: - working-directory: "src/${{ matrix.component }}" - dependency-versions: ${{ matrix.dependency-version }} - - - name: ${{ matrix.component }} Tests - working-directory: "src/${{ matrix.component }}" - run: vendor/bin/simple-phpunit - - tests-js: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }} - restore-keys: | - ${{ runner.os }}-yarn- - - run: yarn --immutable - - run: yarn playwright install - - run: yarn test diff --git a/.github/workflows/toolkit-kits-code-quality.yaml b/.github/workflows/toolkit-kits-code-quality.yaml new file mode 100644 index 00000000000..0ed5ccfc968 --- /dev/null +++ b/.github/workflows/toolkit-kits-code-quality.yaml @@ -0,0 +1,28 @@ +name: Toolkit Kits Code Quality + +on: + push: + paths: + - 'src/Toolkit/kits/**' + pull_request: + paths: + - 'src/Toolkit/kits/**' + +jobs: + kits-cs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + + - name: Install composer packages + uses: ramsey/composer-install@v3 + with: + working-directory: src/Toolkit + + - name: Check kits code style + run: php vendor/bin/twig-cs-fixer check kits + working-directory: src/Toolkit diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml new file mode 100644 index 00000000000..2d0d8b52b95 --- /dev/null +++ b/.github/workflows/unit-tests.yaml @@ -0,0 +1,102 @@ +name: Unit Tests + +on: + push: + paths-ignore: + - 'src/*/doc/**' + - 'src/**/*.md' + - 'ux.symfony.com/**' + pull_request: + paths-ignore: + - 'src/*/doc/**' + - 'src/**/*.md' + - 'ux.symfony.com/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + php: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.2', '8.3', '8.4'] + dependency-version: [''] + symfony-version: [''] + minimum-stability: ['stable'] + include: + # dev packages (probably not needed to have multiple such jobs) + - minimum-stability: 'dev' + php-version: '8.4' + # lowest deps + - php-version: '8.1' + dependency-version: 'lowest' + # LTS version of Symfony + - php-version: '8.1' + symfony-version: '6.4.*' + + env: + SYMFONY_REQUIRE: ${{ matrix.symfony-version || '>=5.4' }} + steps: + - uses: actions/checkout@v4 + + - name: Configure environment + run: | + echo COLUMNS=120 >> $GITHUB_ENV + echo COMPOSER_MIN_STAB='composer config minimum-stability ${{ matrix.minimum-stability || 'stable' }} --ansi' >> $GITHUB_ENV + echo COMPOSER_UP='composer update ${{ matrix.dependency-version == 'lowest' && '--prefer-lowest' || '' }} --no-progress --no-interaction --ansi' >> $GITHUB_ENV + echo PHPUNIT='vendor/bin/simple-phpunit' >> $GITHUB_ENV + [ 'lowest' = '${{ matrix.dependency-version }}' ] && export SYMFONY_DEPRECATIONS_HELPER=weak + + # Swup and Typed have no tests, Turbo has its own workflow file + EXCLUDED_PACKAGES="Typed|Swup|Turbo" + + # Exclude deprecated packages when testing against lowest dependencies + if [ "${{ matrix.dependency-version }}" = "lowest" ]; then + EXCLUDED_PACKAGES="$EXCLUDED_PACKAGES|LazyImage" + fi + + PACKAGES=$(find src/ -mindepth 2 -type f -name composer.json -not -path "*/vendor/*" -printf '%h\n' | sed 's/^src\///' | grep -Ev "$EXCLUDED_PACKAGES" | sort | tr '\n' ' ') + echo "Packages: $PACKAGES" + echo "PACKAGES=$PACKAGES" >> $GITHUB_ENV + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: flex + + - name: Install root dependencies + run: composer install + + - name: Build root packages + run: php .github/build-packages.php + + - name: Run packages tests + run: | + source .github/workflows/.utils.sh + + echo "$PACKAGES" | xargs -n1 | parallel -j +3 "_run_task {} \ + '(cd src/{} \ + && $COMPOSER_MIN_STAB \ + && $COMPOSER_UP \ + && if [ {} = LiveComponent ]; then install_property_info_for_version \"${{ matrix.php-version }}\" \"${{ matrix.minimum-stability }}\"; fi \ + && $PHPUNIT)'" + + js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm i -g corepack && corepack enable + - uses: actions/setup-node@v4 + with: + cache: 'yarn' + cache-dependency-path: | + yarn.lock + package.json + src/**/package.json + - run: yarn --immutable + - run: yarn playwright install + - run: yarn test diff --git a/.github/workflows/ux.symfony.com.yaml b/.github/workflows/ux.symfony.com.yaml index 0506ec5ad4b..d9d8a65d681 100644 --- a/.github/workflows/ux.symfony.com.yaml +++ b/.github/workflows/ux.symfony.com.yaml @@ -6,12 +6,14 @@ on: - 'ux.symfony.com/**' - 'src/*/**' - '!src/*/doc/**' + - '!src/**/*.md' - '.github/**' pull_request: paths: - 'ux.symfony.com/**' - 'src/*/**' - '!src/*/doc/**' + - '!src/**/*.md' - '.github/**' jobs: diff --git a/.gitignore b/.gitignore index 13cd5131be8..509b663c317 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,18 @@ -.doctor-rst.cache -.php-cs-fixer.cache +# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored +.yarn/* +!.yarn/cache +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + node_modules yarn-error.log + +.doctor-rst.cache +.php-cs-fixer.cache +.phpunit.result.cache + /composer.lock /vendor diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index a8c7da94b14..35eb7c6c53d 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -13,14 +13,19 @@ exit(0); } -$fileHeaderComment = <<<'EOF' -This file is part of the Symfony package. +$fileHeaderParts = [ + <<<'EOF' + This file is part of the Symfony package. -(c) Fabien Potencier + (c) Fabien Potencier -For the full copyright and license information, please view the LICENSE -file that was distributed with this source code. -EOF; + EOF, + <<<'EOF' + + For the full copyright and license information, please view the LICENSE + file that was distributed with this source code. + EOF, +]; return (new PhpCsFixer\Config()) ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) @@ -28,8 +33,17 @@ '@PHPUnit75Migration:risky' => true, '@Symfony' => true, '@Symfony:risky' => true, - 'header_comment' => ['header' => $fileHeaderComment], - 'trailing_comma_in_multiline' => ['elements' => ['arrays', 'match', 'parameters']], + 'protected_to_private' => false, + 'header_comment' => [ + 'header' => implode('', $fileHeaderParts), + 'validator' => implode('', [ + '/', + preg_quote($fileHeaderParts[0], '/'), + '(?P.*)??', + preg_quote($fileHeaderParts[1], '/'), + '/s', + ]), + ], ]) ->setRiskyAllowed(true) ->setFinder( @@ -38,5 +52,7 @@ ->append([__FILE__]) ->notPath('#/Fixtures/#') ->notPath('#/var/#') + // does not work well with `fully_qualified_strict_types` rule + ->notPath('LiveComponent/tests/Integration/LiveComponentHydratorTest.php') ) ; diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 00000000000..ec300bf8a31 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,2 @@ +nodeLinker: node-modules +npmAuthToken: ${NPM_AUTH_TOKEN-} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..ad2328a08dd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +# Contributing + +Thank you for considering contributing to Symfony UX! + +Symfony UX is an open source, community-driven project, and we are happy to receive contributions from the community! + +> [!TIP] +> It's a good idea to read the [Symfony's Contribution Guide](https://symfony.com/doc/current/contributing/index.html) first, even if not all of it applies to Symfony UX and should be adapted to this project (e.g.: Symfony UX has only one base branch, `2.x`). + +## Reporting an issue + +If you either find a bug, have a feature request, or need help/have a question, please [open an issue](https://github.com/symfony/ux/issues/new/choose). + +Please provide as much information as possible, +and remember to follow our [Code of Conduct](https://symfony.com/doc/current/contributing/code_of_conduct/index.html) +as well, to ensure a friendly environment for all contributors. + +## Contributing to the code and documentation + +Thanks for your interest in contributing to Symfony UX! Here are some guidelines to help you get started. + +### Forking the repository + +To contribute to Symfony UX, you need to [fork the **symfony/ux** repository](https://github.com/symfony/ux/fork) on GitHub. +This will give you a copy of the code under your GitHub user account, read [the documentation "How to fork a repository"](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo). + +After forking the repository, you can clone it to your local machine: + +```shell +$ git clone git@github.com:/symfony-ux.git symfony-ux +$ cd symfony-ux +# Add the upstream repository, to keep your fork up-to-date +$ git remote add upstream git@github.com:symfony/ux.git +``` + +### Setting up the development environment + +To set up the development environment, you need the following tools: + +- [PHP](https://www.php.net/downloads.php) 8.1 or higher +- [Composer](https://getcomposer.org/download/) +- [Node.js](https://nodejs.org/en/download/package-manager) 22 or higher +- [Corepack](https://github.com/nodejs/corepack) +- [Yarn](https://yarnpkg.com/) 4 or higher + +With these tools installed, you can install the project dependencies: + +```shell +$ composer install +$ corepack enable && yarn install +``` + +### Linking Symfony UX packages to your project + +If you want to test your code in an existing project that uses Symfony UX packages, +you can use the `link` utility provided in this Git repository (that you have to clone). + +This tool scans the `vendor/` directory of your project, finds Symfony UX packages it uses, +and replaces them by symbolic links to the ones in the Git repository. + +```shell +$ php link /path/to/your/project +``` + +### Working with PHP code + +Symfony UX follows Symfony [PHP coding standards](https://symfony.com/doc/current/contributing/code/standards.html) +and [the Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +When contributing, please make sure to follow these standards and to write tests for your code, +runnable with `php vendor/bin/simple-phpunit`. + +### Working with assets + +Assets are specific to each Symfony UX package: + - They are located in the `assets/` directory of each package, and can be either TypeScript or CSS files, respectively compiled through Rollup and PostCSS, + - Assets are mentioned in the `package.json` file of each package, + - Assets **must be** compiled before committing changes, + - Assets **must be** compatible with the [Symfony AssetMapper](https://symfony.com/doc/current/frontend/asset_mapper.html) and [Symfony Webpack Encore](https://symfony.com/doc/current/frontend/encore/index.html). + +To help you with assets, you can run the following commands in a specific package directory (e.g., `src/Map/assets/`): + - `yarn run build`: build (compile) assets from the package, + - `yarn run watch`: watch for modifications and rebuild assets from the package, + - `yarn run test`: run the tests from the package, + - `yarn run check`: run the formatter, linter, and sort imports, and fails if any modifications + - `yarn run check --write`: run the formatter, linter, imports sorting, and write modifications + +Thanks to [Yarn Workspaces](https://yarnpkg.com/features/workspaces), you can also run these commands from the root directory of the project: + - `yarn run build`: build (compile) assets from **all** packages, + - `yarn run test`: run the tests from **all** packages, + - `yarn run check`: run the formatter, linter, and sort imports for **all** packages, and fails if any modifications + - `yarn run check --write`: run the formatter, linter, imports sorting for **all** packages, and write modifications + +### Working on documentation + +Symfony UX documentation is written in ReStructuredText (`.rst`) and is located in the `docs/` directory +of each package. + +When contributing to the documentation, please make sure to follow the Symfony +[documentation guidelines](https://symfony.com/doc/current/contributing/documentation/index.html). + +To verify your changes locally, you can use the `oskarstark/doctor-rst` Docker image. Run the following +command from the root directory of the projet: + +```shell +docker run --rm -it -e DOCS_DIR='/docs' -v ${PWD}:/docs oskarstark/doctor-rst -vvv +``` + +## Useful Git commands + +1. To keep your fork up-to-date with the upstream repository and `2.x` branch, you can run the following commands: +```shell +$ git checkout 2.x && \ + git fetch upstream && \ + git rebase upstream/2.x && \ + git push origin 2.x +``` + +2. To rebase your branch on top of the `2.x` branch, you can run the following commands: +```shell +$ git checkout my-feature-branch && \ + git rebase upstream/2.x && \ + git push -u origin my-feature-branch +``` diff --git a/README.md b/README.md index de88fa0a92d..7875ddb595f 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,7 @@ Help Symfony by [sponsoring][3] its development! ## Contributing -If you want to test your code in an existing project that uses Symfony UX packages, -you can use the `link` utility provided in this Git repository (that you have to clone). -This tool scans the `vendor/` directory of your project, finds Symfony UX packages it uses, -and replaces them by symbolic links to the ones in the Git repository. - -```shell -# Install required dependencies -$ composer install - -# And link Symfony UX packages to your project -$ php link /path/to/your/project -``` +Thank you for considering contributing to Symfony UX! You can find the [contribution guide here](CONTRIBUTING.md). [1]: https://symfony.com/backers [2]: https://mercure.rocks diff --git a/UPGRADE.md b/UPGRADE.md deleted file mode 100644 index 91e1df3aa4a..00000000000 --- a/UPGRADE.md +++ /dev/null @@ -1,21 +0,0 @@ -# UPGRADE - -## FROM 2.16 to 2.17 - -- **Live Components**: Change `defer` attribute to `loading="defer"` #1515. - -## FROM 2.15 to 2.16 - -- **Live Components**: Change `data-action-name` attribute to `data-live-action-param` - and change action arguments to be passed as individual `data-live-` attributes #1418. - See [LiveComponents CHANGELOG](https://github.com/symfony/ux/blob/2.x/src/LiveComponent/CHANGELOG.md#2160) - for more details. - -- **Live Components**: Remove the `|prevent` modifier and place it on the `data-action` - instead - e.g. `data-action="live#action:prevent"`. - -- **Live Components**: Change `data-event` attributes to `data-live-event-param` #1418. - -## FROM 2.14 to 2.15 - -- **Live Components**: Change `data-live-id` attributes to `id` #1484. diff --git a/bin/build_javascript.js b/bin/build_javascript.js deleted file mode 100644 index 7f17622392c..00000000000 --- a/bin/build_javascript.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * This file is used to compile the TypeScript files in the assets/src directory - * of each package. - * - * It allows each package to spawn its own rollup process, which is necessary - * to keep memory usage down. - */ -const { spawnSync } = require('child_process'); -const glob = require('glob'); - -const files = [ - // custom handling for React - 'src/React/assets/src/loader.ts', - 'src/React/assets/src/components.ts', - // custom handling for Svelte - 'src/Svelte/assets/src/loader.ts', - 'src/Svelte/assets/src/components.ts', - // custom handling for Vue - 'src/Vue/assets/src/loader.ts', - 'src/Vue/assets/src/components.ts', - // custom handling for StimulusBundle - 'src/StimulusBundle/assets/src/loader.ts', - 'src/StimulusBundle/assets/src/controllers.ts', - // custom handling for Bridge - ...glob.sync('src/*/src/Bridge/*/assets/src/*controller.ts'), - ...glob.sync('src/*/assets/src/*controller.ts'), -]; - -files.forEach((file) => { - const result = spawnSync('node', [ - 'node_modules/.bin/rollup', - '-c', - '--environment', - `INPUT_FILE:${file}`, - ], { - stdio: 'inherit', - shell: true - }); - - if (result.error) { - console.error(`Error compiling ${file}:`, result.error); - } -}); diff --git a/bin/build_package.js b/bin/build_package.js new file mode 100644 index 00000000000..ed83b079a82 --- /dev/null +++ b/bin/build_package.js @@ -0,0 +1,133 @@ +/** + * This file is used to compile the assets from an UX package. + */ + +const { parseArgs } = require('node:util'); +const path = require('node:path'); +const fs = require('node:fs'); +const glob = require('glob'); +const rollup = require('rollup'); +const LightningCSS = require('lightningcss'); +const { getRollupConfiguration } = require('./rollup'); + +const args = parseArgs({ + allowPositionals: true, + options: { + watch: { + type: 'boolean', + description: 'Watch the source files for changes and rebuild when necessary.', + }, + }, +}); + +async function main() { + const packageRoot = path.resolve(process.cwd(), args.positionals[0]); + + if (!fs.existsSync(packageRoot)) { + console.error(`The package directory "${packageRoot}" does not exist.`); + process.exit(1); + } + + if (!fs.existsSync(path.join(packageRoot, 'package.json'))) { + console.error(`The package directory "${packageRoot}" does not contain a package.json file.`); + process.exit(1); + } + + const packageData = require(path.join(packageRoot, 'package.json')); + const packageName = packageData.name; + const srcDir = path.join(packageRoot, 'src'); + const distDir = path.join(packageRoot, 'dist'); + + if (!fs.existsSync(srcDir)) { + console.error(`The package directory "${packageRoot}" does not contain a "src" directory.`); + process.exit(1); + } + + if (fs.existsSync(distDir)) { + console.log(`Cleaning up the "${distDir}" directory...`); + await fs.promises.rm(distDir, { recursive: true }); + await fs.promises.mkdir(distDir); + } + + const inputScriptFiles = [ + ...glob.sync(path.join(srcDir, '*controller.ts')), + ...(['@symfony/ux-react', '@symfony/ux-vue', '@symfony/ux-svelte'].includes(packageName) + ? [path.join(srcDir, 'loader.ts'), path.join(srcDir, 'components.ts')] + : []), + ...(packageName === '@symfony/stimulus-bundle' + ? [path.join(srcDir, 'loader.ts'), path.join(srcDir, 'controllers.ts')] + : []), + ]; + + const inputStyleFile = packageData.config?.css_source; + const buildCss = async () => { + const inputStyleFileDist = inputStyleFile + ? path.resolve(distDir, `${path.basename(inputStyleFile, '.css')}.min.css`) + : undefined; + if (!inputStyleFile) { + return; + } + + console.log('Minifying CSS...'); + const css = await fs.promises.readFile(inputStyleFile, 'utf-8'); + const { code: minified } = LightningCSS.transform({ + filename: path.basename(inputStyleFile, '.css'), + code: Buffer.from(css), + minify: true, + sourceMap: false, // TODO: Maybe we can add source maps later? :) + }); + await fs.promises.writeFile(inputStyleFileDist, minified); + }; + + if (inputScriptFiles.length === 0) { + console.error( + `No input files found for package "${packageName}" (directory "${packageRoot}").\nEnsure you have at least a file matching the pattern "src/*_controller.ts", or manually specify input files in "${__filename}" file.` + ); + process.exit(1); + } + + const rollupConfig = getRollupConfiguration({ packageRoot, inputFiles: inputScriptFiles }); + + if (args.values.watch) { + console.log( + `Watching for JavaScript${inputStyleFile ? ' and CSS' : ''} files modifications in "${srcDir}" directory...` + ); + + if (inputStyleFile) { + rollupConfig.plugins = (rollupConfig.plugins || []).concat({ + name: 'watcher', + buildStart() { + this.addWatchFile(inputStyleFile); + }, + }); + } + + const watcher = rollup.watch(rollupConfig); + watcher.on('event', ({ result }) => { + if (result) { + result.close(); + } + }); + watcher.on('change', async (id, { event }) => { + if (event === 'update') { + console.log('Files were modified, rebuilding...'); + } + + if (inputStyleFile && id === inputStyleFile) { + await buildCss(); + } + }); + } else { + console.log(`Building JavaScript files from ${packageName} package...`); + const start = Date.now(); + + const bundle = await rollup.rollup(rollupConfig); + await bundle.write(rollupConfig.output); + + await buildCss(); + + console.log(`Done in ${((Date.now() - start) / 1000).toFixed(3)} seconds.`); + } +} + +main(); diff --git a/bin/build_styles.js b/bin/build_styles.js deleted file mode 100644 index ad45bb34b34..00000000000 --- a/bin/build_styles.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Script to "build" the source CSS files to their final destination. - */ - -const glob = require('glob'); -const { exec } = require('child_process'); -const path = require('path'); -const fs = require('fs'); - -let pattern = 'src/*/assets/package.json'; - -// Use glob to find all matching package.json files -glob(pattern, function (err, files) { - if (err) { - console.error('Error while finding package.json files:', err); - process.exit(1); - } - - // Loop over all files - files.forEach(file => { - // Read the package.json file - const pkg = JSON.parse(fs.readFileSync(file, 'utf-8')); - - // Get the css source - const cssSourceRelative = pkg.config && pkg.config.css_source; - - if (!cssSourceRelative) { - return; - } - // Construct the output path - const cssSource = path.join(path.dirname(file), cssSourceRelative); - const outputDir = path.join(path.dirname(file), 'dist'); - const outputFilename = path.basename(cssSource, '.css') + '.min.css'; - const output = path.join(outputDir, outputFilename); - - // Run the clean-css-cli command - exec(`yarn run cleancss -o ${output} ${cssSource}`, function (err) { - if (err) { - console.error(`Error while minifying ${cssSource}:`, err); - return; - } - - console.log(`Minified ${cssSource} to ${output}`); - }); - }); -}); diff --git a/bin/rollup.js b/bin/rollup.js new file mode 100644 index 00000000000..e002bd414b4 --- /dev/null +++ b/bin/rollup.js @@ -0,0 +1,135 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const resolve = require('@rollup/plugin-node-resolve'); +const commonjs = require('@rollup/plugin-commonjs'); +const typescript = require('@rollup/plugin-typescript'); +const glob = require('glob'); + +/** + * Guarantees that any files imported from a peer dependency are treated as an external. + * + * For example, if we import `chart.js/auto`, that would not normally + * match the "chart.js" we pass to the "externals" config. This plugin + * catches that case and adds it as an external. + * + * Inspired by https://github.com/oat-sa/rollup-plugin-wildcard-external + */ +const wildcardExternalsPlugin = (peerDependencies) => ({ + name: 'wildcard-externals', + resolveId(source, importer) { + if (importer) { + let matchesExternal = false; + peerDependencies.forEach((peerDependency) => { + if (source.includes(`/${peerDependency}/`)) { + matchesExternal = true; + } + }); + + if (matchesExternal) { + return { + id: source, + external: true, + moduleSideEffects: true, + }; + } + } + + return null; // other ids should be handled as usually + }, +}); + +/** + * Moves the generated TypeScript declaration files to the correct location. + * + * This could probably be configured in the TypeScript plugin. + */ +const moveTypescriptDeclarationsPlugin = (packageRoot) => ({ + name: 'move-ts-declarations', + writeBundle: async () => { + const isBridge = packageRoot.includes('src/Bridge'); + const globPattern = path.join('dist', '**', 'assets', 'src', '**/*.d.ts'); + const files = glob.sync(globPattern); + + files.forEach((file) => { + const relativePath = file; + // a bit odd, but remove first 7 or 4 directories, which will leave only the relative path to the file + // ex: dist/Chartjs/assets/src/controller.d.ts' => 'dist/controller.d.ts' + const targetFile = relativePath.replace( + `${relativePath + .split('/') + .slice(1, isBridge ? 7 : 4) + .join('/')}/`, + '' + ); + if (!fs.existsSync(path.dirname(targetFile))) { + fs.mkdirSync(path.dirname(targetFile), { recursive: true }); + } + fs.renameSync(file, targetFile); + }); + }, +}); + +/** + * @param {String} packageRoot + * @param {Array} inputFiles + */ +function getRollupConfiguration({ packageRoot, inputFiles }) { + const packagePath = path.join(packageRoot, 'package.json'); + const packageData = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + const peerDependencies = [ + '@hotwired/stimulus', + ...(packageData.peerDependencies ? Object.keys(packageData.peerDependencies) : []), + ]; + + inputFiles.forEach((file) => { + // custom handling for StimulusBundle + if (file.includes('StimulusBundle/assets/src/loader.ts')) { + peerDependencies.push('./controllers.js'); + } + + // React, Vue + if (file.includes('assets/src/loader.ts')) { + peerDependencies.push('./components.js'); + } + }); + + const outDir = path.join(packageRoot, 'dist'); + + return { + input: inputFiles, + output: { + dir: outDir, + entryFileNames: '[name].js', + format: 'esm', + }, + external: peerDependencies, + plugins: [ + resolve(), + typescript({ + filterRoot: '.', + tsconfig: path.join(__dirname, '..', 'tsconfig.json'), + noEmitOnError: true, + include: [ + 'src/**/*.ts', + // TODO: Remove for the next major release + // "@rollup/plugin-typescript" v11.0.0 fixed an issue (https://github.com/rollup/plugins/pull/1310) that + // cause a breaking change for UX React users, the dist file requires "react-dom/client" instead of "react-dom" + // and it will break for users using the Symfony AssetMapper without Symfony Flex (for automatic "importmap.php" upgrade). + '**/node_modules/react-dom/client.js', + ], + compilerOptions: { + outDir: outDir, + declaration: true, + emitDeclarationOnly: true, + }, + }), + commonjs(), + wildcardExternalsPlugin(peerDependencies), + moveTypescriptDeclarationsPlugin(packageRoot), + ], + }; +} + +module.exports = { + getRollupConfiguration, +}; diff --git a/bin/run-vitest-all.sh b/bin/test_package.sh similarity index 50% rename from bin/run-vitest-all.sh rename to bin/test_package.sh index da25aadd713..d5fc420aa6e 100755 --- a/bin/run-vitest-all.sh +++ b/bin/test_package.sh @@ -1,8 +1,20 @@ #!/bin/bash +# This script is used to test an UX package. +# It also handle the case where a package has multiple versions of a peerDependency defined. + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +PROJECT_DIR=$(dirname "$SCRIPT_DIR") + # Flag to track if any test fails all_tests_passed=true +# Check if we have at least one argument +if [ $# -eq 0 ] + then + echo "No arguments supplied, please provide the package's path." +fi + # Check if jq is installed if ! command -v jq &> /dev/null; then echo "jq is required but not installed. Aborting." @@ -11,16 +23,44 @@ fi runTestSuite() { echo -e "Running tests for $workspace...\n" - yarn workspace $workspace run vitest --run || { all_tests_passed=false; } + yarn run -T vitest --run || { all_tests_passed=false; } } processWorkspace() { - local workspace="$1" - local location="$2" + local location="$1" - echo -e "Processing workspace $workspace at location $location...\n" + if [ ! -d "$location" ]; then + echo "No directory found at $location" + return + fi package_json_path="$location/package.json" + if [ ! -f "$package_json_path" ]; then + echo "No package.json found at $package_json_path" + return + fi + + workspace=$(jq -r '.name' "$package_json_path") + if [ -z "$workspace" ]; then + echo "No name found in package.json at $package_json_path" + return + fi + + echo -e "Processing workspace $workspace at location $location...\n" + + echo "Checking '$package_json_path' for peerDependencies and importmap dependencies to have the same version" + deps=$(jq -r '.peerDependencies | keys[]' "$package_json_path") + for library in $deps; do + version=$(jq -r ".peerDependencies.\"$library\"" "$package_json_path") + importmap_version=$(jq -r ".symfony.importmap.\"$library\"" "$package_json_path") + + if [ "$version" != "$importmap_version" ]; then + echo " -> Version mismatch for $library: $version (peerDependencies) vs $importmap_version (importmap)" + echo " -> You need to match the version of the \"peerDependency\" with the version in the \"importmap\"" + exit + fi + done + echo "Checking '$package_json_path' for peerDependencies with multiple versions defined" deps_with_multiple_versions=$(jq -r '.peerDependencies | to_entries[] | select(.value | contains("||")) | .key' "$package_json_path") @@ -41,6 +81,9 @@ processWorkspace() { runTestSuite fi done + + echo " -> Reverting version changes for $library" + yarn workspace "$workspace" add "$library@$versionValue" --peer done else echo -e " -> No peerDependencies found with multiple versions defined\n" @@ -48,19 +91,7 @@ processWorkspace() { fi } -# Get all workspace names -workspaces_info=$(yarn -s workspaces info) - -# Iterate over each workspace using process substitution -while IFS= read -r workspace_info; do - # Split the workspace_info into workspace and location - workspace=$(echo "$workspace_info" | awk '{print $1}') - location=$(echo "$workspace_info" | awk '{print $2}') - - # Call the function to process the workspace - processWorkspace "$workspace" "$location" - -done < <(echo "$workspaces_info" | jq -r 'to_entries[0:] | .[] | "\(.key) \(.value.location)"') +processWorkspace "$(realpath "$PWD/$1")" # Check the flag at the end and exit with code 1 if any test failed if [ "$all_tests_passed" = false ]; then diff --git a/biome.json b/biome.json index bc898bb0845..36d42169ba6 100644 --- a/biome.json +++ b/biome.json @@ -4,6 +4,8 @@ "include": [ "*.json", "*.md", + "bin/*.js", + "test/*.js", "src/*/*.json", "src/*/*/md", "src/*/assets/src/**", @@ -13,7 +15,7 @@ "src/*/src/Bridge/*/assets/src/**", "src/*/src/Bridge/*/assets/test/**" ], - "ignore": ["**/composer.json", "**/vendor", "**/node_modules"] + "ignore": ["**/composer.json", "**/vendor", "**/package.json", "**/node_modules", "**/var"] }, "linter": { "rules": { diff --git a/composer.json b/composer.json index 271f27ec4b1..4a24cfc1386 100644 --- a/composer.json +++ b/composer.json @@ -5,8 +5,9 @@ "dev" ], "require-dev": { - "symfony/filesystem": "^5.2|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "php": ">=8.1", + "symfony/filesystem": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", "php-cs-fixer/shim": "^3.62" } } diff --git a/package.json b/package.json index b33d28bf9a0..a01058a2c74 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "private": true, - "workspaces": ["src/*/assets", "src/*/src/Bridge/*/assets"], + "packageManager": "yarn@4.5.0", + "workspaces": [ + "src/*/assets", + "src/*/src/Bridge/*/assets" + ], "scripts": { - "build": "node bin/build_javascript.js && node bin/build_styles.js", - "test": "bin/run-vitest-all.sh", - "lint": "biome lint --write", - "format": "biome format --write", - "check-lint": "biome lint", - "check-format": "biome format" + "build": "yarn workspaces foreach -Ap --topological-dev run build", + "test": "yarn workspaces foreach -Ap --topological-dev run test", + "check": "biome check", + "ci": "biome ci" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -15,16 +17,25 @@ "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", "@biomejs/biome": "^1.8.3", - "@rollup/plugin-commonjs": "^26.0.1", - "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-commonjs": "^28.0.0", + "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-typescript": "^11.1.6", "@symfony/stimulus-testing": "^2.0.1", - "@vitest/browser": "^2.0.5", - "clean-css-cli": "^5.6.2", + "@vitest/browser": "^2.1.1", + "lightningcss": "^1.28.2", "playwright": "^1.47.0", - "rollup": "^4.21.0", + "rollup": "^4.22.5", "tslib": "^2.6.3", "typescript": "^5.5.4", - "vitest": "^2.0.5" - } + "vitest": "^2.1.1" + }, + "resolutions": { + "@swup/plugin/@swup/prettier-config": "link:node_modules/.cache/null", + "@swup/plugin/@swup/browserslist-config": "link:node_modules/.cache/null", + "@swup/plugin/microbundle": "link:node_modules/.cache/null", + "@swup/plugin/prettier": "link:node_modules/.cache/null", + "@swup/plugin/shelljs": "link:node_modules/.cache/null", + "@swup/plugin/shelljs-live": "link:node_modules/.cache/null" + }, + "version": "2.26.1" } diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index 7ab3b404f8b..00000000000 --- a/rollup.config.js +++ /dev/null @@ -1,115 +0,0 @@ -const resolve = require('@rollup/plugin-node-resolve'); -const commonjs = require('@rollup/plugin-commonjs'); -const typescript = require('@rollup/plugin-typescript'); -const fs = require('fs'); -const glob = require('glob'); -const path = require('path'); - -/** - * Guarantees that any files imported from a peer dependency are treated as an external. - * - * For example, if we import `chart.js/auto`, that would not normally - * match the "chart.js" we pass to the "externals" config. This plugin - * catches that case and adds it as an external. - * - * Inspired by https://github.com/oat-sa/rollup-plugin-wildcard-external - */ -const wildcardExternalsPlugin = (peerDependencies) => ({ - name: 'wildcard-externals', - resolveId(source, importer) { - if (importer) { - let matchesExternal = false; - peerDependencies.forEach((peerDependency) => { - if (source.includes(`/${peerDependency}/`)) { - matchesExternal = true; - } - }); - - if (matchesExternal) { - return { - id: source, - external: true, - moduleSideEffects: true - }; - } - } - - return null; // other ids should be handled as usually - } -}); - -/** - * Moves the generated TypeScript declaration files to the correct location. - * - * This could probably be configured in the TypeScript plugin. - */ -const moveTypescriptDeclarationsPlugin = (packagePath) => ({ - name: 'move-ts-declarations', - writeBundle: async () => { - const isBridge = packagePath.includes('src/Bridge'); - const globPattern = isBridge - ? path.join(packagePath, 'dist', packagePath.replace(/^src\//, ''), '**/*.d.ts') - : path.join(packagePath, 'dist', '*', 'assets', 'src', '**/*.d.ts') - const files = glob.sync(globPattern); - files.forEach((file) => { - // a bit odd, but remove first 7 or 13 directories, which will leave - // only the relative path to the file - const relativePath = file.split('/').slice(isBridge ? 13 : 7).join('/'); - - const targetFile = path.join(packagePath, 'dist', relativePath); - if (!fs.existsSync(path.dirname(targetFile))) { - fs.mkdirSync(path.dirname(targetFile), { recursive: true }); - } - fs.renameSync(file, targetFile); - }); - } -}); - -const file = process.env.INPUT_FILE; -const packageRoot = path.join(file, '..', '..'); -const packagePath = path.join(packageRoot, 'package.json'); -const packageData = JSON.parse(fs.readFileSync(packagePath, 'utf8')); -const peerDependencies = [ - '@hotwired/stimulus', - ...(packageData.peerDependencies ? Object.keys(packageData.peerDependencies) : []) -]; - -// custom handling for StimulusBundle -if (file.includes('StimulusBundle/assets/src/loader.ts')) { - peerDependencies.push('./controllers.js'); -} -// React, Vue -if (file.includes('assets/src/loader.ts')) { - peerDependencies.push('./components.js'); -} - -module.exports = { - input: file, - output: { - file: path.join(packageRoot, 'dist', path.basename(file, '.ts') + '.js'), - format: 'esm', - }, - external: peerDependencies, - plugins: [ - resolve(), - typescript({ - filterRoot: packageRoot, - include: [ - 'src/**/*.ts', - // TODO: Remove for the next major release - // "@rollup/plugin-typescript" v11.0.0 fixed an issue (https://github.com/rollup/plugins/pull/1310) that - // cause a breaking change for UX React users, the dist file requires "react-dom/client" instead of "react-dom" - // and it will break for users using the Symfony AssetMapper without Symfony Flex (for automatic "importmap.php" upgrade). - '**/node_modules/react-dom/client.js' - ], - compilerOptions: { - outDir: '.', - declaration: true, - emitDeclarationOnly: true, - } - }), - commonjs(), - wildcardExternalsPlugin(peerDependencies), - moveTypescriptDeclarationsPlugin(packageRoot), - ], -}; diff --git a/src/Autocomplete/.gitattributes b/src/Autocomplete/.gitattributes index 2b1d42ea804..b9bb8f6e796 100644 --- a/src/Autocomplete/.gitattributes +++ b/src/Autocomplete/.gitattributes @@ -1,7 +1,7 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /assets/src export-ignore /assets/test export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/Autocomplete/.github/PULL_REQUEST_TEMPLATE.md b/src/Autocomplete/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Autocomplete/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Autocomplete/.github/workflows/close-pull-request.yml b/src/Autocomplete/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Autocomplete/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Autocomplete/.gitignore b/src/Autocomplete/.gitignore index 854217846fe..1784dd6561c 100644 --- a/src/Autocomplete/.gitignore +++ b/src/Autocomplete/.gitignore @@ -1,5 +1,7 @@ +/assets/node_modules/ +/vendor/ /composer.lock /phpunit.xml -/vendor/ -/var/ /.phpunit.result.cache + +/var diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md index b869da8e420..03022e74226 100644 --- a/src/Autocomplete/CHANGELOG.md +++ b/src/Autocomplete/CHANGELOG.md @@ -1,5 +1,24 @@ # CHANGELOG +## 2.25.0 + +- Escape `querySelector` dynamic selector with `CSS.escape()` #2663 + +## 2.23.0 + +- Deprecate `ExtraLazyChoiceLoader` in favor of `Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader` +- Reset TomSelect when updating url attribute #1505 +- Add `getAttributes()` method to define additional attributes for autocomplete results #2541 + +## 2.22.0 + +- Take `labelField` TomSelect option into account #2382 + +## 2.21.0 + +- Translate the `option_create` option from TomSelect with remote data setup #2279 +- Add one missing Dutch translation #2279 + ## 2.20.0 - Translate the `option_create` option from TomSelect #2108 diff --git a/src/Autocomplete/assets/LICENSE b/src/Autocomplete/assets/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/Autocomplete/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Autocomplete/assets/README.md b/src/Autocomplete/assets/README.md new file mode 100644 index 00000000000..95ab65ce9cf --- /dev/null +++ b/src/Autocomplete/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-autocomplete + +JavaScript assets of the [symfony/ux-autocomplete](https://packagist.org/packages/symfony/ux-autocomplete) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-autocomplete](https://packagist.org/packages/symfony/ux-autocomplete) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-autocomplete](https://packagist.org/packages/symfony/ux-autocomplete) PHP package version: +```shell +composer require symfony/ux-autocomplete:2.23.0 +npm add @symfony/ux-autocomplete@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-autocomplete/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Autocomplete/assets/dist/controller.d.ts b/src/Autocomplete/assets/dist/controller.d.ts index 59938a721c6..d5cc6b3e272 100644 --- a/src/Autocomplete/assets/dist/controller.d.ts +++ b/src/Autocomplete/assets/dist/controller.d.ts @@ -40,6 +40,7 @@ export default class extends Controller { connect(): void; initializeTomSelect(): void; disconnect(): void; + urlValueChanged(): void; private getMaxOptions; get selectElement(): HTMLSelectElement | null; get formElement(): HTMLInputElement | HTMLSelectElement; diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index c6e9660b1d6..b9712824880 100644 --- a/src/Autocomplete/assets/dist/controller.js +++ b/src/Autocomplete/assets/dist/controller.js @@ -1,32 +1,32 @@ import { Controller } from '@hotwired/stimulus'; import TomSelect from 'tom-select'; -/****************************************************************************** -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -***************************************************************************** */ -/* global Reflect, Promise, SuppressedError, Symbol */ - - -function __classPrivateFieldGet(receiver, state, kind, f) { - if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); - if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); - return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); -} - -typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { - var e = new Error(message); - return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; +/****************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ +/* global Reflect, Promise, SuppressedError, Symbol */ + + +function __classPrivateFieldGet(receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); +} + +typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { + var e = new Error(message); + return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; var _default_1_instances, _default_1_getCommonConfig, _default_1_createAutocomplete, _default_1_createAutocompleteWithHtmlContents, _default_1_createAutocompleteWithRemoteData, _default_1_stripTags, _default_1_mergeObjects, _default_1_createTomSelect; @@ -91,6 +91,9 @@ class default_1 extends Controller { } } } + urlValueChanged() { + this.resetTomSelect(); + } getMaxOptions() { return this.selectElement ? this.selectElement.options.length : 50; } @@ -131,7 +134,6 @@ class default_1 extends Controller { this.element.innerHTML = currentHtml; this.initializeTomSelect(); this.tomSelect.setValue(currentValue); - this.startMutationObserver(); } } changeTomSelectDisabledState(isDisabled) { @@ -195,11 +197,9 @@ class default_1 extends Controller { } createOptionsDataStructure(selectElement) { return Array.from(selectElement.options).map((option) => { - const optgroup = option.closest('optgroup'); return { value: option.value, text: option.text, - group: optgroup ? optgroup.label : null, }; }); } @@ -216,7 +216,7 @@ class default_1 extends Controller { if (filteredOriginalOptions.length !== filteredNewOptions.length) { return false; } - const normalizeOption = (option) => `${option.value}-${option.text}-${option.group}`; + const normalizeOption = (option) => `${option.value}-${option.text}`; const originalOptionsSet = new Set(filteredOriginalOptions.map(normalizeOption)); const newOptionsSet = new Set(filteredNewOptions.map(normalizeOption)); return (originalOptionsSet.size === newOptionsSet.size && @@ -250,6 +250,40 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def this.tomSelect.setTextboxValue(''); }, closeAfterSelect: true, + onOptionAdd: (value, data) => { + let parentElement = this.tomSelect.input; + let optgroupData = null; + const optgroup = data[this.tomSelect.settings.optgroupField]; + if (optgroup && this.tomSelect.optgroups) { + optgroupData = this.tomSelect.optgroups[optgroup]; + if (optgroupData) { + const optgroupElement = parentElement.querySelector(`optgroup[label="${optgroupData.label}"]`); + if (optgroupElement) { + parentElement = optgroupElement; + } + } + } + const optionElement = document.createElement('option'); + optionElement.value = value; + optionElement.text = data[this.tomSelect.settings.labelField]; + const optionOrder = data.$order; + let orderedOption = null; + for (const [, tomSelectOption] of Object.entries(this.tomSelect.options)) { + if (tomSelectOption.$order === optionOrder) { + orderedOption = parentElement.querySelector(`:scope > option[value="${CSS.escape(tomSelectOption[this.tomSelect.settings.valueField])}"]`); + break; + } + } + if (orderedOption) { + orderedOption.insertAdjacentElement('afterend', optionElement); + } + else if (optionOrder >= 0) { + parentElement.append(optionElement); + } + else { + parentElement.prepend(optionElement); + } + }, }; if (!this.selectElement && !this.urlValue) { config.shouldLoad = () => false; @@ -261,22 +295,26 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def }); return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config); }, _default_1_createAutocompleteWithHtmlContents = function _default_1_createAutocompleteWithHtmlContents() { - const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), { + const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this); + const labelField = commonConfig.labelField ?? 'text'; + const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, { maxOptions: this.getMaxOptions(), score: (search) => { const scoringFunction = this.tomSelect.getScoreFunction(search); return (item) => { - return scoringFunction({ ...item, text: __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_stripTags).call(this, item.text) }); + return scoringFunction({ ...item, text: __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_stripTags).call(this, item[labelField]) }); }; }, render: { - item: (item) => `
${item.text}
`, - option: (item) => `
${item.text}
`, + item: (item) => `
${item[labelField]}
`, + option: (item) => `
${item[labelField]}
`, }, }); return __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_createTomSelect).call(this, config); }, _default_1_createAutocompleteWithRemoteData = function _default_1_createAutocompleteWithRemoteData(autocompleteEndpointUrl, minCharacterLength) { - const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this), { + const commonConfig = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_getCommonConfig).call(this); + const labelField = commonConfig.labelField ?? 'text'; + const config = __classPrivateFieldGet(this, _default_1_instances, "m", _default_1_mergeObjects).call(this, commonConfig, { firstUrl: (query) => { const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?'; return `${autocompleteEndpointUrl}${separator}query=${encodeURIComponent(query)}`; @@ -306,8 +344,8 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def optgroupField: 'group_by', score: (search) => (item) => 1, render: { - option: (item) => `
${item.text}
`, - item: (item) => `
${item.text}
`, + option: (item) => `
${item[labelField]}
`, + item: (item) => `
${item[labelField]}
`, loading_more: () => { return `
${this.loadingMoreTextValue}
`; }, @@ -318,7 +356,7 @@ _default_1_instances = new WeakSet(), _default_1_getCommonConfig = function _def return `
${this.noResultsFoundTextValue}
`; }, option_create: (data, escapeData) => { - return `
${this.createOptionTextValue} ${escapeData(data.input)}
`; + return `
${this.createOptionTextValue.replace('%placeholder%', `${escapeData(data.input)}`)}
`; }, }, preload: this.preload, diff --git a/src/Autocomplete/assets/package.json b/src/Autocomplete/assets/package.json index 8af1c434c31..1d9326076ba 100644 --- a/src/Autocomplete/assets/package.json +++ b/src/Autocomplete/assets/package.json @@ -1,10 +1,26 @@ { "name": "@symfony/ux-autocomplete", - "description": "JavaScript-powered autocompletion functionality for forms.", + "description": "JavaScript Autocomplete functionality for Symfony", + "license": "MIT", + "version": "2.26.1", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/autocomplete", + "repository": "https://github.com/symfony/ux-autocomplete", + "type": "module", + "files": [ + "dist" + ], "main": "dist/controller.js", "types": "dist/controller.d.ts", - "version": "1.0.0", - "license": "MIT", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "autocomplete": { diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts index af7f096a387..f89b29141c4 100644 --- a/src/Autocomplete/assets/src/controller.ts +++ b/src/Autocomplete/assets/src/controller.ts @@ -3,10 +3,10 @@ import TomSelect from 'tom-select'; import type { TPluginHash } from 'tom-select/dist/types/contrib/microplugin'; import type { RecursivePartial, - TomSettings, - TomTemplates, TomLoadCallback, TomOption, + TomSettings, + TomTemplates, } from 'tom-select/dist/types/types'; import type { escape_html } from 'tom-select/dist/types/utils'; @@ -17,6 +17,10 @@ export interface AutocompleteConnectOptions { tomSelect: TomSelect; options: any; } +interface OptionDataStructure { + value: string; + text: string; +} export default class extends Controller { static values = { @@ -47,7 +51,7 @@ export default class extends Controller { private mutationObserver: MutationObserver; private isObserving = false; private hasLoadedChoicesPreviously = false; - private originalOptions: Array<{ value: string; text: string; group: string | null }> = []; + private originalOptions: Array = []; initialize() { if (!this.mutationObserver) { @@ -124,6 +128,10 @@ export default class extends Controller { } } + urlValueChanged() { + this.resetTomSelect(); + } + #getCommonConfig(): Partial { const plugins: TPluginHash = {}; @@ -158,6 +166,47 @@ export default class extends Controller { this.tomSelect.setTextboxValue(''); }, closeAfterSelect: true, + // fix positioning (in the dropdown) of options added through addOption() + onOptionAdd: (value: string, data: { [key: string]: any }) => { + let parentElement = this.tomSelect.input as Element; + let optgroupData = null; + + const optgroup = data[this.tomSelect.settings.optgroupField]; + if (optgroup && this.tomSelect.optgroups) { + optgroupData = this.tomSelect.optgroups[optgroup]; + if (optgroupData) { + const optgroupElement = parentElement.querySelector(`optgroup[label="${optgroupData.label}"]`); + if (optgroupElement) { + parentElement = optgroupElement; + } + } + } + + const optionElement = document.createElement('option'); + optionElement.value = value; + optionElement.text = data[this.tomSelect.settings.labelField]; + + const optionOrder = data.$order; + let orderedOption = null; + + for (const [, tomSelectOption] of Object.entries(this.tomSelect.options)) { + if (tomSelectOption.$order === optionOrder) { + orderedOption = parentElement.querySelector( + `:scope > option[value="${CSS.escape(tomSelectOption[this.tomSelect.settings.valueField])}"]` + ); + + break; + } + } + + if (orderedOption) { + orderedOption.insertAdjacentElement('afterend', optionElement); + } else if (optionOrder >= 0) { + parentElement.append(optionElement); + } else { + parentElement.prepend(optionElement); + } + }, }; // for non-autocompleting input elements, avoid the "No results" message that always shows @@ -177,18 +226,21 @@ export default class extends Controller { } #createAutocompleteWithHtmlContents(): TomSelect { - const config = this.#mergeObjects(this.#getCommonConfig(), { + const commonConfig = this.#getCommonConfig(); + const labelField = commonConfig.labelField ?? 'text'; + + const config = this.#mergeObjects(commonConfig, { maxOptions: this.getMaxOptions(), score: (search: string) => { const scoringFunction = this.tomSelect.getScoreFunction(search); return (item: any) => { // strip HTML tags from each option's searchable text - return scoringFunction({ ...item, text: this.#stripTags(item.text) }); + return scoringFunction({ ...item, text: this.#stripTags(item[labelField]) }); }; }, render: { - item: (item: any) => `
${item.text}
`, - option: (item: any) => `
${item.text}
`, + item: (item: any) => `
${item[labelField]}
`, + option: (item: any) => `
${item[labelField]}
`, }, }); @@ -196,7 +248,10 @@ export default class extends Controller { } #createAutocompleteWithRemoteData(autocompleteEndpointUrl: string, minCharacterLength: number | null): TomSelect { - const config: RecursivePartial = this.#mergeObjects(this.#getCommonConfig(), { + const commonConfig = this.#getCommonConfig(); + const labelField = commonConfig.labelField ?? 'text'; + + const config: RecursivePartial = this.#mergeObjects(commonConfig, { firstUrl: (query: string) => { const separator = autocompleteEndpointUrl.includes('?') ? '&' : '?'; @@ -241,8 +296,8 @@ export default class extends Controller { // avoid extra filtering after results are returned score: (search: string) => (item: any) => 1, render: { - option: (item: any) => `
${item.text}
`, - item: (item: any) => `
${item.text}
`, + option: (item: any) => `
${item[labelField]}
`, + item: (item: any) => `
${item[labelField]}
`, loading_more: (): string => { return `
${this.loadingMoreTextValue}
`; }, @@ -253,7 +308,7 @@ export default class extends Controller { return `
${this.noResultsFoundTextValue}
`; }, option_create: (data: TomOption, escapeData: typeof escape_html): string => { - return `
${this.createOptionTextValue} ${escapeData(data.input)}
`; + return `
${this.createOptionTextValue.replace('%placeholder%', `${escapeData(data.input)}`)}
`; }, }, preload: this.preload, @@ -340,8 +395,6 @@ export default class extends Controller { this.element.innerHTML = currentHtml; this.initializeTomSelect(); this.tomSelect.setValue(currentValue); - - this.startMutationObserver(); } } @@ -414,20 +467,16 @@ export default class extends Controller { } } - private createOptionsDataStructure( - selectElement: HTMLSelectElement - ): Array<{ value: string; text: string; group: string | null }> { + private createOptionsDataStructure(selectElement: HTMLSelectElement): Array { return Array.from(selectElement.options).map((option) => { - const optgroup = option.closest('optgroup'); return { value: option.value, text: option.text, - group: optgroup ? optgroup.label : null, }; }); } - private areOptionsEquivalent(newOptions: Array<{ value: string; text: string; group: string | null }>): boolean { + private areOptionsEquivalent(newOptions: Array): boolean { // remove the empty option, which is added by TomSelect so may be missing from new options const filteredOriginalOptions = this.originalOptions.filter((option) => option.value !== ''); const filteredNewOptions = newOptions.filter((option) => option.value !== ''); @@ -447,8 +496,7 @@ export default class extends Controller { return false; } - const normalizeOption = (option: { value: string; text: string; group: string | null }) => - `${option.value}-${option.text}-${option.group}`; + const normalizeOption = (option: OptionDataStructure) => `${option.value}-${option.text}`; const originalOptionsSet = new Set(filteredOriginalOptions.map(normalizeOption)); const newOptionsSet = new Set(filteredNewOptions.map(normalizeOption)); diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts index 3f9a1d92f67..5b72e7fb075 100644 --- a/src/Autocomplete/assets/test/controller.test.ts +++ b/src/Autocomplete/assets/test/controller.test.ts @@ -9,14 +9,14 @@ import { Application } from '@hotwired/stimulus'; import { getByTestId, waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import type TomSelect from 'tom-select'; +import { vi } from 'vitest'; +import createFetchMock from 'vitest-fetch-mock'; import AutocompleteController, { type AutocompleteConnectOptions, type AutocompletePreConnectOptions, } from '../src/controller'; -import userEvent from '@testing-library/user-event'; -import type TomSelect from 'tom-select'; -import createFetchMock from 'vitest-fetch-mock'; -import { vi } from 'vitest'; const shortDelay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -138,6 +138,80 @@ describe('AutocompleteController', () => { expect(fetchMock.requests()[1].url).toEqual('/path/to/autocomplete?query=foo'); }); + it('resets when ajax URL attribute on a select element changes', async () => { + const { container, tomSelect } = await startAutocompleteTest(` + + + `); + + const selectElement = getByTestId(container, 'main-element') as HTMLSelectElement; + + // initial Ajax request on focus + fetchMock.mockResponseOnce( + JSON.stringify({ + results: [ + { + value: 3, + text: 'salad', + }, + ], + }) + ); + + fetchMock.mockResponseOnce( + JSON.stringify({ + results: [ + { + value: 1, + text: 'pizza', + }, + { + value: 2, + text: 'popcorn', + }, + ], + }) + ); + + const controlInput = tomSelect.control_input; + + // wait for the initial Ajax request to finish + userEvent.click(controlInput); + await waitFor(() => { + expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(1); + }); + + controlInput.value = 'foo'; + controlInput.dispatchEvent(new Event('input')); + + await waitFor(() => { + expect(container.querySelectorAll('.option[data-selectable]')).toHaveLength(2); + }); + + expect(selectElement.value).toBe(''); + tomSelect.addItem('2'); + expect(selectElement.value).toBe('2'); + + selectElement.outerHTML = ` + + `; + + // wait for the MutationObserver to flush these changes + await shortDelay(10); + + expect(selectElement.value).toBe(''); + }); + it('connect with ajax URL on an input element', async () => { const { container, tomSelect } = await startAutocompleteTest(` diff --git a/src/Autocomplete/composer.json b/src/Autocomplete/composer.json index 94b94e39918..040fdf157f6 100644 --- a/src/Autocomplete/composer.json +++ b/src/Autocomplete/composer.json @@ -29,8 +29,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-foundation": "^6.3|^7.0", "symfony/http-kernel": "^6.3|^7.0", - "symfony/property-access": "^6.3|^7.0", - "symfony/string": "^6.3|^7.0" + "symfony/property-access": "^6.3|^7.0" }, "require-dev": { "doctrine/collections": "^1.6.8|^2.0", @@ -45,7 +44,6 @@ "symfony/phpunit-bridge": "^6.3|^7.0", "symfony/process": "^6.3|^7.0", "symfony/security-bundle": "^6.3|^7.0", - "symfony/security-csrf": "^6.3|^7.0", "symfony/twig-bundle": "^6.3|^7.0", "symfony/uid": "^6.3|^7.0", "twig/twig": "^2.14.7|^3.0.4", diff --git a/src/Autocomplete/doc/index.rst b/src/Autocomplete/doc/index.rst index def22586012..aa759554ec7 100644 --- a/src/Autocomplete/doc/index.rst +++ b/src/Autocomplete/doc/index.rst @@ -1,7 +1,7 @@ Autocomplete `` element +Transform your ``EntityType``, ``ChoiceType``, ``EnumType`` or *any* ```` element into a smart UI control: .. image:: food-non-ajax.png :alt: Screenshot of a Food select with Tom Select @@ -103,7 +104,8 @@ Or, create the field by hand:: // src/Form/FoodAutocompleteField.php // ... - use Symfony\Bundle\SecurityBundle\Security; + use Symfony\Component\Form\AbstractType; + use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; @@ -252,7 +254,7 @@ to the options above, you can also pass: ``filter_query`` (default: ``null``) If you want to completely control the query made for the "search results", - use this option. This is incompatible with ``searchable_fields``:: + use this option. This is incompatible with ``searchable_fields`` and ``max_results``:: [ 'filter_query' => function(QueryBuilder $qb, string $query, EntityRepository $repository) { @@ -274,6 +276,7 @@ to the options above, you can also pass: ``preload`` (default: ``focus``) Set to ``focus`` to call the ``load`` function when control receives focus. Set to ``true`` to call the ``load`` upon control initialization (with an empty search). + Set to ``false`` not to call the ``load`` function when control receives focus. ``extra_options`` (default ``[]``) Allow you to pass extra options for Ajax-based autocomplete fields. @@ -287,16 +290,22 @@ Passing Extra Options to the Ajax-powered Autocomplete The ability to pass extra options was added in Autocomplete 2.14. -Autocomplete field options are not preserved when the field is rendered on an Ajax call. So, features like exclude some options -based on the current form data are not possible by default. To partially avoid this limitation, the `extra_options` option was added. +Autocomplete field options are **not preserved** when the field is rendered +on an Ajax call. So, features like exclude some options based on the current +form data are not possible by default. + +To partially avoid this limitation, the ``extra_options`` option was added. .. warning:: - Only scalar values (``string``, ``integer``, ``float``, ``boolean``), ``null`` and ``array`` (consisted from the same types as mentioned before) can be passed as extra options. + Only scalar values (``string``, ``integer``, ``float``, ``boolean``), + ``null`` and ``array`` (consisted from the same types as mentioned before) + can be passed as extra options. -Considering the following example, when the form type is rendered for the first time, it will use the ``query_builder`` defined -while adding a ``food`` field to the ``FoodForm``. However, when the Ajax is used to fetch the results, on the consequent renders, -the default ``query_builder`` will be used:: +Considering the following example, when the form type is rendered for the first +time, it will use the ``query_builder`` defined while adding a ``food`` field +to the ``FoodForm``. However, when the Ajax is used to fetch the results, on +the consequent renders, the default ``query_builder`` will be used:: // src/Form/FoodForm.php // ... @@ -310,21 +319,19 @@ the default ``query_builder`` will be used:: $builder ->add('food', FoodAutocompleteField::class, [ 'query_builder' => function (EntityRepository $er) { - $qb = $er->createQueryBuilder('o'); - - $qb->andWhere($qb->expr()->notIn('o.id', [$currentFoodId])); + $qb = $er->createQueryBuilder('o'); - return $qb; - }; - } + return $qb->andWhere($qb->expr()->notIn('o.id', [$currentFoodId])); + }; ]) ; } } -If some food can be consisted of other foods, we might want to exclude the "root" food from the list of available foods. -To achieve this, we can remove the ``query_builder`` option from the above example and pass the ``excluded_foods`` extra option -to the ``FoodAutocompleteField``:: +If some food can be consisted of other foods, we might want to exclude the +"root" food from the list of available foods. To achieve this, we can remove +the ``query_builder`` option from the above example and pass the ``excluded_foods`` +extra option to the ``FoodAutocompleteField``:: // src/Form/FoodForm.php // ... @@ -345,13 +352,13 @@ to the ``FoodAutocompleteField``:: } } -The magic of the ``extra_options`` is that it will be passed to the ``FoodAutocompleteField`` every time an Ajax call is made. -So now, we can just use the ``excluded_foods`` extra option in the default ``query_builder`` of the ``FoodAutocompleteField``:: +The magic of the ``extra_options`` is that it will be passed to the ``FoodAutocompleteField`` +every time an Ajax call is made. So now, we can just use the ``excluded_foods`` +extra option in the default ``query_builder`` of the ``FoodAutocompleteField``:: // src/Form/FoodAutocompleteField.php // ... - use Symfony\Bundle\SecurityBundle\Security; use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; @@ -411,10 +418,6 @@ likely need to create your own :ref:`custom autocomplete endpoint $options - + */ - +public function setOptions(array $options): void - +{ - + $this->options = $options; - +} + + /** + + * @param array $options + + */ + + public function setOptions(array $options): void + + { + + $this->options = $options; + + } .. _manual-stimulus-controller: @@ -751,3 +756,5 @@ the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html .. _`controller.ts`: https://github.com/symfony/ux/blob/2.x/src/Autocomplete/assets/src/controller.ts .. _`Tom Select Render Templates`: https://tom-select.js.org/docs/#render-templates .. _`Tom Select Option Group`: https://tom-select.js.org/examples/optgroups/ +.. _`Symfony Form`: https://symfony.com/doc/current/forms.html +.. _`@symfony/ux-autocomplete npm package`: https://www.npmjs.com/package/@symfony/ux-autocomplete diff --git a/src/Autocomplete/src/AutocompleteBundle.php b/src/Autocomplete/src/AutocompleteBundle.php index adb471a144e..a8add0a42e5 100644 --- a/src/Autocomplete/src/AutocompleteBundle.php +++ b/src/Autocomplete/src/AutocompleteBundle.php @@ -20,7 +20,7 @@ */ final class AutocompleteBundle extends Bundle { - public function build(ContainerBuilder $container) + public function build(ContainerBuilder $container): void { $container->addCompilerPass(new AutocompleteFormTypePass()); } diff --git a/src/Autocomplete/src/AutocompleteResultsExecutor.php b/src/Autocomplete/src/AutocompleteResultsExecutor.php index 0061ca8a55b..03d10e03466 100644 --- a/src/Autocomplete/src/AutocompleteResultsExecutor.php +++ b/src/Autocomplete/src/AutocompleteResultsExecutor.php @@ -73,10 +73,7 @@ public function fetchResults(EntityAutocompleterInterface $autocompleter, string if (!method_exists($autocompleter, 'getGroupBy') || null === $groupBy = $autocompleter->getGroupBy()) { foreach ($paginator as $entity) { - $results[] = [ - 'value' => $autocompleter->getValue($entity), - 'text' => $autocompleter->getLabel($entity), - ]; + $results[] = $this->formatResult($autocompleter, $entity); } return new AutocompleteResults($results, $hasNextPage); @@ -104,10 +101,7 @@ public function fetchResults(EntityAutocompleterInterface $autocompleter, string $optgroupLabels = []; foreach ($paginator as $entity) { - $result = [ - 'value' => $autocompleter->getValue($entity), - 'text' => $autocompleter->getLabel($entity), - ]; + $result = $this->formatResult($autocompleter, $entity); $groupLabels = $groupBy($entity, $result['value'], $result['text']); @@ -124,4 +118,21 @@ public function fetchResults(EntityAutocompleterInterface $autocompleter, string return new AutocompleteResults($results, $hasNextPage, $optgroups); } + + /** + * @return array + */ + private function formatResult(EntityAutocompleterInterface $autocompleter, object $entity): array + { + $attributes = []; + if (method_exists($autocompleter, 'getAttributes')) { + $attributes = $autocompleter->getAttributes($entity); + } + + return [ + ...$attributes, + 'value' => $autocompleter->getValue($entity), + 'text' => $autocompleter->getLabel($entity), + ]; + } } diff --git a/src/Autocomplete/src/AutocompleterRegistry.php b/src/Autocomplete/src/AutocompleterRegistry.php index 31f81c21de7..c3c6586507d 100644 --- a/src/Autocomplete/src/AutocompleterRegistry.php +++ b/src/Autocomplete/src/AutocompleterRegistry.php @@ -28,6 +28,9 @@ public function getAutocompleter(string $alias): ?EntityAutocompleterInterface return $this->autocompletersLocator->has($alias) ? $this->autocompletersLocator->get($alias) : null; } + /** + * @return list + */ public function getAutocompleterNames(): array { return array_keys($this->autocompletersLocator->getProvidedServices()); diff --git a/src/Autocomplete/src/Controller/EntityAutocompleteController.php b/src/Autocomplete/src/Controller/EntityAutocompleteController.php index f8d855120e3..e3143fc53c4 100644 --- a/src/Autocomplete/src/Controller/EntityAutocompleteController.php +++ b/src/Autocomplete/src/Controller/EntityAutocompleteController.php @@ -76,7 +76,11 @@ private function getExtraOptions(Request $request): array return []; } - $extraOptions = $this->getDecodedExtraOptions($request->query->getString(self::EXTRA_OPTIONS)); + try { + $extraOptions = $this->getDecodedExtraOptions($request->query->getString(self::EXTRA_OPTIONS)); + } catch (\JsonException $e) { + throw new BadRequestHttpException('The extra options cannot be parsed.', $e); + } if (!\array_key_exists(AutocompleteChoiceTypeExtension::CHECKSUM_KEY, $extraOptions)) { throw new BadRequestHttpException('The extra options are missing the checksum.'); diff --git a/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php index c44103c8505..3dec572a11b 100644 --- a/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php +++ b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php @@ -40,7 +40,7 @@ */ final class AutocompleteExtension extends Extension implements PrependExtensionInterface { - public function prepend(ContainerBuilder $container) + public function prepend(ContainerBuilder $container): void { $bundles = $container->getParameter('kernel.bundles'); @@ -61,7 +61,7 @@ public function prepend(ContainerBuilder $container) } } - public function load(array $configs, ContainerBuilder $container) + public function load(array $configs, ContainerBuilder $container): void { $this->registerBasicServices($container); if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle'])) { diff --git a/src/Autocomplete/src/DependencyInjection/AutocompleteFormTypePass.php b/src/Autocomplete/src/DependencyInjection/AutocompleteFormTypePass.php index b07c23fce7c..fd6cb19df3f 100644 --- a/src/Autocomplete/src/DependencyInjection/AutocompleteFormTypePass.php +++ b/src/Autocomplete/src/DependencyInjection/AutocompleteFormTypePass.php @@ -29,13 +29,13 @@ class AutocompleteFormTypePass implements CompilerPassInterface /** @var string Tag applied to EntityAutocompleterInterface classes */ public const ENTITY_AUTOCOMPLETER_TAG = 'ux.entity_autocompleter'; - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { $this->processEntityAutocompleteFieldTag($container); $this->processEntityAutocompleterTag($container); } - private function processEntityAutocompleteFieldTag(ContainerBuilder $container) + private function processEntityAutocompleteFieldTag(ContainerBuilder $container): void { foreach ($container->findTaggedServiceIds(self::ENTITY_AUTOCOMPLETE_FIELD_TAG, true) as $serviceId => $tag) { $serviceDefinition = $container->getDefinition($serviceId); @@ -68,7 +68,7 @@ private function getAlias(string $serviceId, Definition $serviceDefinition, arra return $attribute->getAlias() ?: AsEntityAutocompleteField::shortName($class); } - private function processEntityAutocompleterTag(ContainerBuilder $container) + private function processEntityAutocompleterTag(ContainerBuilder $container): void { $servicesMap = []; foreach ($container->findTaggedServiceIds(self::ENTITY_AUTOCOMPLETER_TAG, true) as $serviceId => $tag) { diff --git a/src/Autocomplete/src/Doctrine/EntitySearchUtil.php b/src/Autocomplete/src/Doctrine/EntitySearchUtil.php index d7e09d12745..3db23893b51 100644 --- a/src/Autocomplete/src/Doctrine/EntitySearchUtil.php +++ b/src/Autocomplete/src/Doctrine/EntitySearchUtil.php @@ -47,6 +47,7 @@ public function addSearchClause(QueryBuilder $queryBuilder, string $query, strin ]; $entitiesAlreadyJoined = []; + $aliasAlreadyUsed = []; $searchableProperties = empty($searchableProperties) ? $entityMetadata->getAllPropertyNames() : $searchableProperties; $expressions = []; foreach ($searchableProperties as $propertyName) { @@ -68,10 +69,18 @@ public function addSearchClause(QueryBuilder $queryBuilder, string $query, strin $associatedEntityAlias = SearchEscaper::escapeDqlAlias($associatedEntityName); $associatedPropertyName = $associatedProperties[$i + 1]; - if (!\in_array($associatedEntityName, $entitiesAlreadyJoined, true)) { + $associatedParentName = null; + if (\array_key_exists($i - 1, $associatedProperties) && $queryBuilder->getRootAliases()[0] !== $associatedProperties[$i - 1]) { + $associatedParentName = $associatedProperties[$i - 1]; + } + + $associatedEntityAlias = $associatedParentName ? $associatedParentName.'_'.$associatedEntityAlias : $associatedEntityAlias; + + if (!\in_array($associatedEntityName, $entitiesAlreadyJoined, true) || !\in_array($associatedEntityAlias, $aliasAlreadyUsed, true)) { $parentEntityName = 0 === $i ? $queryBuilder->getRootAliases()[0] : $associatedProperties[$i - 1]; $queryBuilder->leftJoin($parentEntityName.'.'.$associatedEntityName, $associatedEntityAlias); $entitiesAlreadyJoined[] = $associatedEntityName; + $aliasAlreadyUsed[] = $associatedEntityAlias; } if ($i < $numAssociatedProperties - 2) { diff --git a/src/Autocomplete/src/EntityAutocompleterInterface.php b/src/Autocomplete/src/EntityAutocompleterInterface.php index df0874fd91c..4ad8a855f18 100644 --- a/src/Autocomplete/src/EntityAutocompleterInterface.php +++ b/src/Autocomplete/src/EntityAutocompleterInterface.php @@ -18,30 +18,50 @@ /** * Interface for classes that will have an "autocomplete" endpoint exposed. * - * @method mixed getGroupBy() Return group_by option. + * @template T of object + * + * TODO Remove next lines for Symfony UX 3 + * + * @method array getAttributes(object $entity) Returns extra attributes to add to the autocomplete result. + * @method mixed getGroupBy() Return group_by option. */ interface EntityAutocompleterInterface { /** * The fully-qualified entity class this will be autocompleting. + * + * @return class-string */ public function getEntityClass(): string; /** * Create a query builder that filters for the given "query". + * + * @param EntityRepository $repository */ public function createFilteredQueryBuilder(EntityRepository $repository, string $query): QueryBuilder; /** * Returns the "choice_label" used to display this entity. + * + * @param T $entity */ public function getLabel(object $entity): string; /** * Returns the "value" attribute for this entity, usually the id. + * + * @param T $entity */ public function getValue(object $entity): mixed; + /** + * Returns extra attributes to add to the autocomplete result. + * + * TODO Uncomment for Symfony UX 3 + */ + /* public function getAttributes(object $entity): array; */ + /** * Return true if access should be granted to the autocomplete results for the current user. * @@ -51,6 +71,8 @@ public function isGranted(Security $security): bool; /* * Return group_by option. + * + * TODO Uncomment for Symfony UX 3 */ /* public function getGroupBy(): mixed; */ } diff --git a/src/Autocomplete/src/Form/AsEntityAutocompleteField.php b/src/Autocomplete/src/Form/AsEntityAutocompleteField.php index d707c4b71b5..94119fba8b8 100644 --- a/src/Autocomplete/src/Form/AsEntityAutocompleteField.php +++ b/src/Autocomplete/src/Form/AsEntityAutocompleteField.php @@ -11,8 +11,6 @@ namespace Symfony\UX\Autocomplete\Form; -use Symfony\Component\String\UnicodeString; - /** * All form types that want to expose autocomplete functionality should have this. * @@ -37,13 +35,25 @@ public function getRoute(): string return $this->route; } + /** + * @internal + * + * @param class-string $class + */ public static function shortName(string $class): string { - $string = new UnicodeString($class); + if ($pos = (int) strrpos($class, '\\')) { + $class = substr($class, $pos + 1); + } - return $string->afterLast('\\')->snake()->toString(); + return strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $class)); } + /** + * @internal + * + * @param class-string $class + */ public static function getInstance(string $class): ?self { $reflectionClass = new \ReflectionClass($class); diff --git a/src/Autocomplete/src/Form/BaseEntityAutocompleteType.php b/src/Autocomplete/src/Form/BaseEntityAutocompleteType.php index f4e002d0fe4..18804b7753f 100644 --- a/src/Autocomplete/src/Form/BaseEntityAutocompleteType.php +++ b/src/Autocomplete/src/Form/BaseEntityAutocompleteType.php @@ -13,6 +13,7 @@ use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader; use Symfony\Component\Form\Exception\RuntimeException; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\Options; @@ -42,6 +43,10 @@ public function configureOptions(OptionsResolver $resolver): void return null; } + if (class_exists(LazyChoiceLoader::class)) { + return new LazyChoiceLoader($loader); + } + return new ExtraLazyChoiceLoader($loader); }; diff --git a/src/Autocomplete/src/Form/ChoiceList/Loader/ExtraLazyChoiceLoader.php b/src/Autocomplete/src/Form/ChoiceList/Loader/ExtraLazyChoiceLoader.php index dfa3b0b409e..8f49ab4b653 100644 --- a/src/Autocomplete/src/Form/ChoiceList/Loader/ExtraLazyChoiceLoader.php +++ b/src/Autocomplete/src/Form/ChoiceList/Loader/ExtraLazyChoiceLoader.php @@ -17,6 +17,8 @@ /** * Loads choices on demand only. + * + * @deprecated since Autocomplete 2.23 and will be removed in 3.0, use `Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader` instead. */ class ExtraLazyChoiceLoader implements ChoiceLoaderInterface { diff --git a/src/Autocomplete/src/Maker/MakeAutocompleteField.php b/src/Autocomplete/src/Maker/MakeAutocompleteField.php index 41ef37a4f80..1588b2cb22c 100644 --- a/src/Autocomplete/src/Maker/MakeAutocompleteField.php +++ b/src/Autocomplete/src/Maker/MakeAutocompleteField.php @@ -53,7 +53,7 @@ public static function getCommandDescription(): string return 'Generates an Ajax-autocomplete form field class for symfony/ux-autocomplete.'; } - public function configureCommand(Command $command, InputConfiguration $inputConfig) + public function configureCommand(Command $command, InputConfiguration $inputConfig): void { $command ->setHelp(<<addClassDependency(FormInterface::class, 'symfony/form'); } - public function interact(InputInterface $input, ConsoleStyle $io, Command $command) + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void { if (null === $this->doctrineHelper) { throw new \LogicException('Somehow the DoctrineHelper service is missing from MakerBundle.'); @@ -94,7 +94,7 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma ); } - public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator) + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void { if (null === $this->doctrineHelper) { throw new \LogicException('Somehow the DoctrineHelper service is missing from MakerBundle.'); diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomAttributesProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomAttributesProductAutocompleter.php new file mode 100644 index 00000000000..7f86431ae8a --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomAttributesProductAutocompleter.php @@ -0,0 +1,17 @@ + true, + 'value' => 'This value should be replaced with the result of getValue()', + 'text' => 'This value should be replaced with the result of getText()', + ]; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php index 912a864c0e9..09b5b96c993 100644 --- a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php +++ b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php @@ -19,6 +19,9 @@ use Symfony\UX\Autocomplete\EntityAutocompleterInterface; use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product; +/** + * @implements EntityAutocompleterInterface + */ class CustomProductAutocompleter implements EntityAutocompleterInterface { public function __construct( @@ -58,6 +61,11 @@ public function getValue(object $entity): mixed return $entity->getId(); } + public function getAttributes(object $entity): array + { + return []; + } + public function isGranted(Security $security): bool { if ($this->requestStack->getCurrentRequest()?->query->get('enforce_test_security')) { diff --git a/src/Autocomplete/tests/Fixtures/Entity/Category.php b/src/Autocomplete/tests/Fixtures/Entity/Category.php index b6ea24841bd..1372bf8921c 100644 --- a/src/Autocomplete/tests/Fixtures/Entity/Category.php +++ b/src/Autocomplete/tests/Fixtures/Entity/Category.php @@ -32,9 +32,13 @@ class Category #[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class)] private Collection $products; + #[ORM\ManyToMany(targetEntity: CategoryTag::class, mappedBy: 'categories')] + private Collection $tags; + public function __construct() { $this->products = new ArrayCollection(); + $this->tags = new ArrayCollection(); } public function getId(): ?int @@ -96,6 +100,31 @@ public function removeProduct(Product $product): self return $this; } + /** + * @return Collection + */ + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(CategoryTag $tag): self + { + if (!$this->tags->contains($tag)) { + $this->tags[] = $tag; + $tag->addCategory($this); + } + + return $this; + } + + public function removeTag(CategoryTag $tag): self + { + $this->tags->removeElement($tag); + + return $this; + } + public function __toString(): string { return $this->getName(); diff --git a/src/Autocomplete/tests/Fixtures/Entity/CategoryTag.php b/src/Autocomplete/tests/Fixtures/Entity/CategoryTag.php new file mode 100644 index 00000000000..f0d46ac9ccb --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Entity/CategoryTag.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity()] +class CategoryTag +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column()] + private ?int $id = null; + + #[ORM\Column()] + private ?string $name = null; + + #[ORM\ManyToMany(targetEntity: Category::class, inversedBy: 'tags')] + #[ORM\JoinTable(name: 'category_tag')] + private Collection $categories; + + public function __construct() + { + $this->categories = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * @return Collection + */ + public function getCategories(): Collection + { + return $this->categories; + } + + public function addCategory(Category $category): self + { + if (!$this->categories->contains($category)) { + $this->categories[] = $category; + } + + return $this; + } + + public function removeCategory(Category $category): self + { + $this->categories->removeElement($category); + + return $this; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Entity/Product.php b/src/Autocomplete/tests/Fixtures/Entity/Product.php index 7db8056e258..4188c91a556 100644 --- a/src/Autocomplete/tests/Fixtures/Entity/Product.php +++ b/src/Autocomplete/tests/Fixtures/Entity/Product.php @@ -40,12 +40,16 @@ class Product #[ORM\JoinColumn(nullable: false)] private ?Category $category = null; - #[Orm\OneToMany(targetEntity: Ingredient::class, mappedBy: 'product')] + #[ORM\OneToMany(targetEntity: Ingredient::class, mappedBy: 'product')] private Collection $ingredients; + #[ORM\ManyToMany(targetEntity: ProductTag::class, mappedBy: 'products')] + private Collection $tags; + public function __construct() { $this->ingredients = new ArrayCollection(); + $this->tags = new ArrayCollection(); } public function getId(): ?int @@ -142,4 +146,31 @@ public function removeIngredient(Ingredient $ingredient): self return $this; } + + /** + * @return Collection + */ + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(ProductTag $tag): self + { + if (!$this->tags->contains($tag)) { + $this->tags[] = $tag; + $tag->addProduct($this); + } + + return $this; + } + + public function removeTag(ProductTag $tag): self + { + if ($this->tags->removeElement($tag)) { + $tag->removeProduct($this); + } + + return $this; + } } diff --git a/src/Autocomplete/tests/Fixtures/Entity/ProductTag.php b/src/Autocomplete/tests/Fixtures/Entity/ProductTag.php new file mode 100644 index 00000000000..25670557626 --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Entity/ProductTag.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity()] +class ProductTag +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column()] + private ?int $id = null; + + #[ORM\Column()] + private ?string $name = null; + + #[ORM\ManyToMany(targetEntity: Product::class, inversedBy: 'tags')] + #[ORM\JoinTable(name: 'product_tag')] + private Collection $products; + + public function __construct() + { + $this->products = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * @return Collection + */ + public function getProducts(): Collection + { + return $this->products; + } + + public function addProduct(Product $product): self + { + if (!$this->products->contains($product)) { + $this->products[] = $product; + } + + return $this; + } + + public function removeProduct(Product $product): self + { + $this->products->removeElement($product); + + return $this; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php b/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php new file mode 100644 index 00000000000..32eec81c1a2 --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory; + +use Doctrine\ORM\EntityRepository; +use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\CategoryTag; +use Zenstruck\Foundry\ModelFactory; +use Zenstruck\Foundry\Proxy; +use Zenstruck\Foundry\RepositoryProxy; + +/** + * @extends ModelFactory + * + * @method static CategoryTag|Proxy createOne(array $attributes = []) + * @method static CategoryTag[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static CategoryTag|Proxy find(object|array|mixed $criteria) + * @method static CategoryTag|Proxy findOrCreate(array $attributes) + * @method static CategoryTag|Proxy first(string $sortedField = 'id') + * @method static CategoryTag|Proxy last(string $sortedField = 'id') + * @method static CategoryTag|Proxy random(array $attributes = []) + * @method static CategoryTag|Proxy randomOrCreate(array $attributes = []) + * @method static CategoryTag[]|Proxy[] all() + * @method static CategoryTag[]|Proxy[] findBy(array $attributes) + * @method static CategoryTag[]|Proxy[] randomSet(int $number, array $attributes = []) + * @method static CategoryTag[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static EntityRepository|RepositoryProxy repository() + * @method CategoryTag|Proxy create(array|callable $attributes = []) + */ +final class CategoryTagFactory extends ModelFactory +{ + protected function getDefaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } + + protected function initialize(): self + { + return $this; + } + + protected static function getClass(): string + { + return CategoryTag::class; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php b/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php new file mode 100644 index 00000000000..868d61fca4d --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory; + +use Doctrine\ORM\EntityRepository; +use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\ProductTag; +use Zenstruck\Foundry\ModelFactory; +use Zenstruck\Foundry\Proxy; +use Zenstruck\Foundry\RepositoryProxy; + +/** + * @extends ModelFactory + * + * @method static ProductTag|Proxy createOne(array $attributes = []) + * @method static ProductTag[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static ProductTag|Proxy find(object|array|mixed $criteria) + * @method static ProductTag|Proxy findOrCreate(array $attributes) + * @method static ProductTag|Proxy first(string $sortedField = 'id') + * @method static ProductTag|Proxy last(string $sortedField = 'id') + * @method static ProductTag|Proxy random(array $attributes = []) + * @method static ProductTag|Proxy randomOrCreate(array $attributes = []) + * @method static ProductTag[]|Proxy[] all() + * @method static ProductTag[]|Proxy[] findBy(array $attributes) + * @method static ProductTag[]|Proxy[] randomSet(int $number, array $attributes = []) + * @method static ProductTag[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static EntityRepository|RepositoryProxy repository() + * @method ProductTag|Proxy create(array|callable $attributes = []) + */ +final class ProductTagFactory extends ModelFactory +{ + protected function getDefaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } + + protected function initialize(): self + { + return $this; + } + + protected static function getClass(): string + { + return ProductTag::class; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Form/ProductWithTagsAutocompleteType.php b/src/Autocomplete/tests/Fixtures/Form/ProductWithTagsAutocompleteType.php new file mode 100644 index 00000000000..68390e0868c --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Form/ProductWithTagsAutocompleteType.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Form; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; +use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; +use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product; + +#[AsEntityAutocompleteField] +class ProductWithTagsAutocompleteType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'class' => Product::class, + 'choice_label' => function (Product $product) { + return ''.$product->getName().''; + }, + 'multiple' => true, + 'searchable_fields' => [ + 'tags.name', + 'category.tags.name', + ], + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::class; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Kernel.php b/src/Autocomplete/tests/Fixtures/Kernel.php index f1991a22c39..de2877b0a2a 100644 --- a/src/Autocomplete/tests/Fixtures/Kernel.php +++ b/src/Autocomplete/tests/Fixtures/Kernel.php @@ -34,6 +34,7 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\UX\Autocomplete\AutocompleteBundle; use Symfony\UX\Autocomplete\DependencyInjection\AutocompleteFormTypePass; +use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomAttributesProductAutocompleter; use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomGroupByProductAutocompleter; use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomProductAutocompleter; use Symfony\UX\Autocomplete\Tests\Fixtures\Form\ProductType; @@ -176,6 +177,13 @@ protected function configureContainer(ContainerConfigurator $c): void 'alias' => 'custom_group_by_product', ]); + $services->set(CustomAttributesProductAutocompleter::class) + ->public() + ->arg(1, new Reference('ux.autocomplete.entity_search_util')) + ->tag(AutocompleteFormTypePass::ENTITY_AUTOCOMPLETER_TAG, [ + 'alias' => 'custom_attributes_product', + ]); + $services->alias('public.results_executor', 'ux.autocomplete.results_executor') ->public(); diff --git a/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php b/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php index 6e9420b1ffe..305e75c20ae 100644 --- a/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php +++ b/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php @@ -14,6 +14,9 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryFactory; +use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryTagFactory; +use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory; +use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductTagFactory; use Zenstruck\Browser\Test\HasBrowser; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -127,4 +130,31 @@ public function testItUsesTheCustomCallbackValue(): void ->assertJsonMatches('results[0].text', $category->getName()) ; } + + public function testItSearchesByTags(): void + { + $productTag = ProductTagFactory::createOne(['name' => 'technology']); + $categoryTag = CategoryTagFactory::createOne(['name' => 'home appliances']); + $category = CategoryFactory::createOne(['name' => 'Electronics', 'tags' => [$categoryTag]]); + $product1 = ProductFactory::createOne(['name' => 'Smartphone', 'tags' => [$productTag], 'category' => $category]); + $product2 = ProductFactory::createOne(['name' => 'Laptop', 'category' => $category]); + ProductFactory::createOne(['name' => 'Microwave']); + + $this->browser() + ->throwExceptions() + ->get('/test/autocomplete/product_with_tags_autocomplete_type?query=technology') + ->assertSuccessful() + ->assertJsonMatches('length(results)', 1) + ->assertJsonMatches('results[0].value', (string) $product1->getId()) + ->assertJsonMatches('results[0].text', 'Smartphone') + ; + + $this->browser() + ->get('/test/autocomplete/product_with_tags_autocomplete_type?query=home appliance') + ->assertSuccessful() + ->assertJsonMatches('length(results)', 2) + ->assertJsonMatches('results[0].value', (string) $product1->getId()) + ->assertJsonMatches('results[1].value', (string) $product2->getId()) + ; + } } diff --git a/src/Autocomplete/tests/Integration/AutocompleteResultsExecutorTest.php b/src/Autocomplete/tests/Integration/AutocompleteResultsExecutorTest.php new file mode 100644 index 00000000000..b804afd1123 --- /dev/null +++ b/src/Autocomplete/tests/Integration/AutocompleteResultsExecutorTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Integration; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Autocomplete\AutocompleteResultsExecutor; +use Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter\CustomAttributesProductAutocompleter; +use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory; +use Symfony\UX\Autocomplete\Tests\Fixtures\Kernel; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; + +class AutocompleteResultsExecutorTest extends KernelTestCase +{ + use Factories; + use ResetDatabase; + + public function testItReturnsExtraAttributes(): void + { + $kernel = new Kernel('test', true); + $kernel->disableForms(); + $kernel->boot(); + + $product = ProductFactory::createOne(['name' => 'Foo']); + + /** @var AutocompleteResultsExecutor $executor */ + $executor = $kernel->getContainer()->get('public.results_executor'); + $autocompleter = $kernel->getContainer()->get(CustomAttributesProductAutocompleter::class); + $data = $executor->fetchResults($autocompleter, '', 1); + $this->assertCount(1, $data->results); + $this->assertSame(['disabled' => true, 'value' => $product->getId(), 'text' => 'Foo'], $data->results[0]); + } +} diff --git a/src/Autocomplete/tests/Unit/Form/AsEntityAutocompleteFieldTest.php b/src/Autocomplete/tests/Unit/Form/AsEntityAutocompleteFieldTest.php new file mode 100644 index 00000000000..c7fabeafd7d --- /dev/null +++ b/src/Autocomplete/tests/Unit/Form/AsEntityAutocompleteFieldTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Unit\Form; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; +use Symfony\UX\Autocomplete\Tests\Fixtures\Form\ProductType; + +class AsEntityAutocompleteFieldTest extends TestCase +{ + /** + * @dataProvider provideClassNames + */ + public function testShortName(string $shortName, string $className): void + { + $this->assertEquals($shortName, AsEntityAutocompleteField::shortName($className)); + } + + /** + * @return iterable<{string, string}> + */ + public static function provideClassNames(): iterable + { + yield from [ + ['as_entity_autocomplete_field', AsEntityAutocompleteField::class], + ['product_type', ProductType::class], + ['bar', 'Bar'], + ['foo_bar', 'FooBar'], + ['foo_bar', 'Foo\FooBar'], + ['foo_bar', 'Foo\Bar\FooBar'], + ]; + } +} diff --git a/src/Autocomplete/translations/AutocompleteBundle.cs.php b/src/Autocomplete/translations/AutocompleteBundle.cs.php index 32c80c00609..732770fe08f 100644 --- a/src/Autocomplete/translations/AutocompleteBundle.cs.php +++ b/src/Autocomplete/translations/AutocompleteBundle.cs.php @@ -13,5 +13,5 @@ 'Loading more results...' => 'NačítÃĄní dalÅĄÃ­ch vÃŊsledků...', 'No results found' => 'Nenalezeny ÅžÃĄdnÊ poloÅžky', 'No more results' => 'ÅŊÃĄdnÊ dalÅĄÃ­ vÃŊsledky', - // 'Add %placeholder%...' => 'Add %placeholder%...', + 'Add %placeholder%...' => 'Přidat %placeholder%...', ]; diff --git a/src/Autocomplete/translations/AutocompleteBundle.de.php b/src/Autocomplete/translations/AutocompleteBundle.de.php index fb5729ba0a0..107fbae1c70 100644 --- a/src/Autocomplete/translations/AutocompleteBundle.de.php +++ b/src/Autocomplete/translations/AutocompleteBundle.de.php @@ -13,5 +13,5 @@ 'Loading more results...' => 'Lade weitere Ergebnisse...', 'No results found' => 'Keine Übereinstimmungen gefunden', 'No more results' => 'Keine weiteren Ergebnisse', - // 'Add %placeholder%...' => 'Add %placeholder%...', + 'Add %placeholder%...' => '%placeholder% hinzufÃŧgen...', ]; diff --git a/src/Autocomplete/translations/AutocompleteBundle.nl.php b/src/Autocomplete/translations/AutocompleteBundle.nl.php index d9a2a90c092..106be824958 100644 --- a/src/Autocomplete/translations/AutocompleteBundle.nl.php +++ b/src/Autocomplete/translations/AutocompleteBundle.nl.php @@ -13,5 +13,5 @@ 'Loading more results...' => 'Meer resultaten aan het laden...', 'No results found' => 'Geen resultaten gevondenâ€Ļ', 'No more results' => 'Niet meer resultaten gevondenâ€Ļ', - // 'Add %placeholder%...' => 'Add %placeholder%...', + 'Add %placeholder%...' => 'Voeg %placeholder% toe...', ]; diff --git a/src/Autocomplete/translations/AutocompleteBundle.pl.php b/src/Autocomplete/translations/AutocompleteBundle.pl.php index 7a206574b43..3ee285a303c 100644 --- a/src/Autocomplete/translations/AutocompleteBundle.pl.php +++ b/src/Autocomplete/translations/AutocompleteBundle.pl.php @@ -13,5 +13,5 @@ 'Loading more results...' => 'Wczytywanie więcej wynikÃŗw...', 'No results found' => 'Brak wynikÃŗw', 'No more results' => 'Brak więcej wynikÃŗw', - // 'Add %placeholder%...' => 'Add %placeholder%...', + 'Add %placeholder%...' => 'Dodaj %placeholder%...', ]; diff --git a/src/Autocomplete/translations/AutocompleteBundle.sk.php b/src/Autocomplete/translations/AutocompleteBundle.sk.php index 612dfbe946a..d8304039898 100644 --- a/src/Autocomplete/translations/AutocompleteBundle.sk.php +++ b/src/Autocomplete/translations/AutocompleteBundle.sk.php @@ -13,5 +13,5 @@ 'Loading more results...' => 'NačítavajÃē sa ďalÅĄie vÃŊsledky...', 'No results found' => 'Neboli nÃĄjdenÊ Åžiadne vÃŊsledky', 'No more results' => 'ÅŊiadne ďalÅĄie vÃŊsledky', - // 'Add %placeholder%...' => 'Add %placeholder%...', + 'Add %placeholder%...' => 'PridaÅĨ %placeholder%...', ]; diff --git a/src/Autocomplete/translations/AutocompleteBundle.tr.php b/src/Autocomplete/translations/AutocompleteBundle.tr.php index 326d80b400f..5f5afeba205 100644 --- a/src/Autocomplete/translations/AutocompleteBundle.tr.php +++ b/src/Autocomplete/translations/AutocompleteBundle.tr.php @@ -13,5 +13,5 @@ 'Loading more results...' => 'Daha fazla sonuç yÃŧkleniyor...', 'No results found' => 'Sonuç bulunamadÄą', 'No more results' => 'Başka sonuç yok', - // 'Add %placeholder%...' => 'Add %placeholder%...', + 'Add %placeholder%...' => '%placeholder% Ekle...', ]; diff --git a/src/Chartjs/.gitattributes b/src/Chartjs/.gitattributes index 1ba1a889ab8..ec2de4be5f0 100644 --- a/src/Chartjs/.gitattributes +++ b/src/Chartjs/.gitattributes @@ -1,8 +1,8 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /assets/src export-ignore /assets/test export-ignore /assets/vitest.config.js export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/Chartjs/.github/PULL_REQUEST_TEMPLATE.md b/src/Chartjs/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Chartjs/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Chartjs/.github/workflows/close-pull-request.yml b/src/Chartjs/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Chartjs/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Chartjs/.gitignore b/src/Chartjs/.gitignore index bb17c3e124c..2cc9f0231c3 100644 --- a/src/Chartjs/.gitignore +++ b/src/Chartjs/.gitignore @@ -1,4 +1,5 @@ +/assets/node_modules/ /vendor/ -.phpunit.result.cache -.php_cs.cache -composer.lock +/composer.lock +/phpunit.xml +/.phpunit.result.cache diff --git a/src/Chartjs/CHANGELOG.md b/src/Chartjs/CHANGELOG.md index 0bbaa88d6d8..2e4ed856fc6 100644 --- a/src/Chartjs/CHANGELOG.md +++ b/src/Chartjs/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.23.0 + +- Listen to Stimulus `disconnect` event to destroy the chart #1944 + ## 2.18.0 - Replace `chartjs/auto` import with explicit `Chart.register()` call #1263 diff --git a/src/Chartjs/assets/LICENSE b/src/Chartjs/assets/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/Chartjs/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Chartjs/assets/README.md b/src/Chartjs/assets/README.md new file mode 100644 index 00000000000..b5b0376054e --- /dev/null +++ b/src/Chartjs/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-chartjs + +JavaScript assets of the [symfony/ux-chartjs](https://packagist.org/packages/symfony/ux-chartjs) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-chartjs](https://packagist.org/packages/symfony/ux-chartjs) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-chartjs](https://packagist.org/packages/symfony/ux-chartjs) PHP package version: +```shell +composer require symfony/ux-chartjs:2.23.0 +npm add @symfony/ux-chartjs@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-chartjs/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Chartjs/assets/dist/controller.d.ts b/src/Chartjs/assets/dist/controller.d.ts index 4f1287e9048..bc05560d88b 100644 --- a/src/Chartjs/assets/dist/controller.d.ts +++ b/src/Chartjs/assets/dist/controller.d.ts @@ -6,6 +6,7 @@ export default class extends Controller { }; private chart; connect(): void; + disconnect(): void; viewValueChanged(): void; private dispatchEvent; } diff --git a/src/Chartjs/assets/dist/controller.js b/src/Chartjs/assets/dist/controller.js index 9432a885382..7caa0cb1b8e 100644 --- a/src/Chartjs/assets/dist/controller.js +++ b/src/Chartjs/assets/dist/controller.js @@ -35,6 +35,13 @@ class default_1 extends Controller { this.chart = new Chart(canvasContext, payload); this.dispatchEvent('connect', { chart: this.chart }); } + disconnect() { + this.dispatchEvent('disconnect', { chart: this.chart }); + if (this.chart) { + this.chart.destroy(); + this.chart = null; + } + } viewValueChanged() { if (this.chart) { const viewValue = { data: this.viewValue.data, options: this.viewValue.options }; diff --git a/src/Chartjs/assets/package.json b/src/Chartjs/assets/package.json index ed730622523..7015bd5570e 100644 --- a/src/Chartjs/assets/package.json +++ b/src/Chartjs/assets/package.json @@ -2,10 +2,25 @@ "name": "@symfony/ux-chartjs", "description": "Chart.js integration for Symfony", "license": "MIT", - "version": "1.1.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/chartjs", + "repository": "https://github.com/symfony/ux-chartjs", "type": "module", + "files": [ + "dist" + ], "main": "dist/controller.js", "types": "dist/controller.d.ts", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "chart": { diff --git a/src/Chartjs/assets/src/controller.ts b/src/Chartjs/assets/src/controller.ts index 5888eb3cd63..78b7daaa7a3 100644 --- a/src/Chartjs/assets/src/controller.ts +++ b/src/Chartjs/assets/src/controller.ts @@ -57,6 +57,15 @@ export default class extends Controller { this.dispatchEvent('connect', { chart: this.chart }); } + disconnect() { + this.dispatchEvent('disconnect', { chart: this.chart }); + + if (this.chart) { + this.chart.destroy(); + this.chart = null; + } + } + /** * If the underlying data or options change, let's update the chart! */ diff --git a/src/Chartjs/doc/index.rst b/src/Chartjs/doc/index.rst index e54ef1bd35e..ba59bfed1e2 100644 --- a/src/Chartjs/doc/index.rst +++ b/src/Chartjs/doc/index.rst @@ -8,10 +8,6 @@ It is part of `the Symfony UX initiative`_. Installation ------------ -.. caution:: - - Before you start, make sure you have `StimulusBundle configured in your app`_. - Install the bundle using Composer and Symfony Flex: .. code-block:: terminal @@ -26,9 +22,9 @@ needed if you're using AssetMapper): $ npm install --force $ npm run watch - # or use yarn - $ yarn install --force - $ yarn watch +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-chartjs npm package`_ Usage ----- @@ -99,9 +95,6 @@ First, install the plugin: $ npm install chartjs-plugin-zoom -D - # or use yarn - $ yarn add chartjs-plugin-zoom --dev - Then register the plugin globally. This can be done in your ``app.js`` file: .. code-block:: javascript @@ -259,9 +252,9 @@ the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html .. _`Chart.js`: https://www.chartjs.org .. _`the Symfony UX initiative`: https://ux.symfony.com/ .. _`Chart.js documentation`: https://www.chartjs.org/docs/latest/ -.. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html .. _`a lot of plugins`: https://github.com/chartjs/awesome#plugins .. _`zoom plugin`: https://www.chartjs.org/chartjs-plugin-zoom/latest/ .. _`zoom plugin documentation`: https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/integration.html .. _`register Chart.js plugins globally`: https://www.chartjs.org/docs/latest/developers/plugins.html .. _`Tooltip positioner`: https://www.chartjs.org/docs/latest/samples/tooltip/position.html +.. _`@symfony/ux-chartjs npm package`: https://www.npmjs.com/package/@symfony/ux-chartjs diff --git a/src/Chartjs/tests/Kernel/TwigAppKernel.php b/src/Chartjs/tests/Kernel/TwigAppKernel.php index 5d629bd2c88..76de3dbd28c 100644 --- a/src/Chartjs/tests/Kernel/TwigAppKernel.php +++ b/src/Chartjs/tests/Kernel/TwigAppKernel.php @@ -31,7 +31,7 @@ public function registerBundles(): iterable return [new FrameworkBundle(), new TwigBundle(), new StimulusBundle(), new ChartjsBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); diff --git a/src/Cropperjs/.gitattributes b/src/Cropperjs/.gitattributes index 2b1d42ea804..b9bb8f6e796 100644 --- a/src/Cropperjs/.gitattributes +++ b/src/Cropperjs/.gitattributes @@ -1,7 +1,7 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /assets/src export-ignore /assets/test export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/Cropperjs/.github/PULL_REQUEST_TEMPLATE.md b/src/Cropperjs/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Cropperjs/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Cropperjs/.github/workflows/close-pull-request.yml b/src/Cropperjs/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Cropperjs/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Cropperjs/.gitignore b/src/Cropperjs/.gitignore index 30282084317..2cc9f0231c3 100644 --- a/src/Cropperjs/.gitignore +++ b/src/Cropperjs/.gitignore @@ -1,4 +1,5 @@ -vendor -composer.lock -.php_cs.cache -.phpunit.result.cache +/assets/node_modules/ +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.result.cache diff --git a/src/Cropperjs/assets/LICENSE b/src/Cropperjs/assets/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/Cropperjs/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Cropperjs/assets/README.md b/src/Cropperjs/assets/README.md new file mode 100644 index 00000000000..178ff0a48fd --- /dev/null +++ b/src/Cropperjs/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-cropperjs + +JavaScript assets of the [symfony/ux-cropperjs](https://packagist.org/packages/symfony/ux-cropperjs) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-cropperjs](https://packagist.org/packages/symfony/ux-cropperjs) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-cropperjs](https://packagist.org/packages/symfony/ux-cropperjs) PHP package version: +```shell +composer require symfony/ux-cropperjs:2.23.0 +npm add @symfony/ux-cropperjs@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-cropperjs/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Cropperjs/assets/package.json b/src/Cropperjs/assets/package.json index c4949311240..01689c0530f 100644 --- a/src/Cropperjs/assets/package.json +++ b/src/Cropperjs/assets/package.json @@ -2,12 +2,28 @@ "name": "@symfony/ux-cropperjs", "description": "Cropper.js integration for Symfony", "license": "MIT", - "version": "1.1.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/cropperjs", + "repository": "https://github.com/symfony/ux-cropperjs", + "type": "module", + "files": [ + "dist" + ], "main": "dist/controller.js", "types": "dist/controller.d.ts", "config": { "css_source": "src/style.css" }, + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "cropper": { @@ -27,11 +43,11 @@ } }, "peerDependencies": { - "cropperjs": "^1.5.9", - "@hotwired/stimulus": "^3.0.0" + "@hotwired/stimulus": "^3.0.0", + "cropperjs": "^1.5.9" }, "devDependencies": { - "cropperjs": "^1.5.9", - "@hotwired/stimulus": "^3.0.0" + "@hotwired/stimulus": "^3.0.0", + "cropperjs": "^1.5.9" } } diff --git a/src/Cropperjs/assets/test/controller.test.ts b/src/Cropperjs/assets/test/controller.test.ts index 4cc5983fea4..5f1de56ab8b 100644 --- a/src/Cropperjs/assets/test/controller.test.ts +++ b/src/Cropperjs/assets/test/controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import CropperjsController from '../src/controller'; let cropper: Cropper | null = null; diff --git a/src/Cropperjs/doc/index.rst b/src/Cropperjs/doc/index.rst index df4ef6eef35..0e512666761 100644 --- a/src/Cropperjs/doc/index.rst +++ b/src/Cropperjs/doc/index.rst @@ -26,9 +26,9 @@ needed if you're using AssetMapper): $ npm install --force $ npm run watch - # or use yarn - $ yarn install --force - $ yarn watch +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-cropperjs npm package`_ Usage ----- @@ -153,3 +153,4 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`the Symfony UX initiative`: https://ux.symfony.com/ .. _`the Cropper.js options`: https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options .. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html +.. _`@symfony/ux-cropperjs npm package`: https://www.npmjs.com/package/@symfony/ux-cropperjs diff --git a/src/Cropperjs/tests/Kernel/EmptyAppKernel.php b/src/Cropperjs/tests/Kernel/EmptyAppKernel.php index 71926a9823b..a4a7c89bac8 100644 --- a/src/Cropperjs/tests/Kernel/EmptyAppKernel.php +++ b/src/Cropperjs/tests/Kernel/EmptyAppKernel.php @@ -29,7 +29,7 @@ public function registerBundles(): iterable return [new CropperjsBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { } } diff --git a/src/Cropperjs/tests/Kernel/FrameworkAppKernel.php b/src/Cropperjs/tests/Kernel/FrameworkAppKernel.php index 1944eace20c..63535d3a2fb 100644 --- a/src/Cropperjs/tests/Kernel/FrameworkAppKernel.php +++ b/src/Cropperjs/tests/Kernel/FrameworkAppKernel.php @@ -31,7 +31,7 @@ public function registerBundles(): iterable return [new FrameworkBundle(), new CropperjsBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $frameworkConfig = [ diff --git a/src/Cropperjs/tests/Kernel/TwigAppKernel.php b/src/Cropperjs/tests/Kernel/TwigAppKernel.php index 95cad5206c9..9b3a9ce84c3 100644 --- a/src/Cropperjs/tests/Kernel/TwigAppKernel.php +++ b/src/Cropperjs/tests/Kernel/TwigAppKernel.php @@ -34,7 +34,7 @@ public function registerBundles(): iterable return [new FrameworkBundle(), new TwigBundle(), new CropperjsBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $frameworkConfig = [ diff --git a/src/Dropzone/.gitattributes b/src/Dropzone/.gitattributes index 2b1d42ea804..b9bb8f6e796 100644 --- a/src/Dropzone/.gitattributes +++ b/src/Dropzone/.gitattributes @@ -1,7 +1,7 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /assets/src export-ignore /assets/test export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/Dropzone/.github/PULL_REQUEST_TEMPLATE.md b/src/Dropzone/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Dropzone/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Dropzone/.github/workflows/close-pull-request.yml b/src/Dropzone/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Dropzone/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Dropzone/.gitignore b/src/Dropzone/.gitignore index 3ffdd8aef65..2cc9f0231c3 100644 --- a/src/Dropzone/.gitignore +++ b/src/Dropzone/.gitignore @@ -1,3 +1,5 @@ +/assets/node_modules/ /vendor/ -.phpunit.result.cache -composer.lock +/composer.lock +/phpunit.xml +/.phpunit.result.cache diff --git a/src/Dropzone/CHANGELOG.md b/src/Dropzone/CHANGELOG.md index bfe4e024f34..77389fe0f19 100644 --- a/src/Dropzone/CHANGELOG.md +++ b/src/Dropzone/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.20 + +- Enable file replacement via "drag-and-drop" + ## 2.13.2 - Revert "Change JavaScript package to `type: module`" diff --git a/src/Dropzone/assets/LICENSE b/src/Dropzone/assets/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/Dropzone/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Dropzone/assets/README.md b/src/Dropzone/assets/README.md new file mode 100644 index 00000000000..a9dcd553f05 --- /dev/null +++ b/src/Dropzone/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-dropzone + +JavaScript assets of the [symfony/ux-dropzone](https://packagist.org/packages/symfony/ux-dropzone) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-dropzone](https://packagist.org/packages/symfony/ux-dropzone) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-dropzone](https://packagist.org/packages/symfony/ux-dropzone) PHP package version: +```shell +composer require symfony/ux-dropzone:2.23.0 +npm add @symfony/ux-dropzone@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-dropzone/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Dropzone/assets/dist/controller.d.ts b/src/Dropzone/assets/dist/controller.d.ts index 5c834d44488..6e67b85b5cd 100644 --- a/src/Dropzone/assets/dist/controller.d.ts +++ b/src/Dropzone/assets/dist/controller.d.ts @@ -13,5 +13,7 @@ export default class extends Controller { clear(): void; onInputChange(event: any): void; _populateImagePreview(file: Blob): void; + onDragEnter(): void; + onDragLeave(event: any): void; private dispatchEvent; } diff --git a/src/Dropzone/assets/dist/controller.js b/src/Dropzone/assets/dist/controller.js index 2ff1e46c6c3..ebfb380d12a 100644 --- a/src/Dropzone/assets/dist/controller.js +++ b/src/Dropzone/assets/dist/controller.js @@ -4,16 +4,22 @@ class default_1 extends Controller { initialize() { this.clear = this.clear.bind(this); this.onInputChange = this.onInputChange.bind(this); + this.onDragEnter = this.onDragEnter.bind(this); + this.onDragLeave = this.onDragLeave.bind(this); } connect() { this.clear(); this.previewClearButtonTarget.addEventListener('click', this.clear); this.inputTarget.addEventListener('change', this.onInputChange); + this.element.addEventListener('dragenter', this.onDragEnter); + this.element.addEventListener('dragleave', this.onDragLeave); this.dispatchEvent('connect'); } disconnect() { this.previewClearButtonTarget.removeEventListener('click', this.clear); this.inputTarget.removeEventListener('change', this.onInputChange); + this.element.removeEventListener('dragenter', this.onDragEnter); + this.element.removeEventListener('dragleave', this.onDragLeave); } clear() { this.inputTarget.value = ''; @@ -51,6 +57,19 @@ class default_1 extends Controller { }); reader.readAsDataURL(file); } + onDragEnter() { + this.inputTarget.style.display = 'block'; + this.placeholderTarget.style.display = 'block'; + this.previewTarget.style.display = 'none'; + } + onDragLeave(event) { + event.preventDefault(); + if (!this.element.contains(event.relatedTarget)) { + this.inputTarget.style.display = 'none'; + this.placeholderTarget.style.display = 'none'; + this.previewTarget.style.display = 'block'; + } + } dispatchEvent(name, payload = {}) { this.dispatch(name, { detail: payload, prefix: 'dropzone' }); } diff --git a/src/Dropzone/assets/dist/style.min.css b/src/Dropzone/assets/dist/style.min.css index df4fdd88585..4c1e49daedb 100644 --- a/src/Dropzone/assets/dist/style.min.css +++ b/src/Dropzone/assets/dist/style.min.css @@ -1 +1 @@ -.dropzone-container{position:relative;display:flex;min-height:100px;border:2px dashed #bbb;align-items:center;padding:20px 10px}.dropzone-input{position:absolute;display:block;top:0;left:0;width:100%;height:100%;opacity:0;cursor:pointer;z-index:1}.dropzone-preview{display:flex;align-items:center;max-width:100%}.dropzone-preview-image{flex-basis:0;min-width:50px;max-width:50px;height:50px;margin-right:10px;background-size:contain;background-position:50% 50%;background-repeat:no-repeat}.dropzone-preview-filename{word-wrap:anywhere}.dropzone-preview-button{position:absolute;top:0;right:0;z-index:1;border:none;margin:0;padding:0;width:auto;overflow:visible;background:0 0;color:inherit;font:inherit;line-height:normal;-webkit-font-smoothing:inherit;-moz-osx-font-smoothing:inherit;-webkit-appearance:none}.dropzone-preview-button::before{content:'×';padding:3px 7px;cursor:pointer}.dropzone-placeholder{flex-grow:1;text-align:center;color:#999} \ No newline at end of file +.dropzone-container{border:2px dashed #bbb;align-items:center;min-height:100px;padding:20px 10px;display:flex;position:relative}.dropzone-input{opacity:0;cursor:pointer;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.dropzone-preview{align-items:center;max-width:100%;display:flex}.dropzone-preview-image{background-position:50%;background-repeat:no-repeat;background-size:contain;flex-basis:0;min-width:50px;max-width:50px;height:50px;margin-right:10px}.dropzone-preview-filename{word-wrap:anywhere}.dropzone-preview-button{z-index:1;width:auto;color:inherit;font:inherit;-webkit-font-smoothing:inherit;-moz-osx-font-smoothing:inherit;-webkit-appearance:none;background:0 0;border:none;margin:0;padding:0;line-height:normal;position:absolute;top:0;right:0;overflow:visible}.dropzone-preview-button:before{content:"×";cursor:pointer;padding:3px 7px}.dropzone-placeholder{text-align:center;color:#999;flex-grow:1} \ No newline at end of file diff --git a/src/Dropzone/assets/package.json b/src/Dropzone/assets/package.json index 1fc19df7ed1..754b3560a71 100644 --- a/src/Dropzone/assets/package.json +++ b/src/Dropzone/assets/package.json @@ -2,12 +2,28 @@ "name": "@symfony/ux-dropzone", "description": "File input dropzones for Symfony Forms", "license": "MIT", - "version": "1.1.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/dropzone", + "repository": "https://github.com/symfony/ux-dropzone", + "type": "module", + "files": [ + "dist" + ], "main": "dist/controller.js", "types": "dist/controller.d.ts", "config": { "css_source": "src/style.css" }, + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "dropzone": { diff --git a/src/Dropzone/assets/src/controller.ts b/src/Dropzone/assets/src/controller.ts index 51550e43e11..b2533329388 100644 --- a/src/Dropzone/assets/src/controller.ts +++ b/src/Dropzone/assets/src/controller.ts @@ -22,6 +22,8 @@ export default class extends Controller { initialize() { this.clear = this.clear.bind(this); this.onInputChange = this.onInputChange.bind(this); + this.onDragEnter = this.onDragEnter.bind(this); + this.onDragLeave = this.onDragLeave.bind(this); } connect() { @@ -34,12 +36,20 @@ export default class extends Controller { // Listen on input change and display preview this.inputTarget.addEventListener('change', this.onInputChange); + // Add dragenter event listener + this.element.addEventListener('dragenter', this.onDragEnter); + + // Add dragleave event listener + this.element.addEventListener('dragleave', this.onDragLeave); + this.dispatchEvent('connect'); } disconnect() { this.previewClearButtonTarget.removeEventListener('click', this.clear); this.inputTarget.removeEventListener('change', this.onInputChange); + this.element.removeEventListener('dragenter', this.onDragEnter); + this.element.removeEventListener('dragleave', this.onDragLeave); } clear() { @@ -93,6 +103,23 @@ export default class extends Controller { reader.readAsDataURL(file); } + onDragEnter() { + this.inputTarget.style.display = 'block'; + this.placeholderTarget.style.display = 'block'; + this.previewTarget.style.display = 'none'; + } + + onDragLeave(event: any) { + event.preventDefault(); + + // Check if we really leave the main drag area + if (!this.element.contains(event.relatedTarget as Node)) { + this.inputTarget.style.display = 'none'; + this.placeholderTarget.style.display = 'none'; + this.previewTarget.style.display = 'block'; + } + } + private dispatchEvent(name: string, payload: any = {}) { this.dispatch(name, { detail: payload, prefix: 'dropzone' }); } diff --git a/src/Dropzone/assets/test/controller.test.ts b/src/Dropzone/assets/test/controller.test.ts index 7935ebf1b61..b37dadf4bbb 100644 --- a/src/Dropzone/assets/test/controller.test.ts +++ b/src/Dropzone/assets/test/controller.test.ts @@ -8,9 +8,9 @@ */ import { Application, Controller } from '@hotwired/stimulus'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; import { getByTestId, waitFor } from '@testing-library/dom'; import user from '@testing-library/user-event'; -import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; import DropzoneController from '../src/controller'; // Controller used to check the actual controller was properly booted @@ -130,4 +130,27 @@ describe('DropzoneController', () => { expect(dispatched).not.toBeNull(); expect(dispatched.detail).toStrictEqual(file); }); + + it('on drag', async () => { + startStimulus(); + + // Simulate dragenter event + const dragEnterEvent = new Event('dragenter'); + getByTestId(container, 'container').dispatchEvent(dragEnterEvent); + + // Check that the input and placeholder are visible, and preview hidden + await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'none' })); + + // Simulate dragleave event with relatedTarget set to outside the dropzone + const dragLeaveEvent = new Event('dragleave', { bubbles: true }); + Object.defineProperty(dragLeaveEvent, 'relatedTarget', { value: document.body }); + getByTestId(container, 'container').dispatchEvent(dragLeaveEvent); + + // Check that the input and placeholder are hidden, and preview shown + await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'none' })); + await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' })); + await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'block' })); + }); }); diff --git a/src/Dropzone/doc/index.rst b/src/Dropzone/doc/index.rst index cff26ffbda6..85624d9b38b 100644 --- a/src/Dropzone/doc/index.rst +++ b/src/Dropzone/doc/index.rst @@ -28,9 +28,9 @@ needed if you're using AssetMapper): $ npm install --force $ npm run watch - # or use yarn - $ yarn install --force - $ yarn watch +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-dropzone npm package`_ Usage ----- @@ -85,9 +85,9 @@ switching the ``@symfony/ux-dropzone/dist/style.min.css`` autoimport to .. note:: *Note*: you should put the value to ``false`` and not remove the line - so that Symfony Flex won’t try to add the line again in the future. + so that Symfony Flex won't try to add the line again in the future. -Once done, the default stylesheet won’t be used anymore and you can +Once done, the default stylesheet won't be used anymore and you can implement your own CSS on top of the Dropzone. Extend the default behavior @@ -159,3 +159,4 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`the Symfony UX initiative`: https://ux.symfony.com/ .. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html +.. _`@symfony/ux-dropzone npm package`: https://www.npmjs.com/package/@symfony/ux-dropzone diff --git a/src/Dropzone/src/DependencyInjection/DropzoneExtension.php b/src/Dropzone/src/DependencyInjection/DropzoneExtension.php index a40bc5fff68..697d1249840 100644 --- a/src/Dropzone/src/DependencyInjection/DropzoneExtension.php +++ b/src/Dropzone/src/DependencyInjection/DropzoneExtension.php @@ -25,7 +25,7 @@ */ class DropzoneExtension extends Extension implements PrependExtensionInterface { - public function prepend(ContainerBuilder $container) + public function prepend(ContainerBuilder $container): void { // Register the Dropzone form theme if TwigBundle is available $bundles = $container->getParameter('kernel.bundles'); diff --git a/src/Dropzone/tests/Kernel/EmptyAppKernel.php b/src/Dropzone/tests/Kernel/EmptyAppKernel.php index f5734e0a5ce..e1541b7724e 100644 --- a/src/Dropzone/tests/Kernel/EmptyAppKernel.php +++ b/src/Dropzone/tests/Kernel/EmptyAppKernel.php @@ -29,7 +29,7 @@ public function registerBundles(): iterable return [new DropzoneBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { } } diff --git a/src/Dropzone/tests/Kernel/FrameworkAppKernel.php b/src/Dropzone/tests/Kernel/FrameworkAppKernel.php index 4ab07836bd5..0198c6743db 100644 --- a/src/Dropzone/tests/Kernel/FrameworkAppKernel.php +++ b/src/Dropzone/tests/Kernel/FrameworkAppKernel.php @@ -31,7 +31,7 @@ public function registerBundles(): iterable return [new FrameworkBundle(), new DropzoneBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); diff --git a/src/Dropzone/tests/Kernel/TwigAppKernel.php b/src/Dropzone/tests/Kernel/TwigAppKernel.php index 8970425ab72..0da830a90f8 100644 --- a/src/Dropzone/tests/Kernel/TwigAppKernel.php +++ b/src/Dropzone/tests/Kernel/TwigAppKernel.php @@ -34,7 +34,7 @@ public function registerBundles(): iterable return [new FrameworkBundle(), new TwigBundle(), new DropzoneBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); diff --git a/src/Icons/.gitattributes b/src/Icons/.gitattributes index 596519e1403..db1844b9e9f 100644 --- a/src/Icons/.gitattributes +++ b/src/Icons/.gitattributes @@ -1,5 +1,5 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/Icons/.github/PULL_REQUEST_TEMPLATE.md b/src/Icons/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Icons/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Icons/.github/workflows/close-pull-request.yml b/src/Icons/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Icons/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Icons/.gitignore b/src/Icons/.gitignore index 6b4669489f4..1784dd6561c 100644 --- a/src/Icons/.gitignore +++ b/src/Icons/.gitignore @@ -1,4 +1,7 @@ -/vendor +/assets/node_modules/ +/vendor/ /composer.lock -/var +/phpunit.xml /.phpunit.result.cache + +/var diff --git a/src/Icons/CHANGELOG.md b/src/Icons/CHANGELOG.md index 8ff2aad1fb0..52b4fb26f97 100644 --- a/src/Icons/CHANGELOG.md +++ b/src/Icons/CHANGELOG.md @@ -1,5 +1,15 @@ # CHANGELOG +## 2.25.0 + +- Improve DX when `symfony/http-client` is not installed. + +## 2.24.0 + +- Add `xmlns` attribute to icons downloaded with Iconify, to correctly render icons browser as an external file, in SVG editors, and in files explorers or text editors previews. +It **may breaks your pipeline** if you assert on `ux_icon()` or `` output in your tests, and forgot [to lock your icons](https://symfony.com/bundles/ux-icons/current/index.html#locking-on-demand-icons). +We recommend you to **lock** your icons **before** upgrading to UX Icons 2.24. We also suggest you to to **force-lock** your icons **after** upgrading to UX Icons 2.24, to add the attribute `xmlns` to your icons already downloaded from Iconify. + ## 2.20.0 - Add `aliases` configuration option to define icon alternative names. diff --git a/src/Icons/composer.json b/src/Icons/composer.json index 14dfadb5adf..469effc3a2f 100644 --- a/src/Icons/composer.json +++ b/src/Icons/composer.json @@ -52,7 +52,8 @@ "sort-packages": true }, "conflict": { - "symfony/flex": "<1.13" + "symfony/flex": "<1.13", + "symfony/ux-twig-component": "<2.21" }, "extra": { "thanks": { diff --git a/src/Icons/config/iconify.php b/src/Icons/config/iconify.php deleted file mode 100644 index 91ea2f57d6b..00000000000 --- a/src/Icons/config/iconify.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\DependencyInjection\Loader\Configurator; - -use Symfony\UX\Icons\Command\ImportIconCommand; -use Symfony\UX\Icons\Command\LockIconsCommand; -use Symfony\UX\Icons\Command\SearchIconCommand; -use Symfony\UX\Icons\Iconify; -use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry; - -return static function (ContainerConfigurator $container): void { - $container->services() - ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class) - ->args([ - service('.ux_icons.iconify'), - ]) - ->tag('ux_icons.registry') - - ->set('.ux_icons.iconify', Iconify::class) - ->args([ - service('cache.system'), - abstract_arg('endpoint'), - service('http_client')->nullOnInvalid(), - ]) - - ->set('.ux_icons.command.import', ImportIconCommand::class) - ->args([ - service('.ux_icons.iconify'), - service('.ux_icons.local_svg_icon_registry'), - ]) - ->tag('console.command') - - ->set('.ux_icons.command.lock', LockIconsCommand::class) - ->args([ - service('.ux_icons.iconify'), - service('.ux_icons.local_svg_icon_registry'), - service('.ux_icons.icon_finder'), - ]) - ->tag('console.command') - - ->set('.ux_icons.command.search', SearchIconCommand::class) - ->args([ - service('.ux_icons.iconify'), - ]) - ->tag('console.command') - ; -}; diff --git a/src/Icons/config/services.php b/src/Icons/config/services.php index 7d0dc0abd11..4b29a381f7a 100644 --- a/src/Icons/config/services.php +++ b/src/Icons/config/services.php @@ -11,12 +11,17 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\UX\Icons\Command\ImportIconCommand; +use Symfony\UX\Icons\Command\LockIconsCommand; +use Symfony\UX\Icons\Command\SearchIconCommand; use Symfony\UX\Icons\Command\WarmCacheCommand; use Symfony\UX\Icons\IconCacheWarmer; +use Symfony\UX\Icons\Iconify; use Symfony\UX\Icons\IconRenderer; use Symfony\UX\Icons\IconRendererInterface; use Symfony\UX\Icons\Registry\CacheIconRegistry; use Symfony\UX\Icons\Registry\ChainIconRegistry; +use Symfony\UX\Icons\Registry\IconifyOnDemandRegistry; use Symfony\UX\Icons\Registry\LocalSvgIconRegistry; use Symfony\UX\Icons\Twig\IconFinder; use Symfony\UX\Icons\Twig\UXIconExtension; @@ -24,10 +29,15 @@ return static function (ContainerConfigurator $container): void { $container->services() + ->set('.ux_icons.cache') + ->parent('cache.system') + ->private() + ->tag('cache.pool') + ->set('.ux_icons.cache_icon_registry', CacheIconRegistry::class) ->args([ service('.ux_icons.chain_registry'), - service('cache.system'), + service('.ux_icons.cache'), ]) ->set('.ux_icons.local_svg_icon_registry', LocalSvgIconRegistry::class) @@ -53,6 +63,7 @@ service('logger')->ignoreOnInvalid(), ]) ->tag('twig.runtime') + ->tag('ux.twig_component.twig_renderer', ['key' => 'ux:icon']) ->set('.ux_icons.icon_renderer', IconRenderer::class) ->args([ @@ -80,5 +91,39 @@ service('.ux_icons.cache_warmer'), ]) ->tag('console.command') + + ->set('.ux_icons.iconify', Iconify::class) + ->args([ + service('.ux_icons.cache'), + abstract_arg('endpoint'), + service('http_client')->nullOnInvalid(), + ]) + + ->set('.ux_icons.iconify_on_demand_registry', IconifyOnDemandRegistry::class) + ->args([ + service('.ux_icons.iconify'), + ]) + ->tag('ux_icons.registry', ['priority' => -10]) + + ->set('.ux_icons.command.import', ImportIconCommand::class) + ->args([ + service('.ux_icons.iconify'), + service('.ux_icons.local_svg_icon_registry'), + ]) + ->tag('console.command') + + ->set('.ux_icons.command.lock', LockIconsCommand::class) + ->args([ + service('.ux_icons.iconify'), + service('.ux_icons.local_svg_icon_registry'), + service('.ux_icons.icon_finder'), + ]) + ->tag('console.command') + + ->set('.ux_icons.command.search', SearchIconCommand::class) + ->args([ + service('.ux_icons.iconify'), + ]) + ->tag('console.command') ; }; diff --git a/src/Icons/config/twig_component.php b/src/Icons/config/twig_component.php index f466f253662..0b65f64fa7a 100644 --- a/src/Icons/config/twig_component.php +++ b/src/Icons/config/twig_component.php @@ -12,19 +12,9 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\UX\Icons\Twig\UXIconComponent; -use Symfony\UX\Icons\Twig\UXIconComponentListener; return static function (ContainerConfigurator $container): void { $container->services() - ->set('.ux_icons.twig_component_listener', UXIconComponentListener::class) - ->args([ - service('.ux_icons.icon_renderer'), - ]) - ->tag('kernel.event_listener', [ - 'event' => 'Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent', - 'method' => 'onPreCreateForRender', - ]) - ->set('.ux_icons.twig_component.icon', UXIconComponent::class) ->tag('twig.component', ['key' => 'UX:Icon']) ; diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst index 0950e88596f..49f440eb93e 100644 --- a/src/Icons/doc/index.rst +++ b/src/Icons/doc/index.rst @@ -3,9 +3,9 @@ Symfony UX Icons The ``symfony/ux-icons`` package offers simple and intuitive ways to render SVG icons in your Symfony application. It provides a Twig function to include -any local or remote icons from your templates. +both local and remote icons in your templates. -UX Icons gives you a direct access to over 200,000 vector icons from popular +UX Icons gives you direct access to over 200,000 vector icons from popular icon sets such as FontAwesome, Bootstrap Icons, Tabler Icons, Google Material Design Icons, etc. @@ -16,6 +16,9 @@ Installation $ composer require symfony/ux-icons + # To use provided on-demand icon sets, you also need HTTP client: + $ composer require symfony/http-client + SVG Icons --------- @@ -30,7 +33,7 @@ your own. Icon Names ~~~~~~~~~~ -Icons are referenced using an unique identifier that follows one of the following syntaxes: +Icons are referenced using a unique identifier using one of the following syntaxes: * ``prefix:name`` (e.g. ``mdi:check``, ``bi:check``, ``editor:align-left``) * ``name`` only (e.g. ``check``, ``close``, ``menu``) @@ -39,9 +42,9 @@ The icon ``name`` is the same as the file name without the file extension (e.g. .. caution:: - The name must match a standard ``slug`` format: ``[a-z0-9-]+(-[0-9a-z])+``. + The name must match a standard ``slug`` format: ``[a-z0-9-]+(-[a-z0-9])+``. -Depending on your configuration, the ``prefix`` can be the name of an icon set, a directory +Depending on your `configuration`_, the ``prefix`` can be the name of an icon set, a directory where the icon is located, or a combination of both. For example, the ``bi`` prefix refers to the Bootstrap Icons set, while the ``header`` prefix @@ -63,15 +66,6 @@ Loading Icons and embeds the downloaded SVG contents in the template #} {{ ux_icon('flowbite:user-solid') }} -.. note:: - - To search and download icons via `ux.symfony.com/icons`_, the ``symfony/http-client`` - package must be installed in your application: - - .. code-block:: terminal - - $ composer require symfony/http-client - The ``ux_icon()`` function defines a second optional argument where you can define the HTML attributes added to the ```` element: @@ -80,13 +74,22 @@ define the HTML attributes added to the ```` element: {{ ux_icon('user-profile', {class: 'w-4 h-4'}) }} {# renders ... #} - {{ ux_icon('user-profile', {height: '16px', width: '16px', aria-hidden: true}) }} + {{ ux_icon('user-profile', {height: '16px', width: '16px', 'aria-hidden': true}) }} {# renders #} Icon Sets ~~~~~~~~~ +.. note:: + + To use icons from icon sets via `ux.symfony.com/icons`_, the ``symfony/http-client`` + package must be installed in your application: + + .. code-block:: terminal + + $ composer require symfony/http-client + There are many icon sets available, each with their own unique style and set of icons, providing a wide range of icons for different purposes, while maintaining a consistent look and feel across your application. Here are some of the most @@ -166,7 +169,7 @@ HTML Syntax In addition to the ``ux_icon()`` function explained in the previous sections, this package also supports an alternative HTML syntax based on the ```` -tag: +tag if the ``symfony/ux-twig-component`` package is installed: .. code-block:: html @@ -182,7 +185,7 @@ tag: .. tip:: - To use the HTML syntax, the ``symfony/ux-twig-component`` package must be + To use the HTML syntax, the `symfony/ux-twig-component`_ package must be installed in your project. Downloading Icons @@ -228,7 +231,7 @@ site *on-demand*: fetched (and cached). That's all. This works by using the `Iconify`_ API (to which `ux.symfony.com/icons`_ -is a frontend for) to fetch the icon and render it in place. This icon is then cached +is a frontend) to fetch the icon and render it in place. This icon is then cached for future requests for the same icon. .. note:: @@ -263,7 +266,7 @@ the ``assets/icons/`` directory. You can think of importing an icon as *locking Locking On-Demand Icons ~~~~~~~~~~~~~~~~~~~~~~~ -You can *lock* (import) all the `*on-demand* `_ icons you're using in your project by +You can *lock* (import) all the *on-demand* icons you're using in your project by running the following command: .. code-block:: terminal @@ -277,6 +280,18 @@ the report to overwrite existing icons by using the ``--force`` option: $ php bin/console ux:icons:lock --force +.. caution:: + + The process to find icons to lock in your Twig templates is imperfect. It + looks for any string that matches the pattern ``something:something`` so + it's probable there will be false positives. This command should not be used + to audit the icons in your templates in an automated way. Add ``-v`` to see + *potential* invalid icons: + + .. code-block:: terminal + + $ php bin/console ux:icons:lock -v + Rendering Icons --------------- @@ -463,6 +478,18 @@ In production, you can pre-warm the cache by running the following command: This command looks in all your Twig templates for ``ux_icon()`` calls and ```` tags and caches the icons it finds. +.. caution:: + + The process to find icons to cache in your Twig templates is imperfect. It + looks for any string that matches the pattern ``something:something`` so + it's probable there will be false positives. This command should not be used + to audit the icons in your templates in an automated way. Add ``-v`` see + *potential* invalid icons: + + .. code-block:: terminal + + $ php bin/console ux:icons:warm-cache -v + .. caution:: Icons that have a name built dynamically will not be cached. It's advised to @@ -499,11 +526,11 @@ returning the HTML output. .. warning:: - The component does not support embedded content. + The ```` component does not support embedded content. .. code-block:: html+twig - {# The 🧸 will be ignore in the HTML output #} + {# The 🧸 will be ignored in the HTML output #} 🧸 {# Renders "user-profile.svg" #} @@ -514,7 +541,7 @@ returning the HTML output. Configuration ------------- -The UX Icons integrates seamlessly in Symfony applications. All these options are configured under +The UX Icons component integrates seamlessly in Symfony applications. All these options are configured under the ``ux_icons`` key in your application configuration. .. code-block:: yaml @@ -541,30 +568,30 @@ Full Configuration # config/packages/ux_icons.yaml ux_icons: - # The local directory where icons are stored. + # The local directory where icons are stored icon_dir: '%kernel.project_dir%/assets/icons' - # Default attributes to add to all icons. + # Default attributes to add to all icons default_icon_attributes: - # Default: fill: currentColor + 'font-size': '1.25em' - # Icon aliases (alias => icon name). + # Icon aliases (alias => icon name) aliases: - # Exemple: dots: 'clarity:ellipsis-horizontal-line' + 'tabler:save': 'tabler:device-floppy' - # Configuration for the "on demand" icons powered by Iconify.design. + # Configuration for the "on demand" icons powered by Iconify.design iconify: enabled: true - # Whether to use the "on demand" icons powered by Iconify.design. + # Whether to use the "on demand" icons powered by Iconify.design on_demand: true - # The endpoint for the Iconify API. + # The endpoint for the Iconify API endpoint: 'https://api.iconify.design' - # Whether to ignore errors when an icon is not found. + # Whether to ignore errors when an icon is not found ignore_not_found: false Learn more @@ -577,6 +604,7 @@ Learn more .. _`ux.symfony.com/icons`: https://ux.symfony.com/icons .. _`Iconify`: https://iconify.design .. _`symfony/asset-mapper`: https://symfony.com/doc/current/frontend/asset_mapper.html +.. _`symfony/ux-twig-component`: https://symfony.com/bundles/ux-twig-component/current/index.html .. _`W3C guide about SVG icon accessibility`: https://design-system.w3.org/styles/svg-icons.html#svg-accessibility .. _`Bootstrap Icons`: https://icons.getbootstrap.com/ .. _`Boxicons`: https://boxicons.com/ diff --git a/src/Icons/src/Command/ImportIconCommand.php b/src/Icons/src/Command/ImportIconCommand.php index b287231d203..06b2354506c 100644 --- a/src/Icons/src/Command/ImportIconCommand.php +++ b/src/Icons/src/Command/ImportIconCommand.php @@ -18,7 +18,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\UX\Icons\Exception\IconNotFoundException; use Symfony\UX\Icons\Iconify; use Symfony\UX\Icons\Registry\LocalSvgIconRegistry; @@ -41,11 +40,7 @@ public function __construct(private Iconify $iconify, private LocalSvgIconRegist protected function configure(): void { $this - ->addArgument( - 'names', - InputArgument::IS_ARRAY | InputArgument::REQUIRED, - 'Icon name from ux.symfony.com/icons (e.g. "mdi:home")', - ) + ->addArgument('names', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Icon name from ux.symfony.com/icons (e.g. "mdi:home")') ; } @@ -54,7 +49,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $names = $input->getArgument('names'); $result = Command::SUCCESS; + $importedIcons = 0; + $prefixIcons = []; foreach ($names as $name) { if (!preg_match('#^([\w-]+):([\w-]+)$#', $name, $matches)) { $io->error(\sprintf('Invalid icon name "%s".', $name)); @@ -63,35 +60,63 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } - [$fullName, $prefix, $name] = $matches; - - $io->comment(\sprintf('Importing %s...', $fullName)); + [, $prefix, $name] = $matches; + $prefixIcons[$prefix] ??= []; + $prefixIcons[$prefix][$name] = $name; + } - try { - $svg = $this->iconify->fetchSvg($prefix, $name); - } catch (IconNotFoundException $e) { - $io->error($e->getMessage()); + foreach ($prefixIcons as $prefix => $icons) { + if (!$this->iconify->hasIconSet($prefix)) { + $io->error(\sprintf('Icon set "%s" not found.', $prefix)); $result = Command::FAILURE; continue; } - $cursor = new Cursor($output); - $cursor->moveUp(2); - - $this->registry->add(\sprintf('%s/%s', $prefix, $name), $svg); - - $license = $this->iconify->metadataFor($prefix)['license']; - - $io->text(\sprintf( - " ✓ Imported %s:%s (License: %s). Render with: {{ ux_icon('%s') }}", - $prefix, - $name, - $license['url'] ?? '#', - $license['title'], - $fullName, - )); + $metadata = $this->iconify->metadataFor($prefix); $io->newLine(); + $io->writeln(\sprintf(' Icon set: %s (License: %s)', $metadata['name'], $metadata['license']['title'])); + + foreach ($this->iconify->chunk($prefix, array_keys($icons)) as $iconNames) { + $cursor = new Cursor($output); + foreach ($iconNames as $name) { + $io->writeln(\sprintf(' Importing %s:%s ...', $prefix, $name)); + } + $cursor->moveUp(\count($iconNames)); + + try { + $batchResults = $this->iconify->fetchIcons($prefix, $iconNames); + } catch (\InvalidArgumentException $e) { + // At this point no exception should be thrown + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + foreach ($iconNames as $name) { + $cursor->clearLineAfter(); + + // If the icon is not found, the value will be null + if (null === $icon = $batchResults[$name] ?? null) { + $io->writeln(\sprintf(' ✗ Not Found %s:%s', $prefix, $name)); + + continue; + } + + ++$importedIcons; + $this->registry->add(\sprintf('%s/%s', $prefix, $name), (string) $icon); + $io->writeln(\sprintf(' ✓ Imported %s:%s', $prefix, $name)); + } + } + } + + if ($importedIcons === $totalIcons = \count($names)) { + $io->success(\sprintf('Imported %d/%d icons.', $importedIcons, $totalIcons)); + } elseif ($importedIcons > 0) { + $io->warning(\sprintf('Imported %d/%d icons.', $importedIcons, $totalIcons)); + } else { + $io->error(\sprintf('Imported %d/%d icons.', $importedIcons, $totalIcons)); + $result = Command::FAILURE; } return $result; diff --git a/src/Icons/src/Command/LockIconsCommand.php b/src/Icons/src/Command/LockIconsCommand.php index c58a258f9e3..69fab8e971b 100644 --- a/src/Icons/src/Command/LockIconsCommand.php +++ b/src/Icons/src/Command/LockIconsCommand.php @@ -37,6 +37,8 @@ public function __construct( private Iconify $iconify, private LocalSvgIconRegistry $registry, private IconFinder $iconFinder, + private readonly array $iconAliases = [], + private readonly array $iconSetAliases = [], ) { parent::__construct(); } @@ -59,32 +61,49 @@ protected function execute(InputInterface $input, OutputInterface $output): int $count = 0; $io->comment('Scanning project for icons...'); + $finderIcons = $this->iconFinder->icons(); - foreach ($this->iconFinder->icons() as $icon) { + if ($this->iconAliases) { + $io->comment('Adding icons aliases...'); + } + + foreach ([...array_values($this->iconAliases), ...array_values($finderIcons)] as $icon) { if (2 !== \count($parts = explode(':', $icon))) { continue; } - if (!$force && $this->registry->has($icon)) { + [$prefix, $name] = $parts; + $prefix = $this->iconSetAliases[$prefix] ?? $prefix; + + if (!$force && $this->registry->has($prefix.':'.$name)) { // icon already imported continue; } - [$prefix, $name] = $parts; + if (!$this->iconify->hasIconSet($prefix)) { + // not an icon set? example: "og:twitter" + if ($io->isVeryVerbose()) { + $io->writeln(\sprintf(' ✗ IconSet Not Found: %s:%s.', $prefix, $name)); + } + continue; + } try { - $svg = $this->iconify->fetchSvg($prefix, $name); + $iconSvg = $this->iconify->fetchIcon($prefix, $name)->toHtml(); } catch (IconNotFoundException) { // icon not found on iconify + if ($io->isVerbose()) { + $io->writeln(\sprintf(' ✗ Icon Not Found: %s:%s.', $prefix, $name)); + } continue; } - $this->registry->add(\sprintf('%s/%s', $prefix, $name), $svg); + $this->registry->add(\sprintf('%s/%s', $prefix, $name), $iconSvg); $license = $this->iconify->metadataFor($prefix)['license']; ++$count; - $io->text(\sprintf( + $io->writeln(\sprintf( " ✓ Imported %s:%s (License: %s). Render with: {{ ux_icon('%s') }}", $prefix, $name, diff --git a/src/Icons/src/Command/WarmCacheCommand.php b/src/Icons/src/Command/WarmCacheCommand.php index 1451a7cfc89..8263183c700 100644 --- a/src/Icons/src/Command/WarmCacheCommand.php +++ b/src/Icons/src/Command/WarmCacheCommand.php @@ -45,6 +45,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->writeln(\sprintf(' Warmed icon %s.', $name)); } }, + onFailure: function (string $name, \Exception $e) use ($io) { + if ($io->isVerbose()) { + $io->writeln(\sprintf(' Failed to warm (potential) icon %s.', $name)); + } + } ); $io->success('Icon cache warmed.'); diff --git a/src/Icons/src/DependencyInjection/UXIconsExtension.php b/src/Icons/src/DependencyInjection/UXIconsExtension.php index 618749ee701..b93240911cb 100644 --- a/src/Icons/src/DependencyInjection/UXIconsExtension.php +++ b/src/Icons/src/DependencyInjection/UXIconsExtension.php @@ -18,7 +18,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; -use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\UX\Icons\Iconify; /** @@ -87,7 +86,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->arrayNode('iconify') ->info('Configuration for the remote icon service.') - ->{interface_exists(HttpClientInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->canBeDisabled() ->children() ->booleanNode('on_demand') ->info('Whether to download icons "on demand".') @@ -164,22 +163,24 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container ->setArgument(1, $mergedConfig['ignore_not_found']) ; - if ($mergedConfig['iconify']['enabled']) { - $loader->load('iconify.php'); + $container->getDefinition('.ux_icons.iconify') + ->setArgument(1, $mergedConfig['iconify']['endpoint']); - $container->getDefinition('.ux_icons.iconify') - ->setArgument(1, $mergedConfig['iconify']['endpoint']); + $container->getDefinition('.ux_icons.iconify_on_demand_registry') + ->setArgument(1, $iconSetAliases); - $container->getDefinition('.ux_icons.iconify_on_demand_registry') - ->setArgument(1, $iconSetAliases); + $container->getDefinition('.ux_icons.command.lock') + ->setArgument(3, $mergedConfig['aliases']) + ->setArgument(4, $iconSetAliases); - if (!$mergedConfig['iconify']['on_demand']) { - $container->removeDefinition('.ux_icons.iconify_on_demand_registry'); - } + if (!$mergedConfig['iconify']['on_demand'] || !$mergedConfig['iconify']['enabled']) { + $container->removeDefinition('.ux_icons.iconify_on_demand_registry'); } if (!$container->getParameter('kernel.debug')) { $container->removeDefinition('.ux_icons.command.import'); + $container->removeDefinition('.ux_icons.command.search'); + $container->removeDefinition('.ux_icons.command.lock'); } } } diff --git a/src/Icons/src/Exception/HttpClientNotInstalledException.php b/src/Icons/src/Exception/HttpClientNotInstalledException.php new file mode 100644 index 00000000000..eb624ade512 --- /dev/null +++ b/src/Icons/src/Exception/HttpClientNotInstalledException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Icons\Exception; + +/** + * @author Kevin Bond + * + * @internal + */ +final class HttpClientNotInstalledException extends \LogicException +{ +} diff --git a/src/Icons/src/IconCacheWarmer.php b/src/Icons/src/IconCacheWarmer.php index 04215ae713f..2a63ad8499d 100644 --- a/src/Icons/src/IconCacheWarmer.php +++ b/src/Icons/src/IconCacheWarmer.php @@ -27,8 +27,8 @@ public function __construct(private CacheIconRegistry $registry, private IconFin } /** - * @param callable(string,Icon):void|null $onSuccess - * @param callable(string):void|null $onFailure + * @param callable(string,Icon):void|null $onSuccess + * @param callable(string,\Exception):void|null $onFailure */ public function warm(?callable $onSuccess = null, ?callable $onFailure = null): void { @@ -40,8 +40,8 @@ public function warm(?callable $onSuccess = null, ?callable $onFailure = null): $icon = $this->registry->get($name, refresh: true); $onSuccess($name, $icon); - } catch (IconNotFoundException) { - $onFailure($name); + } catch (IconNotFoundException $e) { + $onFailure($name, $e); } } } diff --git a/src/Icons/src/Iconify.php b/src/Icons/src/Iconify.php index d9d3fa15b02..7f7db1ea552 100644 --- a/src/Icons/src/Iconify.php +++ b/src/Icons/src/Iconify.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\UX\Icons\Exception\HttpClientNotInstalledException; use Symfony\UX\Icons\Exception\IconNotFoundException; /** @@ -26,20 +27,24 @@ final class Iconify { public const API_ENDPOINT = 'https://api.iconify.design'; + private const ATTR_XMLNS_URL = 'https://www.w3.org/2000/svg'; + + // URL must be 500 chars max (iconify limit) + // -39 chars: https://api.iconify.design/XXX.json?icons= + // -safe margin + private const MAX_ICONS_QUERY_LENGTH = 400; private HttpClientInterface $http; private \ArrayObject $sets; + private int $maxIconsQueryLength; public function __construct( private CacheInterface $cache, - string $endpoint = self::API_ENDPOINT, - ?HttpClientInterface $http = null, + private string $endpoint = self::API_ENDPOINT, + private ?HttpClientInterface $httpClient = null, + ?int $maxIconsQueryLength = null, ) { - if (!class_exists(HttpClient::class)) { - throw new \LogicException('You must install "symfony/http-client" to use Iconify. Try running "composer require symfony/http-client".'); - } - - $this->http = ScopingHttpClient::forBaseUri($http ?? HttpClient::create(), $endpoint); + $this->maxIconsQueryLength = min(self::MAX_ICONS_QUERY_LENGTH, $maxIconsQueryLength ?? self::MAX_ICONS_QUERY_LENGTH); } public function metadataFor(string $prefix): array @@ -53,7 +58,11 @@ public function fetchIcon(string $prefix, string $name): Icon throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name)); } - $response = $this->http->request('GET', \sprintf('/%s.json?icons=%s', $prefix, $name)); + $response = $this->http()->request('GET', \sprintf('/%s.json?icons=%s', $prefix, $name)); + + if (200 !== $response->getStatusCode()) { + throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name)); + } try { $data = $response->toArray(); @@ -77,26 +86,65 @@ public function fetchIcon(string $prefix, string $name): Icon } return new Icon($data['icons'][$name]['body'], [ + 'xmlns' => self::ATTR_XMLNS_URL, 'viewBox' => \sprintf('0 0 %s %s', $width ?? $height, $height ?? $width), ]); } - public function fetchSvg(string $prefix, string $name): string + public function fetchIcons(string $prefix, array $names): array { if (!isset($this->sets()[$prefix])) { - throw new IconNotFoundException(\sprintf('The icon "%s:%s" does not exist on iconify.design.', $prefix, $name)); + throw new IconNotFoundException(\sprintf('The icon set "%s" does not exist on iconify.design.', $prefix)); } - $content = $this->http - ->request('GET', \sprintf('/%s/%s.svg', $prefix, $name)) - ->getContent() - ; + // Sort to enforce cache hits + sort($names); + $queryString = implode(',', $names); + if (!preg_match('#^[a-z0-9-,]+$#', $queryString)) { + throw new \InvalidArgumentException('Invalid icon names.'.$queryString); + } - if (!str_starts_with($content, 'http()->request('GET', \sprintf('/%s.json', $prefix), [ + 'headers' => [ + 'Accept' => 'application/json', + ], + 'query' => [ + 'icons' => strtolower($queryString), + ], + ]); + + if (200 !== $response->getStatusCode()) { + throw new IconNotFoundException(\sprintf('The icon set "%s" does not exist on iconify.design.', $prefix)); + } + + $data = $response->toArray(); + + $icons = []; + foreach ($names as $iconName) { + $iconData = $data['icons'][$data['aliases'][$iconName]['parent'] ?? $iconName] ?? null; + if (!$iconData) { + continue; + } + + $height = $iconData['height'] ?? $data['height'] ??= $this->sets()[$prefix]['height'] ?? null; + $width = $iconData['width'] ?? $data['width'] ??= $this->sets()[$prefix]['width'] ?? null; + + $icons[$iconName] = new Icon($iconData['body'], [ + 'xmlns' => self::ATTR_XMLNS_URL, + 'viewBox' => \sprintf('0 0 %d %d', $width ?? $height, $height ?? $width), + ]); } - return $content; + return $icons; + } + + public function hasIconSet(string $prefix): bool + { + return isset($this->sets()[$prefix]); } public function getIconSets(): array @@ -106,7 +154,7 @@ public function getIconSets(): array public function searchIcons(string $prefix, string $query) { - $response = $this->http->request('GET', '/search', [ + $response = $this->http()->request('GET', '/search', [ 'query' => [ 'query' => $query, 'prefix' => $prefix, @@ -116,12 +164,59 @@ public function searchIcons(string $prefix, string $query) return new \ArrayObject($response->toArray()); } + /** + * @return iterable + */ + public function chunk(string $prefix, array $names): iterable + { + if (100 < ($prefixLength = \strlen($prefix))) { + throw new \InvalidArgumentException(\sprintf('The icon prefix "%s" is too long.', $prefix)); + } + + $maxLength = $this->maxIconsQueryLength - $prefixLength; + + $curBatch = []; + $curLength = 0; + foreach ($names as $name) { + if (100 < ($nameLength = \strlen($name))) { + throw new \InvalidArgumentException(\sprintf('The icon name "%s" is too long.', $name)); + } + if ($curLength && ($maxLength < ($curLength + $nameLength + 1))) { + yield $curBatch; + + $curBatch = []; + $curLength = 0; + } + $curLength += $nameLength + 1; + $curBatch[] = $name; + } + + if ($curLength) { + yield $curBatch; + } + + yield from []; + } + private function sets(): \ArrayObject { - return $this->sets ??= $this->cache->get('ux-iconify-sets', function () { - $response = $this->http->request('GET', '/collections'); + return $this->sets ??= $this->cache->get('iconify-sets', function () { + $response = $this->http()->request('GET', '/collections'); return new \ArrayObject($response->toArray()); }); } + + private function http(): HttpClientInterface + { + if (isset($this->http)) { + return $this->http; + } + + if (!class_exists(HttpClient::class)) { + throw new HttpClientNotInstalledException('You must install "symfony/http-client" to use icons from ux.symfony.com/icons. Try running "composer require symfony/http-client".'); + } + + return $this->http = ScopingHttpClient::forBaseUri($this->httpClient ?? HttpClient::create(), $this->endpoint); + } } diff --git a/src/Icons/src/Registry/CacheIconRegistry.php b/src/Icons/src/Registry/CacheIconRegistry.php index 84f633d135f..61497f7965c 100644 --- a/src/Icons/src/Registry/CacheIconRegistry.php +++ b/src/Icons/src/Registry/CacheIconRegistry.php @@ -34,7 +34,7 @@ public function get(string $name, bool $refresh = false): Icon } return $this->cache->get( - \sprintf('ux-icon-%s', Icon::nameToId($name)), + Icon::nameToId($name), fn () => $this->inner->get($name), beta: $refresh ? \INF : null, ); diff --git a/src/Icons/src/Registry/ChainIconRegistry.php b/src/Icons/src/Registry/ChainIconRegistry.php index c6e882cff59..d476d25c056 100644 --- a/src/Icons/src/Registry/ChainIconRegistry.php +++ b/src/Icons/src/Registry/ChainIconRegistry.php @@ -34,10 +34,16 @@ public function get(string $name): Icon foreach ($this->registries as $registry) { try { return $registry->get($name); - } catch (IconNotFoundException) { + } catch (IconNotFoundException $e) { } } - throw new IconNotFoundException(\sprintf('Icon "%s" not found.', $name)); + $message = \sprintf('Icon "%s" not found.', $name); + + if (isset($e)) { + $message .= " {$e->getMessage()}"; + } + + throw new IconNotFoundException($message, previous: $e ?? null); } } diff --git a/src/Icons/src/Registry/IconifyOnDemandRegistry.php b/src/Icons/src/Registry/IconifyOnDemandRegistry.php index 5931854ca75..60c1b591cc4 100644 --- a/src/Icons/src/Registry/IconifyOnDemandRegistry.php +++ b/src/Icons/src/Registry/IconifyOnDemandRegistry.php @@ -11,6 +11,7 @@ namespace Symfony\UX\Icons\Registry; +use Symfony\UX\Icons\Exception\HttpClientNotInstalledException; use Symfony\UX\Icons\Exception\IconNotFoundException; use Symfony\UX\Icons\Icon; use Symfony\UX\Icons\Iconify; @@ -36,6 +37,10 @@ public function get(string $name): Icon } [$prefix, $icon] = $parts; - return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon); + try { + return $this->iconify->fetchIcon($this->prefixAliases[$prefix] ?? $prefix, $icon); + } catch (HttpClientNotInstalledException $e) { + throw new IconNotFoundException($e->getMessage()); + } } } diff --git a/src/Icons/src/Twig/IconFinder.php b/src/Icons/src/Twig/IconFinder.php index 0d8fd48fd73..a2b6844221d 100644 --- a/src/Icons/src/Twig/IconFinder.php +++ b/src/Icons/src/Twig/IconFinder.php @@ -67,12 +67,14 @@ public function icons(): array private function templateFiles(LoaderInterface $loader): iterable { if ($loader instanceof FilesystemLoader) { - $paths = []; + $paths = $loader->getPaths(); foreach ($loader->getNamespaces() as $namespace) { $paths = [...$paths, ...$loader->getPaths($namespace)]; } - foreach ((new Finder())->files()->in($paths)->name('*.twig') as $file) { - yield (string) $file; + if ($paths) { + foreach ((new Finder())->files()->in($paths)->name('*.twig') as $file) { + yield (string) $file; + } } } diff --git a/src/Icons/src/Twig/UXIconComponentListener.php b/src/Icons/src/Twig/UXIconComponentListener.php deleted file mode 100644 index 8ccf4df1a12..00000000000 --- a/src/Icons/src/Twig/UXIconComponentListener.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Icons\Twig; - -use Symfony\UX\Icons\IconRendererInterface; -use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent; - -/** - * @author Simon AndrÊ - * - * @internal - */ -final class UXIconComponentListener -{ - public function __construct( - private IconRendererInterface $iconRenderer, - ) { - } - - public function onPreCreateForRender(PreCreateForRenderEvent $event): void - { - if ('ux:icon' !== strtolower($event->getName())) { - return; - } - - $attributes = $event->getInputProps(); - $name = (string) $attributes['name']; - unset($attributes['name']); - - $svg = $this->iconRenderer->renderIcon($name, $attributes); - $event->setRenderedString($svg); - $event->stopPropagation(); - } -} diff --git a/src/Icons/src/Twig/UXIconRuntime.php b/src/Icons/src/Twig/UXIconRuntime.php index 7276ac08644..edaffd8bf5b 100644 --- a/src/Icons/src/Twig/UXIconRuntime.php +++ b/src/Icons/src/Twig/UXIconRuntime.php @@ -47,4 +47,12 @@ public function renderIcon(string $name, array $attributes = []): string throw $e; } } + + public function render(array $args = []): string + { + $name = $args['name']; + unset($args['name']); + + return $this->renderIcon($name, $args); + } } diff --git a/src/Icons/tests/Fixtures/TestKernel.php b/src/Icons/tests/Fixtures/TestKernel.php index 6a9d0f0bdb0..2f573439dd4 100644 --- a/src/Icons/tests/Fixtures/TestKernel.php +++ b/src/Icons/tests/Fixtures/TestKernel.php @@ -60,6 +60,10 @@ protected function configureContainer(ContainerConfigurator $container): void $container->extension('ux_icons', [ 'icon_dir' => '%kernel.project_dir%/tests/Fixtures/icons', + 'aliases' => [ + 'foo' => 'lucide:circle', + 'bar' => 'lu:circle-off', + ], 'icon_sets' => [ 'fla' => [ 'path' => '%kernel.project_dir%/tests/Fixtures/images/flags', diff --git a/src/Icons/tests/Integration/Command/ImportIconCommandTest.php b/src/Icons/tests/Integration/Command/ImportIconCommandTest.php index b664ad453c7..0935fb4dd9a 100644 --- a/src/Icons/tests/Integration/Command/ImportIconCommandTest.php +++ b/src/Icons/tests/Integration/Command/ImportIconCommandTest.php @@ -23,7 +23,7 @@ final class ImportIconCommandTest extends KernelTestCase use InteractsWithConsole; private const ICON_DIR = __DIR__.'/../../Fixtures/icons'; - private const ICONS = ['uiw/dashboard.svg']; + private const ICONS = ['uiw/dashboard.svg', 'lucide/circle.svg']; /** * @before @@ -45,8 +45,8 @@ public function testCanImportIcon(): void $this->executeConsoleCommand('ux:icons:import uiw:dashboard') ->assertSuccessful() + ->assertOutputContains('Icon set: uiw icons (License: MIT)') ->assertOutputContains('Importing uiw:dashboard') - ->assertOutputContains("Imported uiw:dashboard (License: MIT). Render with: {{ ux_icon('uiw:dashboard') }}") ; $this->assertFileExists($expectedFile); @@ -60,13 +60,34 @@ public function testImportInvalidIconName(): void ; } - public function testImportNonExistentIcon(): void + public function testImportNonExistentIconSet(): void { $this->executeConsoleCommand('ux:icons:import something:invalid') ->assertStatusCode(1) - ->assertOutputContains('[ERROR] The icon "something:invalid" does not exist on iconify.design.') + ->assertOutputContains('[ERROR] Icon set "something" not found.') + ; + } + + public function testImportNonExistentIcon(): void + { + $this->executeConsoleCommand('ux:icons:import lucide:not-existing-icon') + ->assertStatusCode(1) + ->assertOutputContains('Not Found lucide:not-existing-icon') + ->assertOutputContains('[ERROR] Imported 0/1 icons.') + ; + + $this->assertFileDoesNotExist(self::ICON_DIR.'/not-existing-icon.svg'); + } + + public function testImportNonExistentIconWithExistentOne(): void + { + $this->executeConsoleCommand('ux:icons:import lucide:circle lucide:not-existing-icon') + ->assertStatusCode(0) + ->assertOutputContains('Imported lucide:circle') + ->assertOutputContains('Not Found lucide:not-existing-icon') + ->assertOutputContains('[WARNING] Imported 1/2 icons.') ; - $this->assertFileDoesNotExist(self::ICON_DIR.'/invalid.svg'); + $this->assertFileDoesNotExist(self::ICON_DIR.'/not-existing-icon.svg'); } } diff --git a/src/Icons/tests/Integration/Command/LockIconsCommandTest.php b/src/Icons/tests/Integration/Command/LockIconsCommandTest.php index f6b2e11f2d4..abcfc45fdf6 100644 --- a/src/Icons/tests/Integration/Command/LockIconsCommandTest.php +++ b/src/Icons/tests/Integration/Command/LockIconsCommandTest.php @@ -25,6 +25,8 @@ final class LockIconsCommandTest extends KernelTestCase private const ICONS = [ __DIR__.'/../../Fixtures/icons/iconamoon/3d-duotone.svg', __DIR__.'/../../Fixtures/icons/flag/eu-4x3.svg', + __DIR__.'/../../Fixtures/icons/lucide/circle.svg', + __DIR__.'/../../Fixtures/icons/lucide/circle-off.svg', ]; /** @@ -50,9 +52,11 @@ public function testImportFoundIcons(): void $this->executeConsoleCommand('ux:icons:lock') ->assertSuccessful() ->assertOutputContains('Scanning project for icons...') + ->assertOutputContains('Imported lucide:circle') + ->assertOutputContains('Imported lucide:circle-off') ->assertOutputContains('Imported flag:eu-4x3') ->assertOutputContains('Imported iconamoon:3d-duotone') - ->assertOutputContains('Imported 2 icons') + ->assertOutputContains('Imported 4 icons') ; foreach (self::ICONS as $icon) { @@ -70,17 +74,21 @@ public function testForceImportFoundIcons(): void $this->executeConsoleCommand('ux:icons:lock') ->assertSuccessful() ->assertOutputContains('Scanning project for icons...') + ->assertOutputContains('Imported lucide:circle') + ->assertOutputContains('Imported lucide:circle-off') ->assertOutputContains('Imported flag:eu-4x3') ->assertOutputContains('Imported iconamoon:3d-duotone') - ->assertOutputContains('Imported 2 icons') + ->assertOutputContains('Imported 4 icons') ; $this->executeConsoleCommand('ux:icons:lock --force') ->assertSuccessful() ->assertOutputContains('Scanning project for icons...') + ->assertOutputContains('Imported lucide:circle') + ->assertOutputContains('Imported lucide:circle-off') ->assertOutputContains('Imported flag:eu-4x3') ->assertOutputContains('Imported iconamoon:3d-duotone') - ->assertOutputContains('Imported 2 icons') + ->assertOutputContains('Imported 4 icons') ; } } diff --git a/src/Icons/tests/Integration/RenderIconsInTwigTest.php b/src/Icons/tests/Integration/RenderIconsInTwigTest.php index 8789f29a150..feba3097dbe 100644 --- a/src/Icons/tests/Integration/RenderIconsInTwigTest.php +++ b/src/Icons/tests/Integration/RenderIconsInTwigTest.php @@ -33,8 +33,8 @@ public function testRenderIcons(): void
  • -
  • -
  • +
  • +
  • HTML, trim($output) @@ -49,7 +49,7 @@ public function testRenderAliasIcons(): void $templateAlias = ''; $outputAlias = self::getContainer()->get(Environment::class)->createTemplate($templateAlias)->render(); - $expected = ''; + $expected = ''; $this->assertSame($outputIcon, $expected); $this->assertSame($outputIcon, $outputAlias); } diff --git a/src/Icons/tests/Unit/IconifyTest.php b/src/Icons/tests/Unit/IconifyTest.php index f61abf3ac52..8516568b028 100644 --- a/src/Icons/tests/Unit/IconifyTest.php +++ b/src/Icons/tests/Unit/IconifyTest.php @@ -15,8 +15,8 @@ use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\JsonMockResponse; -use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\UX\Icons\Exception\IconNotFoundException; +use Symfony\UX\Icons\Icon; use Symfony\UX\Icons\Iconify; /** @@ -29,7 +29,7 @@ public function testFetchIcon(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -47,7 +47,7 @@ public function testFetchIcon(): void $icon = $iconify->fetchIcon('bi', 'heart'); $this->assertEquals($icon->getInnerSvg(), ''); - $this->assertEquals($icon->getAttributes(), ['viewBox' => '0 0 24 24']); + $this->assertEquals($icon->getAttributes(), ['viewBox' => '0 0 24 24', 'xmlns' => 'https://www.w3.org/2000/svg']); } public function testFetchIconByAlias(): void @@ -55,7 +55,7 @@ public function testFetchIconByAlias(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -78,7 +78,7 @@ public function testFetchIconByAlias(): void $icon = $iconify->fetchIcon('bi', 'foo'); $this->assertEquals($icon->getInnerSvg(), ''); - $this->assertEquals($icon->getAttributes(), ['viewBox' => '0 0 24 24']); + $this->assertEquals($icon->getAttributes(), ['viewBox' => '0 0 24 24', 'xmlns' => 'https://www.w3.org/2000/svg']); } public function testFetchIconThrowsWhenIconSetDoesNotExists(): void @@ -96,7 +96,7 @@ public function testFetchIconUsesIconsetViewBoxHeight(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [ 'height' => 17, @@ -124,7 +124,7 @@ public function testFetchIconThrowsWhenViewBoxCannotBeComputed(): void $iconify = new Iconify( cache: new NullAdapter(), endpoint: 'https://example.com', - http: new MockHttpClient([ + httpClient: new MockHttpClient([ new JsonMockResponse([ 'bi' => [], ]), @@ -144,6 +144,130 @@ public function testFetchIconThrowsWhenViewBoxCannotBeComputed(): void $iconify->fetchIcon('bi', 'heart'); } + public function testFetchIconThrowsWhenStatusCodeNot200(): void + { + $iconify = new Iconify( + cache: new NullAdapter(), + endpoint: 'https://example.com', + httpClient: new MockHttpClient([ + new JsonMockResponse([ + 'bi' => [], + ]), + new JsonMockResponse([], ['http_code' => 404]), + ]), + ); + + $this->expectException(IconNotFoundException::class); + $this->expectExceptionMessage('The icon "bi:heart" does not exist on iconify.design.'); + + $iconify->fetchIcon('bi', 'heart'); + } + + public function testFetchIcons(): void + { + $iconify = new Iconify( + cache: new NullAdapter(), + endpoint: 'https://example.com', + httpClient: new MockHttpClient([ + new JsonMockResponse([ + 'bi' => [], + ]), + new JsonMockResponse([ + 'icons' => [ + 'heart' => [ + 'body' => '', + 'height' => 17, + ], + 'bar' => [ + 'body' => '', + 'height' => 17, + ], + ], + ]), + ]), + ); + + $icons = $iconify->fetchIcons('bi', ['heart', 'bar']); + + $this->assertCount(2, $icons); + $this->assertSame(['bar', 'heart'], array_keys($icons)); + $this->assertContainsOnlyInstancesOf(Icon::class, $icons); + } + + public function testFetchIconsByAliases(): void + { + $iconify = new Iconify( + cache: new NullAdapter(), + endpoint: 'https://example.com', + httpClient: new MockHttpClient([ + new JsonMockResponse([ + 'mdi' => [], + ]), + new JsonMockResponse([ + 'aliases' => [ + 'capsule' => [ + 'parent' => 'pill', + ], + 'sign' => [ + 'parent' => 'draw', + ], + ], + 'icons' => [ + 'pill' => [ + 'body' => '', + ], + 'glasses' => [ + 'body' => '', + ], + 'draw' => [ + 'body' => '', + ], + ], + ]), + ]), + ); + + $icons = $iconify->fetchIcons('mdi', ['capsule', 'sign', 'glasses']); + + $this->assertCount(3, $icons); + $this->assertSame(['capsule', 'glasses', 'sign'], array_keys($icons)); + $this->assertContainsOnlyInstancesOf(Icon::class, $icons); + } + + public function testFetchIconsThrowsWithInvalidIconNames(): void + { + $iconify = new Iconify( + cache: new NullAdapter(), + endpoint: 'https://example.com', + httpClient: new MockHttpClient([ + new JsonMockResponse([ + 'bi' => [], + ]), + ]), + ); + + $this->expectException(\InvalidArgumentException::class); + + $iconify->fetchIcons('bi', ['à', 'foo']); + } + + public function testFetchIconsThrowsWithTooManyIcons(): void + { + $iconify = new Iconify( + cache: new NullAdapter(), + endpoint: 'https://example.com', + httpClient: new MockHttpClient([ + new JsonMockResponse([ + 'bi' => [], + ]), + ]), + ); + + $this->expectException(\InvalidArgumentException::class); + + $iconify->fetchIcons('bi', array_fill(0, 50, '1234567890')); + } + public function testGetMetadata(): void { $responseFile = __DIR__.'/../Fixtures/Iconify/collections.json'; @@ -154,20 +278,83 @@ public function testGetMetadata(): void $this->assertSame('Font Awesome Solid', $metadata['name']); } - public function testFetchSvg(): void + /** + * @dataProvider provideChunkCases + */ + public function testChunk(int $maxQueryLength, string $prefix, array $names, array $chunks): void { - $client = new MockHttpClient([ - new MockResponse(file_get_contents(__DIR__.'/../Fixtures/Iconify/collections.json'), [ - 'response_headers' => ['content-type' => 'application/json'], - ]), - new MockResponse(file_get_contents(__DIR__.'/../Fixtures/Iconify/icon.svg')), - ]); - $iconify = new Iconify(new NullAdapter(), 'https://localhost', $client); + $iconify = new Iconify( + new NullAdapter(), + 'https://example.com', + new MockHttpClient([]), + $maxQueryLength, + ); + + $this->assertSame($chunks, iterator_to_array($iconify->chunk($prefix, $names))); + } + + public static function provideChunkCases(): iterable + { + yield 'no icon should make no chunk' => [ + 10, + 'ppppp', + [], + [], + ]; + + yield 'one icon should make one chunk' => [ + 10, + 'ppppp', + ['aaaa1'], + [['aaaa1']], + ]; + + yield 'two icons that should make two chunck' => [ + 10, + 'ppppp', + ['aa1', 'aa2'], + [['aa1'], ['aa2']], + ]; + + yield 'three icons that should make two chunck' => [ + 15, + 'ppppp', + ['aaa1', 'aaa2', 'aaa3'], + [['aaa1', 'aaa2'], ['aaa3']], + ]; + + yield 'four icons that should make two chunck' => [ + 15, + 'ppppp', + ['aaaaaaaa1', 'a2', 'a3', 'a4'], + [['aaaaaaaa1'], ['a2', 'a3', 'a4']], + ]; + } + + public function testChunkThrowWithIconPrefixTooLong(): void + { + $iconify = new Iconify(new NullAdapter(), 'https://example.com', new MockHttpClient([])); + + $prefix = str_pad('p', 101, 'p'); + $name = 'icon'; + + $this->expectExceptionMessage(\sprintf('The icon prefix "%s" is too long.', $prefix)); + + // We need to iterate over the iterator to trigger the exception + $result = iterator_to_array($iconify->chunk($prefix, [$name])); + } + + public function testChunkThrowWithIconNameTooLong(): void + { + $iconify = new Iconify(new NullAdapter(), 'https://example.com', new MockHttpClient([])); + + $prefix = 'prefix'; + $name = str_pad('n', 101, 'n'); - $svg = $iconify->fetchSvg('fa6-regular', 'bar'); + $this->expectExceptionMessage(\sprintf('The icon name "%s" is too long.', $name)); - $this->assertIsString($svg); - $this->stringContains('-.224l.235-.468ZM6.013 2.06c-.649-.1', $svg); + // We need to iterate over the iterator to trigger the exception + $result = iterator_to_array($iconify->chunk($prefix, [$name])); } private function createHttpClient(mixed $data, int $code = 200): MockHttpClient diff --git a/src/LazyImage/.gitattributes b/src/LazyImage/.gitattributes index 2b1d42ea804..b9bb8f6e796 100644 --- a/src/LazyImage/.gitattributes +++ b/src/LazyImage/.gitattributes @@ -1,7 +1,7 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /assets/src export-ignore /assets/test export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/LazyImage/.github/PULL_REQUEST_TEMPLATE.md b/src/LazyImage/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/LazyImage/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/LazyImage/.github/workflows/close-pull-request.yml b/src/LazyImage/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/LazyImage/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/LazyImage/.gitignore b/src/LazyImage/.gitignore index 30282084317..2cc9f0231c3 100644 --- a/src/LazyImage/.gitignore +++ b/src/LazyImage/.gitignore @@ -1,4 +1,5 @@ -vendor -composer.lock -.php_cs.cache -.phpunit.result.cache +/assets/node_modules/ +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.result.cache diff --git a/src/LazyImage/CHANGELOG.md b/src/LazyImage/CHANGELOG.md index bb989a0eee0..e5a3d00312e 100644 --- a/src/LazyImage/CHANGELOG.md +++ b/src/LazyImage/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.26.0 + +- Deprecate the package + ## 2.17.0 - Add support for `intervention/image` 3.0+ diff --git a/src/LazyImage/README.md b/src/LazyImage/README.md index 8a771c401dd..2578b75bfac 100644 --- a/src/LazyImage/README.md +++ b/src/LazyImage/README.md @@ -1,5 +1,15 @@ # Symfony UX LazyImage +> [!WARNING] +> **Deprecated**: This package has been **deprecated** in 2.x and will be removed in the next major version. + +The package has been deprecated in favor of [modern techniques to improve image loading performance](https://web.dev/learn/images/performance-issues) natively supported +by all major browsers (``). + +To keep using BlurHash functionality, you can use the package [kornrunner/php-blurhash](https://github.com/kornrunner/php-blurhash). + +--- + Symfony UX LazyImage is a Symfony bundle providing utilities to improve image loading performance. It is part of [the Symfony UX initiative](https://ux.symfony.com/). diff --git a/src/LazyImage/assets/LICENSE b/src/LazyImage/assets/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/LazyImage/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/LazyImage/assets/README.md b/src/LazyImage/assets/README.md new file mode 100644 index 00000000000..cb8d1cbd6ff --- /dev/null +++ b/src/LazyImage/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-lazy-image + +JavaScript assets of the [symfony/ux-lazy-image](https://packagist.org/packages/symfony/ux-lazy-image) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-lazy-image](https://packagist.org/packages/symfony/ux-lazy-image) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-lazy-image](https://packagist.org/packages/symfony/ux-lazy-image) PHP package version: +```shell +composer require symfony/ux-lazy-image:2.23.0 +npm add @symfony/ux-lazy-image@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-lazy-image/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/LazyImage/assets/package.json b/src/LazyImage/assets/package.json index cc320117158..7ae49a7c8cb 100644 --- a/src/LazyImage/assets/package.json +++ b/src/LazyImage/assets/package.json @@ -2,9 +2,25 @@ "name": "@symfony/ux-lazy-image", "description": "Lazy image loader and utilities for Symfony", "license": "MIT", - "version": "1.1.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/lazy-image", + "repository": "https://github.com/symfony/ux-lazy-image", + "type": "module", + "files": [ + "dist" + ], "main": "dist/controller.js", "types": "dist/controller.d.ts", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "lazy-image": { diff --git a/src/LazyImage/assets/test/controller.test.ts b/src/LazyImage/assets/test/controller.test.ts index 5802fe16101..346a2e7be79 100644 --- a/src/LazyImage/assets/test/controller.test.ts +++ b/src/LazyImage/assets/test/controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import LazyImageController from '../src/controller'; // Controller used to check the actual controller was properly booted diff --git a/src/LazyImage/composer.json b/src/LazyImage/composer.json index b964763c412..f15a99eda48 100644 --- a/src/LazyImage/composer.json +++ b/src/LazyImage/composer.json @@ -31,7 +31,8 @@ "php": ">=8.1", "symfony/config": "^5.4|^6.0|^7.0", "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0" + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { "intervention/image": "^2.5|^3.0", diff --git a/src/LazyImage/doc/index.rst b/src/LazyImage/doc/index.rst index 272384a0cc1..fd767d0024e 100644 --- a/src/LazyImage/doc/index.rst +++ b/src/LazyImage/doc/index.rst @@ -1,6 +1,16 @@ Symfony UX LazyImage ==================== +.. warning:: + + **This package is no longer recommended.** Instead, use the + `modern techniques to improve image loading performance`_ natively supported + by all major browsers. + +.. warning:: + + **Deprecated: This package has been deprecated in 2.x and will be removed in the next major version.** + Symfony UX LazyImage is a Symfony bundle providing utilities to improve image loading performance. It is part of `the Symfony UX initiative`_. @@ -30,9 +40,9 @@ needed if you're using AssetMapper): $ npm install --force $ npm run watch - # or use yarn - $ yarn install --force - $ yarn watch +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-lazy-image npm package`_ Usage ----- @@ -55,7 +65,7 @@ replaced by the high-definition version once the page has been rendered: > With this setup, the user will initially see ``images/small.png``. Then, -once the page has loaded and the user’s browser has downloaded the +once the page has loaded and the user's browser has downloaded the larger image, the ``src`` attribute will change to ``image/large.png``. There is also support for the ``srcset`` attribute by passing an @@ -214,7 +224,7 @@ on the page and should be less than 2.5 seconds. It's part of the `Core Web Vita and is used by Google to evaluate the user experience of a website, impacting the Search ranking. -Using the Symfony UX LazyImage for your LCP image can be a good idea at first, +Using the Symfony UX LazyImage for your LCP image can be a good idea at first, but in reality, it will lower the LCP score because: - `The progressive loading (through blurhash) is not taken into account in the LCP calculation`_; @@ -237,7 +247,7 @@ A solution is to not use the Stimulus controller for the LCP image but to use width="200" height="150" /> - + This way, the browser will display the BlurHash image as soon as possible, and will load the high-definition image at the same time, without waiting for the Stimulus controller to be loaded. @@ -249,6 +259,7 @@ This bundle aims at following the same Backward Compatibility promise as the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html +.. _`modern techniques to improve image loading performance`: https://web.dev/learn/images/performance-issues .. _`the Symfony UX initiative`: https://ux.symfony.com/ .. _`BlurHash implementation`: https://blurha.sh .. _`StimulusBundle`: https://symfony.com/bundles/StimulusBundle/current/index.html @@ -259,3 +270,4 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`Core Web Vitals`: https://web.dev/vitals/ .. _`The progressive loading (through blurhash) is not taken into account in the LCP calculation`: https://github.com/w3c/largest-contentful-paint/issues/71_ .. _`didn't preload the image`: https://symfony.com/doc/current/web_link.html +.. _`@symfony/ux-lazy-image npm package`: https://www.npmjs.com/package/@symfony/ux-lazy-image diff --git a/src/LazyImage/phpunit.xml.dist b/src/LazyImage/phpunit.xml.dist index ba15dfc214b..8fe2e0f23dc 100644 --- a/src/LazyImage/phpunit.xml.dist +++ b/src/LazyImage/phpunit.xml.dist @@ -14,7 +14,7 @@ - + diff --git a/src/LazyImage/src/BlurHash/BlurHash.php b/src/LazyImage/src/BlurHash/BlurHash.php index 447a6f4f9eb..6778e029f0d 100644 --- a/src/LazyImage/src/BlurHash/BlurHash.php +++ b/src/LazyImage/src/BlurHash/BlurHash.php @@ -18,6 +18,8 @@ use kornrunner\Blurhash\Blurhash as BlurhashEncoder; use Symfony\Contracts\Cache\CacheInterface; +trigger_deprecation('symfony/ux-lazy-image', '2.27.0', 'The package is deprecated and will be removed in 3.0.'); + /** * @author Titouan Galopin * diff --git a/src/LazyImage/src/BlurHash/BlurHashInterface.php b/src/LazyImage/src/BlurHash/BlurHashInterface.php index c4ac1d7f47b..aaad364b580 100644 --- a/src/LazyImage/src/BlurHash/BlurHashInterface.php +++ b/src/LazyImage/src/BlurHash/BlurHashInterface.php @@ -11,6 +11,8 @@ namespace Symfony\UX\LazyImage\BlurHash; +trigger_deprecation('symfony/ux-lazy-image', '2.27.0', 'The package is deprecated and will be removed in 3.0.'); + /** * @author Titouan Galopin */ diff --git a/src/LazyImage/src/DependencyInjection/Configuration.php b/src/LazyImage/src/DependencyInjection/Configuration.php index 4c2fd632a33..9e4927d674e 100644 --- a/src/LazyImage/src/DependencyInjection/Configuration.php +++ b/src/LazyImage/src/DependencyInjection/Configuration.php @@ -14,6 +14,8 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +trigger_deprecation('symfony/ux-lazy-image', '2.27.0', 'The package is deprecated and will be removed in 3.0.'); + /** * @author Hugo Alliaume * diff --git a/src/LazyImage/src/DependencyInjection/LazyImageExtension.php b/src/LazyImage/src/DependencyInjection/LazyImageExtension.php index ccddfd34d9a..52405f71bbb 100644 --- a/src/LazyImage/src/DependencyInjection/LazyImageExtension.php +++ b/src/LazyImage/src/DependencyInjection/LazyImageExtension.php @@ -25,6 +25,8 @@ use Symfony\UX\LazyImage\Twig\BlurHashExtension; use Symfony\UX\LazyImage\Twig\BlurHashRuntime; +trigger_deprecation('symfony/ux-lazy-image', '2.27.0', 'The package is deprecated and will be removed in 3.0.'); + /** * @author Titouan Galopin * diff --git a/src/LazyImage/src/LazyImageBundle.php b/src/LazyImage/src/LazyImageBundle.php index b222a688e05..b39725c98a8 100644 --- a/src/LazyImage/src/LazyImageBundle.php +++ b/src/LazyImage/src/LazyImageBundle.php @@ -13,6 +13,8 @@ use Symfony\Component\HttpKernel\Bundle\Bundle; +trigger_deprecation('symfony/ux-lazy-image', '2.27.0', 'The package is deprecated and will be removed in 3.0.'); + /** * @author Titouan Galopin * diff --git a/src/LazyImage/src/Twig/BlurHashExtension.php b/src/LazyImage/src/Twig/BlurHashExtension.php index 5ac2f3b520f..1b31a2331d3 100644 --- a/src/LazyImage/src/Twig/BlurHashExtension.php +++ b/src/LazyImage/src/Twig/BlurHashExtension.php @@ -14,6 +14,8 @@ use Twig\Extension\AbstractExtension; use Twig\TwigFunction; +trigger_deprecation('symfony/ux-lazy-image', '2.27.0', 'The package is deprecated and will be removed in 3.0.'); + /** * @author Titouan Galopin * diff --git a/src/LazyImage/src/Twig/BlurHashRuntime.php b/src/LazyImage/src/Twig/BlurHashRuntime.php index 49d931cab8b..25a96466847 100644 --- a/src/LazyImage/src/Twig/BlurHashRuntime.php +++ b/src/LazyImage/src/Twig/BlurHashRuntime.php @@ -14,6 +14,8 @@ use Symfony\UX\LazyImage\BlurHash\BlurHashInterface; use Twig\Extension\RuntimeExtensionInterface; +trigger_deprecation('symfony/ux-lazy-image', '2.27.0', 'The package is deprecated and will be removed in 3.0.'); + /** * @author Hugo Alliaume */ diff --git a/src/LazyImage/tests/BlurHash/BlurHashTest.php b/src/LazyImage/tests/BlurHash/BlurHashTest.php index 60baaf42982..1304d1bbdca 100644 --- a/src/LazyImage/tests/BlurHash/BlurHashTest.php +++ b/src/LazyImage/tests/BlurHash/BlurHashTest.php @@ -47,7 +47,7 @@ public function testEncode() public function testWithCustomGetImageContent(): void { $kernel = new class('test', true) extends TwigAppKernel { - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { parent::registerContainerConfiguration($loader); @@ -99,7 +99,7 @@ public function testEnsureCacheIsNotUsedWhenNotConfigured() public function testEnsureCacheIsUsedWhenConfigured() { $kernel = new class('test', true) extends TwigAppKernel { - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { parent::registerContainerConfiguration($loader); @@ -154,7 +154,7 @@ public function testCreateDataUriThumbnail() public function testCreateDataUriThumbnailWithCache() { $kernel = new class('test', true) extends TwigAppKernel { - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { parent::registerContainerConfiguration($loader); diff --git a/src/LazyImage/tests/Kernel/EmptyAppKernel.php b/src/LazyImage/tests/Kernel/EmptyAppKernel.php index 4d215d31c28..03394163784 100644 --- a/src/LazyImage/tests/Kernel/EmptyAppKernel.php +++ b/src/LazyImage/tests/Kernel/EmptyAppKernel.php @@ -29,7 +29,7 @@ public function registerBundles(): iterable return [new LazyImageBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { } } diff --git a/src/LazyImage/tests/Kernel/FrameworkAppKernel.php b/src/LazyImage/tests/Kernel/FrameworkAppKernel.php index 2dd2e4b7d29..dac1de8118f 100644 --- a/src/LazyImage/tests/Kernel/FrameworkAppKernel.php +++ b/src/LazyImage/tests/Kernel/FrameworkAppKernel.php @@ -31,7 +31,7 @@ public function registerBundles(): iterable return [new FrameworkBundle(), new LazyImageBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); diff --git a/src/LazyImage/tests/Kernel/TwigAppKernel.php b/src/LazyImage/tests/Kernel/TwigAppKernel.php index 185798d31dd..51869833765 100644 --- a/src/LazyImage/tests/Kernel/TwigAppKernel.php +++ b/src/LazyImage/tests/Kernel/TwigAppKernel.php @@ -32,7 +32,7 @@ public function registerBundles(): iterable return [new FrameworkBundle(), new TwigBundle(), new LazyImageBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); diff --git a/src/LazyImage/tests/baseline-ignore b/src/LazyImage/tests/baseline-ignore new file mode 100644 index 00000000000..8661cedc451 --- /dev/null +++ b/src/LazyImage/tests/baseline-ignore @@ -0,0 +1 @@ +%Since symfony/ux-lazy-image 2\.27\.0: The package is deprecated and will be removed in 3\.0\.% diff --git a/src/LiveComponent/.gitattributes b/src/LiveComponent/.gitattributes index 2b1d42ea804..b9bb8f6e796 100644 --- a/src/LiveComponent/.gitattributes +++ b/src/LiveComponent/.gitattributes @@ -1,7 +1,7 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /assets/src export-ignore /assets/test export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/LiveComponent/.github/PULL_REQUEST_TEMPLATE.md b/src/LiveComponent/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/LiveComponent/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/LiveComponent/.github/workflows/close-pull-request.yml b/src/LiveComponent/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/LiveComponent/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/LiveComponent/.gitignore b/src/LiveComponent/.gitignore index 854217846fe..1784dd6561c 100644 --- a/src/LiveComponent/.gitignore +++ b/src/LiveComponent/.gitignore @@ -1,5 +1,7 @@ +/assets/node_modules/ +/vendor/ /composer.lock /phpunit.xml -/vendor/ -/var/ /.phpunit.result.cache + +/var diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 112a1f9ef90..699db588d47 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,28 @@ # CHANGELOG +## 2.26.0 + +- `LiveProp`: Pass the property name as second parameter of the `modifier` callable +- Add compatibility layer to fix deprecation with `Symfony\Component\PropertyInfo\PropertyInfoExtractor::getTypes()`. + If you use PHP 8.2 or higher, we recommend you to update dependency `symfony/property-info` to at least 7.1.0 + +## 2.25.0 + +- Add support for [Symfony UID](https://symfony.com/doc/current/components/uid.html) hydration/dehydration +- `ComponentWithFormTrait` now correctly checks for a `TranslatableInterface` placeholder for `'); + it('returns true if element lives inside of a div', () => { + const targetElement = htmlToElement(''); const component = createComponent('
    '); component.element.appendChild(targetElement); + expect(elementBelongsToThisComponent(targetElement, component)).toBeFalsy(); + }); + + it('returns true if element lives inside of live controller', () => { + const targetElement = htmlToElement(''); + const component = createComponent('
    '); + component.element.appendChild(targetElement); + expect(elementBelongsToThisComponent(targetElement, component)).toBeTruthy(); }); @@ -287,7 +295,6 @@ describe('elementBelongsToThisComponent', () => { const component = createComponent('
    '); component.element.appendChild(childComponent.element); - //expect(elementBelongsToThisComponent(targetElement, childComponent)).toBeTruthy(); expect(elementBelongsToThisComponent(targetElement, component)).toBeFalsy(); }); diff --git a/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts b/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts index bce5d55f057..2ed1b2a5a26 100644 --- a/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts +++ b/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts @@ -1,5 +1,5 @@ -import { normalizeAttributesForComparison } from '../src/normalize_attributes_for_comparison'; import { htmlToElement } from '../src/dom_utils'; +import { normalizeAttributesForComparison } from '../src/normalize_attributes_for_comparison'; describe('normalizeAttributesForComparison', () => { it('makes no changes if value and attribute not set', () => { diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 958fdcc8b53..41a6b11a29c 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -1,14 +1,14 @@ import { Application } from '@hotwired/stimulus'; -import LiveController from '../src/live_controller'; import { waitFor } from '@testing-library/dom'; -import { htmlToElement } from '../src/dom_utils'; -import Component from '../src/Component'; +import { Response } from 'node-fetch'; import type { BackendAction, BackendInterface, ChildrenFingerprints } from '../src/Backend/Backend'; import BackendRequest from '../src/Backend/BackendRequest'; -import { Response } from 'node-fetch'; +import Component from '../src/Component'; +import type { ElementDriver } from '../src/Component/ElementDriver'; import { setDeepData } from '../src/data_manipulation_utils'; +import { htmlToElement } from '../src/dom_utils'; +import LiveController from '../src/live_controller'; import LiveControllerDefault from '../src/live_controller'; -import type { ElementDriver } from '../src/Component/ElementDriver'; let activeTests: FunctionalTest[] = []; @@ -98,8 +98,6 @@ class FunctionalTest { class MockedBackend implements BackendInterface { private expectedMockedAjaxCalls: Array = []; - public csrfToken: string | null = null; - addMockedAjaxCall(mock: MockedAjaxCall) { this.expectedMockedAjaxCalls.push(mock); } @@ -141,10 +139,6 @@ class MockedBackend implements BackendInterface { return matchedMock.createBackendRequest(); } - updateCsrfToken(csrfToken: string) { - this.csrfToken = csrfToken; - } - getExpectedMockedAjaxCalls(): Array { return this.expectedMockedAjaxCalls; } @@ -469,7 +463,6 @@ export function initComponent(props: any = {}, controllerValues: any = {}) { data-live-url-value="http://localhost/components/_test_component_${Math.round(Math.random() * 1000)}" data-live-props-value="${dataToJsonAttribute(props)}" ${controllerValues.debounce ? `data-live-debounce-value="${controllerValues.debounce}"` : ''} - ${controllerValues.csrf ? `data-live-csrf-value="${controllerValues.csrf}"` : ''} ${controllerValues.id ? `id="${controllerValues.id}"` : ''} ${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''} ${controllerValues.listeners ? `data-live-listeners-value="${dataToJsonAttribute(controllerValues.listeners)}"` : ''} diff --git a/src/LiveComponent/assets/test/url_utils.test.ts b/src/LiveComponent/assets/test/url_utils.test.ts index fcb711f59cc..bce7f86b88f 100644 --- a/src/LiveComponent/assets/test/url_utils.test.ts +++ b/src/LiveComponent/assets/test/url_utils.test.ts @@ -115,6 +115,32 @@ describe('url_utils', () => { expect(urlUtils.search).toEqual(''); }); }); + + describe('fromQueryString', () => { + const urlUtils: UrlUtils = new UrlUtils(window.location.href); + + beforeEach(() => { + // Reset search before each test + urlUtils.search = ''; + }); + + it('parses a query string with value', () => { + urlUtils.search = '?param1=value1'; + expect(urlUtils.get('param1')).toEqual('value1'); + }); + + it('parses a query string with empty value', () => { + urlUtils.search = '?param1=¶m2=value2'; + expect(urlUtils.get('param1')).toEqual(''); + expect(urlUtils.get('param2')).toEqual('value2'); + }); + + it('parses a query string without equal sign', () => { + urlUtils.search = '?param1¶m2=value2'; + expect(urlUtils.get('param1')).toEqual(''); + expect(urlUtils.get('param2')).toEqual('value2'); + }); + }); }); describe('HistoryStrategy', () => { diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json index 4647b49437a..093723c9521 100644 --- a/src/LiveComponent/composer.json +++ b/src/LiveComponent/composer.json @@ -27,10 +27,12 @@ }, "require": { "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/property-access": "^5.4.5|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", "symfony/stimulus-bundle": "^2.9", - "symfony/ux-twig-component": "^2.8", - "twig/twig": "^3.8.0" + "symfony/ux-twig-component": "^2.25.1", + "twig/twig": "^3.10.3" }, "require-dev": { "doctrine/annotations": "^1.0", @@ -45,16 +47,18 @@ "symfony/framework-bundle": "^5.4|^6.0|^7.0", "symfony/options-resolver": "^5.4|^6.0|^7.0", "symfony/phpunit-bridge": "^6.1|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", "symfony/security-bundle": "^5.4|^6.0|^7.0", "symfony/serializer": "^5.4|^6.0|^7.0", "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/uid": "^5.4|^6.0|^7.0", "symfony/validator": "^5.4|^6.0|^7.0", "zenstruck/browser": "^1.2.0", "zenstruck/foundry": "^2.0" }, "conflict": { - "symfony/config": "<5.4.0" + "symfony/config": "<5.4.0", + "symfony/type-info": "<7.2", + "symfony/property-info": "~7.0.0" }, "config": { "sort-packages": true diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index e24b87a6bed..290b07bb5dc 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -69,10 +69,6 @@ Want some demos? Check out https://ux.symfony.com/live-component#demo Installation ------------ -.. caution:: - - Before you start, make sure you have `StimulusBundle configured in your app`_. - Install the bundle using Composer and Symfony Flex: .. code-block:: terminal @@ -87,9 +83,9 @@ needed if you're using AssetMapper): $ npm install --force $ npm run watch - # or use yarn - $ yarn install --force - $ yarn watch +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-live-component npm package`_ If your project is localized in different languages (either via the `locale route parameter`_ or by `setting the locale in the request`_) add the ``{_locale}`` attribute to @@ -118,7 +114,7 @@ Suppose you've already built a basic Twig component:: use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; - #[AsTwigComponent()] + #[AsTwigComponent] class RandomNumber { public function getRandomNumber(): int @@ -146,7 +142,7 @@ re-rendered live on the frontend), replace the component's + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; + use Symfony\UX\LiveComponent\DefaultActionTrait; - - #[AsTwigComponent()] + - #[AsTwigComponent] + #[AsLiveComponent] class RandomNumber { @@ -184,6 +180,15 @@ fresh copy of our component. That HTML will replace the current HTML. In other words, you just generated a new random number! That's cool, but let's keep going becauseâ€Ļ things get cooler. +.. tip:: + + If you use the `Symfony MakerBundle`_, you can easily create a new component + with the ``make:twig-component`` command: + + .. code-block:: terminal + + $ php bin/console make:twig-component --live EditPost + .. tip:: Need to do some extra data initialization on your component? Create @@ -203,6 +208,8 @@ Let's make our component more flexible by adding a ``$max`` property:: #[AsLiveComponent] class RandomNumber { + use DefaultActionTrait; + #[LiveProp] public int $max = 1000; @@ -242,7 +249,7 @@ LiveProps must be a value that can be sent to JavaScript. Supported values are scalars (int, float, string, bool, null), arrays (of scalar values), enums, DateTime objects, Doctrine entity objects, DTOs, or array of DTOs. -See :ref:`hydration` for handling more complex data. +See :ref:`hydration ` for handling more complex data. Data Binding ------------ @@ -402,10 +409,6 @@ If you're building a form (:ref:`more on forms later `), instead of adding ``data-model`` to every field, you can instead rely on the ``name`` attribute. -.. versionadded:: 2.3 - - The ``data-model`` attribute on the ``form`` is required since version 2.3. - To activate this, you must add a ``data-model`` attribute to the ``
    `` element: @@ -481,6 +484,17 @@ library. Make sure it is installed in you application: $ composer require phpdocumentor/reflection-docblock +.. versionadded:: 2.26 + + Support for `Symfony TypeInfo`_ component was added in LiveComponents 2.26. + +To get rid of deprecations about ``PropertyInfoExtractor::getTypes()`` from the `Symfony PropertyInfo`_ component, +ensure to upgrade ``symfony/property-info`` to at least 7.1, which requires **PHP 8.2**:: + +.. code-block:: terminal + + $ composer require symfony/property-info:^7.1 + Writable Object Properties or Array Keys ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -544,10 +558,6 @@ changed, added or removed:: Checkboxes, Select Elements Radios & Arrays ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 2.8 - - The ability to use checkboxes to set boolean values was added in LiveComponent 2.8. - Checkboxes can be used to set a boolean or an array of strings:: #[AsLiveComponent] @@ -601,10 +611,6 @@ single value or an array of values:: LiveProp Date Formats ~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 2.8 - - The ``format`` option was introduced in Live Components 2.8. - If you have a writable ``LiveProp`` that is some sort of ``DateTime`` instance, you can control the format of the model on the frontend with the ``format`` option:: @@ -628,7 +634,7 @@ the user to switch the *entity* to another? For example: @@ -712,10 +718,6 @@ make this work: Hydrating with the Serializer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 2.8 - - The ``useSerializerForHydration`` option was added in LiveComponent 2.8. - To hydrate/dehydrate through Symfony's serializer, use the ``useSerializerForHydration`` option:: @@ -1004,10 +1006,6 @@ changes until loading has taken longer than a certain amount of time: Targeting Loading for a Specific Action ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 2.5 - - The ``action()`` modifier was introduced in Live Components 2.5. - To only toggle the loading behavior when a specific action is triggered, use the ``action()`` modifier with the name of the action - e.g. ``saveForm()``: @@ -1021,10 +1019,6 @@ use the ``action()`` modifier with the name of the action - e.g. ``saveForm()``: Targeting Loading When a Specific Model Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 2.5 - - The ``model()`` modifier was introduced in Live Components 2.5. - You can also toggle the loading behavior only if a specific model value was just changed using the ``model()`` modifier: @@ -1046,10 +1040,11 @@ Actions Live components require a single "default action" that is used to re-render it. By default, this is an empty ``__invoke()`` method and can -be added with the ``DefaultActionTrait``. Live components are actually -Symfony controllers so you can add the normal controller -attributes/annotations (i.e. ``#[Cache]``/``#[Security]``) to either the -entire class just a single action. +be added with the ``DefaultActionTrait``. + +Live components __are__ actually Symfony controllers so you can add +controller attributes (i.e. ``#[IsGranted]``) to either the entire class +just a single action. You can also trigger custom actions on your component. Let's pretend we want to add a "Reset Max" button to our "random number" component @@ -1184,7 +1179,7 @@ You can also pass arguments to your action by adding each as a
    In your component, to allow each argument to be passed, add -the ``#[LiveArg()]`` attribute:: +the ``#[LiveArg]`` attribute:: // src/Twig/Components/ItemList.php namespace App\Twig\Components; @@ -1207,29 +1202,17 @@ the ``#[LiveArg()]`` attribute:: Actions and CSRF Protection ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -When you trigger an action, a POST request is sent that contains a -``X-CSRF-TOKEN`` header. This header is automatically populated and -validated. In other wordsâ€Ļ you get CSRF protection without any work. +When an action is triggered, a POST request is sent with a custom ``Accept`` +header. This header is automatically set and validated for you. In other +words, you benefit from CSRF protection effortlessly, thanks to the +``same-origin`` and ``CORS`` policies enforced by browsers. -Your only job is to make sure that the CSRF component is installed: +.. warning:: -.. code-block:: terminal - - $ composer require symfony/security-csrf + To ensure this built-in CSRF protection remains effective, pay attention + to your CORS headers (e.g. *DO NOT* use ``Access-Control-Allow-Origin: *``). -If you want to disable CSRF for a single component you can set -``csrf`` option to ``false``:: - - namespace App\Twig\Components; - - use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; - use Symfony\UX\LiveComponent\Attribute\LiveProp; - - #[AsLiveComponent(csrf: false)] - class MyLiveComponent - { - // ... - } +In test-mode, the CSRF protection is disabled to make testing easier. Actions, Redirecting and AbstractController ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1337,6 +1320,39 @@ The files will be available in a regular ``$request->files`` files bag:: need to specify ``multiple`` attribute on HTML element and end ``name`` with ``[]``. +.. _downloads: + +Downloading files +----------------- + +Currently, Live Components do not natively support returning file responses directly from a LiveAction. However, you can implement file downloads by redirecting to a route that handles the file response. + +Create a LiveAction that generates the URL for the file download and returns a ``RedirectResponse``:: + + #[LiveAction] + public function initiateDownload(UrlGeneratorInterface $urlGenerator): RedirectResponse + { + $url = $urlGenerator->generate('app_file_download'); + return new RedirectResponse($url); + } + +.. code-block:: html+twig + +
    + +
    + + +.. tip:: + + When Turbo is enabled, if a LiveAction response redirects to another URL, Turbo will make a request to prefetch the content. Here, adding ``data-turbo="false"`` ensures that the download URL is called only once. + + .. _forms: Forms @@ -1475,9 +1491,7 @@ or omit it entirely to let the ``initialFormData`` property default to ``null``: {# templates/post/new.html.twig #} {# ... #} - {{ component('PostForm', { - form: form - }) }} + {{ component('PostForm') }} Submitting the Form via a LiveAction ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1872,10 +1886,6 @@ When the user clicks ``removeComment()``, a similar process happens. Using LiveCollectionType ~~~~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 2.2 - - The ``LiveCollectionType`` and the ``LiveCollectionTrait`` was added in LiveComponent 2.2. - The ``LiveCollectionType`` uses the same method described above, but in a generic way, so it needs even less code. This form type adds an 'Add' and a 'Delete' button for each row by default, which work out of the box @@ -2425,7 +2435,7 @@ the ``loading-template`` option to point to a template: {# With the component function #} - {{ component('SomeHeavyComponent', { loading: 'defer', loading-template: 'spinning-wheel.html.twig' }) }} + {{ component('SomeHeavyComponent', { loading: 'defer', 'loading-template': 'spinning-wheel.html.twig' }) }} Or override the ``loadingContent`` block: @@ -2448,7 +2458,7 @@ To change the initial tag from a ``div`` to something else, use the ``loading-ta .. code-block:: twig - {{ component('SomeHeavyComponent', { loading: 'defer', loading-tag: 'span' }) }} + {{ component('SomeHeavyComponent', { loading: 'defer', 'loading-tag': 'span' }) }} Polling ------- @@ -2626,6 +2636,47 @@ This way you can also use the component multiple times in the same page and avoi +.. versionadded:: 2.26 + + The property name is passed into the modifier function since LiveComponents 2.26. + +The ``modifier`` function can also take the name of the property as a secondary parameter. +It can be used to perform more generic operations inside of the modifier that can be re-used for multiple props:: + + abstract class AbstractSearchModule + { + #[LiveProp(writable: true, url: true, modifier: 'modifyQueryProp')] + public string $query = ''; + + protected string $urlPrefix = ''; + + public function modifyQueryProp(LiveProp $liveProp, string $propName): LiveProp + { + if ($this->urlPrefix) { + return $liveProp->withUrl(new UrlMapping(as: $this->urlPrefix.'-'.$propName)); + } + return $liveProp; + } + } + + #[AsLiveComponent] + class ImportantSearchModule extends AbstractSearchModule + { + } + + #[AsLiveComponent] + class SecondarySearchModule extends AbstractSearchModule + { + protected string $urlPrefix = 'secondary'; + } + +.. code-block:: html+twig + + + + +The ``query`` value will appear in the URL like ``/search?query=my+important+query&secondary-query=my+secondary+query``. + Validating the Query Parameter Values ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -2672,10 +2723,6 @@ validated. To validate it, you have to set up a `PostMount hook`_:: Communication Between Components: Emitting Events ------------------------------------------------- -.. versionadded:: 2.8 - - The ability to emit events was added in Live Components 2.8. - Events allow you to communicate between any two components that live on your page. @@ -2756,6 +2803,16 @@ You can also pass extra (scalar) data to the listeners:: ]); } +From a Twig template: + + .. code-block:: html+twig + + + Backward Compatibility promise ------------------------------ @@ -243,8 +692,9 @@ the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html .. _`the Symfony UX initiative`: https://ux.symfony.com/ -.. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html .. _`Google Maps`: https://github.com/symfony/ux-google-map .. _`Leaflet`: https://github.com/symfony/ux-leaflet-map .. _`Symfony UX Map Google Maps brige docs`: https://github.com/symfony/ux/blob/2.x/src/Map/src/Bridge/Google/README.md .. _`Symfony UX Map Leaflet bridge docs`: https://github.com/symfony/ux/blob/2.x/src/Map/src/Bridge/Leaflet/README.md +.. _`Twig Component`: https://symfony.com/bundles/ux-twig-component/current/index.html +.. _`Live Actions`: https://symfony.com/bundles/ux-live-component/current/index.html#actions diff --git a/src/Map/src/Bridge/Google/.gitattributes b/src/Map/src/Bridge/Google/.gitattributes index e56a1c904f6..cd54bdcca24 100644 --- a/src/Map/src/Bridge/Google/.gitattributes +++ b/src/Map/src/Bridge/Google/.gitattributes @@ -2,6 +2,5 @@ /assets/test export-ignore /assets/vitest.config.js export-ignore /tests export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /phpunit.xml.dist export-ignore diff --git a/src/Map/src/Bridge/Google/CHANGELOG.md b/src/Map/src/Bridge/Google/CHANGELOG.md index 46f7d49c2f0..bfaea94609e 100644 --- a/src/Map/src/Bridge/Google/CHANGELOG.md +++ b/src/Map/src/Bridge/Google/CHANGELOG.md @@ -1,10 +1,20 @@ # CHANGELOG +## 2.25 + +- Downgrade PHP requirement from 8.3 to 8.1 + +## 2.22 + +- Add support for configuring a default Map ID +- Add argument `$defaultMapId` to `Symfony\UX\Map\Bridge\Google\Renderer\GoogleRendererFactory` constructor +- Add argument `$defaultMapId` to `Symfony\UX\Map\Bridge\Google\Renderer\GoogleRenderer` constructor + ## 2.20 ### BC Breaks -- Renamed importmap entry `@symfony/ux-google-map/map-controller` to `@symfony/ux-google-map`, +- Renamed importmap entry `@symfony/ux-google-map/map-controller` to `@symfony/ux-google-map`, you will need to update your importmap. ## 2.19 diff --git a/src/Map/src/Bridge/Google/README.md b/src/Map/src/Bridge/Google/README.md index d46bd700833..f6985ecf918 100644 --- a/src/Map/src/Bridge/Google/README.md +++ b/src/Map/src/Bridge/Google/README.md @@ -2,6 +2,25 @@ [Google Maps](https://developers.google.com/maps/documentation/javascript/overview) integration for Symfony UX Map. +## Installation + +Install the bridge using Composer and Symfony Flex: + +```shell +composer require symfony/ux-google-map +``` + +If you're using WebpackEncore, install your assets and restart Encore (not +needed if you're using AssetMapper): + +```shell +npm install --force +npm run watch +``` + +> [!NOTE] +> Alternatively, [@symfony/ux-google-map package](https://www.npmjs.com/package/@symfony/ux-google-map) can be used to install the JavaScript assets without requiring PHP. + ## DSN example ```dotenv @@ -10,7 +29,7 @@ UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default # With options UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?version=weekly UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?language=fr®ion=FR -UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default??libraries[]=geometry&libraries[]=places +UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?libraries[]=geometry&libraries[]=places ``` Available options: @@ -46,9 +65,12 @@ $map = (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(6); -// To configure controls options, and some other options: +// To configure control options and other map options: $googleOptions = (new GoogleOptions()) - ->mapId('YOUR_MAP_ID') + // You can skip this option if you configure "ux_map.google_maps.default_map_id" + // in your "config/packages/ux_map.yaml". + ->mapId('YOUR_MAP_ID') + ->gestureHandling(GestureHandling::GREEDY) ->backgroundColor('#f00') ->doubleClickZoom(true) @@ -116,7 +138,7 @@ export default class extends Controller // 1. To use a custom image for the marker const beachFlagImg = document.createElement("img"); - // Note: instead of using an hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`. + // Note: instead of using a hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`. beachFlagImg.src = "https://developers.google.com/maps/documentation/javascript/examples/full/images/beachflag.png"; definition.rawOptions = { content: beachFlagImg @@ -124,7 +146,7 @@ export default class extends Controller // 2. To use a custom glyph for the marker const pinElement = new google.maps.marker.PinElement({ - // Note: instead of using an hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`. + // Note: instead of using a hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`. glyph: new URL('https://maps.gstatic.com/mapfiles/place_api/icons/v2/museum_pinlet.svg'), glyphColor: "white", }); diff --git a/src/Map/src/Bridge/Google/assets/LICENSE b/src/Map/src/Bridge/Google/assets/LICENSE new file mode 100644 index 00000000000..e374a5c8339 --- /dev/null +++ b/src/Map/src/Bridge/Google/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Map/src/Bridge/Google/assets/README.md b/src/Map/src/Bridge/Google/assets/README.md new file mode 100644 index 00000000000..917af847804 --- /dev/null +++ b/src/Map/src/Bridge/Google/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-google-map + +JavaScript assets of the [symfony/ux-google-map](https://packagist.org/packages/symfony/ux-google-map) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-google-map](https://packagist.org/packages/symfony/ux-google-map) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-google-map](https://packagist.org/packages/symfony/ux-google-map) PHP package version: +```shell +composer require symfony/ux-google-map:2.23.0 +npm add @symfony/ux-google-map@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://github.com/symfony/ux/tree/2.x/src/Map/src/Bridge/Google) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts index 5095762fc07..6f2efe0aae3 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts @@ -1,27 +1,42 @@ -import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition, PolygonDefinition } from '@symfony/ux-map'; import type { LoaderOptions } from '@googlemaps/js-api-loader'; +import AbstractMapController from '@symfony/ux-map'; +import type { Icon, InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; type MapOptions = Pick; -export default class extends AbstractMapController { - static values: { - providerOptions: ObjectConstructor; - }; +export default class extends AbstractMapController { providerOptionsValue: Pick; + map: google.maps.Map; + parser: DOMParser; connect(): Promise; + centerValueChanged(): void; + zoomValueChanged(): void; protected dispatchEvent(name: string, payload?: Record): void; protected doCreateMap({ center, zoom, options, }: { center: Point | null; zoom: number | null; options: MapOptions; }): google.maps.Map; - protected doCreateMarker(definition: MarkerDefinition): google.maps.marker.AdvancedMarkerElement; - protected doCreatePolygon(definition: PolygonDefinition): google.maps.Polygon; + protected doCreateMarker({ definition, }: { + definition: MarkerDefinition; + }): google.maps.marker.AdvancedMarkerElement; + protected doRemoveMarker(marker: google.maps.marker.AdvancedMarkerElement): void; + protected doCreatePolygon({ definition, }: { + definition: PolygonDefinition; + }): google.maps.Polygon; + protected doRemovePolygon(polygon: google.maps.Polygon): void; + protected doCreatePolyline({ definition, }: { + definition: PolylineDefinition; + }): google.maps.Polyline; + protected doRemovePolyline(polyline: google.maps.Polyline): void; protected doCreateInfoWindow({ definition, element, }: { - definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; - element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon; + definition: InfoWindowWithoutPositionDefinition; + element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon | google.maps.Polyline; }): google.maps.InfoWindow; + protected doFitBoundsToMarkers(): void; private createTextOrElement; + protected doCreateIcon({ definition, element, }: { + definition: Icon; + element: google.maps.marker.AdvancedMarkerElement; + }): void; private closeInfoWindowsExcept; - protected doFitBoundsToMarkers(): void; } export {}; diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.js b/src/Map/src/Bridge/Google/assets/dist/map_controller.js index 30fbe283118..1bc19b99e50 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.js @@ -1,58 +1,116 @@ -import { Controller } from '@hotwired/stimulus'; import { Loader } from '@googlemaps/js-api-loader'; +import { Controller } from '@hotwired/stimulus'; -let default_1$1 = class default_1 extends Controller { +const IconTypes = { + Url: 'url', + Svg: 'svg', + UxIcon: 'ux-icon', +}; +class default_1 extends Controller { constructor() { super(...arguments); - this.markers = []; + this.markers = new Map(); + this.polygons = new Map(); + this.polylines = new Map(); this.infoWindows = []; - this.polygons = []; + this.isConnected = false; } connect() { - const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; + const options = this.optionsValue; this.dispatchEvent('pre-connect', { options }); - this.map = this.doCreateMap({ center, zoom, options }); - markers.forEach((marker) => this.createMarker(marker)); - polygons.forEach((polygon) => this.createPolygon(polygon)); - if (fitBoundsToMarkers) { + this.createMarker = this.createDrawingFactory('marker', this.markers, this.doCreateMarker.bind(this)); + this.createPolygon = this.createDrawingFactory('polygon', this.polygons, this.doCreatePolygon.bind(this)); + this.createPolyline = this.createDrawingFactory('polyline', this.polylines, this.doCreatePolyline.bind(this)); + this.map = this.doCreateMap({ + center: this.hasCenterValue ? this.centerValue : null, + zoom: this.hasZoomValue ? this.zoomValue : null, + options, + }); + this.markersValue.forEach((definition) => this.createMarker({ definition })); + this.polygonsValue.forEach((definition) => this.createPolygon({ definition })); + this.polylinesValue.forEach((definition) => this.createPolyline({ definition })); + if (this.fitBoundsToMarkersValue) { this.doFitBoundsToMarkers(); } this.dispatchEvent('connect', { map: this.map, - markers: this.markers, - polygons: this.polygons, + markers: [...this.markers.values()], + polygons: [...this.polygons.values()], + polylines: [...this.polylines.values()], infoWindows: this.infoWindows, }); - } - createMarker(definition) { - this.dispatchEvent('marker:before-create', { definition }); - const marker = this.doCreateMarker(definition); - this.dispatchEvent('marker:after-create', { marker }); - this.markers.push(marker); - return marker; - } - createPolygon(definition) { - this.dispatchEvent('polygon:before-create', { definition }); - const polygon = this.doCreatePolygon(definition); - this.dispatchEvent('polygon:after-create', { polygon }); - this.polygons.push(polygon); - return polygon; + this.isConnected = true; } createInfoWindow({ definition, element, }) { this.dispatchEvent('info-window:before-create', { definition, element }); const infoWindow = this.doCreateInfoWindow({ definition, element }); - this.dispatchEvent('info-window:after-create', { infoWindow, element }); + this.dispatchEvent('info-window:after-create', { infoWindow, definition, element }); this.infoWindows.push(infoWindow); return infoWindow; } -}; -default_1$1.values = { + markersValueChanged() { + if (!this.isConnected) { + return; + } + this.onDrawChanged(this.markers, this.markersValue, this.createMarker, this.doRemoveMarker); + if (this.fitBoundsToMarkersValue) { + this.doFitBoundsToMarkers(); + } + } + polygonsValueChanged() { + if (!this.isConnected) { + return; + } + this.onDrawChanged(this.polygons, this.polygonsValue, this.createPolygon, this.doRemovePolygon); + } + polylinesValueChanged() { + if (!this.isConnected) { + return; + } + this.onDrawChanged(this.polylines, this.polylinesValue, this.createPolyline, this.doRemovePolyline); + } + createDrawingFactory(type, draws, factory) { + const eventBefore = `${type}:before-create`; + const eventAfter = `${type}:after-create`; + return ({ definition }) => { + this.dispatchEvent(eventBefore, { definition }); + const drawing = factory({ definition }); + this.dispatchEvent(eventAfter, { [type]: drawing, definition }); + draws.set(definition['@id'], drawing); + return drawing; + }; + } + onDrawChanged(draws, newDrawDefinitions, factory, remover) { + const idsToRemove = new Set(draws.keys()); + newDrawDefinitions.forEach((definition) => { + idsToRemove.delete(definition['@id']); + }); + idsToRemove.forEach((id) => { + const draw = draws.get(id); + remover(draw); + draws.delete(id); + }); + newDrawDefinitions.forEach((definition) => { + if (!draws.has(definition['@id'])) { + factory({ definition }); + } + }); + } +} +default_1.values = { providerOptions: Object, - view: Object, + center: Object, + zoom: Number, + fitBoundsToMarkers: Boolean, + markers: Array, + polygons: Array, + polylines: Array, + options: Object, }; let _google; -class default_1 extends default_1$1 { +const parser = new DOMParser(); +class map_controller extends default_1 { async connect() { if (!_google) { _google = { maps: {} }; @@ -61,6 +119,9 @@ class default_1 extends default_1$1 { libraries = ['core', ...libraries.filter((library) => library !== 'core')]; const librariesImplementations = await Promise.all(libraries.map((library) => loader.importLibrary(library))); librariesImplementations.map((libraryImplementation, index) => { + if (typeof libraryImplementation !== 'object' || libraryImplementation === null) { + return; + } const library = libraries[index]; if (['marker', 'places', 'geometry', 'journeySharing', 'drawing', 'visualization'].includes(library)) { _google.maps[library] = libraryImplementation; @@ -71,6 +132,17 @@ class default_1 extends default_1$1 { }); } super.connect(); + this.parser = new DOMParser(); + } + centerValueChanged() { + if (this.map && this.hasCenterValue && this.centerValue) { + this.map.setCenter(this.centerValue); + } + } + zoomValueChanged() { + if (this.map && this.hasZoomValue && this.zoomValue) { + this.map.setZoom(this.zoomValue); + } } dispatchEvent(name, payload = {}) { this.dispatch(name, { @@ -92,8 +164,8 @@ class default_1 extends default_1$1 { zoom, }); } - doCreateMarker(definition) { - const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + doCreateMarker({ definition, }) { + const { '@id': _id, position, title, infoWindow, icon, extra, rawOptions = {}, ...otherOptions } = definition; const marker = new _google.maps.marker.AdvancedMarkerElement({ position, title, @@ -104,10 +176,16 @@ class default_1 extends default_1$1 { if (infoWindow) { this.createInfoWindow({ definition: infoWindow, element: marker }); } + if (icon) { + this.doCreateIcon({ definition: icon, element: marker }); + } return marker; } - doCreatePolygon(definition) { - const { points, title, infoWindow, rawOptions = {} } = definition; + doRemoveMarker(marker) { + marker.map = null; + } + doCreatePolygon({ definition, }) { + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; const polygon = new _google.maps.Polygon({ ...rawOptions, paths: points, @@ -121,6 +199,27 @@ class default_1 extends default_1$1 { } return polygon; } + doRemovePolygon(polygon) { + polygon.setMap(null); + } + doCreatePolyline({ definition, }) { + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; + const polyline = new _google.maps.Polyline({ + ...rawOptions, + path: points, + map: this.map, + }); + if (title) { + polyline.set('title', title); + } + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polyline }); + } + return polyline; + } + doRemovePolyline(polyline) { + polyline.setMap(null); + } doCreateInfoWindow({ definition, element, }) { const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition; const infoWindow = new _google.maps.InfoWindow({ @@ -159,6 +258,19 @@ class default_1 extends default_1$1 { } return infoWindow; } + doFitBoundsToMarkers() { + if (this.markers.size === 0) { + return; + } + const bounds = new google.maps.LatLngBounds(); + this.markers.forEach((marker) => { + if (!marker.position) { + return; + } + bounds.extend(marker.position); + }); + this.map.fitBounds(bounds); + } createTextOrElement(content) { if (!content) { return null; @@ -170,6 +282,25 @@ class default_1 extends default_1$1 { } return content; } + doCreateIcon({ definition, element, }) { + const { type, width, height } = definition; + if (type === IconTypes.Svg) { + element.content = parser.parseFromString(definition.html, 'image/svg+xml').documentElement; + } + else if (type === IconTypes.UxIcon) { + element.content = parser.parseFromString(definition._generated_html, 'image/svg+xml').documentElement; + } + else if (type === IconTypes.Url) { + const icon = document.createElement('img'); + icon.width = width; + icon.height = height; + icon.src = definition.url; + element.content = icon; + } + else { + throw new Error(`Unsupported icon type: ${type}.`); + } + } closeInfoWindowsExcept(infoWindow) { this.infoWindows.forEach((otherInfoWindow) => { if (otherInfoWindow !== infoWindow) { @@ -177,22 +308,6 @@ class default_1 extends default_1$1 { } }); } - doFitBoundsToMarkers() { - if (this.markers.length === 0) { - return; - } - const bounds = new google.maps.LatLngBounds(); - this.markers.forEach((marker) => { - if (!marker.position) { - return; - } - bounds.extend(marker.position); - }); - this.map.fitBounds(bounds); - } } -default_1.values = { - providerOptions: Object, -}; -export { default_1 as default }; +export { map_controller as default }; diff --git a/src/Map/src/Bridge/Google/assets/package.json b/src/Map/src/Bridge/Google/assets/package.json index 8b8dfdbc8d7..730b81d3202 100644 --- a/src/Map/src/Bridge/Google/assets/package.json +++ b/src/Map/src/Bridge/Google/assets/package.json @@ -2,10 +2,27 @@ "name": "@symfony/ux-google-map", "description": "GoogleMaps bridge for Symfony UX Map, integrate interactive maps in your Symfony applications", "license": "MIT", - "version": "1.0.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux", + "google-maps", + "map" + ], + "homepage": "https://ux.symfony.com/map", + "repository": "https://github.com/symfony/ux-google-map", "type": "module", + "files": [ + "dist" + ], "main": "dist/map_controller.js", "types": "dist/map_controller.d.ts", + "scripts": { + "build": "node ../../../../../../bin/build_package.js .", + "watch": "node ../../../../../../bin/build_package.js . --watch", + "test": "../../../../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "map": { @@ -33,6 +50,7 @@ "devDependencies": { "@googlemaps/js-api-loader": "^1.16.6", "@hotwired/stimulus": "^3.0.0", + "@symfony/ux-map": "workspace:*", "@types/google.maps": "^3.55.9" } } diff --git a/src/Map/src/Bridge/Google/assets/src/map_controller.ts b/src/Map/src/Bridge/Google/assets/src/map_controller.ts index 05116d80253..db073bc7732 100644 --- a/src/Map/src/Bridge/Google/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Google/assets/src/map_controller.ts @@ -7,10 +7,17 @@ * file that was distributed with this source code. */ -import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition, PolygonDefinition } from '@symfony/ux-map'; import type { LoaderOptions } from '@googlemaps/js-api-loader'; import { Loader } from '@googlemaps/js-api-loader'; +import AbstractMapController, { IconTypes } from '@symfony/ux-map'; +import type { + Icon, + InfoWindowWithoutPositionDefinition, + MarkerDefinition, + Point, + PolygonDefinition, + PolylineDefinition, +} from '@symfony/ux-map'; type MapOptions = Pick< google.maps.MapOptions, @@ -30,6 +37,8 @@ type MapOptions = Pick< let _google: typeof google; +const parser = new DOMParser(); + export default class extends AbstractMapController< MapOptions, google.maps.Map, @@ -38,20 +47,22 @@ export default class extends AbstractMapController< google.maps.InfoWindowOptions, google.maps.InfoWindow, google.maps.PolygonOptions, - google.maps.Polygon + google.maps.Polygon, + google.maps.PolylineOptions, + google.maps.Polyline > { - static values = { - providerOptions: Object, - }; - declare providerOptionsValue: Pick< LoaderOptions, 'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version' | 'libraries' >; + declare map: google.maps.Map; + + public parser: DOMParser; + async connect() { if (!_google) { - _google = { maps: {} }; + _google = { maps: {} as typeof google.maps }; let { libraries = [], ...loaderOptions } = this.providerOptionsValue; @@ -65,11 +76,16 @@ export default class extends AbstractMapController< libraries.map((library) => loader.importLibrary(library)) ); librariesImplementations.map((libraryImplementation, index) => { + if (typeof libraryImplementation !== 'object' || libraryImplementation === null) { + return; + } + const library = libraries[index]; // The following libraries are in a sub-namespace if (['marker', 'places', 'geometry', 'journeySharing', 'drawing', 'visualization'].includes(library)) { - _google.maps[library] = libraryImplementation; + // @ts-ignore + _google.maps[library] = libraryImplementation as any; } else { _google.maps = { ..._google.maps, ...libraryImplementation }; } @@ -77,6 +93,19 @@ export default class extends AbstractMapController< } super.connect(); + this.parser = new DOMParser(); + } + + public centerValueChanged(): void { + if (this.map && this.hasCenterValue && this.centerValue) { + this.map.setCenter(this.centerValue); + } + } + + public zoomValueChanged(): void { + if (this.map && this.hasZoomValue && this.zoomValue) { + this.map.setZoom(this.zoomValue); + } } protected dispatchEvent(name: string, payload: Record = {}): void { @@ -111,10 +140,12 @@ export default class extends AbstractMapController< }); } - protected doCreateMarker( - definition: MarkerDefinition - ): google.maps.marker.AdvancedMarkerElement { - const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + protected doCreateMarker({ + definition, + }: { + definition: MarkerDefinition; + }): google.maps.marker.AdvancedMarkerElement { + const { '@id': _id, position, title, infoWindow, icon, extra, rawOptions = {}, ...otherOptions } = definition; const marker = new _google.maps.marker.AdvancedMarkerElement({ position, @@ -128,13 +159,23 @@ export default class extends AbstractMapController< this.createInfoWindow({ definition: infoWindow, element: marker }); } + if (icon) { + this.doCreateIcon({ definition: icon, element: marker }); + } + return marker; } - protected doCreatePolygon( - definition: PolygonDefinition - ): google.maps.Polygon { - const { points, title, infoWindow, rawOptions = {} } = definition; + protected doRemoveMarker(marker: google.maps.marker.AdvancedMarkerElement): void { + marker.map = null; + } + + protected doCreatePolygon({ + definition, + }: { + definition: PolygonDefinition; + }): google.maps.Polygon { + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; const polygon = new _google.maps.Polygon({ ...rawOptions, @@ -153,17 +194,44 @@ export default class extends AbstractMapController< return polygon; } + protected doRemovePolygon(polygon: google.maps.Polygon) { + polygon.setMap(null); + } + + protected doCreatePolyline({ + definition, + }: { + definition: PolylineDefinition; + }): google.maps.Polyline { + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; + + const polyline = new _google.maps.Polyline({ + ...rawOptions, + path: points, + map: this.map, + }); + + if (title) { + polyline.set('title', title); + } + + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polyline }); + } + + return polyline; + } + + protected doRemovePolyline(polyline: google.maps.Polyline): void { + polyline.setMap(null); + } + protected doCreateInfoWindow({ definition, element, }: { - definition: - | MarkerDefinition< - google.maps.marker.AdvancedMarkerElementOptions, - google.maps.InfoWindowOptions - >['infoWindow'] - | PolygonDefinition['infoWindow']; - element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon; + definition: InfoWindowWithoutPositionDefinition; + element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon | google.maps.Polyline; }): google.maps.InfoWindow { const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition; @@ -207,6 +275,23 @@ export default class extends AbstractMapController< return infoWindow; } + protected doFitBoundsToMarkers(): void { + if (this.markers.size === 0) { + return; + } + + const bounds = new google.maps.LatLngBounds(); + this.markers.forEach((marker) => { + if (!marker.position) { + return; + } + + bounds.extend(marker.position); + }); + + this.map.fitBounds(bounds); + } + private createTextOrElement(content: string | null): string | HTMLElement | null { if (!content) { return null; @@ -222,6 +307,30 @@ export default class extends AbstractMapController< return content; } + protected doCreateIcon({ + definition, + element, + }: { + definition: Icon; + element: google.maps.marker.AdvancedMarkerElement; + }): void { + const { type, width, height } = definition; + + if (type === IconTypes.Svg) { + element.content = parser.parseFromString(definition.html, 'image/svg+xml').documentElement; + } else if (type === IconTypes.UxIcon) { + element.content = parser.parseFromString(definition._generated_html, 'image/svg+xml').documentElement; + } else if (type === IconTypes.Url) { + const icon = document.createElement('img'); + icon.width = width; + icon.height = height; + icon.src = definition.url; + element.content = icon; + } else { + throw new Error(`Unsupported icon type: ${type}.`); + } + } + private closeInfoWindowsExcept(infoWindow: google.maps.InfoWindow) { this.infoWindows.forEach((otherInfoWindow) => { if (otherInfoWindow !== infoWindow) { @@ -229,21 +338,4 @@ export default class extends AbstractMapController< } }); } - - protected doFitBoundsToMarkers(): void { - if (this.markers.length === 0) { - return; - } - - const bounds = new google.maps.LatLngBounds(); - this.markers.forEach((marker) => { - if (!marker.position) { - return; - } - - bounds.extend(marker.position); - }); - - this.map.fitBounds(bounds); - } } diff --git a/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts b/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts index f1b08abba5c..ff5d31d041a 100644 --- a/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts +++ b/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import GoogleController from '../src/map_controller'; // Controller used to check the actual controller was properly booted @@ -28,7 +28,7 @@ class CheckController extends Controller { const startStimulus = () => { const application = Application.start(); application.register('check', CheckController); - application.register('google', GoogleController); + application.register('symfony--ux-google-map--map', GoogleController); }; describe('GoogleMapsController', () => { @@ -38,10 +38,16 @@ describe('GoogleMapsController', () => { container = mountDOM(`
    `); }); diff --git a/src/Map/src/Bridge/Google/composer.json b/src/Map/src/Bridge/Google/composer.json index 0160a0bd186..7889f360787 100644 --- a/src/Map/src/Bridge/Google/composer.json +++ b/src/Map/src/Bridge/Google/composer.json @@ -16,11 +16,15 @@ } ], "require": { - "php": ">=8.3", + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.18.1", "symfony/ux-map": "^2.19" }, "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0" + "symfony/phpunit-bridge": "^7.2", + "symfony/ux-icons": "^2.18", + "spatie/phpunit-snapshot-assertions": "^4.2.17", + "phpunit/phpunit": "^9.6.22" }, "autoload": { "psr-4": { "Symfony\\UX\\Map\\Bridge\\Google\\": "src/" }, diff --git a/src/Map/src/Bridge/Google/phpunit.xml.dist b/src/Map/src/Bridge/Google/phpunit.xml.dist index 1c3807e6255..751314ecb8e 100644 --- a/src/Map/src/Bridge/Google/phpunit.xml.dist +++ b/src/Map/src/Bridge/Google/phpunit.xml.dist @@ -12,7 +12,7 @@ ./src - + diff --git a/src/Map/src/Bridge/Google/src/GoogleOptions.php b/src/Map/src/Bridge/Google/src/GoogleOptions.php index 8b26efcfba9..68d6bbd6810 100644 --- a/src/Map/src/Bridge/Google/src/GoogleOptions.php +++ b/src/Map/src/Bridge/Google/src/GoogleOptions.php @@ -46,6 +46,11 @@ public function mapId(?string $mapId): self return $this; } + public function hasMapId(): bool + { + return null !== $this->mapId; + } + public function gestureHandling(GestureHandling $gestureHandling): self { $this->gestureHandling = $gestureHandling; @@ -127,6 +132,41 @@ public function fullscreenControlOptions(FullscreenControlOptions $fullscreenCon return $this; } + /** + * @internal + */ + public static function fromArray(array $array): self + { + $array += ['zoomControl' => false, 'mapTypeControl' => false, 'streetViewControl' => false, 'fullscreenControl' => false]; + + if (isset($array['zoomControlOptions'])) { + $array['zoomControl'] = true; + $array['zoomControlOptions'] = ZoomControlOptions::fromArray($array['zoomControlOptions']); + } + + if (isset($array['mapTypeControlOptions'])) { + $array['mapTypeControl'] = true; + $array['mapTypeControlOptions'] = MapTypeControlOptions::fromArray($array['mapTypeControlOptions']); + } + + if (isset($array['streetViewControlOptions'])) { + $array['streetViewControl'] = true; + $array['streetViewControlOptions'] = StreetViewControlOptions::fromArray($array['streetViewControlOptions']); + } + + if (isset($array['fullscreenControlOptions'])) { + $array['fullscreenControl'] = true; + $array['fullscreenControlOptions'] = FullscreenControlOptions::fromArray($array['fullscreenControlOptions']); + } + + $array['gestureHandling'] = GestureHandling::from($array['gestureHandling']); + + return new self(...$array); + } + + /** + * @internal + */ public function toArray(): array { $array = [ diff --git a/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php index 35256551f23..ab3df9d4960 100644 --- a/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php @@ -18,13 +18,26 @@ * * @author Hugo Alliaume */ -final readonly class FullscreenControlOptions +final class FullscreenControlOptions { public function __construct( - private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_START, + private readonly ControlPosition $position = ControlPosition::INLINE_END_BLOCK_START, ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + position: ControlPosition::from($array['position']), + ); + } + + /** + * @internal + */ public function toArray(): array { return [ diff --git a/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php index 99e1fba1fb7..3cc28e3662c 100644 --- a/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php @@ -18,18 +18,33 @@ * * @author Hugo Alliaume */ -final readonly class MapTypeControlOptions +final class MapTypeControlOptions { /** * @param array<'hybrid'|'roadmap'|'satellite'|'terrain'|string> $mapTypeIds */ public function __construct( - private array $mapTypeIds = [], - private ControlPosition $position = ControlPosition::BLOCK_START_INLINE_START, - private MapTypeControlStyle $style = MapTypeControlStyle::DEFAULT, + private readonly array $mapTypeIds = [], + private readonly ControlPosition $position = ControlPosition::BLOCK_START_INLINE_START, + private readonly MapTypeControlStyle $style = MapTypeControlStyle::DEFAULT, ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + mapTypeIds: $array['mapTypeIds'], + position: ControlPosition::from($array['position']), + style: MapTypeControlStyle::from($array['style']), + ); + } + + /** + * @internal + */ public function toArray(): array { return [ diff --git a/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php index 926b8831945..2fa9a89c1f6 100644 --- a/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php @@ -18,13 +18,26 @@ * * @author Hugo Alliaume */ -final readonly class StreetViewControlOptions +final class StreetViewControlOptions { public function __construct( - private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + private readonly ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + position: ControlPosition::from($array['position']), + ); + } + + /** + * @internal + */ public function toArray(): array { return [ diff --git a/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php index 979947a2354..b669e5b53c5 100644 --- a/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php @@ -18,13 +18,26 @@ * * @author Hugo Alliaume */ -final readonly class ZoomControlOptions +final class ZoomControlOptions { public function __construct( - private ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + private readonly ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + position: ControlPosition::from($array['position']), + ); + } + + /** + * @internal + */ public function toArray(): array { return [ diff --git a/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php index 808278abbf3..331137ed4a5 100644 --- a/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRenderer.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Bridge\Google\Renderer; use Symfony\UX\Map\Bridge\Google\GoogleOptions; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\MapOptionsInterface; use Symfony\UX\Map\Renderer\AbstractRenderer; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -21,28 +22,29 @@ * * @internal */ -final readonly class GoogleRenderer extends AbstractRenderer +final class GoogleRenderer extends AbstractRenderer { /** * Parameters are based from https://googlemaps.github.io/js-api-loader/interfaces/LoaderOptions.html documentation. */ public function __construct( StimulusHelper $stimulusHelper, - #[\SensitiveParameter] - private string $apiKey, - private ?string $id = null, - private ?string $language = null, - private ?string $region = null, - private ?string $nonce = null, - private ?int $retries = null, - private ?string $url = null, - private ?string $version = null, + UxIconRenderer $uxIconRenderer, + #[\SensitiveParameter] private readonly string $apiKey, + private readonly ?string $id = null, + private readonly ?string $language = null, + private readonly ?string $region = null, + private readonly ?string $nonce = null, + private readonly ?int $retries = null, + private readonly ?string $url = null, + private readonly ?string $version = null, /** * @var array<'core'|'maps'|'places'|'geocoding'|'routes'|'marker'|'geometry'|'elevation'|'streetView'|'journeySharing'|'drawing'|'visualization'> */ - private array $libraries = [], + private readonly array $libraries = [], + private readonly ?string $defaultMapId = null, ) { - parent::__construct($stimulusHelper); + parent::__construct($stimulusHelper, $uxIconRenderer); } protected function getName(): string @@ -66,7 +68,20 @@ protected function getProviderOptions(): array protected function getDefaultMapOptions(): MapOptionsInterface { - return new GoogleOptions(); + return new GoogleOptions(mapId: $this->defaultMapId); + } + + protected function tapOptions(MapOptionsInterface $options): MapOptionsInterface + { + if (!$options instanceof GoogleOptions) { + throw new \InvalidArgumentException(\sprintf('The options must be an instance of "%s", got "%s" instead.', GoogleOptions::class, get_debug_type($options))); + } + + if (!$options->hasMapId()) { + $options->mapId($this->defaultMapId); + } + + return $options; } public function __toString(): string diff --git a/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php index a391f676152..29f81edc3d0 100644 --- a/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php +++ b/src/Map/src/Bridge/Google/src/Renderer/GoogleRendererFactory.php @@ -13,16 +13,26 @@ use Symfony\UX\Map\Exception\InvalidArgumentException; use Symfony\UX\Map\Exception\UnsupportedSchemeException; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Renderer\AbstractRendererFactory; use Symfony\UX\Map\Renderer\Dsn; use Symfony\UX\Map\Renderer\RendererFactoryInterface; use Symfony\UX\Map\Renderer\RendererInterface; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; /** * @author Hugo Alliaume */ final class GoogleRendererFactory extends AbstractRendererFactory implements RendererFactoryInterface { + public function __construct( + StimulusHelper $stimulus, + UxIconRenderer $uxIconRenderer, + private ?string $defaultMapId = null, + ) { + parent::__construct($stimulus, $uxIconRenderer); + } + public function create(Dsn $dsn): RendererInterface { if (!$this->supports($dsn)) { @@ -33,7 +43,8 @@ public function create(Dsn $dsn): RendererInterface return new GoogleRenderer( $this->stimulus, - $apiKey, + $this->uxIconRenderer, + apiKey: $apiKey, id: $dsn->getOption('id'), language: $dsn->getOption('language'), region: $dsn->getOption('region'), @@ -42,6 +53,7 @@ public function create(Dsn $dsn): RendererInterface url: $dsn->getOption('url'), version: $dsn->getOption('version', 'weekly'), libraries: ['maps', 'marker', ...$dsn->getOption('libraries', [])], + defaultMapId: $this->defaultMapId, ); } diff --git a/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php b/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php index ccde8a72939..b5ed565dae0 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php @@ -43,12 +43,14 @@ public function testWithMinimalConfiguration(): void 'position' => ControlPosition::INLINE_END_BLOCK_START->value, ], ], $options->toArray()); + + self::assertEquals($options, GoogleOptions::fromArray($options->toArray())); } public function testWithMinimalConfigurationAndWithoutControls(): void { $options = new GoogleOptions( - mapId: '2b2d73ba4b8c7b41', + mapId: 'abcdefgh12345678', gestureHandling: GestureHandling::GREEDY, backgroundColor: '#f00', disableDoubleClickZoom: true, @@ -59,10 +61,12 @@ public function testWithMinimalConfigurationAndWithoutControls(): void ); self::assertSame([ - 'mapId' => '2b2d73ba4b8c7b41', + 'mapId' => 'abcdefgh12345678', 'gestureHandling' => GestureHandling::GREEDY->value, 'backgroundColor' => '#f00', 'disableDoubleClickZoom' => true, ], $options->toArray()); + + self::assertEquals($options, GoogleOptions::fromArray($options->toArray())); } } diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php index eac705cfd7c..424b3a2943e 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererFactoryTest.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Bridge\Google\Tests; use Symfony\UX\Map\Bridge\Google\Renderer\GoogleRendererFactory; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Renderer\RendererFactoryInterface; use Symfony\UX\Map\Test\RendererFactoryTestCase; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -20,7 +21,7 @@ final class GoogleRendererFactoryTest extends RendererFactoryTestCase { public function createRendererFactory(): RendererFactoryInterface { - return new GoogleRendererFactory(new StimulusHelper(null)); + return new GoogleRendererFactory(new StimulusHelper(null), new UxIconRenderer(null)); } public static function supportsRenderer(): iterable diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php index 32ca96df600..a2f7e7dc127 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php @@ -11,54 +11,101 @@ namespace Symfony\UX\Map\Bridge\Google\Tests; +use Symfony\UX\Icons\IconRendererInterface; use Symfony\UX\Map\Bridge\Google\GoogleOptions; use Symfony\UX\Map\Bridge\Google\Renderer\GoogleRenderer; +use Symfony\UX\Map\Icon\Icon; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; +use Symfony\UX\Map\Polygon; +use Symfony\UX\Map\Polyline; use Symfony\UX\Map\Test\RendererTestCase; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; class GoogleRendererTest extends RendererTestCase { - public function provideTestRenderMap(): iterable + public static function provideTestRenderMap(): iterable { $map = (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12); + $marker1 = new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', id: 'marker1'); + $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2'); + $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); yield 'simple map, with minimum options' => [ - 'expected_render' => '
    ', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => $map, ]; yield 'with every options' => [ - 'expected_render' => '
    ', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10, url: 'https://maps.googleapis.com/maps/api/js', version: 'quarterly'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10, url: 'https://maps.googleapis.com/maps/api/js', version: 'quarterly'), 'map' => $map, ]; yield 'with custom attributes' => [ - 'expected_render' => '
    ', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), 'map' => $map, 'attributes' => ['data-controller' => 'my-custom-controller', 'class' => 'map'], ]; yield 'with markers and infoWindows' => [ - 'expected_render' => '
    ', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), - 'map' => (clone $map) - ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Paris')) + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker(new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', id: 'marker1')) ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'))), ]; + yield 'with all markers removed' => [ + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker($marker1) + ->addMarker($marker2) + ->removeMarker($marker1) + ->removeMarker($marker2), + ]; + + yield 'with marker remove and new ones added' => [ + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker($marker3) + ->removeMarker($marker3) + ->addMarker($marker1) + ->addMarker($marker2), + ]; + + yield 'with polygons and infoWindows' => [ + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addPolygon(new Polygon(points: [new Point(48.8566, 2.3522), new Point(48.8566, 2.3522), new Point(48.8566, 2.3522)])) + ->addPolygon(new Polygon(points: [new Point(1.1, 2.2), new Point(3.3, 4.4), new Point(5.5, 6.6)], infoWindow: new InfoWindow(content: 'Polygon'))), + ]; + + yield 'with polylines and infoWindows' => [ + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addPolyline(new Polyline(points: [new Point(48.8566, 2.3522), new Point(48.8566, 2.3522), new Point(48.8566, 2.3522)])) + ->addPolyline(new Polyline(points: [new Point(1.1, 2.2), new Point(3.3, 4.4), new Point(5.5, 6.6)], infoWindow: new InfoWindow(content: 'Polygon'))), + ]; + yield 'with controls enabled' => [ - 'expected_render' => '
    ', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), - 'map' => (clone $map) + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) ->options(new GoogleOptions( zoomControl: true, mapTypeControl: true, @@ -68,9 +115,10 @@ public function provideTestRenderMap(): iterable ]; yield 'without controls enabled' => [ - 'expected_render' => '
    ', - 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), - 'map' => (clone $map) + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), apiKey: 'api_key'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) ->options(new GoogleOptions( zoomControl: false, mapTypeControl: false, @@ -78,5 +126,47 @@ public function provideTestRenderMap(): iterable fullscreenControl: false, )), ]; + + yield 'with default map id' => [ + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), 'my_api_key', defaultMapId: 'DefaultMapId'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12), + ]; + + yield 'with default map id, when passing options (except the "mapId")' => [ + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), 'my_api_key', defaultMapId: 'DefaultMapId'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->options(new GoogleOptions()), + ]; + + yield 'with default map id overridden by option "mapId"' => [ + 'renderer' => new GoogleRenderer(new StimulusHelper(null), new UxIconRenderer(null), 'my_api_key', defaultMapId: 'DefaultMapId'), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->options(new GoogleOptions(mapId: 'CustomMapId')), + ]; + + yield 'markers with icons' => [ + 'renderer' => new GoogleRenderer( + new StimulusHelper(null), + new UxIconRenderer(new class implements IconRendererInterface { + public function renderIcon(string $name, array $attributes = []): string + { + return '...'; + } + }), + 'my_api_key' + ), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker(new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', icon: Icon::url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/icons/geo-alt.svg')->width(32)->height(32))) + ->addMarker(new Marker(position: new Point(45.7640, 4.8357), title: 'Lyon', icon: Icon::ux('fa:map-marker')->width(32)->height(32))) + ->addMarker(new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', icon: Icon::svg('...'))), + ]; } } diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set markers with icons__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set markers with icons__1.txt new file mode 100644 index 00000000000..155943865d5 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set markers with icons__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set simple map, with minimum options__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set simple map, with minimum options__1.txt new file mode 100644 index 00000000000..91cbdc7634d --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set simple map, with minimum options__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with all markers removed__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with all markers removed__1.txt new file mode 100644 index 00000000000..91cbdc7634d --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with all markers removed__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with controls enabled__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with controls enabled__1.txt new file mode 100644 index 00000000000..91cbdc7634d --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with controls enabled__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with custom attributes__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with custom attributes__1.txt new file mode 100644 index 00000000000..f1f0a9a5909 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with custom attributes__1.txt @@ -0,0 +1,14 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id overridden by option mapId__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id overridden by option mapId__1.txt new file mode 100644 index 00000000000..365f78a9269 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id overridden by option mapId__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id, when passing options (except the mapId)__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id, when passing options (except the mapId)__1.txt new file mode 100644 index 00000000000..6d398ba08c5 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id, when passing options (except the mapId)__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id__1.txt new file mode 100644 index 00000000000..6d398ba08c5 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with default map id__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with every options__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with every options__1.txt new file mode 100644 index 00000000000..db10b08c850 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with every options__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt new file mode 100644 index 00000000000..04d1e3965fb --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with markers and infoWindows__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with markers and infoWindows__1.txt new file mode 100644 index 00000000000..c185e4fb2a1 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with markers and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt new file mode 100644 index 00000000000..76f32b102f7 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt new file mode 100644 index 00000000000..a1d6ecf8754 --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set without controls enabled__1.txt b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set without controls enabled__1.txt new file mode 100644 index 00000000000..3e44c36583a --- /dev/null +++ b/src/Map/src/Bridge/Google/tests/__snapshots__/GoogleRendererTest__testRenderMap with data set without controls enabled__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/.gitattributes b/src/Map/src/Bridge/Leaflet/.gitattributes index e56a1c904f6..cd54bdcca24 100644 --- a/src/Map/src/Bridge/Leaflet/.gitattributes +++ b/src/Map/src/Bridge/Leaflet/.gitattributes @@ -2,6 +2,5 @@ /assets/test export-ignore /assets/vitest.config.js export-ignore /tests export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /phpunit.xml.dist export-ignore diff --git a/src/Map/src/Bridge/Leaflet/CHANGELOG.md b/src/Map/src/Bridge/Leaflet/CHANGELOG.md index e380bd8e66c..a9e17804201 100644 --- a/src/Map/src/Bridge/Leaflet/CHANGELOG.md +++ b/src/Map/src/Bridge/Leaflet/CHANGELOG.md @@ -1,11 +1,28 @@ # CHANGELOG +## 2.27 + +- Add `attributionControl` and `attributionControlOptions` to `LeafletOptions`, + to configure [attribution control](https://leafletjs.com/reference.html#map-attributioncontrol) and its options +- Add `zoomControl` and `zoomControlOptions` to `LeafletOptions`, + to configure [zoom control](https://leafletjs.com/reference.html#map-zoomcontrol) and its options + +## 2.26 + +- Using `new LeafletOptions(tileLayer: false)` will now disable the default `TileLayer`. + Useful when using a custom tiles layer rendering engine not configurable with `L.tileLayer().addTo(map)` method + (e.g.: [Esri/esri-leaflet-vector](https://github.com/Esri/esri-leaflet-vector)) + +## 2.25 + +- Downgrade PHP requirement from 8.3 to 8.1 + ## 2.20 ### BC Breaks -- Renamed importmap entry `@symfony/ux-leaflet-map/map-controller` to `@symfony/ux-leaflet-map`, - you will need to update your importmap. +- Renamed importmap entry `@symfony/ux-leaflet-map/map-controller` to `@symfony/ux-leaflet-map`, + you will need to update your importmap. ## 2.19 diff --git a/src/Map/src/Bridge/Leaflet/README.md b/src/Map/src/Bridge/Leaflet/README.md index a267776de69..e6e1732088a 100644 --- a/src/Map/src/Bridge/Leaflet/README.md +++ b/src/Map/src/Bridge/Leaflet/README.md @@ -2,6 +2,25 @@ [Leaflet](https://leafletjs.com/) integration for Symfony UX Map. +## Installation + +Install the bridge using Composer and Symfony Flex: + +```shell +composer require symfony/ux-leaflet-map +``` + +If you're using WebpackEncore, install your assets and restart Encore (not +needed if you're using AssetMapper): + +```shell +npm install --force +npm run watch +``` + +> [!NOTE] +> Alternatively, [@symfony/ux-leaflet-map package](https://www.npmjs.com/package/@symfony/ux-leaflet-map) can be used to install the JavaScript assets without requiring PHP. + ## DSN example ```dotenv @@ -14,7 +33,10 @@ You can use the `LeafletOptions` class to configure your `Map`:: ```php use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; +use Symfony\UX\Map\Bridge\Leaflet\Option\AttributionControlOptions; +use Symfony\UX\Map\Bridge\Leaflet\Option\ControlPosition; use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; +use Symfony\UX\Map\Bridge\Leaflet\Option\ZoomControlOptions; use Symfony\UX\Map\Point; use Symfony\UX\Map\Map; @@ -31,6 +53,10 @@ $leafletOptions = (new LeafletOptions()) 'maxZoom' => 10, ] )) + ->attributionControl(false) + ->attributionControlOptions(new AttributionControlOptions(ControlPosition::BOTTOM_LEFT)) + ->zoomControl(false) + ->zoomControlOptions(new ZoomControlOptions(ControlPosition::TOP_LEFT)) ; // Add the custom options to the map @@ -68,12 +94,12 @@ export default class extends Controller _onMarkerBeforeCreate(event) { // You can access the marker definition and the Leaflet object - // Note: `definition.rawOptions` is the raw options object that will be passed to the `L.marker` constructor. + // Note: `definition.rawOptions` is the raw options object that will be passed to the `L.marker` constructor. const { definition, L } = event.detail; // Use a custom icon for the marker const redIcon = L.icon({ - // Note: instead of using an hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`. + // Note: instead of using a hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`. iconUrl: 'https://leafletjs.com/examples/custom-icons/leaf-red.png', shadowUrl: 'https://leafletjs.com/examples/custom-icons/leaf-shadow.png', iconSize: [38, 95], // size of the icon @@ -82,7 +108,7 @@ export default class extends Controller shadowAnchor: [4, 62], // the same for the shadow popupAnchor: [-3, -76] // point from which the popup should open relative to the iconAnchor }) - + definition.rawOptions = { icon: redIcon, } @@ -90,6 +116,20 @@ export default class extends Controller } ``` +### Disable the default tile layer + +If you need to use a custom tiles layer rendering engine that is not compatible with the `L.tileLayer().addTo(map)` method +(e.g. e.g.: [Esri/esri-leaflet-vector](https://github.com/Esri/esri-leaflet-vector)), you can disable the default tile layer by passing `tileLayer: false` to the `LeafletOptions`: + +```php +use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; + +$leafletOptions = new LeafletOptions(tileLayer: false); +// or +$leafletOptions = (new LeafletOptions()) + ->tileLayer(false); +``` + ## Known issues ### Unable to find `leaflet/dist/leaflet.min.css` file when using Webpack Encore @@ -105,10 +145,10 @@ webpack compiled with 1 error  ELIFECYCLE  Command failed with exit code 1. ``` -That's because the Leaflet's Stimulus controller references the `leaflet/dist/leaflet.min.css` file, +That's because the Leaflet's Stimulus controller references the `leaflet/dist/leaflet.min.css` file, which exists on [jsDelivr](https://www.jsdelivr.com/package/npm/leaflet) (used by the Symfony AssetMapper component), but does not in the [`leaflet` npm package](https://www.npmjs.com/package/leaflet). -The correct path is `leaflet/dist/leaflet.css`, but it is not possible to fix it because it would break compatibility +The correct path is `leaflet/dist/leaflet.css`, but it is not possible to fix it because it would break compatibility with the Symfony AssetMapper component. As a workaround, you can configure Webpack Encore to add an alias for the `leaflet/dist/leaflet.min.css` file: diff --git a/src/Map/src/Bridge/Leaflet/assets/LICENSE b/src/Map/src/Bridge/Leaflet/assets/LICENSE new file mode 100644 index 00000000000..e374a5c8339 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Map/src/Bridge/Leaflet/assets/README.md b/src/Map/src/Bridge/Leaflet/assets/README.md new file mode 100644 index 00000000000..ff134243eee --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-leaflet-map + +JavaScript assets of the [symfony/ux-leaflet-map](https://packagist.org/packages/symfony/ux-leaflet-map) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-leaflet-map](https://packagist.org/packages/symfony/ux-leaflet-map) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-leaflet-map](https://packagist.org/packages/symfony/ux-leaflet-map) PHP package version: +```shell +composer require symfony/ux-leaflet-map:2.23.0 +npm add @symfony/ux-leaflet-map@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://github.com/symfony/ux/tree/2.x/src/Map/src/Bridge/Google) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts index 6b32a8df45b..c7c80e91b7e 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts @@ -1,29 +1,57 @@ import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition, PolygonDefinition } from '@symfony/ux-map'; +import type { Icon, InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; -import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions, PolygonOptions } from 'leaflet'; -type MapOptions = Pick & { +import type { ControlPosition, MapOptions as LeafletMapOptions, MarkerOptions, PolylineOptions as PolygonOptions, PolylineOptions, PopupOptions } from 'leaflet'; +type MapOptions = Pick & { + attributionControlOptions?: { + position: ControlPosition; + prefix: string | false; + }; + zoomControlOptions?: { + position: ControlPosition; + zoomInText: string; + zoomInTitle: string; + zoomOutText: string; + zoomOutTitle: string; + }; tileLayer: { url: string; attribution: string; options: Record; - }; + } | false; }; -export default class extends AbstractMapController { +export default class extends AbstractMapController { + map: L.Map; connect(): void; + centerValueChanged(): void; + zoomValueChanged(): void; protected dispatchEvent(name: string, payload?: Record): void; protected doCreateMap({ center, zoom, options, }: { center: Point | null; zoom: number | null; options: MapOptions; }): L.Map; - protected doCreateMarker(definition: MarkerDefinition): L.Marker; - protected doCreatePolygon(definition: PolygonDefinition): L.Polygon; + protected doCreateMarker({ definition }: { + definition: MarkerDefinition; + }): L.Marker; + protected doRemoveMarker(marker: L.Marker): void; + protected doCreatePolygon({ definition, }: { + definition: PolygonDefinition; + }): L.Polygon; + protected doRemovePolygon(polygon: L.Polygon): void; + protected doCreatePolyline({ definition, }: { + definition: PolylineDefinition; + }): L.Polyline; + protected doRemovePolyline(polyline: L.Polyline): void; protected doCreateInfoWindow({ definition, element, }: { - definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; - element: L.Marker | L.Polygon; + definition: InfoWindowWithoutPositionDefinition; + element: L.Marker | L.Polygon | L.Polyline; }): L.Popup; + protected doCreateIcon({ definition, element, }: { + definition: Icon; + element: L.Marker; + }): void; protected doFitBoundsToMarkers(): void; } export {}; diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js index 6d4a18371f0..747d858e4f1 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js @@ -2,54 +2,111 @@ import { Controller } from '@hotwired/stimulus'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; +const IconTypes = { + Url: 'url', + Svg: 'svg', + UxIcon: 'ux-icon', +}; class default_1 extends Controller { constructor() { super(...arguments); - this.markers = []; + this.markers = new Map(); + this.polygons = new Map(); + this.polylines = new Map(); this.infoWindows = []; - this.polygons = []; + this.isConnected = false; } connect() { - const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; + const options = this.optionsValue; this.dispatchEvent('pre-connect', { options }); - this.map = this.doCreateMap({ center, zoom, options }); - markers.forEach((marker) => this.createMarker(marker)); - polygons.forEach((polygon) => this.createPolygon(polygon)); - if (fitBoundsToMarkers) { + this.createMarker = this.createDrawingFactory('marker', this.markers, this.doCreateMarker.bind(this)); + this.createPolygon = this.createDrawingFactory('polygon', this.polygons, this.doCreatePolygon.bind(this)); + this.createPolyline = this.createDrawingFactory('polyline', this.polylines, this.doCreatePolyline.bind(this)); + this.map = this.doCreateMap({ + center: this.hasCenterValue ? this.centerValue : null, + zoom: this.hasZoomValue ? this.zoomValue : null, + options, + }); + this.markersValue.forEach((definition) => this.createMarker({ definition })); + this.polygonsValue.forEach((definition) => this.createPolygon({ definition })); + this.polylinesValue.forEach((definition) => this.createPolyline({ definition })); + if (this.fitBoundsToMarkersValue) { this.doFitBoundsToMarkers(); } this.dispatchEvent('connect', { map: this.map, - markers: this.markers, - polygons: this.polygons, + markers: [...this.markers.values()], + polygons: [...this.polygons.values()], + polylines: [...this.polylines.values()], infoWindows: this.infoWindows, }); - } - createMarker(definition) { - this.dispatchEvent('marker:before-create', { definition }); - const marker = this.doCreateMarker(definition); - this.dispatchEvent('marker:after-create', { marker }); - this.markers.push(marker); - return marker; - } - createPolygon(definition) { - this.dispatchEvent('polygon:before-create', { definition }); - const polygon = this.doCreatePolygon(definition); - this.dispatchEvent('polygon:after-create', { polygon }); - this.polygons.push(polygon); - return polygon; + this.isConnected = true; } createInfoWindow({ definition, element, }) { this.dispatchEvent('info-window:before-create', { definition, element }); const infoWindow = this.doCreateInfoWindow({ definition, element }); - this.dispatchEvent('info-window:after-create', { infoWindow, element }); + this.dispatchEvent('info-window:after-create', { infoWindow, definition, element }); this.infoWindows.push(infoWindow); return infoWindow; } + markersValueChanged() { + if (!this.isConnected) { + return; + } + this.onDrawChanged(this.markers, this.markersValue, this.createMarker, this.doRemoveMarker); + if (this.fitBoundsToMarkersValue) { + this.doFitBoundsToMarkers(); + } + } + polygonsValueChanged() { + if (!this.isConnected) { + return; + } + this.onDrawChanged(this.polygons, this.polygonsValue, this.createPolygon, this.doRemovePolygon); + } + polylinesValueChanged() { + if (!this.isConnected) { + return; + } + this.onDrawChanged(this.polylines, this.polylinesValue, this.createPolyline, this.doRemovePolyline); + } + createDrawingFactory(type, draws, factory) { + const eventBefore = `${type}:before-create`; + const eventAfter = `${type}:after-create`; + return ({ definition }) => { + this.dispatchEvent(eventBefore, { definition }); + const drawing = factory({ definition }); + this.dispatchEvent(eventAfter, { [type]: drawing, definition }); + draws.set(definition['@id'], drawing); + return drawing; + }; + } + onDrawChanged(draws, newDrawDefinitions, factory, remover) { + const idsToRemove = new Set(draws.keys()); + newDrawDefinitions.forEach((definition) => { + idsToRemove.delete(definition['@id']); + }); + idsToRemove.forEach((id) => { + const draw = draws.get(id); + remover(draw); + draws.delete(id); + }); + newDrawDefinitions.forEach((definition) => { + if (!draws.has(definition['@id'])) { + factory({ definition }); + } + }); + } } default_1.values = { providerOptions: Object, - view: Object, + center: Object, + zoom: Number, + fitBoundsToMarkers: Boolean, + markers: Array, + polygons: Array, + polylines: Array, + options: Object, }; class map_controller extends default_1 { @@ -63,6 +120,16 @@ class map_controller extends default_1 { }); super.connect(); } + centerValueChanged() { + if (this.map && this.hasCenterValue && this.centerValue && this.hasZoomValue && this.zoomValue) { + this.map.setView(this.centerValue, this.zoomValue); + } + } + zoomValueChanged() { + if (this.map && this.hasZoomValue && this.zoomValue) { + this.map.setZoom(this.zoomValue); + } + } dispatchEvent(name, payload = {}) { this.dispatch(name, { prefix: 'ux:map', @@ -77,23 +144,39 @@ class map_controller extends default_1 { ...options, center: center === null ? undefined : center, zoom: zoom === null ? undefined : zoom, + attributionControl: false, + zoomControl: false, }); - L.tileLayer(options.tileLayer.url, { - attribution: options.tileLayer.attribution, - ...options.tileLayer.options, - }).addTo(map); + if (options.tileLayer) { + L.tileLayer(options.tileLayer.url, { + attribution: options.tileLayer.attribution, + ...options.tileLayer.options, + }).addTo(map); + } + if (typeof options.attributionControlOptions !== 'undefined') { + L.control.attribution({ ...options.attributionControlOptions }).addTo(map); + } + if (typeof options.zoomControlOptions !== 'undefined') { + L.control.zoom({ ...options.zoomControlOptions }).addTo(map); + } return map; } - doCreateMarker(definition) { - const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; - const marker = L.marker(position, { title, ...otherOptions, ...rawOptions }).addTo(this.map); + doCreateMarker({ definition }) { + const { '@id': _id, position, title, infoWindow, icon, extra, rawOptions = {}, ...otherOptions } = definition; + const marker = L.marker(position, { title: title || undefined, ...otherOptions, ...rawOptions }).addTo(this.map); if (infoWindow) { this.createInfoWindow({ definition: infoWindow, element: marker }); } + if (icon) { + this.doCreateIcon({ definition: icon, element: marker }); + } return marker; } - doCreatePolygon(definition) { - const { points, title, infoWindow, rawOptions = {} } = definition; + doRemoveMarker(marker) { + marker.remove(); + } + doCreatePolygon({ definition, }) { + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; const polygon = L.polygon(points, { ...rawOptions }).addTo(this.map); if (title) { polygon.bindPopup(title); @@ -103,6 +186,23 @@ class map_controller extends default_1 { } return polygon; } + doRemovePolygon(polygon) { + polygon.remove(); + } + doCreatePolyline({ definition, }) { + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; + const polyline = L.polyline(points, { ...rawOptions }).addTo(this.map); + if (title) { + polyline.bindPopup(title); + } + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polyline }); + } + return polyline; + } + doRemovePolyline(polyline) { + polyline.remove(); + } doCreateInfoWindow({ definition, element, }) { const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; element.bindPopup([headerContent, content].filter((x) => x).join('
    '), { ...otherOptions, ...rawOptions }); @@ -115,14 +215,45 @@ class map_controller extends default_1 { } return popup; } + doCreateIcon({ definition, element, }) { + const { type, width, height } = definition; + let icon; + if (type === IconTypes.Svg) { + icon = L.divIcon({ + html: definition.html, + iconSize: [width, height], + className: '', + }); + } + else if (type === IconTypes.UxIcon) { + icon = L.divIcon({ + html: definition._generated_html, + iconSize: [width, height], + className: '', + }); + } + else if (type === IconTypes.Url) { + icon = L.icon({ + iconUrl: definition.url, + iconSize: [width, height], + className: '', + }); + } + else { + throw new Error(`Unsupported icon type: ${type}.`); + } + element.setIcon(icon); + } doFitBoundsToMarkers() { - if (this.markers.length === 0) { + if (this.markers.size === 0) { return; } - this.map.fitBounds(this.markers.map((marker) => { + const bounds = []; + this.markers.forEach((marker) => { const position = marker.getLatLng(); - return [position.lat, position.lng]; - })); + bounds.push([position.lat, position.lng]); + }); + this.map.fitBounds(bounds); } } diff --git a/src/Map/src/Bridge/Leaflet/assets/package.json b/src/Map/src/Bridge/Leaflet/assets/package.json index 3dd5663a147..276e40b7ffa 100644 --- a/src/Map/src/Bridge/Leaflet/assets/package.json +++ b/src/Map/src/Bridge/Leaflet/assets/package.json @@ -2,10 +2,27 @@ "name": "@symfony/ux-leaflet-map", "description": "Leaflet bridge for Symfony UX Map, integrate interactive maps in your Symfony applications", "license": "MIT", - "version": "1.0.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux", + "leaflet", + "map" + ], + "homepage": "https://ux.symfony.com/map", + "repository": "https://github.com/symfony/ux-leaflet-map", "type": "module", + "files": [ + "dist" + ], "main": "dist/map_controller.js", "types": "dist/map_controller.d.ts", + "scripts": { + "build": "node ../../../../../../bin/build_package.js .", + "watch": "node ../../../../../../bin/build_package.js . --watch", + "test": "../../../../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "map": { @@ -32,6 +49,7 @@ }, "devDependencies": { "@hotwired/stimulus": "^3.0.0", + "@symfony/ux-map": "workspace:*", "@types/leaflet": "^1.9.12", "leaflet": "^1.9.4" } diff --git a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts index 12ed1f2922f..21845d0f24c 100644 --- a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -1,35 +1,74 @@ -import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition, PolygonDefinition } from '@symfony/ux-map'; +import AbstractMapController, { IconTypes } from '@symfony/ux-map'; +import type { + Icon, + InfoWindowWithoutPositionDefinition, + MarkerDefinition, + Point, + PolygonDefinition, + PolylineDefinition, +} from '@symfony/ux-map'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; -import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions, PolygonOptions } from 'leaflet'; - -type MapOptions = Pick & { - tileLayer: { url: string; attribution: string; options: Record }; +import type { + ControlPosition, + LatLngBoundsExpression, + MapOptions as LeafletMapOptions, + MarkerOptions, + PolylineOptions as PolygonOptions, + PolylineOptions, + PopupOptions, +} from 'leaflet'; + +type MapOptions = Pick & { + attributionControlOptions?: { position: ControlPosition; prefix: string | false }; + zoomControlOptions?: { + position: ControlPosition; + zoomInText: string; + zoomInTitle: string; + zoomOutText: string; + zoomOutTitle: string; + }; + tileLayer: { url: string; attribution: string; options: Record } | false; }; export default class extends AbstractMapController< MapOptions, - typeof L.Map, + L.Map, MarkerOptions, - typeof L.Marker, + L.Marker, PopupOptions, - typeof L.Popup, + L.Popup, PolygonOptions, - typeof L.Polygon + L.Polygon, + PolylineOptions, + L.Polyline > { + declare map: L.Map; + connect(): void { L.Marker.prototype.options.icon = L.divIcon({ html: '', iconSize: [25, 41], iconAnchor: [12.5, 41], popupAnchor: [0, -41], - className: '', + className: '', // Adding an empty class to the icon to avoid the default Leaflet styles }); super.connect(); } + public centerValueChanged(): void { + if (this.map && this.hasCenterValue && this.centerValue && this.hasZoomValue && this.zoomValue) { + this.map.setView(this.centerValue, this.zoomValue); + } + } + + public zoomValueChanged(): void { + if (this.map && this.hasZoomValue && this.zoomValue) { + this.map.setZoom(this.zoomValue); + } + } + protected dispatchEvent(name: string, payload: Record = {}): void { this.dispatch(name, { prefix: 'ux:map', @@ -49,30 +88,54 @@ export default class extends AbstractMapController< ...options, center: center === null ? undefined : center, zoom: zoom === null ? undefined : zoom, + attributionControl: false, + zoomControl: false, }); - L.tileLayer(options.tileLayer.url, { - attribution: options.tileLayer.attribution, - ...options.tileLayer.options, - }).addTo(map); + if (options.tileLayer) { + L.tileLayer(options.tileLayer.url, { + attribution: options.tileLayer.attribution, + ...options.tileLayer.options, + }).addTo(map); + } + + if (typeof options.attributionControlOptions !== 'undefined') { + L.control.attribution({ ...options.attributionControlOptions }).addTo(map); + } + + if (typeof options.zoomControlOptions !== 'undefined') { + L.control.zoom({ ...options.zoomControlOptions }).addTo(map); + } return map; } - protected doCreateMarker(definition: MarkerDefinition): L.Marker { - const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + protected doCreateMarker({ definition }: { definition: MarkerDefinition }): L.Marker { + const { '@id': _id, position, title, infoWindow, icon, extra, rawOptions = {}, ...otherOptions } = definition; - const marker = L.marker(position, { title, ...otherOptions, ...rawOptions }).addTo(this.map); + const marker = L.marker(position, { title: title || undefined, ...otherOptions, ...rawOptions }).addTo( + this.map + ); if (infoWindow) { this.createInfoWindow({ definition: infoWindow, element: marker }); } + if (icon) { + this.doCreateIcon({ definition: icon, element: marker }); + } + return marker; } - protected doCreatePolygon(definition: PolygonDefinition): L.Polygon { - const { points, title, infoWindow, rawOptions = {} } = definition; + protected doRemoveMarker(marker: L.Marker): void { + marker.remove(); + } + + protected doCreatePolygon({ + definition, + }: { definition: PolygonDefinition }): L.Polygon { + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; const polygon = L.polygon(points, { ...rawOptions }).addTo(this.map); @@ -87,12 +150,38 @@ export default class extends AbstractMapController< return polygon; } + protected doRemovePolygon(polygon: L.Polygon) { + polygon.remove(); + } + + protected doCreatePolyline({ + definition, + }: { definition: PolylineDefinition }): L.Polyline { + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; + + const polyline = L.polyline(points, { ...rawOptions }).addTo(this.map); + + if (title) { + polyline.bindPopup(title); + } + + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polyline }); + } + + return polyline; + } + + protected doRemovePolyline(polyline: L.Polyline): void { + polyline.remove(); + } + protected doCreateInfoWindow({ definition, element, }: { - definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; - element: L.Marker | L.Polygon; + definition: InfoWindowWithoutPositionDefinition; + element: L.Marker | L.Polygon | L.Polyline; }): L.Popup { const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; @@ -110,17 +199,50 @@ export default class extends AbstractMapController< return popup; } + protected doCreateIcon({ + definition, + element, + }: { + definition: Icon; + element: L.Marker; + }): void { + const { type, width, height } = definition; + + let icon: L.DivIcon | L.Icon; + if (type === IconTypes.Svg) { + icon = L.divIcon({ + html: definition.html, + iconSize: [width, height], + className: '', // Adding an empty class to the icon to avoid the default Leaflet styles + }); + } else if (type === IconTypes.UxIcon) { + icon = L.divIcon({ + html: definition._generated_html, + iconSize: [width, height], + className: '', // Adding an empty class to the icon to avoid the default Leaflet styles + }); + } else if (type === IconTypes.Url) { + icon = L.icon({ + iconUrl: definition.url, + iconSize: [width, height], + className: '', // Adding an empty class to the icon to avoid the default Leaflet styles + }); + } else { + throw new Error(`Unsupported icon type: ${type}.`); + } + element.setIcon(icon); + } + protected doFitBoundsToMarkers(): void { - if (this.markers.length === 0) { + if (this.markers.size === 0) { return; } - this.map.fitBounds( - this.markers.map((marker: L.Marker) => { - const position = marker.getLatLng(); - - return [position.lat, position.lng]; - }) - ); + const bounds: LatLngBoundsExpression = []; + this.markers.forEach((marker) => { + const position = marker.getLatLng(); + bounds.push([position.lat, position.lng]); + }); + this.map.fitBounds(bounds); } } diff --git a/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts b/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts index 5a51bf5f8a0..680f6d3a718 100644 --- a/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts +++ b/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import LeafletController from '../src/map_controller'; // Controller used to check the actual controller was properly booted @@ -28,7 +28,7 @@ class CheckController extends Controller { const startStimulus = () => { const application = Application.start(); application.register('check', CheckController); - application.register('leaflet', LeafletController); + application.register('symfony--ux-leaflet-map--map', LeafletController); }; describe('LeafletController', () => { @@ -36,13 +36,20 @@ describe('LeafletController', () => { beforeEach(() => { container = mountDOM(` -
    + `); }); diff --git a/src/Map/src/Bridge/Leaflet/composer.json b/src/Map/src/Bridge/Leaflet/composer.json index 16aa04b5dd6..f33e00cd48a 100644 --- a/src/Map/src/Bridge/Leaflet/composer.json +++ b/src/Map/src/Bridge/Leaflet/composer.json @@ -16,11 +16,15 @@ } ], "require": { - "php": ">=8.3", + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.18.1", "symfony/ux-map": "^2.19" }, "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0" + "symfony/phpunit-bridge": "^7.2", + "symfony/ux-icons": "^2.18", + "spatie/phpunit-snapshot-assertions": "^4.2.17", + "phpunit/phpunit": "^9.6.22" }, "autoload": { "psr-4": { "Symfony\\UX\\Map\\Bridge\\Leaflet\\": "src/" }, diff --git a/src/Map/src/Bridge/Leaflet/phpunit.xml.dist b/src/Map/src/Bridge/Leaflet/phpunit.xml.dist index 1c3807e6255..751314ecb8e 100644 --- a/src/Map/src/Bridge/Leaflet/phpunit.xml.dist +++ b/src/Map/src/Bridge/Leaflet/phpunit.xml.dist @@ -12,7 +12,7 @@ ./src - + diff --git a/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php b/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php index 0450477339d..2b55d8e50e0 100644 --- a/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php +++ b/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php @@ -11,7 +11,9 @@ namespace Symfony\UX\Map\Bridge\Leaflet; +use Symfony\UX\Map\Bridge\Leaflet\Option\AttributionControlOptions; use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; +use Symfony\UX\Map\Bridge\Leaflet\Option\ZoomControlOptions; use Symfony\UX\Map\MapOptionsInterface; /** @@ -20,24 +22,95 @@ final class LeafletOptions implements MapOptionsInterface { public function __construct( - private TileLayer $tileLayer = new TileLayer( + private TileLayer|false $tileLayer = new TileLayer( url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: 'Š OpenStreetMap', ), + private bool $attributionControl = true, + private AttributionControlOptions $attributionControlOptions = new AttributionControlOptions(), + private bool $zoomControl = true, + private ZoomControlOptions $zoomControlOptions = new ZoomControlOptions(), ) { } - public function tileLayer(TileLayer $tileLayer): self + public function tileLayer(TileLayer|false $tileLayer): self { $this->tileLayer = $tileLayer; return $this; } + public function attributionControl(bool $enable = true): self + { + $this->attributionControl = $enable; + + return $this; + } + + public function attributionControlOptions(AttributionControlOptions $attributionControlOptions): self + { + $this->attributionControl = true; + $this->attributionControlOptions = $attributionControlOptions; + + return $this; + } + + public function zoomControl(bool $enable = true): self + { + $this->zoomControl = $enable; + + return $this; + } + + public function zoomControlOptions(ZoomControlOptions $zoomControlOptions): self + { + $this->zoomControl = true; + $this->zoomControlOptions = $zoomControlOptions; + + return $this; + } + + /** + * @internal + */ + public static function fromArray(array $array): MapOptionsInterface + { + $array += ['attributionControl' => false, 'zoomControl' => false, 'tileLayer' => false]; + + if ($array['tileLayer']) { + $array['tileLayer'] = TileLayer::fromArray($array['tileLayer']); + } + + if (isset($array['attributionControlOptions'])) { + $array['attributionControl'] = true; + $array['attributionControlOptions'] = AttributionControlOptions::fromArray($array['attributionControlOptions']); + } + + if (isset($array['zoomControlOptions'])) { + $array['zoomControl'] = true; + $array['zoomControlOptions'] = ZoomControlOptions::fromArray($array['zoomControlOptions']); + } + + return new self(...$array); + } + + /** + * @internal + */ public function toArray(): array { - return [ - 'tileLayer' => $this->tileLayer->toArray(), + $array = [ + 'tileLayer' => $this->tileLayer ? $this->tileLayer->toArray() : false, ]; + + if ($this->attributionControl) { + $array['attributionControlOptions'] = $this->attributionControlOptions->toArray(); + } + + if ($this->zoomControl) { + $array['zoomControlOptions'] = $this->zoomControlOptions->toArray(); + } + + return $array; } } diff --git a/src/Map/src/Bridge/Leaflet/src/Option/AttributionControlOptions.php b/src/Map/src/Bridge/Leaflet/src/Option/AttributionControlOptions.php new file mode 100644 index 00000000000..6c5947274a3 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/src/Option/AttributionControlOptions.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Leaflet\Option; + +/** + * Options for the rendering of the attribution control. + * + * @see https://leafletjs.com/reference.html#control-zoom + */ +final class AttributionControlOptions +{ + public function __construct( + private readonly ControlPosition $position = ControlPosition::BOTTOM_RIGHT, + private readonly string|false $prefix = 'Leaflet', + ) { + } + + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + position: ControlPosition::from($array['position']), + prefix: $array['prefix'], + ); + } + + /** + * @internal + */ + public function toArray(): array + { + return [ + 'position' => $this->position->value, + 'prefix' => $this->prefix, + ]; + } +} diff --git a/src/Map/src/Bridge/Leaflet/src/Option/ControlPosition.php b/src/Map/src/Bridge/Leaflet/src/Option/ControlPosition.php new file mode 100644 index 00000000000..ae9ecda22a2 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/src/Option/ControlPosition.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Leaflet\Option; + +/** + * @see https://leafletjs.com/reference.html#control-position + */ +enum ControlPosition: string +{ + case TOP_LEFT = 'topleft'; + case TOP_RIGHT = 'topright'; + case BOTTOM_LEFT = 'bottomleft'; + case BOTTOM_RIGHT = 'bottomright'; +} diff --git a/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php index 526572538b3..cc704d363bf 100644 --- a/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php +++ b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php @@ -18,24 +18,39 @@ * * @author Hugo Alliaume */ -final readonly class TileLayer +final class TileLayer { /** * @param array $options */ public function __construct( - private string $url, - private string $attribution, - private array $options = [], + private readonly string $url, + private readonly string $attribution, + private readonly array $options = [], ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + url: $array['url'], + attribution: $array['attribution'], + options: $array['options'], + ); + } + + /** + * @internal + */ public function toArray(): array { return [ 'url' => $this->url, 'attribution' => $this->attribution, - 'options' => (object) $this->options, + 'options' => $this->options, ]; } } diff --git a/src/Map/src/Bridge/Leaflet/src/Option/ZoomControlOptions.php b/src/Map/src/Bridge/Leaflet/src/Option/ZoomControlOptions.php new file mode 100644 index 00000000000..f2f916f82c2 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/src/Option/ZoomControlOptions.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Leaflet\Option; + +/** + * Options for the rendering of the zoom control. + * + * @see https://leafletjs.com/reference.html#control-zoom + */ +final class ZoomControlOptions +{ + public function __construct( + private readonly ControlPosition $position = ControlPosition::TOP_LEFT, + private readonly string $zoomInText = '', + private readonly string $zoomInTitle = 'Zoom in', + private readonly string $zoomOutText = '', + private readonly string $zoomOutTitle = 'Zoom out', + ) { + } + + /** + * @internal + */ + public static function fromArray(array $array): self + { + if (isset($array['position'])) { + $array['position'] = ControlPosition::from($array['position']); + } + + return new self(...$array); + } + + /** + * @internal + */ + public function toArray(): array + { + return [ + 'position' => $this->position->value, + 'zoomInText' => $this->zoomInText, + 'zoomInTitle' => $this->zoomInTitle, + 'zoomOutText' => $this->zoomOutText, + 'zoomOutTitle' => $this->zoomOutTitle, + ]; + } +} diff --git a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php index 05f1348ef72..652272f038e 100644 --- a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php +++ b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRenderer.php @@ -20,7 +20,7 @@ * * @internal */ -final readonly class LeafletRenderer extends AbstractRenderer +final class LeafletRenderer extends AbstractRenderer { protected function getName(): string { diff --git a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php index d5dc0c5dbd3..f213cafac20 100644 --- a/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php +++ b/src/Map/src/Bridge/Leaflet/src/Renderer/LeafletRendererFactory.php @@ -28,7 +28,7 @@ public function create(Dsn $dsn): RendererInterface throw new UnsupportedSchemeException($dsn); } - return new LeafletRenderer($this->stimulus); + return new LeafletRenderer($this->stimulus, $this->uxIconRenderer); } protected function getSupportedSchemes(): array diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php index a5d50fba7a7..14a5822ff1d 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php @@ -21,15 +21,26 @@ public function testWithMinimalConfiguration(): void { $leafletOptions = new LeafletOptions(); - $array = $leafletOptions->toArray(); - self::assertSame([ 'tileLayer' => [ 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'attribution' => 'Š OpenStreetMap', - 'options' => $array['tileLayer']['options'], // stdClass + 'options' => [], + ], + 'attributionControlOptions' => [ + 'position' => 'bottomright', + 'prefix' => 'Leaflet', ], - ], $array); + 'zoomControlOptions' => [ + 'position' => 'topleft', + 'zoomInText' => '', + 'zoomInTitle' => 'Zoom in', + 'zoomOutText' => '', + 'zoomOutTitle' => 'Zoom out', + ], + ], $leafletOptions->toArray()); + + self::assertEquals($leafletOptions, LeafletOptions::fromArray($leafletOptions->toArray())); } public function testWithMaximumConfiguration(): void @@ -47,18 +58,70 @@ public function testWithMaximumConfiguration(): void ), ); - $array = $leafletOptions->toArray(); + self::assertSame([ + 'tileLayer' => [ + 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'attribution' => 'Š OpenStreetMap', + 'options' => [ + 'maxZoom' => 19, + 'minZoom' => 1, + 'maxNativeZoom' => 18, + 'zoomOffset' => 0, + ], + ], + 'attributionControlOptions' => [ + 'position' => 'bottomright', + 'prefix' => 'Leaflet', + ], + 'zoomControlOptions' => [ + 'position' => 'topleft', + 'zoomInText' => '', + 'zoomInTitle' => 'Zoom in', + 'zoomOutText' => '', + 'zoomOutTitle' => 'Zoom out', + ], + ], $leafletOptions->toArray()); + + self::assertEquals($leafletOptions, LeafletOptions::fromArray($leafletOptions->toArray())); + } + + public function testWithTileLayerFalse(): void + { + $leafletOptions = new LeafletOptions(tileLayer: false); + + self::assertSame([ + 'tileLayer' => false, + 'attributionControlOptions' => [ + 'position' => 'bottomright', + 'prefix' => 'Leaflet', + ], + 'zoomControlOptions' => [ + 'position' => 'topleft', + 'zoomInText' => '', + 'zoomInTitle' => 'Zoom in', + 'zoomOutText' => '', + 'zoomOutTitle' => 'Zoom out', + ], + ], $leafletOptions->toArray()); + + self::assertEquals($leafletOptions, LeafletOptions::fromArray($leafletOptions->toArray())); + } + + public function testWithoutControls(): void + { + $leafletOptions = new LeafletOptions( + attributionControl: false, + zoomControl: false, + ); self::assertSame([ 'tileLayer' => [ 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'attribution' => 'Š OpenStreetMap', - 'options' => $array['tileLayer']['options'], // stdClass + 'options' => [], ], - ], $array); - self::assertSame(19, $array['tileLayer']['options']->maxZoom); - self::assertSame(1, $array['tileLayer']['options']->minZoom); - self::assertSame(18, $array['tileLayer']['options']->maxNativeZoom); - self::assertSame(0, $array['tileLayer']['options']->zoomOffset); + ], $leafletOptions->toArray()); + + self::assertEquals($leafletOptions, LeafletOptions::fromArray($leafletOptions->toArray())); } } diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php index 7ead676cd7f..8fbfdedf115 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererFactoryTest.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Bridge\Leaflet\Tests; use Symfony\UX\Map\Bridge\Leaflet\Renderer\LeafletRendererFactory; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Renderer\RendererFactoryInterface; use Symfony\UX\Map\Test\RendererFactoryTestCase; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -20,7 +21,7 @@ final class LeafletRendererFactoryTest extends RendererFactoryTestCase { public function createRendererFactory(): RendererFactoryInterface { - return new LeafletRendererFactory(new StimulusHelper(null)); + return new LeafletRendererFactory(new StimulusHelper(null), new UxIconRenderer(null)); } public static function supportsRenderer(): iterable diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php index d9ad391ca15..da0a05c2f56 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php @@ -11,41 +11,106 @@ namespace Symfony\UX\Map\Bridge\Leaflet\Tests; +use Symfony\UX\Icons\IconRendererInterface; use Symfony\UX\Map\Bridge\Leaflet\Renderer\LeafletRenderer; +use Symfony\UX\Map\Icon\Icon; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; +use Symfony\UX\Map\Polygon; +use Symfony\UX\Map\Polyline; use Symfony\UX\Map\Test\RendererTestCase; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; class LeafletRendererTest extends RendererTestCase { - public function provideTestRenderMap(): iterable + public static function provideTestRenderMap(): iterable { $map = (new Map()) ->center(new Point(48.8566, 2.3522)) ->zoom(12); + $marker1 = new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', id: 'marker1'); + $marker2 = new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'), id: 'marker2'); + $marker3 = new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', id: 'marker3'); + yield 'simple map' => [ - 'expected_render' => '
    ', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), - 'map' => $map, + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), + 'map' => (clone $map), ]; yield 'with custom attributes' => [ - 'expected_render' => '
    ', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), - 'map' => $map, + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), + 'map' => (clone $map), 'attributes' => ['data-controller' => 'my-custom-controller', 'class' => 'map'], ]; yield 'with markers and infoWindows' => [ - 'expected_render' => '
    ', - 'renderer' => new LeafletRenderer(new StimulusHelper(null)), - 'map' => (clone $map) - ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Paris')) - ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'))), + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker($marker1) + ->addMarker(new Marker(position: new Point(48.8566, 2.3522), title: 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'))), + ]; + + yield 'with all markers removed' => [ + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker($marker1) + ->addMarker($marker2) + ->removeMarker($marker1) + ->removeMarker($marker2), + ]; + + yield 'with marker remove and new ones added' => [ + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker($marker3) + ->removeMarker($marker3) + ->addMarker($marker1) + ->addMarker($marker2), + ]; + + yield 'with polygons and infoWindows' => [ + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addPolygon(new Polygon(points: [new Point(48.8566, 2.3522), new Point(48.8566, 2.3522), new Point(48.8566, 2.3522)], id: 'polygon1')) + ->addPolygon(new Polygon(points: [new Point(1.1, 2.2), new Point(3.3, 4.4), new Point(5.5, 6.6)], infoWindow: new InfoWindow(content: 'Polygon'), id: 'polygon2')), + ]; + + yield 'with polylines and infoWindows' => [ + 'renderer' => new LeafletRenderer(new StimulusHelper(null), new UxIconRenderer(null)), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addPolyline(new Polyline(points: [new Point(48.8566, 2.3522), new Point(48.8566, 2.3522), new Point(48.8566, 2.3522)], id: 'polyline1')) + ->addPolyline(new Polyline(points: [new Point(1.1, 2.2), new Point(3.3, 4.4), new Point(5.5, 6.6)], infoWindow: new InfoWindow(content: 'Polyline'), id: 'polyline2')), + ]; + + yield 'markers with icons' => [ + 'renderer' => new LeafletRenderer( + new StimulusHelper(null), + new UxIconRenderer(new class implements IconRendererInterface { + public function renderIcon(string $name, array $attributes = []): string + { + return '...'; + } + })), + 'map' => (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker(new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', icon: Icon::url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/icons/geo-alt.svg')->width(32)->height(32))) + ->addMarker(new Marker(position: new Point(45.7640, 4.8357), title: 'Lyon', icon: Icon::ux('fa:map-marker')->width(32)->height(32))) + ->addMarker(new Marker(position: new Point(45.8566, 2.3522), title: 'Dijon', icon: Icon::svg('...'))), ]; } } diff --git a/src/Map/src/Bridge/Leaflet/tests/Option/AttributionControlOptionsTest.php b/src/Map/src/Bridge/Leaflet/tests/Option/AttributionControlOptionsTest.php new file mode 100644 index 00000000000..05b914deacc --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/Option/AttributionControlOptionsTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Leaflet\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Leaflet\Option\AttributionControlOptions; +use Symfony\UX\Map\Bridge\Leaflet\Option\ControlPosition; + +class AttributionControlOptionsTest extends TestCase +{ + public function testToArray(): void + { + $options = new AttributionControlOptions(); + + self::assertSame([ + 'position' => ControlPosition::BOTTOM_RIGHT->value, + 'prefix' => 'Leaflet', + ], $options->toArray()); + } + + public function testToArrayWithDifferentConfiguration(): void + { + $options = new AttributionControlOptions( + position: ControlPosition::BOTTOM_LEFT, + prefix: 'Leaflet prefix', + ); + + self::assertSame([ + 'position' => ControlPosition::BOTTOM_LEFT->value, + 'prefix' => 'Leaflet prefix', + ], $options->toArray()); + } +} diff --git a/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php b/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php index c0c8f998d78..f8508b83d73 100644 --- a/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php @@ -26,13 +26,13 @@ public function testToArray() ], ); - $array = $tileLayer->toArray(); - self::assertSame([ 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'attribution' => 'Š OpenStreetMap contributors', - 'options' => $array['options'], // stdClass - ], $array); - self::assertSame(19, $array['options']->maxZoom); + 'options' => [ + 'maxZoom' => 19, + ], + ], $tileLayer->toArray()); + self::assertEquals(TileLayer::fromArray($tileLayer->toArray()), $tileLayer); } } diff --git a/src/Map/src/Bridge/Leaflet/tests/Option/ZoomControlOptionsTest.php b/src/Map/src/Bridge/Leaflet/tests/Option/ZoomControlOptionsTest.php new file mode 100644 index 00000000000..e21ac360ffd --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/Option/ZoomControlOptionsTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Bridge\Leaflet\Tests\Option; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Bridge\Leaflet\Option\ControlPosition; +use Symfony\UX\Map\Bridge\Leaflet\Option\ZoomControlOptions; + +class ZoomControlOptionsTest extends TestCase +{ + public function testToArray(): void + { + $options = new ZoomControlOptions( + position: ControlPosition::TOP_LEFT, + ); + + self::assertSame([ + 'position' => ControlPosition::TOP_LEFT->value, + 'zoomInText' => '', + 'zoomInTitle' => 'Zoom in', + 'zoomOutText' => '', + 'zoomOutTitle' => 'Zoom out', + ], $options->toArray()); + } +} diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set markers with icons__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set markers with icons__1.txt new file mode 100644 index 00000000000..a30fe285064 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set markers with icons__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set simple map__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set simple map__1.txt new file mode 100644 index 00000000000..0e99279fcb5 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set simple map__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with all markers removed__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with all markers removed__1.txt new file mode 100644 index 00000000000..0e99279fcb5 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with all markers removed__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with custom attributes__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with custom attributes__1.txt new file mode 100644 index 00000000000..b4b78c96372 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with custom attributes__1.txt @@ -0,0 +1,14 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt new file mode 100644 index 00000000000..65e01c12fe5 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with marker remove and new ones added__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with markers and infoWindows__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with markers and infoWindows__1.txt new file mode 100644 index 00000000000..d8bc48d5eb1 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with markers and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt new file mode 100644 index 00000000000..7b6ae68b194 --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polygons and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt new file mode 100644 index 00000000000..eebd057985a --- /dev/null +++ b/src/Map/src/Bridge/Leaflet/tests/__snapshots__/LeafletRendererTest__testRenderMap with data set with polylines and infoWindows__1.txt @@ -0,0 +1,13 @@ + +
    \ No newline at end of file diff --git a/src/Map/src/Distance/DistanceCalculator.php b/src/Map/src/Distance/DistanceCalculator.php new file mode 100644 index 00000000000..43adfcabdd9 --- /dev/null +++ b/src/Map/src/Distance/DistanceCalculator.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Distance; + +use Symfony\UX\Map\Point; + +/** + * @author Simon AndrÊ + */ +final class DistanceCalculator implements DistanceCalculatorInterface +{ + public function __construct( + private readonly DistanceCalculatorInterface $calculator = new VincentyDistanceCalculator(), + private readonly DistanceUnit $unit = DistanceUnit::Meter, + ) { + } + + public function calculateDistance(Point $point1, Point $point2): float + { + return $this->calculator->calculateDistance($point1, $point2) + * $this->unit->getConversionFactor(); + } +} diff --git a/src/Map/src/Distance/DistanceCalculatorInterface.php b/src/Map/src/Distance/DistanceCalculatorInterface.php new file mode 100644 index 00000000000..1019118d2a3 --- /dev/null +++ b/src/Map/src/Distance/DistanceCalculatorInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Distance; + +use Symfony\UX\Map\Point; + +/** + * Interface for distance calculators. + * + * @author Simon AndrÊ + */ +interface DistanceCalculatorInterface +{ + /** + * Returns the distance between two points given their coordinates. + * + * @return float the distance between the two points, in meters + */ + public function calculateDistance(Point $point1, Point $point2): float; +} diff --git a/src/Map/src/Distance/DistanceUnit.php b/src/Map/src/Distance/DistanceUnit.php new file mode 100644 index 00000000000..fa581e6ac1e --- /dev/null +++ b/src/Map/src/Distance/DistanceUnit.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Distance; + +/** + * Represents a distance unit used in mapping and geospatial calculations. + * + * This enum defines common units for measuring distances. Each unit has an associated + * conversion factor which is used to convert a value from meters to that unit. + * + * @author Simon AndrÊ + */ +enum DistanceUnit: string +{ + /** + * The "meter" unit. + * + * This is the International System of Units (SI) base unit for length. + */ + case Meter = 'm'; + + /** + * The "kilometer" unit. + * + * This unit is commonly used for longer distances. + */ + case Kilometer = 'km'; + + /** + * The "mile" unit. + * + * This unit is widely used in the United States. + */ + case Mile = 'mi'; + + /** + * The "nautical mile" unit. + * + * This unit is typically used in navigation. + */ + case NauticalMile = 'nmi'; + + /** + * Returns the conversion factor to convert this unit to meters. + */ + public function getConversionFactor(): float + { + return match ($this) { + self::Meter => 1.0, + self::Kilometer => 0.001, + self::Mile => 0.000621371, + self::NauticalMile => 0.000539957, + }; + } + + /** + * Returns the conversion factor to convert this unit to another unit. + */ + public function getConversionFactorTo(DistanceUnit $unit): float + { + return $this->getConversionFactor() / $unit->getConversionFactor(); + } + + /** + * Returns the conversion factor to convert another unit to this unit. + */ + public function getConversionFactorFrom(DistanceUnit $unit): float + { + return $unit->getConversionFactor() / $this->getConversionFactor(); + } +} diff --git a/src/Map/src/Distance/HaversineDistanceCalculator.php b/src/Map/src/Distance/HaversineDistanceCalculator.php new file mode 100644 index 00000000000..f4a9fe0c2fa --- /dev/null +++ b/src/Map/src/Distance/HaversineDistanceCalculator.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Distance; + +use Symfony\UX\Map\Point; + +/** + * Haversine formula-based distance calculator. + * + * This calculator is accurate but slower than the spherical cosine formula. + * + * @author Simon AndrÊ + */ +final class HaversineDistanceCalculator implements DistanceCalculatorInterface +{ + /** + * @const float The Earth's radius in meters. + */ + private const EARTH_RADIUS = 6371000.0; + + public function calculateDistance(Point $point1, Point $point2): float + { + $lat1Rad = deg2rad($point1->getLatitude()); + $lat2Rad = deg2rad($point2->getLatitude()); + $deltaLat = deg2rad($point2->getLatitude() - $point1->getLatitude()); + $deltaLng = deg2rad($point2->getLongitude() - $point1->getLongitude()); + + $a = sin($deltaLat / 2) ** 2 + cos($lat1Rad) * cos($lat2Rad) * sin($deltaLng / 2) ** 2; + $c = 2 * asin(min(1.0, sqrt($a))); + + return self::EARTH_RADIUS * $c; + } +} diff --git a/src/Map/src/Distance/SphericalCosineDistanceCalculator.php b/src/Map/src/Distance/SphericalCosineDistanceCalculator.php new file mode 100644 index 00000000000..f77716a14b3 --- /dev/null +++ b/src/Map/src/Distance/SphericalCosineDistanceCalculator.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Distance; + +use Symfony\UX\Map\Point; + +/** + * Sphere-based distance calculator using the cosine of the spherical distance. + * + * This calculator is faster than the Haversine formula, but less accurate. + * + * @author Simon AndrÊ + */ +final class SphericalCosineDistanceCalculator implements DistanceCalculatorInterface +{ + /** + * @const float The Earth's radius in meters. + */ + private const EARTH_RADIUS = 6371000.0; + + public function calculateDistance(Point $point1, Point $point2): float + { + $lat1Rad = deg2rad($point1->getLatitude()); + $lat2Rad = deg2rad($point2->getLatitude()); + $lng1Rad = deg2rad($point1->getLongitude()); + $lng2Rad = deg2rad($point2->getLongitude()); + + $cosDistance = sin($lat1Rad) * sin($lat2Rad) + cos($lat1Rad) * cos($lat2Rad) * cos($lng2Rad - $lng1Rad); + + // Correct for floating-point errors. + $cosDistance = min(1.0, max(-1.0, $cosDistance)); + $angle = acos($cosDistance); + + return self::EARTH_RADIUS * $angle; + } +} diff --git a/src/Map/src/Distance/VincentyDistanceCalculator.php b/src/Map/src/Distance/VincentyDistanceCalculator.php new file mode 100644 index 00000000000..db25a91ac05 --- /dev/null +++ b/src/Map/src/Distance/VincentyDistanceCalculator.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Distance; + +use Symfony\UX\Map\Point; + +/** + * Vincenty formula-based distance calculator. + * + * This calculator is more accurate than the Haversine formula, but slower. + * + * @author Simon AndrÊ + */ +final class VincentyDistanceCalculator implements DistanceCalculatorInterface +{ + /** + * WS-84 ellipsoid parameters. + */ + // Major Axis in meters + private const A = 6378137.0; + // Flattening + private const F = 1 / 298.257223563; + // Minor Axis in meters + private const B = 6356752.314245; + + public function calculateDistance(Point $point1, Point $point2): float + { + $phi1 = deg2rad($point1->getLatitude()); + $phi2 = deg2rad($point2->getLatitude()); + $lambda1 = deg2rad($point1->getLongitude()); + $lambda2 = deg2rad($point2->getLongitude()); + + $L = $lambda2 - $lambda1; + $U1 = atan((1 - self::F) * tan($phi1)); + $U2 = atan((1 - self::F) * tan($phi2)); + $sinU1 = sin($U1); + $cosU1 = cos($U1); + $sinU2 = sin($U2); + $cosU2 = cos($U2); + + $lambda = $L; + $iterLimit = 100; + do { + $sinLambda = sin($lambda); + $cosLambda = cos($lambda); + $sinSigma = sqrt(($cosU2 * $sinLambda) ** 2 + + ($cosU1 * $sinU2 - $sinU1 * $cosU2 * $cosLambda) ** 2); + + if (0.0 === $sinSigma) { + return 0.0; + } + + $cosSigma = $sinU1 * $sinU2 + $cosU1 * $cosU2 * $cosLambda; + $sigma = atan2($sinSigma, $cosSigma); + $sinAlpha = $cosU1 * $cosU2 * $sinLambda / $sinSigma; + $cosSqAlpha = 1 - $sinAlpha * $sinAlpha; + $cos2SigmaM = (0.0 === $cosSqAlpha) ? 0.0 : $cosSigma - 2 * $sinU1 * $sinU2 / $cosSqAlpha; + $C = self::F / 16 * $cosSqAlpha * (4 + self::F * (4 - 3 * $cosSqAlpha)); + + $lambdaPrev = $lambda; + $lambda = $L + (1 - $C) * self::F * $sinAlpha + * ($sigma + $C * $sinSigma * ($cos2SigmaM + $C * $cosSigma * (-1 + 2 * $cos2SigmaM * $cos2SigmaM))); + } while (abs($lambda - $lambdaPrev) > 1e-12 && --$iterLimit > 0); + + if (0 === $iterLimit) { + throw new \RuntimeException('Vincenty formula failed to converge.'); + } + + $uSq = $cosSqAlpha * (self::A * self::A - self::B * self::B) / (self::B * self::B); + $Acoeff = 1 + $uSq / 16384 * (4096 + $uSq * (-768 + $uSq * (320 - 175 * $uSq))); + $Bcoeff = $uSq / 1024 * (256 + $uSq * (-128 + $uSq * (74 - 47 * $uSq))); + $deltaSigma = $Bcoeff * $sinSigma * ($cos2SigmaM + $Bcoeff / 4 * ($cosSigma * (-1 + 2 * $cos2SigmaM * $cos2SigmaM) + - $Bcoeff / 6 * $cos2SigmaM * (-3 + 4 * $sinSigma * $sinSigma) * (-3 + 4 * $cos2SigmaM * $cos2SigmaM))); + $distance = self::B * $Acoeff * ($sigma - $deltaSigma); + + return $distance; + } +} diff --git a/src/Map/src/Element.php b/src/Map/src/Element.php new file mode 100644 index 00000000000..c7f752ea8ec --- /dev/null +++ b/src/Map/src/Element.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +/** + * @author Sylvain Blondeau + * + * @internal + */ +interface Element +{ +} diff --git a/src/Map/src/Elements.php b/src/Map/src/Elements.php new file mode 100644 index 00000000000..6dee1939f13 --- /dev/null +++ b/src/Map/src/Elements.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +/** + * Represents a collection of map elements. + * + * @author Sylvain Blondeau + * + * @internal + */ +abstract class Elements +{ + private \SplObjectStorage $elements; + + public function __construct( + array $elements, + ) { + $this->elements = new \SplObjectStorage(); + foreach ($elements as $element) { + $this->elements->attach($element); + } + } + + public function add(Element $element): static + { + $this->elements->attach($element, $element->id ?? $this->elements->getHash($element)); + + return $this; + } + + private function getElement(string $id): ?Element + { + foreach ($this->elements as $element) { + if ($element->id === $id) { + return $element; + } + } + + return null; + } + + public function remove(Element|string $elementOrId): static + { + if (\is_string($elementOrId)) { + $elementOrId = $this->getElement($elementOrId); + } + + if (null === $elementOrId) { + return $this; + } + + if ($this->elements->contains($elementOrId)) { + $this->elements->detach($elementOrId); + } + + return $this; + } + + public function toArray(): array + { + foreach ($this->elements as $element) { + $elements[] = $element->toArray(); + } + + return $elements ?? []; + } + + abstract public static function fromArray(array $elements): self; +} diff --git a/src/Map/src/Exception/UnableToDenormalizeOptionsException.php b/src/Map/src/Exception/UnableToDenormalizeOptionsException.php new file mode 100644 index 00000000000..73f5a782e9d --- /dev/null +++ b/src/Map/src/Exception/UnableToDenormalizeOptionsException.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +final class UnableToDenormalizeOptionsException extends LogicException +{ + public function __construct(string $message) + { + parent::__construct(\sprintf('Unable to denormalize the map options: %s', $message)); + } + + public static function missingProviderKey(string $key): self + { + return new self(\sprintf('the provider key "%s" is missing in the normalized options.', $key)); + } + + public static function unsupportedProvider(string $provider, array $supportedProviders): self + { + return new self(\sprintf('the provider "%s" is not supported. Supported providers are "%s".', $provider, implode('", "', $supportedProviders))); + } +} diff --git a/src/Map/src/Exception/UnableToNormalizeOptionsException.php b/src/Map/src/Exception/UnableToNormalizeOptionsException.php new file mode 100644 index 00000000000..ef29c1c4945 --- /dev/null +++ b/src/Map/src/Exception/UnableToNormalizeOptionsException.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +use Symfony\UX\Map\MapOptionsInterface; + +final class UnableToNormalizeOptionsException extends LogicException +{ + public function __construct(string $message) + { + parent::__construct(\sprintf('Unable to normalize the map options: %s', $message)); + } + + /** + * @param class-string $optionsClass + */ + public static function unsupportedProviderClass(string $optionsClass): self + { + return new self(\sprintf('the class "%s" is not supported.', $optionsClass)); + } +} diff --git a/src/Map/src/Icon/Icon.php b/src/Map/src/Icon/Icon.php new file mode 100644 index 00000000000..d097952de23 --- /dev/null +++ b/src/Map/src/Icon/Icon.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +use Symfony\UX\Map\Exception\InvalidArgumentException; + +/** + * Represents an icon that can be displayed on a map marker. + * + * @author Sylvain Blondeau + * @author Hugo Alliaume + */ +abstract class Icon +{ + /** + * Creates a new icon based on a URL (e.g.: `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/icons/geo-alt.svg`). + * + * @param non-empty-string $url + */ + public static function url(string $url): UrlIcon + { + return new UrlIcon($url); + } + + /** + * Creates a new icon based on an SVG string (e.g.: `...`). + * Using an SVG string may not be the best option if you want to customize the icon afterward, + * it would be preferable to use {@see Icon::ux()} or {@see Icon::url()} instead. + * + * @param non-empty-string $html + */ + public static function svg(string $html): SvgIcon + { + return new SvgIcon($html); + } + + /** + * Creates a new icon based on a UX icon name (e.g.: `fa:map-marker`). + * + * @param non-empty-string $name + */ + public static function ux(string $name): UxIcon + { + return new UxIcon($name); + } + + /** + * @param positive-int $width + * @param positive-int $height + */ + protected function __construct( + protected IconType $type, + protected int $width = 24, + protected int $height = 24, + ) { + } + + /** + * Sets the width of the icon. + * + * @param positive-int $width + */ + public function width(int $width): static + { + $this->width = $width; + + return $this; + } + + /** + * Sets the height of the icon. + * + * @param positive-int $height + */ + public function height(int $height): static + { + $this->height = $height; + + return $this; + } + + /** + * @internal + */ + public function toArray(): array + { + return [ + 'type' => $this->type->value, + 'width' => $this->width, + 'height' => $this->height, + ]; + } + + /** + * @param array{ type: value-of, width: positive-int, height: positive-int } + * &(array{ url: non-empty-string } + * |array{ html: non-empty-string } + * |array{ name: non-empty-string }) $data + * + * @internal + */ + public static function fromArray(array $data): static + { + return match ($data['type']) { + IconType::Url->value => UrlIcon::fromArray($data), + IconType::Svg->value => SvgIcon::fromArray($data), + IconType::UxIcon->value => UxIcon::fromArray($data), + default => throw new InvalidArgumentException(\sprintf('Invalid icon type %s.', $data['type'])), + }; + } +} diff --git a/src/Map/src/Icon/IconType.php b/src/Map/src/Icon/IconType.php new file mode 100644 index 00000000000..ed6befaaebd --- /dev/null +++ b/src/Map/src/Icon/IconType.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +/** + * Represents an inline SVG icon. + * + * @author Hugo Alliaume + */ +enum IconType: string +{ + case Url = 'url'; + case Svg = 'svg'; + case UxIcon = 'ux-icon'; +} diff --git a/src/Map/src/Icon/SvgIcon.php b/src/Map/src/Icon/SvgIcon.php new file mode 100644 index 00000000000..fc59f0c8184 --- /dev/null +++ b/src/Map/src/Icon/SvgIcon.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +/** + * Represents an inline SVG icon. + * + * @author Sylvain Blondeau + * @author Hugo Alliaume + * + * @internal + */ +class SvgIcon extends Icon +{ + /** + * @param non-empty-string $html + */ + protected function __construct( + protected string $html, + ) { + parent::__construct(IconType::Svg); + } + + /** + * @param array{ html: string } $data + */ + public static function fromArray(array $data): static + { + return new self( + html: $data['html'], + ); + } + + /** + * @throws \LogicException the SvgIcon can not be customized + */ + public function width(int $width): never + { + throw new \LogicException('Unable to configure the SvgIcon width, please configure it in the HTML with the "width" attribute on the root element instead.'); + } + + /** + * @throws \LogicException the SvgIcon can not be customized + */ + public function height(int $height): never + { + throw new \LogicException('Unable to configure the SvgIcon height, please configure it in the HTML with the "height" attribute on the root element instead.'); + } + + public function toArray(): array + { + return [ + ...parent::toArray(), + 'html' => $this->html, + ]; + } +} diff --git a/src/Map/src/Icon/UrlIcon.php b/src/Map/src/Icon/UrlIcon.php new file mode 100644 index 00000000000..07b81c0d4fe --- /dev/null +++ b/src/Map/src/Icon/UrlIcon.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +/** + * Represents an URL icon. + * + * @author Sylvain Blondeau + * @author Hugo Alliaume + * + * @internal + */ +class UrlIcon extends Icon +{ + /** + * @param non-empty-string $url + * @param positive-int $width + * @param positive-int $height + */ + protected function __construct( + protected string $url, + int $width = 24, + int $height = 24, + ) { + parent::__construct(IconType::Url, $width, $height); + } + + public static function fromArray(array $data): static + { + return new self( + url: $data['url'], + width: $data['width'], + height: $data['height'], + ); + } + + public function toArray(): array + { + return [ + ...parent::toArray(), + 'url' => $this->url, + ]; + } +} diff --git a/src/Map/src/Icon/UxIcon.php b/src/Map/src/Icon/UxIcon.php new file mode 100644 index 00000000000..ed456b312f1 --- /dev/null +++ b/src/Map/src/Icon/UxIcon.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +/** + * Represents an UX icon. + * + * @author Sylvain Blondeau + * @author Hugo Alliaume + * + * @internal + */ +class UxIcon extends Icon +{ + /** + * @param non-empty-string $name + * @param positive-int $width + * @param positive-int $height + */ + protected function __construct( + protected string $name, + int $width = 24, + int $height = 24, + ) { + parent::__construct(IconType::UxIcon, $width, $height); + } + + public static function fromArray(array $data): static + { + return new self( + name: $data['name'], + width: $data['width'], + height: $data['height'], + ); + } + + public function toArray(): array + { + return [ + ...parent::toArray(), + 'name' => $this->name, + ]; + } +} diff --git a/src/Map/src/Icon/UxIconRenderer.php b/src/Map/src/Icon/UxIconRenderer.php new file mode 100644 index 00000000000..1e9812014a3 --- /dev/null +++ b/src/Map/src/Icon/UxIconRenderer.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Icon; + +use Symfony\UX\Icons\IconRendererInterface; + +/** + * @author Sylvain Blondeau + * + * @internal + */ +class UxIconRenderer +{ + public function __construct( + private readonly ?IconRendererInterface $renderer, + ) { + } + + /** + * @param array $attributes + */ + public function render(string $name, array $attributes = []): string + { + if (null === $this->renderer) { + throw new \LogicException('You cannot use an UX Icon as the "UX Icons" package is not installed. Try running "composer require symfony/ux-icons" to install it.'); + } + + return $this->renderer->renderIcon($name, [ + 'xmlns' => 'http://www.w3.org/2000/svg', + ...$attributes, + ]); + } +} diff --git a/src/Map/src/InfoWindow.php b/src/Map/src/InfoWindow.php index f98fbb6995e..7f1136543e8 100644 --- a/src/Map/src/InfoWindow.php +++ b/src/Map/src/InfoWindow.php @@ -16,19 +16,19 @@ * * @author Hugo Alliaume */ -final readonly class InfoWindow +final class InfoWindow { /** * @param array $extra Extra data, can be used by the developer to store additional information and * use them later JavaScript side */ public function __construct( - private ?string $headerContent = null, - private ?string $content = null, - private ?Point $position = null, - private bool $opened = false, - private bool $autoClose = true, - private array $extra = [], + private readonly ?string $headerContent = null, + private readonly ?string $content = null, + private readonly ?Point $position = null, + private readonly bool $opened = false, + private readonly bool $autoClose = true, + private readonly array $extra = [], ) { } @@ -50,7 +50,7 @@ public function toArray(): array 'position' => $this->position?->toArray(), 'opened' => $this->opened, 'autoClose' => $this->autoClose, - 'extra' => (object) $this->extra, + 'extra' => $this->extra, ]; } @@ -61,7 +61,7 @@ public function toArray(): array * position: array{lat: float, lng: float}|null, * opened: bool, * autoClose: bool, - * extra: object, + * extra: array, * } $data * * @internal diff --git a/src/Map/src/Live/ComponentWithMapTrait.php b/src/Map/src/Live/ComponentWithMapTrait.php new file mode 100644 index 00000000000..cd6322c7471 --- /dev/null +++ b/src/Map/src/Live/ComponentWithMapTrait.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Live; + +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\Map\Map; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; +use Symfony\UX\TwigComponent\Attribute\PostMount; + +/** + * @author Hugo Alliaume + * + * @experimental + */ +trait ComponentWithMapTrait +{ + /** + * @internal + */ + #[LiveProp(hydrateWith: 'hydrateMap', dehydrateWith: 'dehydrateMap')] + #[ExposeInTemplate(getter: 'getMap')] + public ?Map $map = null; + + abstract protected function instantiateMap(): Map; + + public function getMap(): Map + { + return $this->map ??= $this->instantiateMap(); + } + + /** + * @internal + */ + #[PostMount] + public function initializeMap(array $data): array + { + // allow the Map object to be passed into the component() as "map" + if (\array_key_exists('map', $data)) { + $this->map = $data['map']; + unset($data['map']); + } + + return $data; + } + + /** + * @internal + */ + public function hydrateMap(array $data): Map + { + return Map::fromArray($data); + } + + /** + * @internal + */ + public function dehydrateMap(Map $map): array + { + return $map->toArray(); + } +} diff --git a/src/Map/src/Map.php b/src/Map/src/Map.php index 3ab240ae1e2..52832a8f311 100644 --- a/src/Map/src/Map.php +++ b/src/Map/src/Map.php @@ -20,22 +20,28 @@ */ final class Map { + private Markers $markers; + private Polygons $polygons; + private Polylines $polylines; + + /** + * @param Marker[] $markers + * @param Polygon[] $polygons + * @param Polyline[] $polylines + */ public function __construct( private readonly ?string $rendererName = null, private ?MapOptionsInterface $options = null, private ?Point $center = null, private ?float $zoom = null, private bool $fitBoundsToMarkers = false, - /** - * @var array - */ - private array $markers = [], - - /** - * @var array - */ - private array $polygons = [], + array $markers = [], + array $polygons = [], + array $polylines = [], ) { + $this->markers = new Markers($markers); + $this->polygons = new Polygons($polygons); + $this->polylines = new Polylines($polylines); } public function getRendererName(): ?string @@ -83,14 +89,42 @@ public function hasOptions(): bool public function addMarker(Marker $marker): self { - $this->markers[] = $marker; + $this->markers->add($marker); + + return $this; + } + + public function removeMarker(Marker|string $markerOrId): self + { + $this->markers->remove($markerOrId); return $this; } public function addPolygon(Polygon $polygon): self { - $this->polygons[] = $polygon; + $this->polygons->add($polygon); + + return $this; + } + + public function removePolygon(Polygon|string $polygonOrId): self + { + $this->polygons->remove($polygonOrId); + + return $this; + } + + public function addPolyline(Polyline $polyline): self + { + $this->polylines->add($polyline); + + return $this; + } + + public function removePolyline(Polyline|string $polylineOrId): self + { + $this->polylines->remove($polylineOrId); return $this; } @@ -111,9 +145,10 @@ public function toArray(): array 'center' => $this->center?->toArray(), 'zoom' => $this->zoom, 'fitBoundsToMarkers' => $this->fitBoundsToMarkers, - 'options' => (object) ($this->options?->toArray() ?? []), - 'markers' => array_map(static fn (Marker $marker) => $marker->toArray(), $this->markers), - 'polygons' => array_map(static fn (Polygon $polygon) => $polygon->toArray(), $this->polygons), + 'options' => $this->options ? MapOptionsNormalizer::normalize($this->options) : [], + 'markers' => $this->markers->toArray(), + 'polygons' => $this->polygons->toArray(), + 'polylines' => $this->polylines->toArray(), ]; } @@ -123,24 +158,23 @@ public function toArray(): array * zoom?: float, * markers?: list, * polygons?: list, + * polylines?: list, * fitBoundsToMarkers?: bool, - * options?: object, + * options?: array, * } $map * * @internal */ public static function fromArray(array $map): self { - $map['fitBoundsToMarkers'] = true; + if (isset($map['options'])) { + $map['options'] = [] === $map['options'] ? null : MapOptionsNormalizer::denormalize($map['options']); + } if (isset($map['center'])) { $map['center'] = Point::fromArray($map['center']); } - if (isset($map['zoom']) || isset($map['center'])) { - $map['fitBoundsToMarkers'] = false; - } - $map['markers'] ??= []; if (!\is_array($map['markers'])) { throw new InvalidArgumentException('The "markers" parameter must be an array.'); @@ -153,6 +187,12 @@ public static function fromArray(array $map): self } $map['polygons'] = array_map(Polygon::fromArray(...), $map['polygons']); + $map['polylines'] ??= []; + if (!\is_array($map['polylines'])) { + throw new InvalidArgumentException('The "polylines" parameter must be an array.'); + } + $map['polylines'] = array_map(Polyline::fromArray(...), $map['polylines']); + return new self(...$map); } } diff --git a/src/Map/src/MapOptionsInterface.php b/src/Map/src/MapOptionsInterface.php index de7b1e20211..8e2343c7e8c 100644 --- a/src/Map/src/MapOptionsInterface.php +++ b/src/Map/src/MapOptionsInterface.php @@ -17,6 +17,13 @@ interface MapOptionsInterface { /** + * @internal + */ + public static function fromArray(array $array): self; + + /** + * @internal + * * @return array */ public function toArray(): array; diff --git a/src/Map/src/MapOptionsNormalizer.php b/src/Map/src/MapOptionsNormalizer.php new file mode 100644 index 00000000000..21866436d35 --- /dev/null +++ b/src/Map/src/MapOptionsNormalizer.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +use Symfony\UX\Map\Bridge as MapBridge; +use Symfony\UX\Map\Exception\UnableToDenormalizeOptionsException; +use Symfony\UX\Map\Exception\UnableToNormalizeOptionsException; + +/** + * Normalizes and denormalizes map options. + * + * @internal + * + * @author Hugo Alliaume + */ +final class MapOptionsNormalizer +{ + /** + * @var string + */ + private const KEY_PROVIDER = '@provider'; + + /** + * @var array> + */ + public static array $providers = [ + 'google' => MapBridge\Google\GoogleOptions::class, + 'leaflet' => MapBridge\Leaflet\LeafletOptions::class, + ]; + + public static function denormalize(array $array): MapOptionsInterface + { + if (null === ($provider = $array[self::KEY_PROVIDER] ?? null)) { + throw UnableToDenormalizeOptionsException::missingProviderKey(self::KEY_PROVIDER); + } + + unset($array[self::KEY_PROVIDER]); + + if (null === $class = self::$providers[$provider] ?? null) { + throw UnableToDenormalizeOptionsException::unsupportedProvider($provider, array_keys(self::$providers)); + } + + return $class::fromArray($array); + } + + public static function normalize(MapOptionsInterface $options): array + { + $provider = array_search($options::class, self::$providers, true); + if (!\is_string($provider)) { + throw UnableToNormalizeOptionsException::unsupportedProviderClass($options::class); + } + + $array = $options->toArray(); + $array[self::KEY_PROVIDER] = $provider; + + return $array; + } +} diff --git a/src/Map/src/Marker.php b/src/Map/src/Marker.php index ac0dc0e0af6..ed58c4840c5 100644 --- a/src/Map/src/Marker.php +++ b/src/Map/src/Marker.php @@ -12,23 +12,27 @@ namespace Symfony\UX\Map; use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\Icon\Icon; +use Symfony\UX\Map\Icon\IconType; /** * Represents a marker on a map. * * @author Hugo Alliaume */ -final readonly class Marker +final class Marker implements Element { /** * @param array $extra Extra data, can be used by the developer to store additional information and * use them later JavaScript side */ public function __construct( - private Point $position, - private ?string $title = null, - private ?InfoWindow $infoWindow = null, - private array $extra = [], + public readonly Point $position, + public readonly ?string $title = null, + public readonly ?InfoWindow $infoWindow = null, + public readonly array $extra = [], + public readonly ?string $id = null, + public readonly ?Icon $icon = null, ) { } @@ -37,7 +41,9 @@ public function __construct( * position: array{lat: float, lng: float}, * title: string|null, * infoWindow: array|null, - * extra: object, + * icon: array{type: value-of, width: positive-int, height: positive-int, ...}|null, + * extra: array, + * id: string|null * } */ public function toArray(): array @@ -46,7 +52,9 @@ public function toArray(): array 'position' => $this->position->toArray(), 'title' => $this->title, 'infoWindow' => $this->infoWindow?->toArray(), - 'extra' => (object) $this->extra, + 'icon' => $this->icon?->toArray(), + 'extra' => $this->extra, + 'id' => $this->id, ]; } @@ -55,7 +63,9 @@ public function toArray(): array * position: array{lat: float, lng: float}, * title: string|null, * infoWindow: array|null, - * extra: object, + * icon: array{type: value-of, width: positive-int, height: positive-int, ...}|null, + * extra: array, + * id: string|null * } $marker * * @internal @@ -70,6 +80,9 @@ public static function fromArray(array $marker): self if (isset($marker['infoWindow'])) { $marker['infoWindow'] = InfoWindow::fromArray($marker['infoWindow']); } + if (isset($marker['icon'])) { + $marker['icon'] = Icon::fromArray($marker['icon']); + } return new self(...$marker); } diff --git a/src/Map/src/Markers.php b/src/Map/src/Markers.php new file mode 100644 index 00000000000..b82e3cf05a6 --- /dev/null +++ b/src/Map/src/Markers.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +/** + * Represents a Marker collection. + * + * @author Sylvain Blondeau + * + * @internal + */ +final class Markers extends Elements +{ + public static function fromArray(array $elements): self + { + $elementObjects = []; + + foreach ($elements as $element) { + $elementObjects[] = Marker::fromArray($element); + } + + return new self(elements: $elementObjects); + } +} diff --git a/src/Map/src/Point.php b/src/Map/src/Point.php index f34f37a2387..283f95ba615 100644 --- a/src/Map/src/Point.php +++ b/src/Map/src/Point.php @@ -18,11 +18,11 @@ * * @author Hugo Alliaume */ -final readonly class Point +final class Point { public function __construct( - public float $latitude, - public float $longitude, + public readonly float $latitude, + public readonly float $longitude, ) { if ($latitude < -90 || $latitude > 90) { throw new InvalidArgumentException(\sprintf('Latitude must be between -90 and 90 degrees, "%s" given.', $latitude)); @@ -33,6 +33,16 @@ public function __construct( } } + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } + /** * @return array{lat: float, lng: float} */ diff --git a/src/Map/src/Polygon.php b/src/Map/src/Polygon.php index 5d474346e7d..2622634cb2c 100644 --- a/src/Map/src/Polygon.php +++ b/src/Map/src/Polygon.php @@ -18,38 +18,52 @@ * * @author [Pierre Svgnt] */ -final readonly class Polygon +final class Polygon implements Element { /** - * @param array $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side + * @param array|array> $points a list of point representing the polygon, or a list of paths (each path is an array of points) representing a polygon with holes + * @param array $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side */ public function __construct( - private array $points, - private ?string $title = null, - private ?InfoWindow $infoWindow = null, - private array $extra = [], + private readonly array $points, + private readonly ?string $title = null, + private readonly ?InfoWindow $infoWindow = null, + private readonly array $extra = [], + public readonly ?string $id = null, ) { } /** * Convert the polygon to an array representation. + * + * @return array{ + * points: array|array, + * title: string|null, + * infoWindow: array|null, + * extra: array, + * id: string|null + * } */ public function toArray(): array { return [ - 'points' => array_map(fn (Point $point) => $point->toArray(), $this->points), + 'points' => current($this->points) instanceof Point + ? array_map(fn (Point $point) => $point->toArray(), $this->points) + : array_map(fn (array $path) => array_map(fn (Point $point) => $point->toArray(), $path), $this->points), 'title' => $this->title, 'infoWindow' => $this->infoWindow?->toArray(), - 'extra' => (object) $this->extra, + 'extra' => $this->extra, + 'id' => $this->id, ]; } /** * @param array{ - * points: array, + * points: array>, * title: string|null, * infoWindow: array|null, - * extra: object, + * extra: array, + * id: string|null * } $polygon * * @internal @@ -59,7 +73,10 @@ public static function fromArray(array $polygon): self if (!isset($polygon['points'])) { throw new InvalidArgumentException('The "points" parameter is required.'); } - $polygon['points'] = array_map(Point::fromArray(...), $polygon['points']); + + $polygon['points'] = isset($polygon['points'][0]['lat'], $polygon['points'][0]['lng']) + ? array_map(Point::fromArray(...), $polygon['points']) + : array_map(fn (array $points) => array_map(Point::fromArray(...), $points), $polygon['points']); if (isset($polygon['infoWindow'])) { $polygon['infoWindow'] = InfoWindow::fromArray($polygon['infoWindow']); diff --git a/src/Map/src/Polygons.php b/src/Map/src/Polygons.php new file mode 100644 index 00000000000..5d75ba8f0c1 --- /dev/null +++ b/src/Map/src/Polygons.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +/** + * Represents a Polygon collection. + * + * @author Sylvain Blondeau + * + * @internal + */ +final class Polygons extends Elements +{ + public static function fromArray(array $elements): self + { + $elementObjects = []; + + foreach ($elements as $element) { + $elementObjects[] = Polygon::fromArray($element); + } + + return new self(elements: $elementObjects); + } +} diff --git a/src/Map/src/Polyline.php b/src/Map/src/Polyline.php new file mode 100644 index 00000000000..9a51e62ba9e --- /dev/null +++ b/src/Map/src/Polyline.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +use Symfony\UX\Map\Exception\InvalidArgumentException; + +/** + * Represents a polyline on a map. + * + * @author [Sylvain Blondeau] + */ +final class Polyline implements Element +{ + /** + * @param array $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side + */ + public function __construct( + private readonly array $points, + private readonly ?string $title = null, + private readonly ?InfoWindow $infoWindow = null, + private readonly array $extra = [], + public readonly ?string $id = null, + ) { + } + + /** + * Convert the polyline to an array representation. + * + * @return array{ + * points: array, + * title: string|null, + * infoWindow: array|null, + * extra: array, + * id: string|null + * } + */ + public function toArray(): array + { + return [ + 'points' => array_map(fn (Point $point) => $point->toArray(), $this->points), + 'title' => $this->title, + 'infoWindow' => $this->infoWindow?->toArray(), + 'extra' => $this->extra, + 'id' => $this->id, + ]; + } + + /** + * @param array{ + * points: array, + * title: string|null, + * infoWindow: array|null, + * extra: array, + * id: string|null + * } $polyline + * + * @internal + */ + public static function fromArray(array $polyline): self + { + if (!isset($polyline['points'])) { + throw new InvalidArgumentException('The "points" parameter is required.'); + } + $polyline['points'] = array_map(Point::fromArray(...), $polyline['points']); + + if (isset($polyline['infoWindow'])) { + $polyline['infoWindow'] = InfoWindow::fromArray($polyline['infoWindow']); + } + + return new self(...$polyline); + } +} diff --git a/src/Map/src/Polylines.php b/src/Map/src/Polylines.php new file mode 100644 index 00000000000..810f180d134 --- /dev/null +++ b/src/Map/src/Polylines.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +/** + * Represents a Polyline collection. + * + * @author Sylvain Blondeau + * + * @internal + */ +final class Polylines extends Elements +{ + public static function fromArray(array $elements): self + { + $elementObjects = []; + + foreach ($elements as $element) { + $elementObjects[] = Polyline::fromArray($element); + } + + return new self(elements: $elementObjects); + } +} diff --git a/src/Map/src/Renderer/AbstractRenderer.php b/src/Map/src/Renderer/AbstractRenderer.php index aa229009fd9..ad3ba4334b1 100644 --- a/src/Map/src/Renderer/AbstractRenderer.php +++ b/src/Map/src/Renderer/AbstractRenderer.php @@ -11,6 +11,8 @@ namespace Symfony\UX\Map\Renderer; +use Symfony\UX\Map\Icon\IconType; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\Map\Map; use Symfony\UX\Map\MapOptionsInterface; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -18,10 +20,11 @@ /** * @author Hugo Alliaume */ -abstract readonly class AbstractRenderer implements RendererInterface +abstract class AbstractRenderer implements RendererInterface { public function __construct( - private StimulusHelper $stimulus, + private readonly StimulusHelper $stimulus, + private readonly UxIconRenderer $uxIconRenderer, ) { } @@ -31,6 +34,18 @@ abstract protected function getProviderOptions(): array; abstract protected function getDefaultMapOptions(): MapOptionsInterface; + /** + * @template T of MapOptionsInterface + * + * @param T $options + * + * @return T + */ + protected function tapOptions(MapOptionsInterface $options): MapOptionsInterface + { + return $options; + } + final public function renderMap(Map $map, array $attributes = []): string { if (!$map->hasOptions()) { @@ -39,13 +54,15 @@ final public function renderMap(Map $map, array $attributes = []): string $map->options($defaultMapOptions); } + $map->options($this->tapOptions($map->getOptions())); + $controllers = []; if ($attributes['data-controller'] ?? null) { $controllers[$attributes['data-controller']] = []; } $controllers['@symfony/ux-'.$this->getName().'-map/map'] = [ 'provider-options' => (object) $this->getProviderOptions(), - 'view' => $map->toArray(), + ...$this->getMapAttributes($map), ]; $stimulusAttributes = $this->stimulus->createStimulusAttributes(); @@ -67,4 +84,30 @@ final public function renderMap(Map $map, array $attributes = []): string return \sprintf('
    ', $stimulusAttributes); } + + private function getMapAttributes(Map $map): array + { + $computeId = fn (array $array) => hash('xxh3', json_encode($array, \JSON_THROW_ON_ERROR)); + + $attrs = $map->toArray(); + + foreach ($attrs['markers'] as $key => $marker) { + if (isset($marker['icon']['type']) && IconType::UxIcon->value === $marker['icon']['type']) { + $attrs['markers'][$key]['icon']['_generated_html'] = $this->uxIconRenderer->render($marker['icon']['name'], [ + 'width' => $marker['icon']['width'], + 'height' => $marker['icon']['height'], + ]); + } + $attrs['markers'][$key]['@id'] = $computeId($marker); + } + foreach ($attrs['polygons'] as $key => $polygon) { + $attrs['polygons'][$key]['@id'] = $computeId($polygon); + } + + foreach ($attrs['polylines'] as $key => $polyline) { + $attrs['polylines'][$key]['@id'] = $computeId($polyline); + } + + return $attrs; + } } diff --git a/src/Map/src/Renderer/AbstractRendererFactory.php b/src/Map/src/Renderer/AbstractRendererFactory.php index 02587a75d09..769c39d6f1b 100644 --- a/src/Map/src/Renderer/AbstractRendererFactory.php +++ b/src/Map/src/Renderer/AbstractRendererFactory.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Renderer; use Symfony\UX\Map\Exception\IncompleteDsnException; +use Symfony\UX\Map\Icon\UxIconRenderer; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; /** @@ -21,6 +22,7 @@ abstract class AbstractRendererFactory { public function __construct( protected StimulusHelper $stimulus, + protected UxIconRenderer $uxIconRenderer, ) { } diff --git a/src/Map/src/Renderer/Dsn.php b/src/Map/src/Renderer/Dsn.php index ecac16ddff0..adde1abb8ef 100644 --- a/src/Map/src/Renderer/Dsn.php +++ b/src/Map/src/Renderer/Dsn.php @@ -16,13 +16,13 @@ /** * @author Hugo Alliaume */ -final readonly class Dsn +final class Dsn { - private string $scheme; - private string $host; - private ?string $user; - private array $options; - private string $originalDsn; + private readonly string $scheme; + private readonly string $host; + private readonly ?string $user; + private readonly array $options; + private readonly string $originalDsn; public function __construct(#[\SensitiveParameter] string $dsn) { diff --git a/src/Map/src/Renderer/NullRenderer.php b/src/Map/src/Renderer/NullRenderer.php index 76ab4a22612..772a3d7f2e3 100644 --- a/src/Map/src/Renderer/NullRenderer.php +++ b/src/Map/src/Renderer/NullRenderer.php @@ -19,10 +19,10 @@ * * @internal */ -final readonly class NullRenderer implements RendererInterface +final class NullRenderer implements RendererInterface { public function __construct( - private array $availableBridges = [], + private readonly array $availableBridges = [], ) { } diff --git a/src/Map/src/Renderer/NullRendererFactory.php b/src/Map/src/Renderer/NullRendererFactory.php index 0d2c28a7fb6..e27ccff7925 100644 --- a/src/Map/src/Renderer/NullRendererFactory.php +++ b/src/Map/src/Renderer/NullRendererFactory.php @@ -13,13 +13,13 @@ use Symfony\UX\Map\Exception\UnsupportedSchemeException; -final readonly class NullRendererFactory implements RendererFactoryInterface +final class NullRendererFactory implements RendererFactoryInterface { /** * @param array $availableBridges */ public function __construct( - private array $availableBridges = [], + private readonly array $availableBridges = [], ) { } diff --git a/src/Map/src/Renderer/Renderer.php b/src/Map/src/Renderer/Renderer.php index ca2da7fa071..6f230d66178 100644 --- a/src/Map/src/Renderer/Renderer.php +++ b/src/Map/src/Renderer/Renderer.php @@ -18,13 +18,13 @@ * * @internal */ -final readonly class Renderer +final class Renderer { public function __construct( /** * @param iterable $factories */ - private iterable $factories, + private readonly iterable $factories, ) { } diff --git a/src/Map/src/Renderer/Renderers.php b/src/Map/src/Renderer/Renderers.php index ea7fae8eed1..ea6262ab568 100644 --- a/src/Map/src/Renderer/Renderers.php +++ b/src/Map/src/Renderer/Renderers.php @@ -57,7 +57,7 @@ public function renderMap(Map $map, array $attributes = []): string return $renderer->renderMap($map, $attributes); } - public function __toString() + public function __toString(): string { return implode(', ', array_keys($this->renderers)); } diff --git a/src/Map/src/Test/RendererFactoryTestCase.php b/src/Map/src/Test/RendererFactoryTestCase.php index 6d8914ef2b1..6a254c392ce 100644 --- a/src/Map/src/Test/RendererFactoryTestCase.php +++ b/src/Map/src/Test/RendererFactoryTestCase.php @@ -11,6 +11,7 @@ namespace Symfony\UX\Map\Test; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\UX\Map\Exception\UnsupportedSchemeException; use Symfony\UX\Map\Renderer\Dsn; @@ -55,6 +56,7 @@ public static function incompleteDsnRenderer(): iterable /** * @dataProvider supportsRenderer */ + #[DataProvider('supportsRenderer')] public function testSupports(bool $expected, string $dsn): void { $factory = $this->createRendererFactory(); @@ -65,6 +67,7 @@ public function testSupports(bool $expected, string $dsn): void /** * @dataProvider createRenderer */ + #[DataProvider('createRenderer')] public function testCreate(string $expected, string $dsn): void { $factory = $this->createRendererFactory(); @@ -76,6 +79,7 @@ public function testCreate(string $expected, string $dsn): void /** * @dataProvider unsupportedSchemeRenderer */ + #[DataProvider('unsupportedSchemeRenderer')] public function testUnsupportedSchemeException(string $dsn, ?string $message = null): void { $factory = $this->createRendererFactory(); diff --git a/src/Map/src/Test/RendererTestCase.php b/src/Map/src/Test/RendererTestCase.php index b9c3fe07244..6e8ff909fef 100644 --- a/src/Map/src/Test/RendererTestCase.php +++ b/src/Map/src/Test/RendererTestCase.php @@ -11,7 +11,9 @@ namespace Symfony\UX\Map\Test; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Spatie\Snapshots\MatchesSnapshots; use Symfony\UX\Map\Map; use Symfony\UX\Map\Renderer\RendererInterface; @@ -20,16 +22,32 @@ */ abstract class RendererTestCase extends TestCase { + use MatchesSnapshots; + /** - * @return iterable}> + * @return iterable}> */ - abstract public function provideTestRenderMap(): iterable; + abstract public static function provideTestRenderMap(): iterable; /** * @dataProvider provideTestRenderMap */ - public function testRenderMap(string $expectedRender, RendererInterface $renderer, Map $map, array $attributes = []): void + #[DataProvider('provideTestRenderMap')] + public function testRenderMap(RendererInterface $renderer, Map $map, array $attributes = []): void + { + $rendered = $renderer->renderMap($map, $attributes); + $rendered = $this->prettify($rendered); + + $this->assertMatchesSnapshot($rendered); + } + + private function prettify(string $html): string { - self::assertSame($expectedRender, $renderer->renderMap($map, $attributes)); + $html = preg_replace('/ ([a-zA-Z-]+=")/', "\n $1", $html); + $html = str_replace('">', "\"\n>", $html); + $html = ''."\n".$html; + + return $html; } } diff --git a/src/Map/src/Twig/MapRuntime.php b/src/Map/src/Twig/MapRuntime.php index cfb47560bd2..a729e46e31c 100644 --- a/src/Map/src/Twig/MapRuntime.php +++ b/src/Map/src/Twig/MapRuntime.php @@ -15,6 +15,7 @@ use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; use Symfony\UX\Map\Polygon; +use Symfony\UX\Map\Polyline; use Symfony\UX\Map\Renderer\RendererInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -34,12 +35,14 @@ public function __construct( * @param array $attributes * @param array $markers * @param array $polygons + * @param array $polylines */ public function renderMap( ?Map $map = null, array $attributes = [], ?array $markers = null, ?array $polygons = null, + ?array $polylines = null, ?array $center = null, ?float $zoom = null, ): string { @@ -55,8 +58,11 @@ public function renderMap( foreach ($markers ?? [] as $marker) { $map->addMarker(Marker::fromArray($marker)); } - foreach ($polygons ?? [] as $polygons) { - $map->addPolygon(Polygon::fromArray($polygons)); + foreach ($polygons ?? [] as $polygon) { + $map->addPolygon(Polygon::fromArray($polygon)); + } + foreach ($polylines ?? [] as $polyline) { + $map->addPolyline(Polyline::fromArray($polyline)); } if (null !== $center) { $map->center(Point::fromArray($center)); @@ -67,4 +73,12 @@ public function renderMap( return $this->renderer->renderMap($map, $attributes); } + + public function render(array $args = []): string + { + $map = array_intersect_key($args, ['map' => 0, 'markers' => 1, 'polygons' => 2, 'polylines' => 3, 'center' => 4, 'zoom' => 5]); + $attributes = array_diff_key($args, $map); + + return $this->renderMap(...$map, attributes: $attributes); + } } diff --git a/src/Map/src/Twig/UXMapComponent.php b/src/Map/src/Twig/UXMapComponent.php index 39e362b34b9..4c167420d52 100644 --- a/src/Map/src/Twig/UXMapComponent.php +++ b/src/Map/src/Twig/UXMapComponent.php @@ -14,6 +14,7 @@ use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; use Symfony\UX\Map\Polygon; +use Symfony\UX\Map\Polyline; /** * @author Simon AndrÊ @@ -35,4 +36,9 @@ final class UXMapComponent * @var Polygon[] */ public array $polygons; + + /** + * @var Polyline[] + */ + public array $polylines; } diff --git a/src/Map/src/Twig/UXMapComponentListener.php b/src/Map/src/Twig/UXMapComponentListener.php deleted file mode 100644 index 51034c53b4d..00000000000 --- a/src/Map/src/Twig/UXMapComponentListener.php +++ /dev/null @@ -1,42 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\UX\Map\Twig; - -use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent; - -/** - * @author Simon AndrÊ - * - * @internal - */ -final class UXMapComponentListener -{ - public function __construct( - private MapRuntime $mapRuntime, - ) { - } - - public function onPreCreateForRender(PreCreateForRenderEvent $event): void - { - if ('ux:map' !== strtolower($event->getName())) { - return; - } - - $attributes = $event->getInputProps(); - $map = array_intersect_key($attributes, ['markers' => 0, 'polygons' => 0, 'center' => 1, 'zoom' => 2]); - $attributes = array_diff_key($attributes, $map); - - $html = $this->mapRuntime->renderMap(...$map, attributes: $attributes); - $event->setRenderedString($html); - $event->stopPropagation(); - } -} diff --git a/src/Map/src/UXMapBundle.php b/src/Map/src/UXMapBundle.php index 1392926131f..2f4bb35a837 100644 --- a/src/Map/src/UXMapBundle.php +++ b/src/Map/src/UXMapBundle.php @@ -44,6 +44,12 @@ public function configure(DefinitionConfigurator $definition): void $rootNode ->children() ->scalarNode('renderer')->defaultNull()->end() + ->arrayNode('google_maps') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('default_map_id')->defaultNull()->end() + ->end() + ->end() ->end() ; } @@ -75,9 +81,17 @@ public function loadExtension(array $config, ContainerConfigurator $container, C foreach (self::$bridges as $name => $bridge) { if (ContainerBuilder::willBeAvailable('symfony/ux-'.$name.'-map', $bridge['renderer_factory'], ['symfony/ux-map'])) { $container->services() - ->set('ux_map.renderer_factory.'.$name, $bridge['renderer_factory']) + ->set($rendererFactoryName = 'ux_map.renderer_factory.'.$name, $bridge['renderer_factory']) ->parent('ux_map.renderer_factory.abstract') ->tag('ux_map.renderer_factory'); + + if ('google' === $name) { + $container->services() + ->get($rendererFactoryName) + ->args([ + '$defaultMapId' => $config['google_maps']['default_map_id'], + ]); + } } } } diff --git a/src/Map/src/Utils/CoordinateUtils.php b/src/Map/src/Utils/CoordinateUtils.php new file mode 100644 index 00000000000..6730080d0ce --- /dev/null +++ b/src/Map/src/Utils/CoordinateUtils.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Utils; + +/** + * Utility class to convert between decimal and DMS coordinates. + * + * @author Simon AndrÊ + */ +final class CoordinateUtils +{ + /** + * Converts a decimal coordinate to DMS (degrees, minutes, seconds). + * + * CoordinateUtils::decimalToDMS(48.8588443) + * --> [48, 51, 31.8388] + * + * @param float $decimal the decimal coordinate to convert to DMS + * + * @return array{0: int, 1: int, 2: float} + */ + public static function decimalToDMS(float $decimal): array + { + $sign = $decimal < 0 ? -1 : 1; + $decimal = abs($decimal); + $degrees = (int) $decimal * $sign; + $minutes = (int) (($decimal - abs($degrees)) * 60); + $seconds = ($decimal - abs($degrees) - $minutes / 60) * 3600; + + return [$degrees, $minutes, round($seconds, 6)]; + } + + /** + * Converts a DMS (degrees, minutes, seconds) coordinate to decimal. + * + * CoordinateUtils::DMSToDecimal(48, 51, 31.8388) + * --> 48.8588443 + * + * @param int $degrees the degrees part of the DMS coordinate + * @param int $minutes the minutes part of the DMS coordinate + * @param float $seconds the seconds part of the DMS coordinate + * + * @return float the decimal coordinate + */ + public static function DMSToDecimal(int $degrees, int $minutes, float $seconds): float + { + $sign = $degrees < 0 ? -1 : 1; + $degrees = abs($degrees); + $decimal = $degrees + $minutes / 60 + $seconds / 3600; + + return round($decimal * $sign, 6); + } +} diff --git a/src/Map/tests/Distance/DistanceCalculatorTest.php b/src/Map/tests/Distance/DistanceCalculatorTest.php new file mode 100644 index 00000000000..3f6c982a165 --- /dev/null +++ b/src/Map/tests/Distance/DistanceCalculatorTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Distance; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Distance\DistanceCalculator; +use Symfony\UX\Map\Distance\DistanceCalculatorInterface; +use Symfony\UX\Map\Distance\HaversineDistanceCalculator; +use Symfony\UX\Map\Distance\SphericalCosineDistanceCalculator; +use Symfony\UX\Map\Distance\VincentyDistanceCalculator; +use Symfony\UX\Map\Point; + +class DistanceCalculatorTest extends TestCase +{ + public function testCalculateDistanceUseCalculator(): void + { + $calculator = new class implements DistanceCalculatorInterface { + public function calculateDistance(Point $point1, Point $point2): float + { + return $point1->getLatitude() + $point2->getLongitude(); + } + }; + + $distanceCalculator = new DistanceCalculator($calculator); + $this->assertSame(0.0, $distanceCalculator->calculateDistance(new Point(0.0, 0.0), new Point(0.0, 0.0))); + $this->assertSame(90.0, $distanceCalculator->calculateDistance(new Point(45.0, 0.0), new Point(0.0, 45.0))); + } + + /** + * Test that the non-reference calculators (Haversine and Spherical Cosine) + * produce results close to the reference (Vincenty) within an acceptable margin. + * + * @dataProvider distanceAccuracyProvider + */ + public function testAccuracyAgainstVincenty(Point $point1, Point $point2, float $tolerance): void + { + $vincenty = new VincentyDistanceCalculator(); + $referenceDistance = $vincenty->calculateDistance($point1, $point2); + + $calculators = [ + new HaversineDistanceCalculator(), + new SphericalCosineDistanceCalculator(), + ]; + + foreach ($calculators as $calculator) { + $distance = $calculator->calculateDistance($point1, $point2); + $difference = abs($referenceDistance - $distance); + + $this->assertLessThanOrEqual($tolerance, $difference, \sprintf('%s difference (%.2f m) exceeds tolerance (%.2f m).', $calculator::class, $difference, $tolerance)); + } + } + + /** + * @return array + */ + public static function distanceAccuracyProvider(): array + { + return [ + 'Short distance: around the equator (111.32m)' => [ + new Point(0.0, 0.0), + new Point(0.0, 0.001), + .25, + ], + 'Small distance: Highbury to Emirates (445.61m)' => [ + new Point(51.55703, -0.10280), + new Point(51.55509, -0.10844), + 2.5, + ], + 'Moderate distance: Rennes to Saint-Malo (64.537 km)' => [ + new Point(48.1173, -1.6778), + new Point(48.6497, -2.0258), + 50.0, + ], + 'Moderate distance: Paris to London (343.556 km)' => [ + new Point(48.8566, 2.3522), + new Point(51.5074, -0.1278), + 500.0, + ], + 'Long distance: Metropolis to Gotham City (1,144.291 km)' => [ + new Point(40.7128, -74.0060), + new Point(41.8781, -87.6298), + 5000.0, + ], + 'Long distance: New York to Los Angeles (3,935.746 km)' => [ + new Point(40.7128, -74.0060), + new Point(34.0522, -118.2437), + 10000.0, + ], + ]; + } +} diff --git a/src/Map/tests/Distance/DistanceUnitTest.php b/src/Map/tests/Distance/DistanceUnitTest.php new file mode 100644 index 00000000000..f33b985de1a --- /dev/null +++ b/src/Map/tests/Distance/DistanceUnitTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Distance; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Distance\DistanceUnit; + +class DistanceUnitTest extends TestCase +{ + public function testConversionFactorIsPositive() + { + foreach (DistanceUnit::cases() as $unit) { + $this->assertGreaterThan(0, $unit->getConversionFactor()); + } + } + + public function testConversionFactorToMeterIsSameAsConversionFactor() + { + foreach (DistanceUnit::cases() as $unit) { + $this->assertEquals($unit->getConversionFactor(), $unit->getConversionFactorTo(DistanceUnit::Meter)); + } + } + + /** + * @dataProvider provideConvertedUnits + */ + public function testConversionFactorFrom(DistanceUnit $unit, DistanceUnit $otherUnit, float $expected): void + { + $this->assertEqualsWithDelta($expected, $unit->getConversionFactorFrom($otherUnit), 0.001); + } + + public static function provideConvertedUnits(): iterable + { + yield 'Kilometer to Kilometer' => [DistanceUnit::Kilometer, DistanceUnit::Kilometer, 1.0]; + yield 'Kilometer to Meter' => [DistanceUnit::Kilometer, DistanceUnit::Meter, 1000.0]; + yield 'Kilometer to Mile' => [DistanceUnit::Kilometer, DistanceUnit::Mile, 0.621371]; + yield 'Kilometer to Nautical Mile' => [DistanceUnit::Kilometer, DistanceUnit::NauticalMile, 0.539957]; + + yield 'Meter to Kilometer' => [DistanceUnit::Meter, DistanceUnit::Kilometer, 0.001]; + yield 'Meter to Meter' => [DistanceUnit::Meter, DistanceUnit::Meter, 1.0]; + yield 'Meter to Mile' => [DistanceUnit::Meter, DistanceUnit::Mile, 0.000621371]; + yield 'Meter to Nautical Mile' => [DistanceUnit::Meter, DistanceUnit::NauticalMile, 0.000539957]; + + yield 'Mile to Kilometer' => [DistanceUnit::Mile, DistanceUnit::Kilometer, 1.609344]; + yield 'Mile to Meter' => [DistanceUnit::Mile, DistanceUnit::Meter, 1609.344]; + yield 'Mile to Mile' => [DistanceUnit::Mile, DistanceUnit::Mile, 1.0]; + yield 'Mile to Nautical Mile' => [DistanceUnit::Mile, DistanceUnit::NauticalMile, 0.868976]; + + yield 'Nautical Mile to Kilometer' => [DistanceUnit::NauticalMile, DistanceUnit::Kilometer, 1.852]; + yield 'Nautical Mile to Meter' => [DistanceUnit::NauticalMile, DistanceUnit::Meter, 1852.0]; + yield 'Nautical Mile to Mile' => [DistanceUnit::NauticalMile, DistanceUnit::Mile, 1.15078]; + yield 'Nautical Mile to Nautical Mile' => [DistanceUnit::NauticalMile, DistanceUnit::NauticalMile, 1.0]; + } +} diff --git a/src/Map/tests/DummyOptions.php b/src/Map/tests/DummyOptions.php new file mode 100644 index 00000000000..04c1f8fd8ce --- /dev/null +++ b/src/Map/tests/DummyOptions.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use Symfony\UX\Map\MapOptionsInterface; +use Symfony\UX\Map\MapOptionsNormalizer; + +final class DummyOptions implements MapOptionsInterface +{ + public function __construct( + private readonly string $mapId, + private readonly string $mapType, + ) { + } + + public static function registerToNormalizer(): void + { + MapOptionsNormalizer::$providers['dummy'] = self::class; + } + + public static function unregisterFromNormalizer(): void + { + unset(MapOptionsNormalizer::$providers['dummy']); + } + + public static function fromArray(array $array): MapOptionsInterface + { + return new self( + $array['mapId'], + $array['mapType'], + ); + } + + public function toArray(): array + { + return [ + 'mapId' => $this->mapId, + 'mapType' => $this->mapType, + ]; + } +} diff --git a/src/Map/tests/IconTest.php b/src/Map/tests/IconTest.php new file mode 100644 index 00000000000..35ddeb943fb --- /dev/null +++ b/src/Map/tests/IconTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Icon\Icon; +use Symfony\UX\Map\Icon\SvgIcon; +use Symfony\UX\Map\Icon\UrlIcon; +use Symfony\UX\Map\Icon\UxIcon; + +class IconTest extends TestCase +{ + public static function provideIcons(): iterable + { + yield 'url' => [ + 'icon' => Icon::url('https://image.png')->width(12)->height(12), + 'expectedInstance' => UrlIcon::class, + 'expectedToArray' => ['type' => 'url', 'width' => 12, 'height' => 12, 'url' => 'https://image.png'], + ]; + yield 'svg' => [ + 'icon' => Icon::svg(''), + 'expectedInstance' => SvgIcon::class, + 'expectedToArray' => ['type' => 'svg', 'width' => 24, 'height' => 24, 'html' => ''], + ]; + yield 'ux' => [ + 'icon' => Icon::ux('bi:heart')->width(48)->height(48), + 'expectedInstance' => UxIcon::class, + 'expectedToArray' => ['type' => 'ux-icon', 'width' => 48, 'height' => 48, 'name' => 'bi:heart'], + ]; + } + + /** + * @dataProvider provideIcons + * + * @param class-string $expectedInstance + */ + public function testIconConstruction(Icon $icon, string $expectedInstance, array $expectedToArray): void + { + self::assertInstanceOf($expectedInstance, $icon); + } + + /** + * @dataProvider provideIcons + */ + public function testToArray(Icon $icon, string $expectedInstance, array $expectedToArray): void + { + self::assertSame($expectedToArray, $icon->toArray()); + } + + /** + * @dataProvider provideIcons + */ + public function testFromArray(Icon $icon, string $expectedInstance, array $expectedToArray): void + { + self::assertEquals($icon, Icon::fromArray($expectedToArray)); + } + + public static function dataProviderForTestSvgIconCustomizationMethodsCanNotBeCalled(): iterable + { + $refl = new \ReflectionClass(SvgIcon::class); + $customizationMethods = array_diff( + array_map( + fn (\ReflectionMethod $method) => $method->name, + array_filter($refl->getMethods(\ReflectionMethod::IS_PUBLIC), fn (\ReflectionMethod $method) => SvgIcon::class === $method->getDeclaringClass()->getName()) + ), + ['toArray', 'fromArray'] + ); + + foreach ($customizationMethods as $method) { + if (\in_array($method, ['width', 'height'], true)) { + yield $method => [$method, 12]; + } elseif (\in_array($method, $customizationMethods, true)) { + throw new \LogicException(\sprintf('The "%s" method is not supposed to be called on the SvgIcon, please modify the test provider.', $method)); + } + } + } + + /** + * @dataProvider dataProviderForTestSvgIconCustomizationMethodsCanNotBeCalled + */ + public function testSvgIconCustomizationMethodsCanNotBeCalled(string $method, mixed ...$args): void + { + $this->expectException(\LogicException::class); + if (\in_array($method, ['width', 'height'], true)) { + $this->expectExceptionMessage(\sprintf('Unable to configure the SvgIcon %s, please configure it in the HTML with the "%s" attribute on the root element instead.', $method, $method)); + } + + $icon = Icon::svg(''); + $icon->{$method}(...$args); + } +} diff --git a/src/Map/tests/MapFactoryTest.php b/src/Map/tests/MapFactoryTest.php index fcff3b0539c..25194376f96 100644 --- a/src/Map/tests/MapFactoryTest.php +++ b/src/Map/tests/MapFactoryTest.php @@ -12,10 +12,24 @@ namespace Symfony\UX\Map\Tests; use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; +use Symfony\UX\Map\Marker; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Polygon; class MapFactoryTest extends TestCase { + protected function setUp(): void + { + DummyOptions::registerToNormalizer(); + } + + protected function tearDown(): void + { + DummyOptions::unregisterFromNormalizer(); + } + public function testFromArray(): void { $array = self::createMapArray(); @@ -39,6 +53,46 @@ public function testFromArray(): void $this->assertSame($array['polygons'][0]['title'], $polygons[0]['title']); $this->assertSame($array['polygons'][0]['infoWindow']['headerContent'], $polygons[0]['infoWindow']['headerContent']); $this->assertSame($array['polygons'][0]['infoWindow']['content'], $polygons[0]['infoWindow']['content']); + + $this->assertCount(1, $polylines = $map->toArray()['polylines']); + $this->assertEquals($array['polylines'][0]['points'], $polylines[0]['points']); + $this->assertEquals($array['polylines'][0]['points'], $polylines[0]['points']); + $this->assertSame($array['polylines'][0]['title'], $polylines[0]['title']); + $this->assertSame($array['polylines'][0]['infoWindow']['headerContent'], $polylines[0]['infoWindow']['headerContent']); + $this->assertSame($array['polylines'][0]['infoWindow']['content'], $polylines[0]['infoWindow']['content']); + } + + public function testToArrayFromArray(): void + { + $map = (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker(new Marker( + position: new Point(48.8566, 2.3522), + title: 'Paris', + infoWindow: new InfoWindow('Welcome to Paris, the city of lights', extra: ['color' => 'red']), + extra: ['color' => 'blue'], + )) + ->addMarker(new Marker( + position: new Point(44.837789, -0.57918), + title: 'Bordeaux', + infoWindow: new InfoWindow('Welcome to Bordeaux, the city of wine', extra: ['color' => 'red']), + extra: ['color' => 'blue'], + )) + ->addPolygon(new Polygon( + points: [ + new Point(48.858844, 2.294351), + new Point(48.853, 2.3499), + new Point(48.8566, 2.3522), + ], + title: 'Polygon 1', + infoWindow: new InfoWindow('Polygon 1', 'Polygon 1', extra: ['color' => 'red']), + extra: ['color' => 'blue'], + )); + + $newMap = Map::fromArray($map->toArray()); + + $this->assertEquals($map->toArray(), $newMap->toArray()); } public function testFromArrayWithInvalidCenter(): void @@ -107,6 +161,30 @@ public function testFromArrayWithInvalidPolygon(): void Map::fromArray($array); } + public function testFromArrayWithInvalidPolylines(): void + { + $array = self::createMapArray(); + $array['polylines'] = 'invalid'; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "polylines" parameter must be an array.'); + Map::fromArray($array); + } + + public function testFromArrayWithInvalidPolyline(): void + { + $array = self::createMapArray(); + $array['polylines'] = [ + [ + 'invalid', + ], + ]; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "points" parameter is required.'); + Map::fromArray($array); + } + private static function createMapArray(): array { return [ @@ -151,6 +229,29 @@ private static function createMapArray(): array ], ], ], + 'polylines' => [ + [ + 'points' => [ + [ + 'lat' => 48.858844, + 'lng' => 2.294351, + ], + [ + 'lat' => 48.853, + 'lng' => 2.3499, + ], + [ + 'lat' => 48.8566, + 'lng' => 2.3522, + ], + ], + 'title' => 'Polyline 1', + 'infoWindow' => [ + 'headerContent' => 'Polyline 1', + 'content' => 'Polyline 1', + ], + ], + ], ]; } } diff --git a/src/Map/tests/MapOptionsNormalizerTest.php b/src/Map/tests/MapOptionsNormalizerTest.php new file mode 100644 index 00000000000..1348a4a464c --- /dev/null +++ b/src/Map/tests/MapOptionsNormalizerTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\UnableToDenormalizeOptionsException; +use Symfony\UX\Map\MapOptionsNormalizer; + +final class MapOptionsNormalizerTest extends TestCase +{ + protected function tearDown(): void + { + DummyOptions::unregisterFromNormalizer(); + } + + public function testDenormalizingWhenProviderKeyIsMissing(): void + { + $this->expectException(UnableToDenormalizeOptionsException::class); + $this->expectExceptionMessage(' the provider key "@provider" is missing in the normalized options.'); + + MapOptionsNormalizer::denormalize([]); + } + + public function testDenormalizingWhenProviderIsNotSupported(): void + { + $this->expectException(UnableToDenormalizeOptionsException::class); + $this->expectExceptionMessage(' the provider "foo" is not supported. Supported providers are "google", "leaflet".'); + + MapOptionsNormalizer::denormalize(['@provider' => 'foo']); + } + + public function testDenormalizingAndNormalizing(): void + { + DummyOptions::registerToNormalizer(); + + $options = MapOptionsNormalizer::denormalize([ + '@provider' => 'dummy', + 'mapId' => 'abcdef', + 'mapType' => 'satellite', + ]); + + self::assertInstanceOf(DummyOptions::class, $options); + self::assertEquals([ + 'mapId' => 'abcdef', + 'mapType' => 'satellite', + ], $options->toArray()); + + self::assertEquals([ + '@provider' => 'dummy', + 'mapId' => 'abcdef', + 'mapType' => 'satellite', + ], MapOptionsNormalizer::normalize($options)); + + self::assertEquals($options, MapOptionsNormalizer::denormalize(MapOptionsNormalizer::normalize($options))); + } +} diff --git a/src/Map/tests/MapTest.php b/src/Map/tests/MapTest.php index 95703724466..c16fe3d1b78 100644 --- a/src/Map/tests/MapTest.php +++ b/src/Map/tests/MapTest.php @@ -15,13 +15,23 @@ use Symfony\UX\Map\Exception\InvalidArgumentException; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; -use Symfony\UX\Map\MapOptionsInterface; use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; use Symfony\UX\Map\Polygon; +use Symfony\UX\Map\Polyline; class MapTest extends TestCase { + protected function setUp(): void + { + DummyOptions::registerToNormalizer(); + } + + protected function tearDown(): void + { + DummyOptions::unregisterFromNormalizer(); + } + public function testCenterValidation(): void { self::expectException(InvalidArgumentException::class); @@ -57,6 +67,7 @@ public function testZoomAndCenterCanBeOmittedIfFitBoundsToMarkers(): void 'options' => $array['options'], 'markers' => [], 'polygons' => [], + 'polylines' => [], ], $array); } @@ -76,6 +87,7 @@ public function testWithMinimumConfiguration(): void 'options' => $array['options'], 'markers' => [], 'polygons' => [], + 'polylines' => [], ], $array); } @@ -86,18 +98,12 @@ public function testWithMaximumConfiguration(): void ->center(new Point(48.8566, 2.3522)) ->zoom(6) ->fitBoundsToMarkers() - ->options(new class implements MapOptionsInterface { - public function toArray(): array - { - return [ - 'mapTypeId' => 'roadmap', - ]; - } - }) + ->options(new DummyOptions(mapId: '1a2b3c4d5e', mapType: 'roadmap')) ->addMarker(new Marker( position: new Point(48.8566, 2.3522), title: 'Paris', - infoWindow: new InfoWindow(headerContent: 'Paris', content: 'Paris', position: new Point(48.8566, 2.3522)) + infoWindow: new InfoWindow(headerContent: 'Paris', content: 'Paris', position: new Point(48.8566, 2.3522), extra: ['baz' => 'qux']), + extra: ['foo' => 'bar'], )) ->addMarker(new Marker( position: new Point(45.764, 4.8357), @@ -133,15 +139,41 @@ public function toArray(): array autoClose: true, ), )) + ->addPolyline(new Polyline( + points: [ + new Point(48.858844, 2.294351), + new Point(48.853, 2.3499), + new Point(48.8566, 2.3522), + ], + title: 'Polyline 1', + infoWindow: null, + )) + ->addPolyline(new Polyline( + points: [ + new Point(45.764043, 4.835659), + new Point(45.75, 4.85), + new Point(45.77, 4.82), + ], + title: 'Polyline 2', + infoWindow: new InfoWindow( + headerContent: 'Polyline 2', + content: 'A polyline around Lyon with some additional info.', + position: new Point(45.764, 4.8357), + opened: true, + autoClose: true, + ), + )) ; - $array = $map->toArray(); - self::assertEquals([ 'center' => ['lat' => 48.8566, 'lng' => 2.3522], 'zoom' => 6.0, 'fitBoundsToMarkers' => true, - 'options' => $array['options'], + 'options' => [ + '@provider' => 'dummy', + 'mapId' => '1a2b3c4d5e', + 'mapType' => 'roadmap', + ], 'markers' => [ [ 'position' => ['lat' => 48.8566, 'lng' => 2.3522], @@ -152,9 +184,11 @@ public function toArray(): array 'position' => ['lat' => 48.8566, 'lng' => 2.3522], 'opened' => false, 'autoClose' => true, - 'extra' => $array['markers'][0]['infoWindow']['extra'], + 'extra' => ['baz' => 'qux'], ], - 'extra' => $array['markers'][0]['extra'], + 'icon' => null, + 'extra' => ['foo' => 'bar'], + 'id' => null, ], [ 'position' => ['lat' => 45.764, 'lng' => 4.8357], @@ -165,9 +199,11 @@ public function toArray(): array 'position' => ['lat' => 45.764, 'lng' => 4.8357], 'opened' => true, 'autoClose' => true, - 'extra' => $array['markers'][1]['infoWindow']['extra'], + 'extra' => [], ], - 'extra' => $array['markers'][1]['extra'], + 'icon' => null, + 'extra' => [], + 'id' => null, ], [ 'position' => ['lat' => 43.2965, 'lng' => 5.3698], @@ -178,9 +214,11 @@ public function toArray(): array 'position' => ['lat' => 43.2965, 'lng' => 5.3698], 'opened' => true, 'autoClose' => true, - 'extra' => $array['markers'][2]['infoWindow']['extra'], + 'extra' => [], ], - 'extra' => $array['markers'][2]['extra'], + 'icon' => null, + 'extra' => [], + 'id' => null, ], ], 'polygons' => [ @@ -192,7 +230,8 @@ public function toArray(): array ], 'title' => 'Polygon 1', 'infoWindow' => null, - 'extra' => $array['polygons'][0]['extra'], + 'extra' => [], + 'id' => null, ], [ 'points' => [ @@ -207,13 +246,43 @@ public function toArray(): array 'position' => ['lat' => 45.764, 'lng' => 4.8357], 'opened' => true, 'autoClose' => true, - 'extra' => $array['polygons'][1]['infoWindow']['extra'], + 'extra' => [], ], - 'extra' => $array['polygons'][1]['extra'], + 'extra' => [], + 'id' => null, ], ], - ], $array); - - self::assertSame('roadmap', $array['options']->mapTypeId); + 'polylines' => [ + [ + 'points' => [ + ['lat' => 48.858844, 'lng' => 2.294351], + ['lat' => 48.853, 'lng' => 2.3499], + ['lat' => 48.8566, 'lng' => 2.3522], + ], + 'title' => 'Polyline 1', + 'infoWindow' => null, + 'extra' => [], + 'id' => null, + ], + [ + 'points' => [ + ['lat' => 45.764043, 'lng' => 4.835659], + ['lat' => 45.75, 'lng' => 4.85], + ['lat' => 45.77, 'lng' => 4.82], + ], + 'title' => 'Polyline 2', + 'infoWindow' => [ + 'headerContent' => 'Polyline 2', + 'content' => 'A polyline around Lyon with some additional info.', + 'position' => ['lat' => 45.764, 'lng' => 4.8357], + 'opened' => true, + 'autoClose' => true, + 'extra' => [], + ], + 'extra' => [], + 'id' => null, + ], + ], + ], $map->toArray()); } } diff --git a/src/Map/tests/MarkerTest.php b/src/Map/tests/MarkerTest.php index e55dca0e29d..db1ae73bd2b 100644 --- a/src/Map/tests/MarkerTest.php +++ b/src/Map/tests/MarkerTest.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Map\Tests; use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Icon\Icon; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; @@ -30,7 +31,9 @@ public function testToArray(): void 'position' => ['lat' => 48.8566, 'lng' => 2.3522], 'title' => null, 'infoWindow' => null, + 'icon' => null, 'extra' => $array['extra'], + 'id' => null, ], $array); $marker = new Marker( @@ -41,6 +44,7 @@ public function testToArray(): void content: "Capitale de la France, est une grande ville europÊenne et un centre mondial de l'art, de la mode, de la gastronomie et de la culture.", opened: true, ), + icon: Icon::url('https://example.com/image.png'), ); $array = $marker->toArray(); @@ -56,7 +60,14 @@ public function testToArray(): void 'autoClose' => true, 'extra' => $array['infoWindow']['extra'], ], + 'icon' => [ + 'type' => 'url', + 'width' => 24, + 'height' => 24, + 'url' => 'https://example.com/image.png', + ], 'extra' => $array['extra'], + 'id' => null, ], $array); } } diff --git a/src/Map/tests/PointTest.php b/src/Map/tests/PointTest.php index 961080400a6..2032680694f 100644 --- a/src/Map/tests/PointTest.php +++ b/src/Map/tests/PointTest.php @@ -36,6 +36,20 @@ public function testInvalidPoint(float $latitude, float $longitude, string $expe new Point($latitude, $longitude); } + public function testGetLatitude(): void + { + $point = new Point(48.8566, 2.3533); + + self::assertSame(48.8566, $point->getLatitude()); + } + + public function testGetLongitude(): void + { + $point = new Point(48.8566, 2.3533); + + self::assertSame(2.3533, $point->getLongitude()); + } + public function testToArray(): void { $point = new Point(48.8566, 2.3533); diff --git a/src/Map/tests/PolygonTest.php b/src/Map/tests/PolygonTest.php new file mode 100644 index 00000000000..ccd9755ff13 --- /dev/null +++ b/src/Map/tests/PolygonTest.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\InfoWindow; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Polygon; + +class PolygonTest extends TestCase +{ + public function testToArray() + { + $point1 = new Point(1.1, 2.2); + $point2 = new Point(3.3, 4.4); + + $infoWindow = new InfoWindow('info content'); + + $polygon = new Polygon( + points: [$point1, $point2], + title: 'Test Polygon', + infoWindow: $infoWindow, + extra: ['foo' => 'bar'], + id: 'poly1' + ); + + $array = $polygon->toArray(); + $this->assertSame([ + 'points' => [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]], + 'title' => 'Test Polygon', + 'infoWindow' => [ + 'headerContent' => 'info content', + 'content' => null, + 'position' => null, + 'opened' => false, + 'autoClose' => true, + 'extra' => $array['infoWindow']['extra'], + ], + 'extra' => ['foo' => 'bar'], + 'id' => 'poly1', + ], $array); + } + + public function testToArrayMultidimensional() + { + $point1 = new Point(1.1, 2.2); + $point2 = new Point(3.3, 4.4); + $point3 = new Point(5.5, 6.6); + + $polygon = new Polygon( + points: [[$point1, $point2], [$point3]], + ); + + $array = $polygon->toArray(); + $this->assertSame([ + 'points' => [ + [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]], + [['lat' => 5.5, 'lng' => 6.6]], + ], + 'title' => null, + 'infoWindow' => null, + 'extra' => $array['extra'], + 'id' => null, + ], $array); + } + + public function testFromArray() + { + $data = [ + 'points' => [ + ['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4], + ], + 'title' => 'Test Polygon', + 'infoWindow' => ['content' => 'info content'], + 'extra' => ['foo' => 'bar'], + 'id' => 'poly1', + ]; + + $polygon = Polygon::fromArray($data); + + $this->assertInstanceOf(Polygon::class, $polygon); + + $array = $polygon->toArray(); + $this->assertSame([ + 'points' => [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]], + 'title' => 'Test Polygon', + 'infoWindow' => [ + 'headerContent' => null, + 'content' => 'info content', + 'position' => null, + 'opened' => false, + 'autoClose' => true, + 'extra' => $array['infoWindow']['extra'], + ], + 'extra' => ['foo' => 'bar'], + 'id' => 'poly1', + ], $array); + } + + public function testFromArrayMultidimensional() + { + $data = [ + 'points' => [ + [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]], + [['lat' => 5.5, 'lng' => 6.6]], + ], + 'title' => 'Test Polygon', + 'infoWindow' => ['content' => 'info content'], + 'extra' => ['foo' => 'bar'], + 'id' => 'poly1', + ]; + + $polygon = Polygon::fromArray($data); + + $this->assertInstanceOf(Polygon::class, $polygon); + + $array = $polygon->toArray(); + $this->assertSame([ + 'points' => [ + [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]], + [['lat' => 5.5, 'lng' => 6.6]], + ], + 'title' => 'Test Polygon', + 'infoWindow' => [ + 'headerContent' => null, + 'content' => 'info content', + 'position' => null, + 'opened' => false, + 'autoClose' => true, + 'extra' => $array['infoWindow']['extra'], + ], + 'extra' => ['foo' => 'bar'], + 'id' => 'poly1', + ], $array); + } + + public function testFromArrayThrowsExceptionIfPointsMissing() + { + $this->expectException(InvalidArgumentException::class); + Polygon::fromArray(['invalid' => 'No points']); + } +} diff --git a/src/Map/tests/Twig/MapExtensionTest.php b/src/Map/tests/Twig/MapExtensionTest.php index e78e0ec3d16..9e7c2b45b6e 100644 --- a/src/Map/tests/Twig/MapExtensionTest.php +++ b/src/Map/tests/Twig/MapExtensionTest.php @@ -19,10 +19,7 @@ use Symfony\UX\Map\Tests\Kernel\TwigAppKernel; use Symfony\UX\Map\Twig\MapExtension; use Symfony\UX\Map\Twig\MapRuntime; -use Twig\DeprecatedCallableInfo; use Twig\Environment; -use Twig\Loader\ArrayLoader; -use Twig\Loader\ChainLoader; class MapExtensionTest extends KernelTestCase { @@ -50,42 +47,6 @@ public function testRuntimeIsRegistered(): void $this->assertInstanceOf(MapRuntime::class, $twig->getRuntime(MapRuntime::class)); } - /** - * @group legacy - */ - public function testRenderFunctionIsDeprecated(): void - { - $map = (new Map()) - ->center(new Point(latitude: 5, longitude: 10)) - ->zoom(4); - - $renderer = self::createMock(RendererInterface::class); - $renderer - ->expects(self::once()) - ->method('renderMap') - ->with($map, []) - ->willReturn('') - ; - self::getContainer()->set('test.ux_map.renderers', $renderer); - - /** @var Environment $twig */ - $twig = self::getContainer()->get('twig'); - $twig->setLoader(new ChainLoader([ - new ArrayLoader([ - 'test' => '{{ render_map(map) }}', - ]), - $twig->getLoader(), - ])); - - if (class_exists(DeprecatedCallableInfo::class)) { - $this->expectDeprecation('Since symfony/ux-map 2.20: Twig Function "render_map" is deprecated; use "ux_map" instead in test at line 1.'); - } else { - $this->expectDeprecation('Since symfony/ux-map 2.20: Twig Function "render_map" is deprecated. Use "ux_map" instead in test at line 1.'); - } - $html = $twig->render('test', ['map' => $map]); - $this->assertSame('', $html); - } - public function testMapFunctionWithArray(): void { $map = (new Map()) @@ -103,7 +64,7 @@ public function testMapFunctionWithArray(): void self::getContainer()->set('test.ux_map.renderers', $renderer); $twig = self::getContainer()->get('twig'); - $template = $twig->createTemplate('{{ ux_map(center: {lat: 5, lng: 10}, zoom: 4, attributes: attributes) }}'); + $template = $twig->createTemplate('{{ ux_map(center={lat: 5, lng: 10}, zoom=4, attributes=attributes) }}'); $this->assertSame( '
    ', diff --git a/src/Map/tests/Utils/CoordinateUtilsTest.php b/src/Map/tests/Utils/CoordinateUtilsTest.php new file mode 100644 index 00000000000..e7ac82dcf79 --- /dev/null +++ b/src/Map/tests/Utils/CoordinateUtilsTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Utils; + +use PHPUnit\Framework\TestCase; + +class CoordinateUtilsTest extends TestCase +{ + public function testDecimalToDMSConvertsCorrectly(): void + { + $result = CoordinateUtils::decimalToDMS(48.8588443); + $this->assertSame([48, 51, 31.83948], $result); + } + + public function testDecimalToDMSHandlesNegativeValues(): void + { + $result = CoordinateUtils::decimalToDMS(-48.8588443); + $this->assertSame([-48, 51, 31.83948], $result); + } + + public function testDMSToDecimalConvertsCorrectly(): void + { + $result = CoordinateUtils::DMSToDecimal(48, 51, 31.8388); + $this->assertSame(48.858844, $result); + } + + public function testDMSToDecimalHandlesNegativeValues(): void + { + $result = CoordinateUtils::DMSToDecimal(-48, 51, 31.8388); + $this->assertSame(-48.858844, $result); + } + + public function testDMSToDecimalHandlesZeroValues(): void + { + $result = CoordinateUtils::DMSToDecimal(0, 0, 0.0); + $this->assertSame(0.0, $result); + } +} diff --git a/src/Notify/.gitattributes b/src/Notify/.gitattributes index 2b1d42ea804..b9bb8f6e796 100644 --- a/src/Notify/.gitattributes +++ b/src/Notify/.gitattributes @@ -1,7 +1,7 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /assets/src export-ignore /assets/test export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/Notify/.github/PULL_REQUEST_TEMPLATE.md b/src/Notify/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Notify/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Notify/.github/workflows/close-pull-request.yml b/src/Notify/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Notify/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Notify/.gitignore b/src/Notify/.gitignore index bb17c3e124c..2cc9f0231c3 100644 --- a/src/Notify/.gitignore +++ b/src/Notify/.gitignore @@ -1,4 +1,5 @@ +/assets/node_modules/ /vendor/ -.phpunit.result.cache -.php_cs.cache -composer.lock +/composer.lock +/phpunit.xml +/.phpunit.result.cache diff --git a/src/Notify/CHANGELOG.md b/src/Notify/CHANGELOG.md index ef21c92f59e..e1daa04d829 100644 --- a/src/Notify/CHANGELOG.md +++ b/src/Notify/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.24.0 + +- Added `options` to Notification + ## 2.13.2 - Revert "Change JavaScript package to `type: module`" diff --git a/src/Notify/README.md b/src/Notify/README.md index 5b4f79ff626..68029ad4798 100644 --- a/src/Notify/README.md +++ b/src/Notify/README.md @@ -5,7 +5,7 @@ Symfony UX Notify is a Symfony bundle integrating server-sent in Symfony applications using [Mercure](https://mercure.rocks/). It is part of [the Symfony UX initiative](https://ux.symfony.com/). -![Example of a native notification](https://github.com/symfony/ux/blob/2.x/doc/native-notification-example.png?raw=true) +![Example of a native notification](doc/native-notification-example.png) **This repository is a READ-ONLY sub-tree split**. See https://github.com/symfony/ux to create issues or submit pull requests. diff --git a/src/Notify/assets/LICENSE b/src/Notify/assets/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/Notify/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Notify/assets/README.md b/src/Notify/assets/README.md new file mode 100644 index 00000000000..b2723340a52 --- /dev/null +++ b/src/Notify/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-notify + +JavaScript assets of the [symfony/ux-notify](https://packagist.org/packages/symfony/ux-notify) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-notify](https://packagist.org/packages/symfony/ux-notify) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-notify](https://packagist.org/packages/symfony/ux-notify) PHP package version: +```shell +composer require symfony/ux-notify:2.23.0 +npm add @symfony/ux-notify@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-notify/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Notify/assets/dist/controller.d.ts b/src/Notify/assets/dist/controller.d.ts index 73ba68163eb..e9252002f3c 100644 --- a/src/Notify/assets/dist/controller.d.ts +++ b/src/Notify/assets/dist/controller.d.ts @@ -13,6 +13,6 @@ export default class extends Controller { initialize(): void; connect(): void; disconnect(): void; - _notify(content: string | undefined): void; + _notify(title: string | undefined, options: NotificationOptions | undefined): void; private dispatchEvent; } diff --git a/src/Notify/assets/dist/controller.js b/src/Notify/assets/dist/controller.js index 7350487c71d..a69ae1a254b 100644 --- a/src/Notify/assets/dist/controller.js +++ b/src/Notify/assets/dist/controller.js @@ -26,7 +26,10 @@ class default_1 extends Controller { return; } this.eventSources.forEach((eventSource) => { - const listener = (event) => this._notify(JSON.parse(event.data).summary); + const listener = (event) => { + const { summary, content } = JSON.parse(event.data); + this._notify(summary, content); + }; eventSource.addEventListener('message', listener); this.listeners.set(eventSource, listener); }); @@ -42,17 +45,17 @@ class default_1 extends Controller { }); this.eventSources = []; } - _notify(content) { - if (!content) + _notify(title, options) { + if (!title) return; if ('granted' === Notification.permission) { - new Notification(content); + new Notification(title, options); return; } if ('denied' !== Notification.permission) { Notification.requestPermission().then((permission) => { if ('granted' === permission) { - new Notification(content); + new Notification(title, options); } }); } diff --git a/src/Notify/assets/package.json b/src/Notify/assets/package.json index 5f47e196287..325e386a191 100644 --- a/src/Notify/assets/package.json +++ b/src/Notify/assets/package.json @@ -2,9 +2,25 @@ "name": "@symfony/ux-notify", "description": "Native notification integration for Symfony using Mercure", "license": "MIT", - "version": "1.0.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/notify", + "repository": "https://github.com/symfony/ux-notify", + "type": "module", + "files": [ + "dist" + ], "main": "dist/controller.js", "types": "dist/controller.d.ts", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "notify": { diff --git a/src/Notify/assets/src/controller.ts b/src/Notify/assets/src/controller.ts index 6a5571fa0b9..db1f206f9c8 100644 --- a/src/Notify/assets/src/controller.ts +++ b/src/Notify/assets/src/controller.ts @@ -49,7 +49,12 @@ export default class extends Controller { } this.eventSources.forEach((eventSource) => { - const listener = (event: MessageEvent) => this._notify(JSON.parse(event.data).summary); + const listener = (event: MessageEvent) => { + const { summary, content } = JSON.parse(event.data); + + this._notify(summary, content); + }; + eventSource.addEventListener('message', listener); this.listeners.set(eventSource, listener); }); @@ -70,11 +75,11 @@ export default class extends Controller { this.eventSources = []; } - _notify(content: string | undefined) { - if (!content) return; + _notify(title: string | undefined, options: NotificationOptions | undefined) { + if (!title) return; if ('granted' === Notification.permission) { - new Notification(content); + new Notification(title, options); return; } @@ -82,7 +87,7 @@ export default class extends Controller { if ('denied' !== Notification.permission) { Notification.requestPermission().then((permission) => { if ('granted' === permission) { - new Notification(content); + new Notification(title, options); } }); } diff --git a/src/Notify/assets/test/controller.test.ts b/src/Notify/assets/test/controller.test.ts index bfccfdfd2fe..08a84dc26fc 100644 --- a/src/Notify/assets/test/controller.test.ts +++ b/src/Notify/assets/test/controller.test.ts @@ -8,10 +8,10 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; -import NotifyController from '../src/controller'; +import { getByTestId, waitFor } from '@testing-library/dom'; import { vi } from 'vitest'; +import NotifyController from '../src/controller'; // Controller used to check the actual controller was properly booted class CheckController extends Controller { diff --git a/src/Notify/doc/index.rst b/src/Notify/doc/index.rst index 32611d7a6c4..ab39e2f583b 100644 --- a/src/Notify/doc/index.rst +++ b/src/Notify/doc/index.rst @@ -25,9 +25,9 @@ needed if you're using AssetMapper): $ npm install --force $ npm run watch - # or use yarn - $ yarn install --force - $ yarn watch +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-notify npm package`_ Usage ----- @@ -158,3 +158,4 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`Mercure`: https://mercure.rocks .. _`running Mercure server`: https://symfony.com/doc/current/mercure.html#running-a-mercure-hub .. _`native notifications`: https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API +.. _`@symfony/ux-notify npm package`: https://www.npmjs.com/package/@symfony/ux-notify diff --git a/src/Notify/tests/Kernel/TwigAppKernel.php b/src/Notify/tests/Kernel/TwigAppKernel.php index de278e8fcdc..59910fd30eb 100644 --- a/src/Notify/tests/Kernel/TwigAppKernel.php +++ b/src/Notify/tests/Kernel/TwigAppKernel.php @@ -36,7 +36,7 @@ public function registerBundles(): iterable yield new NotifyBundle(); } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); diff --git a/src/React/.gitattributes b/src/React/.gitattributes index 1ba1a889ab8..ec2de4be5f0 100644 --- a/src/React/.gitattributes +++ b/src/React/.gitattributes @@ -1,8 +1,8 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /assets/src export-ignore /assets/test export-ignore /assets/vitest.config.js export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/React/.github/PULL_REQUEST_TEMPLATE.md b/src/React/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/React/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/React/.github/workflows/close-pull-request.yml b/src/React/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/React/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/React/.gitignore b/src/React/.gitignore index 30282084317..2cc9f0231c3 100644 --- a/src/React/.gitignore +++ b/src/React/.gitignore @@ -1,4 +1,5 @@ -vendor -composer.lock -.php_cs.cache -.phpunit.result.cache +/assets/node_modules/ +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.result.cache diff --git a/src/React/CHANGELOG.md b/src/React/CHANGELOG.md index 77b48ddca31..da9a6a6bd36 100644 --- a/src/React/CHANGELOG.md +++ b/src/React/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG +## 2.26.0 + +- Improve error handling when resolving a React component + +## 2.21.0 + +- Add `permanent` option to the `react_component` Twig function, to prevent the + _unmounting_ when the component is deconnected and immediately re-connected + ## 2.13.2 - Revert "Change JavaScript package to `type: module`" diff --git a/src/React/assets/LICENSE b/src/React/assets/LICENSE new file mode 100644 index 00000000000..0ed3a246553 --- /dev/null +++ b/src/React/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/React/assets/README.md b/src/React/assets/README.md new file mode 100644 index 00000000000..c1996d8b8e1 --- /dev/null +++ b/src/React/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-react + +JavaScript assets of the [symfony/ux-react](https://packagist.org/packages/symfony/ux-react) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-react](https://packagist.org/packages/symfony/ux-react) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-react](https://packagist.org/packages/symfony/ux-react) PHP package version: +```shell +composer require symfony/ux-react:2.23.0 +npm add @symfony/ux-react@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-react/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/React/assets/dist/loader.d.ts b/src/React/assets/dist/loader.d.ts index 5d3cd679ebb..109970c9fc5 100644 --- a/src/React/assets/dist/loader.d.ts +++ b/src/React/assets/dist/loader.d.ts @@ -1,5 +1,5 @@ -import { type ComponentCollection } from './components.js'; import type { ComponentClass, FunctionComponent } from 'react'; +import { type ComponentCollection } from './components.js'; type Component = string | FunctionComponent | ComponentClass; declare global { function resolveReactComponent(name: string): Component; diff --git a/src/React/assets/dist/register_controller.js b/src/React/assets/dist/register_controller.js index 2eef8cd8077..d0c38da6a83 100644 --- a/src/React/assets/dist/register_controller.js +++ b/src/React/assets/dist/register_controller.js @@ -10,6 +10,10 @@ function registerReactControllerComponents(context) { const component = reactControllers[`./${name}.jsx`] || reactControllers[`./${name}.tsx`]; if (typeof component === 'undefined') { const possibleValues = Object.keys(reactControllers).map((key) => key.replace('./', '').replace('.jsx', '').replace('.tsx', '')); + if (possibleValues.includes(name)) { + throw new Error(` + React controller "${name}" could not be resolved. Ensure the module exports the controller as a default export.`); + } throw new Error(`React controller "${name}" does not exist. Possible values: ${possibleValues.join(', ')}`); } return component; diff --git a/src/React/assets/dist/render_controller.d.ts b/src/React/assets/dist/render_controller.d.ts index fc7b1dd4371..f8f47bb98a9 100644 --- a/src/React/assets/dist/render_controller.d.ts +++ b/src/React/assets/dist/render_controller.d.ts @@ -1,11 +1,16 @@ -import { type ReactElement } from 'react'; import { Controller } from '@hotwired/stimulus'; +import { type ReactElement } from 'react'; export default class extends Controller { readonly componentValue?: string; readonly propsValue?: object; + readonly permanentValue: boolean; static values: { component: StringConstructor; props: ObjectConstructor; + permanent: { + type: BooleanConstructor; + default: boolean; + }; }; connect(): void; disconnect(): void; diff --git a/src/React/assets/dist/render_controller.js b/src/React/assets/dist/render_controller.js index 8aa8dc45468..44dbaafcd2d 100644 --- a/src/React/assets/dist/render_controller.js +++ b/src/React/assets/dist/render_controller.js @@ -1,25 +1,43 @@ +import { Controller } from '@hotwired/stimulus'; import React from 'react'; import require$$0 from 'react-dom'; -import { Controller } from '@hotwired/stimulus'; -var createRoot; +var client = {}; -var m = require$$0; -if (process.env.NODE_ENV === 'production') { - createRoot = m.createRoot; - m.hydrateRoot; -} else { - var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED; - createRoot = function(c, o) { - i.usingClientEntryPoint = true; - try { - return m.createRoot(c, o); - } finally { - i.usingClientEntryPoint = false; - } - }; +var hasRequiredClient; + +function requireClient () { + if (hasRequiredClient) return client; + hasRequiredClient = 1; + + var m = require$$0; + if (process.env.NODE_ENV === 'production') { + client.createRoot = m.createRoot; + client.hydrateRoot = m.hydrateRoot; + } else { + var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED; + client.createRoot = function(c, o) { + i.usingClientEntryPoint = true; + try { + return m.createRoot(c, o); + } finally { + i.usingClientEntryPoint = false; + } + }; + client.hydrateRoot = function(c, h, o) { + i.usingClientEntryPoint = true; + try { + return m.hydrateRoot(c, h, o); + } finally { + i.usingClientEntryPoint = false; + } + }; + } + return client; } +var clientExports = requireClient(); + class default_1 extends Controller { connect() { const props = this.propsValue ? this.propsValue : null; @@ -36,6 +54,9 @@ class default_1 extends Controller { }); } disconnect() { + if (this.permanentValue) { + return; + } this.element.root.unmount(); this.dispatchEvent('unmount', { component: this.componentValue, @@ -45,7 +66,7 @@ class default_1 extends Controller { _renderReactElement(reactElement) { const element = this.element; if (!element.root) { - element.root = createRoot(this.element); + element.root = clientExports.createRoot(this.element); } element.root.render(reactElement); } @@ -56,6 +77,7 @@ class default_1 extends Controller { default_1.values = { component: String, props: Object, + permanent: { type: Boolean, default: false }, }; export { default_1 as default }; diff --git a/src/React/assets/package.json b/src/React/assets/package.json index a4f7f6753d6..27d88f24bed 100644 --- a/src/React/assets/package.json +++ b/src/React/assets/package.json @@ -2,9 +2,25 @@ "name": "@symfony/ux-react", "description": "Integration of React in Symfony", "license": "MIT", - "version": "1.0.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/react", + "repository": "https://github.com/symfony/ux-react", + "type": "module", + "files": [ + "dist" + ], "main": "dist/register_controller.js", "types": "dist/register_controller.d.ts", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "react": { diff --git a/src/React/assets/src/loader.ts b/src/React/assets/src/loader.ts index ad162fea9f7..605d592e7c0 100644 --- a/src/React/assets/src/loader.ts +++ b/src/React/assets/src/loader.ts @@ -7,8 +7,8 @@ * file that was distributed with this source code. */ -import { type ComponentCollection, components } from './components.js'; import type { ComponentClass, FunctionComponent } from 'react'; +import { type ComponentCollection, components } from './components.js'; type Component = string | FunctionComponent | ComponentClass; diff --git a/src/React/assets/src/register_controller.ts b/src/React/assets/src/register_controller.ts index e9d59afe84d..8be8923a660 100644 --- a/src/React/assets/src/register_controller.ts +++ b/src/React/assets/src/register_controller.ts @@ -37,6 +37,12 @@ export function registerReactControllerComponents(context: __WebpackModuleApi.Re const possibleValues = Object.keys(reactControllers).map((key) => key.replace('./', '').replace('.jsx', '').replace('.tsx', '') ); + + if (possibleValues.includes(name)) { + throw new Error(` + React controller "${name}" could not be resolved. Ensure the module exports the controller as a default export.`); + } + throw new Error(`React controller "${name}" does not exist. Possible values: ${possibleValues.join(', ')}`); } diff --git a/src/React/assets/src/render_controller.ts b/src/React/assets/src/render_controller.ts index 595b5a6ae22..215c6ce1ff1 100644 --- a/src/React/assets/src/render_controller.ts +++ b/src/React/assets/src/render_controller.ts @@ -7,24 +7,24 @@ * file that was distributed with this source code. */ +import { Controller } from '@hotwired/stimulus'; import React, { type ReactElement } from 'react'; import { createRoot } from 'react-dom/client'; -import { Controller } from '@hotwired/stimulus'; export default class extends Controller { declare readonly componentValue?: string; declare readonly propsValue?: object; + declare readonly permanentValue: boolean; static values = { component: String, props: Object, + permanent: { type: Boolean, default: false }, }; connect() { const props = this.propsValue ? this.propsValue : null; - this.dispatchEvent('connect', { component: this.componentValue, props: props }); - if (!this.componentValue) { throw new Error('No component specified.'); } @@ -40,6 +40,12 @@ export default class extends Controller { } disconnect() { + if (this.permanentValue) { + // Prevent unmounting the component if the controller is permanent + // (no render is allowed after unmounting) + return; + } + (this.element as any).root.unmount(); this.dispatchEvent('unmount', { component: this.componentValue, diff --git a/src/React/assets/test/register_controller.test.tsx b/src/React/assets/test/register_controller.test.tsx index 756de1cdba4..dcb3808219b 100644 --- a/src/React/assets/test/register_controller.test.tsx +++ b/src/React/assets/test/register_controller.test.tsx @@ -8,15 +8,16 @@ */ import { registerReactControllerComponents } from '../src/register_controller'; -import MyTsxComponent from './fixtures/MyTsxComponent'; // @ts-ignore import MyJsxComponent from './fixtures/MyJsxComponent'; +import MyTsxComponent from './fixtures/MyTsxComponent'; import RequireContext = __WebpackModuleApi.RequireContext; const createFakeFixturesContext = (): RequireContext => { const files: any = { './MyJsxComponent.jsx': { default: MyJsxComponent }, './MyTsxComponent.tsx': { default: MyTsxComponent }, + './NoDefaultExportComponent.jsx': { default: undefined }, }; const context = (id: string): any => files[id]; @@ -45,4 +46,13 @@ describe('registerReactControllerComponents', () => { 'React controller "MyABCComponent" does not exist. Possible values: MyJsxComponent, MyTsxComponent' ); }); + + it('throws when no default export found in imported module', () => { + registerReactControllerComponents(createFakeFixturesContext()); + const resolveComponent = (window as any).resolveReactComponent; + + expect(() => resolveComponent('NoDefaultExportComponent')).toThrow( + 'React controller "NoDefaultExportComponent" could not be resolved. Ensure the module exports the controller as a default export.' + ); + }); }); diff --git a/src/React/assets/test/render_controller.test.tsx b/src/React/assets/test/render_controller.test.tsx index c9d7d326668..f8202d88658 100644 --- a/src/React/assets/test/render_controller.test.tsx +++ b/src/React/assets/test/render_controller.test.tsx @@ -7,10 +7,10 @@ * file that was distributed with this source code. */ -import React from 'react'; import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import React from 'react'; import ReactController from '../src/render_controller'; // Controller used to check the actual controller was properly booted diff --git a/src/React/doc/index.rst b/src/React/doc/index.rst index 954061c8308..0faa32c46ff 100644 --- a/src/React/doc/index.rst +++ b/src/React/doc/index.rst @@ -2,12 +2,14 @@ Symfony UX React ================ Symfony UX React is a Symfony bundle integrating `React`_ in -Symfony applications. It is part of `the Symfony UX initiative`_. +Symfony applications. It is part of the `Symfony UX initiative`_. React is a JavaScript library for building user interfaces. Symfony UX React provides tools to render React components from Twig, handling rendering and data transfers. +You can see a live example of this integration on the `Symfony UX React demo`_. + Symfony UX React supports React 18+. Installation @@ -18,10 +20,6 @@ Installation This package works best with WebpackEncore. To use it with AssetMapper, see :ref:`Using with AssetMapper `. -.. caution:: - - Before you start, make sure you have `StimulusBundle configured in your app`_. - Install the bundle using Composer and Symfony Flex: .. code-block:: terminal @@ -39,9 +37,9 @@ Next, install a package to help React: $ npm install -D @babel/preset-react --force $ npm run watch - # or use yarn - $ yarn add @babel/preset-react --dev --force - $ yarn watch +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-react npm package`_ That's it! Any files inside ``assets/react/controllers/`` can now be rendered as React components. @@ -49,6 +47,9 @@ React components. Usage ----- +Register components +~~~~~~~~~~~~~~~~~~~ + The Flex recipe will have already added the ``registerReactControllerComponents()`` code to your ``assets/app.js`` file: @@ -63,7 +64,11 @@ This will load all React components located in the ``assets/react/controllers`` directory. These are known as **React controller components**: top-level components that are meant to be rendered from Twig. -You can render any React controller component in Twig using the ``react_component()``. +Render in Twig +~~~~~~~~~~~~~~ + +You can render any React controller component in your Twig templates, using the +``react_component()`` function. For example: @@ -76,6 +81,10 @@ For example: return
    Hello {props.fullName}
    ; } +.. note:: + + Ensure your module exports the controller as the ``export default``. The default export is used when resolving components. + .. code-block:: html+twig {# templates/home.html.twig #} @@ -90,6 +99,31 @@ For example:
    {% endblock %} +Permanent components +~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.21 + + The ability to mark a component ``permanent`` was added in UX React 2.21. + +The controller responsible to render the React components can be configured +to keep the React component mounted when the root element is removed from +the DOM, using the ``permanent`` option. + +This is particularly useful when the root element of a component is moved around +in the DOM or is removed and immediately re-added to the DOM (e.g. when using +`Turbo`_ and its `data-turbo-permanent` attribute). + +.. code-block:: html+twig + + {# templates/home.html.twig #} + {% extends 'base.html.twig' %} + + {# The React component will stay mounted if the div is moved in the DOM #} +
    + Loading... +
    + .. _using-with-asset-mapper: Using with AssetMapper @@ -100,7 +134,7 @@ requires some extra steps. #. Compile your ``.jsx`` files to pure JavaScript files. This can be done by installing Babel and the ``@babel/preset-react`` preset. Example: - https://github.com/symfony/ux/blob/2.x/ux.symfony.com/package.json + https://github.com/symfony/ux/blob/2.x/ux.symfony.com/assets/react/build/package.json #. Point this library at the "built" controllers directory that contains the final JavaScript files: @@ -127,5 +161,7 @@ the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html .. _`React`: https://reactjs.org/ -.. _`the Symfony UX initiative`: https://ux.symfony.com/ -.. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html +.. _`Symfony UX initiative`: https://ux.symfony.com/ +.. _`Symfony UX React demo`: https://ux.symfony.com/react +.. _`Turbo`: https://turbo.hotwire.dev/ +.. _`@symfony/ux-react npm package`: https://www.npmjs.com/package/@symfony/ux-react diff --git a/src/React/phpunit.xml.dist b/src/React/phpunit.xml.dist index ba15dfc214b..55453659718 100644 --- a/src/React/phpunit.xml.dist +++ b/src/React/phpunit.xml.dist @@ -1,13 +1,18 @@ - + + + + ./src + + + @@ -23,9 +28,4 @@ - - - ./src - - diff --git a/src/React/src/Twig/ReactComponentExtension.php b/src/React/src/Twig/ReactComponentExtension.php index aabbbd9e12a..9780df3147b 100644 --- a/src/React/src/Twig/ReactComponentExtension.php +++ b/src/React/src/Twig/ReactComponentExtension.php @@ -45,15 +45,24 @@ public function getFunctions(): array ]; } - public function renderReactComponent(string $componentName, array $props = []): string + /** + * @param array $props + * @param array{permanent?: bool} $options + */ + public function renderReactComponent(string $componentName, array $props = [], array $options = []): string { - $params = ['component' => $componentName]; + $values = ['component' => $componentName]; if ($props) { - $params['props'] = $props; + $values['props'] = $props; + } + if ($options) { + if (\is_bool($permanent = $options['permanent'] ?? null)) { + $values['permanent'] = $permanent; + } } $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); - $stimulusAttributes->addController('@symfony/ux-react/react', $params); + $stimulusAttributes->addController('@symfony/ux-react/react', $values); return (string) $stimulusAttributes; } diff --git a/src/React/tests/Kernel/TwigAppKernel.php b/src/React/tests/Kernel/TwigAppKernel.php index cb1027ddde3..762c1bcbe87 100644 --- a/src/React/tests/Kernel/TwigAppKernel.php +++ b/src/React/tests/Kernel/TwigAppKernel.php @@ -31,7 +31,7 @@ public function registerBundles(): iterable return [new FrameworkBundle(), new StimulusBundle(), new TwigBundle(), new ReactBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); diff --git a/src/React/tests/Twig/ReactComponentExtensionTest.php b/src/React/tests/Twig/ReactComponentExtensionTest.php index 9fbf8c8b0a3..a522572f16b 100644 --- a/src/React/tests/Twig/ReactComponentExtensionTest.php +++ b/src/React/tests/Twig/ReactComponentExtensionTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\React\Tests; +namespace Symfony\UX\React\Tests\Twig; use PHPUnit\Framework\TestCase; use Symfony\UX\React\Tests\Kernel\TwigAppKernel; @@ -41,6 +41,39 @@ public function testRenderComponent() ); } + /** + * @dataProvider provideOptions + */ + public function testRenderComponentWithOptions(array $options, string|false $expected) + { + $kernel = new TwigAppKernel('test', true); + $kernel->boot(); + + /** @var ReactComponentExtension $extension */ + $extension = $kernel->getContainer()->get('test.twig.extension.react'); + + $rendered = $extension->renderReactComponent( + 'SubDir/MyComponent', + ['fullName' => 'Titouan Galopin'], + $options, + ); + + $this->assertStringContainsString('data-controller="symfony--ux-react--react" data-symfony--ux-react--react-component-value="SubDir/MyComponent" data-symfony--ux-react--react-props-value="{"fullName":"Titouan Galopin"}"', $rendered); + if (false === $expected) { + $this->assertStringNotContainsString('data-symfony--ux-react--react-permanent-value', $rendered); + } else { + $this->assertStringContainsString($expected, $rendered); + } + } + + public static function provideOptions(): iterable + { + yield 'permanent' => [['permanent' => true], 'data-symfony--ux-react--react-permanent-value="true"']; + yield 'not permanent' => [['permanent' => false], 'data-symfony--ux-react--react-permanent-value="false"']; + yield 'permanent not bool' => [['permanent' => 12345], false]; + yield 'no permanent' => [[], false]; + } + public function testRenderComponentWithoutProps() { $kernel = new TwigAppKernel('test', true); diff --git a/src/StimulusBundle/.gitattributes b/src/StimulusBundle/.gitattributes index 2b1d42ea804..b9bb8f6e796 100644 --- a/src/StimulusBundle/.gitattributes +++ b/src/StimulusBundle/.gitattributes @@ -1,7 +1,7 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /assets/src export-ignore /assets/test export-ignore +/doc export-ignore /phpunit.xml.dist export-ignore /tests export-ignore diff --git a/src/StimulusBundle/.github/PULL_REQUEST_TEMPLATE.md b/src/StimulusBundle/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/StimulusBundle/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/StimulusBundle/.github/workflows/close-pull-request.yml b/src/StimulusBundle/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/StimulusBundle/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/StimulusBundle/.gitignore b/src/StimulusBundle/.gitignore index d776edf227a..6e705b91ed7 100644 --- a/src/StimulusBundle/.gitignore +++ b/src/StimulusBundle/.gitignore @@ -1,5 +1,7 @@ -.php-cs-fixer.cache -.phpunit.result.cache -composer.lock -vendor/ -tests/fixtures/var +/assets/node_modules/ +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.result.cache + +/tests/fixtures/var diff --git a/src/StimulusBundle/CHANGELOG.md b/src/StimulusBundle/CHANGELOG.md index dc18f6526dc..46cc4512108 100644 --- a/src/StimulusBundle/CHANGELOG.md +++ b/src/StimulusBundle/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.20.1 + +- Normalize Stimulus controller name in event name + ## 2.14.2 - Fix bug with finding UX Packages with non-standard project structure diff --git a/src/StimulusBundle/assets/LICENSE b/src/StimulusBundle/assets/LICENSE new file mode 100644 index 00000000000..3ed9f412ce5 --- /dev/null +++ b/src/StimulusBundle/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/StimulusBundle/assets/README.md b/src/StimulusBundle/assets/README.md new file mode 100644 index 00000000000..41bdf682ba6 --- /dev/null +++ b/src/StimulusBundle/assets/README.md @@ -0,0 +1,15 @@ +# @symfony/stimulus-bundle + +JavaScript assets of the [symfony/stimulus-bundle](https://packagist.org/packages/symfony/stimulus-bundle) PHP package. + +## Installation + +Due to compatibility issues with JSDelivr causing the package not to work as expected, the package is not yet released on NPM. +Read more at [symfony/ux#2708](https://github.com/symfony/ux/issues/2708). + +## Resources + +- [Documentation](https://symfony.com/bundles/StimulusBundle/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/StimulusBundle/assets/dist/loader.js b/src/StimulusBundle/assets/dist/loader.js index 1d1ce80d04a..b2c49cfa2b7 100644 --- a/src/StimulusBundle/assets/dist/loader.js +++ b/src/StimulusBundle/assets/dist/loader.js @@ -25,18 +25,33 @@ class StimulusLazyControllerHandler { this.lazyLoadNewControllers(document.documentElement); } lazyLoadExistingControllers(element) { - this.queryControllerNamesWithin(element).forEach((controllerName) => this.loadLazyController(controllerName)); + Array.from(element.querySelectorAll(`[${controllerAttribute}]`)) + .flatMap(extractControllerNamesFrom) + .forEach((controllerName) => this.loadLazyController(controllerName)); } - async loadLazyController(name) { - if (canRegisterController(name, this.application)) { - if (this.lazyControllers[name] === undefined) { - return; - } - const controllerModule = await this.lazyControllers[name](); - registerController(name, controllerModule.default, this.application); + loadLazyController(name) { + if (!this.lazyControllers[name]) { + return; } + const controllerLoader = this.lazyControllers[name]; + delete this.lazyControllers[name]; + if (!canRegisterController(name, this.application)) { + return; + } + this.application.logDebugActivity(name, 'lazy:loading'); + controllerLoader() + .then((controllerModule) => { + this.application.logDebugActivity(name, 'lazy:loaded'); + registerController(name, controllerModule.default, this.application); + }) + .catch((error) => { + console.error(`Error loading controller "${name}":`, error); + }); } lazyLoadNewControllers(element) { + if (Object.keys(this.lazyControllers).length === 0) { + return; + } new MutationObserver((mutationsList) => { for (const { attributeName, target, type } of mutationsList) { switch (type) { @@ -58,9 +73,6 @@ class StimulusLazyControllerHandler { childList: true, }); } - queryControllerNamesWithin(element) { - return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)).flatMap(extractControllerNamesFrom); - } } function registerController(name, controller, application) { if (canRegisterController(name, application)) { diff --git a/src/StimulusBundle/assets/package.json b/src/StimulusBundle/assets/package.json index 03cf5905649..bd629fbce24 100644 --- a/src/StimulusBundle/assets/package.json +++ b/src/StimulusBundle/assets/package.json @@ -1,9 +1,26 @@ { "name": "@symfony/stimulus-bundle", "description": "Integration of @hotwired/stimulus into Symfony", - "version": "1.0.0", + "private": true, "license": "MIT", + "version": "2.26.1", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/stimulus", + "repository": "https://github.com/symfony/stimulus-bundle", + "type": "module", + "files": [ + "dist" + ], "main": "dist/loader.js", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "needsPackageAsADependency": false, "importmap": { @@ -13,6 +30,6 @@ }, "peerDependencies": { "@hotwired/stimulus": "^3.0.0", - "@symfony/stimulus-bridge": "^3.2.0" + "@symfony/stimulus-bridge": "^3.2.0 || ^4.0.0" } } diff --git a/src/StimulusBundle/assets/src/loader.ts b/src/StimulusBundle/assets/src/loader.ts index d937a61cb60..178214a6aad 100644 --- a/src/StimulusBundle/assets/src/loader.ts +++ b/src/StimulusBundle/assets/src/loader.ts @@ -14,11 +14,11 @@ */ import { Application, type ControllerConstructor } from '@hotwired/stimulus'; import { - eagerControllers, - lazyControllers, - isApplicationDebug, type EagerControllersCollection, type LazyControllersCollection, + eagerControllers, + isApplicationDebug, + lazyControllers, } from './controllers.js'; const controllerAttribute = 'data-controller'; @@ -64,22 +64,40 @@ class StimulusLazyControllerHandler { } private lazyLoadExistingControllers(element: Element) { - this.queryControllerNamesWithin(element).forEach((controllerName) => this.loadLazyController(controllerName)); + Array.from(element.querySelectorAll(`[${controllerAttribute}]`)) + .flatMap(extractControllerNamesFrom) + .forEach((controllerName) => this.loadLazyController(controllerName)); } - private async loadLazyController(name: string) { - if (canRegisterController(name, this.application)) { - if (this.lazyControllers[name] === undefined) { - return; - } + private loadLazyController(name: string) { + if (!this.lazyControllers[name]) { + return; + } - const controllerModule = await this.lazyControllers[name](); + // Delete the loader to avoid loading it twice + const controllerLoader = this.lazyControllers[name]; + delete this.lazyControllers[name]; - registerController(name, controllerModule.default, this.application); + if (!canRegisterController(name, this.application)) { + return; } + + this.application.logDebugActivity(name, 'lazy:loading'); + + controllerLoader() + .then((controllerModule) => { + this.application.logDebugActivity(name, 'lazy:loaded'); + registerController(name, controllerModule.default, this.application); + }) + .catch((error) => { + console.error(`Error loading controller "${name}":`, error); + }); } private lazyLoadNewControllers(element: Element) { + if (Object.keys(this.lazyControllers).length === 0) { + return; + } new MutationObserver((mutationsList) => { for (const { attributeName, target, type } of mutationsList) { switch (type) { @@ -107,10 +125,6 @@ class StimulusLazyControllerHandler { childList: true, }); } - - private queryControllerNamesWithin(element: Element): string[] { - return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)).flatMap(extractControllerNamesFrom); - } } function registerController(name: string, controller: ControllerConstructor, application: Application) { diff --git a/src/StimulusBundle/assets/test/loader.test.ts b/src/StimulusBundle/assets/test/loader.test.ts index cd7f884fe55..27fdb650713 100644 --- a/src/StimulusBundle/assets/test/loader.test.ts +++ b/src/StimulusBundle/assets/test/loader.test.ts @@ -1,9 +1,9 @@ +import { Application, Controller } from '@hotwired/stimulus'; +import { waitFor } from '@testing-library/dom'; // load from dist because the source TypeScript file points directly to controllers.js, // which does not actually exist in the source code import { loadControllers } from '../dist/loader'; -import { Application, Controller } from '@hotwired/stimulus'; import type { EagerControllersCollection, LazyControllersCollection } from '../src/controllers'; -import { waitFor } from '@testing-library/dom'; let isController1Initialized = false; let isController2Initialized = false; diff --git a/src/StimulusBundle/doc/index.rst b/src/StimulusBundle/doc/index.rst index 07702d2d25b..39e3aa484bb 100644 --- a/src/StimulusBundle/doc/index.rst +++ b/src/StimulusBundle/doc/index.rst @@ -8,7 +8,7 @@ StimulusBundle: Symfony integration with Stimulus This bundle adds integration between Symfony, `Stimulus`_ and the Symfony UX packages: * Twig ``stimulus_`` functions & filters to add Stimulus controllers, - actions & targets in your templates; + actions & targets in your templates; * Integration to load :ref:`UX Packages ` (extra Stimulus controllers) Installation @@ -100,33 +100,7 @@ common problems. StimulusBundle activates any 3rd party Stimulus controllers that are mentioned in your ``assets/controllers.json`` file. This file is updated whenever you install a UX package. -The official UX packages are: - -* `ux-autocomplete`_: Transform ``EntityType``, ``ChoiceType`` or *any* - `` - // ... + {# ... #} Backward Compatibility promise ------------------------------ @@ -298,3 +305,4 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`the Symfony UX initiative`: https://ux.symfony.com/ .. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html .. _Heroicons: https://heroicons.com/ +.. _`@symfony/ux-toggle-password npm package`: https://www.npmjs.com/package/@symfony/ux-toggle-password diff --git a/src/Toolkit/.gitattributes b/src/Toolkit/.gitattributes new file mode 100644 index 00000000000..81d9dbfaa9e --- /dev/null +++ b/src/Toolkit/.gitattributes @@ -0,0 +1,5 @@ +/.git* export-ignore +/.symfony.bundle.yaml export-ignore +/phpunit.xml.dist export-ignore +/doc export-ignore +/tests export-ignore diff --git a/src/Toolkit/.github/PULL_REQUEST_TEMPLATE.md b/src/Toolkit/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Toolkit/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Toolkit/.github/workflows/close-pull-request.yml b/src/Toolkit/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Toolkit/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Toolkit/.gitignore b/src/Toolkit/.gitignore new file mode 100644 index 00000000000..cf8b688b719 --- /dev/null +++ b/src/Toolkit/.gitignore @@ -0,0 +1,7 @@ +vendor +composer.lock +.phpunit.result.cache +var +.twig-cs-fixer.cache +tests/ui/output +tests/ui/screens diff --git a/src/Toolkit/.symfony.bundle.yaml b/src/Toolkit/.symfony.bundle.yaml new file mode 100644 index 00000000000..6d9a74acb76 --- /dev/null +++ b/src/Toolkit/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/src/Toolkit/CHANGELOG.md b/src/Toolkit/CHANGELOG.md new file mode 100644 index 00000000000..f19dba21ca7 --- /dev/null +++ b/src/Toolkit/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 2.25 + +- Package added diff --git a/src/Toolkit/LICENSE b/src/Toolkit/LICENSE new file mode 100644 index 00000000000..bc38d714ef6 --- /dev/null +++ b/src/Toolkit/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Toolkit/README.md b/src/Toolkit/README.md new file mode 100644 index 00000000000..bb6e5d0d96b --- /dev/null +++ b/src/Toolkit/README.md @@ -0,0 +1,16 @@ +# Symfony UX Toolkit + +**EXPERIMENTAL** This component is currently experimental and is +likely to change, or even change drastically. + +Symfony UX Toolkit provides a set of ready-to-use UI components for Symfony applications. + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-toolkit/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Toolkit/bin/ux-toolkit-kit-create b/src/Toolkit/bin/ux-toolkit-kit-create new file mode 100755 index 00000000000..6b08db63583 --- /dev/null +++ b/src/Toolkit/bin/ux-toolkit-kit-create @@ -0,0 +1,45 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if ('cli' !== \PHP_SAPI) { + throw new Exception('This script must be run from the command line.'); +} + +use Symfony\Component\Console\Application; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\Toolkit\Command\CreateKitCommand; + +function includeIfExists(string $file): bool +{ + return file_exists($file) && include $file; +} + +if ( + !includeIfExists(__DIR__ . '/../../../autoload.php') && + !includeIfExists(__DIR__ . '/../vendor/autoload.php') +) { + fwrite(STDERR, 'Install dependencies using Composer.'.PHP_EOL); + exit(1); +} + +if (!class_exists(Application::class)) { + fwrite(STDERR, 'You need the "symfony/console" component in order to run the UX Toolkit kit linter.'.PHP_EOL); + exit(1); +} + +$filesystem = new Filesystem(); + +(new Application())->add($command = new CreateKitCommand($filesystem)) + ->getApplication() + ->setDefaultCommand($command->getName(), true) + ->run() +; diff --git a/src/Toolkit/bin/ux-toolkit-kit-debug b/src/Toolkit/bin/ux-toolkit-kit-debug new file mode 100755 index 00000000000..f10d9edc0f1 --- /dev/null +++ b/src/Toolkit/bin/ux-toolkit-kit-debug @@ -0,0 +1,55 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if ('cli' !== \PHP_SAPI) { + throw new Exception('This script must be run from the command line.'); +} + +use Symfony\Component\Console\Application; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\Service\ServiceLocatorTrait; +use Symfony\Contracts\Service\ServiceProviderInterface; +use Symfony\UX\Toolkit\Command\DebugKitCommand; +use Symfony\UX\Toolkit\Kit\KitSynchronizer; +use Symfony\UX\Toolkit\Kit\KitFactory; +use Symfony\UX\Toolkit\Registry\GitHubRegistry; +use Symfony\UX\Toolkit\Registry\LocalRegistry; +use Symfony\UX\Toolkit\Registry\RegistryFactory; +use Symfony\UX\Toolkit\Registry\Type; + +function includeIfExists(string $file): bool +{ + return file_exists($file) && include $file; +} + +if ( + !includeIfExists(__DIR__ . '/../../../autoload.php') && + !includeIfExists(__DIR__ . '/../vendor/autoload.php') +) { + fwrite(STDERR, 'Install dependencies using Composer.'.PHP_EOL); + exit(1); +} + +if (!class_exists(Application::class)) { + fwrite(STDERR, 'You need the "symfony/console" component in order to run the UX Toolkit kit linter.'.PHP_EOL); + exit(1); +} + +$filesystem = new Filesystem(); +$kitFactory = new KitFactory($filesystem, new KitSynchronizer($filesystem)); + +(new Application())->add($command = new DebugKitCommand($kitFactory)) + ->getApplication() + ->setDefaultCommand($command->getName(), true) + ->run() +; diff --git a/src/Toolkit/composer.json b/src/Toolkit/composer.json new file mode 100644 index 00000000000..2f6d2ae7140 --- /dev/null +++ b/src/Toolkit/composer.json @@ -0,0 +1,78 @@ +{ + "name": "symfony/ux-toolkit", + "type": "symfony-bundle", + "description": "A tool to easily create a design system in your Symfony app with customizable, well-crafted Twig components", + "keywords": [ + "symfony-ux", + "twig", + "components" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Hugo Alliaume", + "email": "hugo@alliau.me" + }, + { + "name": "Jean-François LÊpine", + "email": "lepinejeanfrancois@gmail.com" + }, + { + "name": "Simon AndrÊ", + "email": "smn.andre@gmail.com" + } + ], + "require": { + "php": ">=8.1", + "twig/twig": "^3.0", + "symfony/console": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|^7.0", + "symfony/ux-twig-component": "^2.25.1", + "symfony/yaml": "^6.4|^7.0" + }, + "require-dev": { + "symfony/finder": "6.4|^7.0", + "twig/extra-bundle": "^3.19|^4.0", + "twig/html-extra": "^3.19", + "zenstruck/console-test": "^1.7", + "symfony/http-client": "6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/phpunit-bridge": "^7.2", + "vincentlanglet/twig-cs-fixer": "^3.5", + "spatie/phpunit-snapshot-assertions": "^4.2.17", + "phpunit/phpunit": "^9.6.22", + "symfony/ux-icons": "^2.18", + "tales-from-a-dev/twig-tailwind-extra": "^0.4.0" + }, + "bin": [ + "bin/ux-toolkit-kit-create", + "bin/ux-toolkit-kit-debug" + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Toolkit\\": "src" + }, + "exclude-from-classmap": [] + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Toolkit\\Tests\\": "tests/" + } + }, + "conflict": { + "symfony/ux-twig-component": "<2.21" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + } +} diff --git a/src/Toolkit/config/services.php b/src/Toolkit/config/services.php new file mode 100644 index 00000000000..3406e962806 --- /dev/null +++ b/src/Toolkit/config/services.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\UX\Toolkit\Command\DebugKitCommand; +use Symfony\UX\Toolkit\Command\InstallComponentCommand; +use Symfony\UX\Toolkit\Kit\KitContextRunner; +use Symfony\UX\Toolkit\Kit\KitFactory; +use Symfony\UX\Toolkit\Kit\KitSynchronizer; +use Symfony\UX\Toolkit\Registry\GitHubRegistry; +use Symfony\UX\Toolkit\Registry\LocalRegistry; +use Symfony\UX\Toolkit\Registry\RegistryFactory; +use Symfony\UX\Toolkit\Registry\Type; + +/* + * @author Hugo Alliaume + */ +return static function (ContainerConfigurator $container): void { + $container->services() + // Commands + + ->set('.ux_toolkit.command.debug_kit', DebugKitCommand::class) + ->args([ + service('.ux_toolkit.kit.kit_factory'), + ]) + ->tag('console.command') + + ->set('.ux_toolkit.command.install', InstallComponentCommand::class) + ->args([ + service('.ux_toolkit.registry.registry_factory'), + service('filesystem'), + ]) + ->tag('console.command') + + // Registry + + ->set('.ux_toolkit.registry.registry_factory', RegistryFactory::class) + ->args([ + service_locator([ + Type::Local->value => service('.ux_toolkit.registry.local'), + Type::GitHub->value => service('.ux_toolkit.registry.github'), + ]), + ]) + + ->set('.ux_toolkit.registry.local', LocalRegistry::class) + ->args([ + service('.ux_toolkit.kit.kit_factory'), + service('filesystem'), + ]) + + ->set('.ux_toolkit.registry.github', GitHubRegistry::class) + ->args([ + service('.ux_toolkit.kit.kit_factory'), + service('filesystem'), + service('http_client')->nullOnInvalid(), + ]) + + // Kit + + ->set('.ux_toolkit.kit.kit_factory', KitFactory::class) + ->args([ + service('filesystem'), + service('.ux_toolkit.kit.kit_synchronizer'), + ]) + + ->set('.ux_toolkit.kit.kit_synchronizer', KitSynchronizer::class) + ->args([ + service('filesystem'), + ]) + + ->set('ux_toolkit.kit.kit_context_runner', KitContextRunner::class) + ->public() + ->args([ + service('twig'), + service('ux.twig_component.component_factory'), + ]) + ; +}; diff --git a/src/Toolkit/doc/index.rst b/src/Toolkit/doc/index.rst new file mode 100644 index 00000000000..556ef1e669b --- /dev/null +++ b/src/Toolkit/doc/index.rst @@ -0,0 +1,140 @@ +Symfony UX Toolkit +================== + +**EXPERIMENTAL** This component is currently experimental and is likely +to change, or even change drastically. + +Symfony UX Toolkit provides a set of ready-to-use kits for Symfony applications. +It is part of `the Symfony UX initiative`_. + +Kits are a nice way to begin a new Symfony application, by providing a set +of `Twig components`_ (based on Tailwind CSS, but fully customizable depending +on your needs). + +Please note that the **UX Toolkit is not a library of UI components**, +but **a tool to help you build your own UI components**. +It uses the same approach than the popular `Shadcn UI`_, +and a similar approach than `Tailwind Plus`_. + +After installing the UX Toolkit, you can start pulling the components you need +from `UX Toolkit Kits`_, and use them in your project. +They become **your own components**, and **you can customize them as you want**. + +Additionally, some `Twig components`_ use ``html_cva`` and ``tailwind_merge``, +you can either remove them from your project or install ``twig/html-extra`` +and ``tales-from-a-dev/twig-tailwind-extra`` to use them. + +Also, we do not force you to use Tailwind CSS at all. You can use whatever +CSS framework you want, but you will need to adapt the UI components to it. + +Installation +------------ + +Install the UX Toolkit using Composer and Symfony Flex: + +.. code-block:: terminal + + # The UX Toolkit is a development dependency: + $ composer require --dev symfony/ux-toolkit + + # If you want to keep `html_cva` and `tailwind_merge` in your Twig components: + $ composer require twig/extra-bundle twig/html-extra:^3.12.0 tales-from-a-dev/twig-tailwind-extra + +Usage +----- + +You may find a list of components in the `UX Components page`_, with the installation instructions for each of them. + +For example, if you want to install a `Button` component, you will find the following instruction: + +.. code-block:: terminal + + $ php bin/console ux:toolkit:install-component Button --kit= + +It will create the ``templates/components/Button.html.twig`` file, and you will be able to use the `Button` component like this: + +.. code-block:: html+twig + + Click me + +Create your own kit +------------------- + +You have the ability to create and share your own kit with the community, +by using the ``php vendor/bin/ux-toolkit-kit-create`` command in a new GitHub repository: + +.. code-block:: terminal + + # Create your new project + $ mkdir my-ux-toolkit-kit + $ cd my-ux-toolkit-kit + + # Initialize your project + $ git init + $ composer init + + # Install the UX Toolkit + $ composer require --dev symfony/ux-toolkit + + # Create your kit + $ php vendor/bin/ux-toolkit-kit-create + + # ... edit the files, add your components, examples, etc. + + # Share your kit + $ git add . + $ git commit -m "Create my-kit UX Toolkit" + $ git branch -M main + $ git remote add origin git@github.com:my-username/my-ux-toolkit-kit.git + $ git push -u origin main + +Repository and kits structure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +After creating your kit, the repository should have the following structure: + +.. code-block:: text + + . + ├── docs + │ └── components + │ └── Button.twig + ├── manifest.json + └── templates + └── components + └── Button.html.twig + +A kit is composed of: + +- A ``manifest.json`` file, that describes the kit (name, license, homepage, authors, ...), +- A ``templates/components`` directory, that contains the Twig components, +- A ``docs/components`` directory, optional, that contains the documentation for each "root" Twig component. + +Using your kit +~~~~~~~~~~~~~~ + +Once your kit is published on GitHub, you can use it by specifying the ``--kit`` option when installing a component: + +.. code-block:: terminal + + $ php bin/console ux:toolkit:install-component Button --kit=github.com/my-username/my-ux-toolkit-kit + + # or for a specific version + $ php bin/console ux:toolkit:install-component Button --kit=github.com/my-username/my-ux-toolkit-kit:1.0.0 + +Backward Compatibility promise +------------------------------ + +This bundle aims at following the same Backward Compatibility promise as +the Symfony framework: +https://symfony.com/doc/current/contributing/code/bc.html + +However, the UI components and other files provided by the Toolkit **are not** covered by the Backward Compatibility +promise. +We may break them in patch or minor release, but you won't get impacted unless you re-install the same UI component. + +.. _`the Symfony UX initiative`: https://ux.symfony.com/ +.. _`Twig components`: https://symfony.com/bundles/ux-twig-component/current/index.html +.. _`UX Toolkit Kits`: https://ux.symfony.com/toolkit#kits +.. _`Shadcn UI`: https://ui.shadcn.com/ +.. _`Tailwind Plus`: https://tailwindcss.com/plus diff --git a/src/Toolkit/kits/shadcn/INSTALL.md b/src/Toolkit/kits/shadcn/INSTALL.md new file mode 100644 index 00000000000..8442bae9bcd --- /dev/null +++ b/src/Toolkit/kits/shadcn/INSTALL.md @@ -0,0 +1,102 @@ +# Getting started + +This kit provides ready-to-use and fully-customizable UI Twig components based on [Shadcn UI](https://ui.shadcn.com/) components's **design**. + +Please note that not every Shadcn UI component is available in this kit, but we are working on it! + +## Requirements + +This kit requires TailwindCSS to work: +- If you use Symfony AssetMapper, you can install TailwindCSS with the [TailwindBundle](https://symfony.com/bundles/TailwindBundle/current/index.html), +- If you use Webpack Encore, you can follow the [TailwindCSS installation guide for Symfony](https://tailwindcss.com/docs/installation/framework-guides/symfony) + +## Installation + +In your `assets/styles/app.css`, after the TailwindCSS imports, add the following code: + +```css +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.269 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); +} + +@layer base { + * { + border-color: var(--border); + outline-color: var(--ring); + } + + body { + background-color: var(--background); + color: var(--foreground); + } +} +``` + +And voilà! You are now ready to use Shadcn components in your Symfony project. diff --git a/src/Toolkit/kits/shadcn/docs/components/Alert.md b/src/Toolkit/kits/shadcn/docs/components/Alert.md new file mode 100644 index 00000000000..5317941445c --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Alert.md @@ -0,0 +1,47 @@ +# Alert + +A notification component that displays important messages with an icon, title, and description. + +```twig {"preview":true} + + + Heads up! + + You can add components to your app using the cli. + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + + Heads up! + + You can add components to your app using the cli. + + +``` + +### Destructive + +```twig {"preview":true} + + + Error + + Your session has expired. Please log in again. + + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/AspectRatio.md b/src/Toolkit/kits/shadcn/docs/components/AspectRatio.md new file mode 100644 index 00000000000..4c8e2d32865 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/AspectRatio.md @@ -0,0 +1,47 @@ +# AspectRatio + +A container that maintains a specific width-to-height ratio for its content. + +```twig {"preview":true,"height":"400px"} + + Landscape photograph by Tobias Tullius + +``` + +## Installation + + + +## Usage + + + +## Examples + +### With a 1 / 1 aspect ratio + +```twig {"preview":true,"height":"400px"} + + Landscape photograph by Tobias Tullius + +``` + +### With a 16 / 9 aspect ratio + +```twig {"preview":true,"height":"400px"} + + Landscape photograph by Tobias Tullius + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Avatar.md b/src/Toolkit/kits/shadcn/docs/components/Avatar.md new file mode 100644 index 00000000000..022831b0ba9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Avatar.md @@ -0,0 +1,56 @@ +# Avatar + +A circular element that displays a user's profile image or initials as a fallback. + +```twig {"preview":true} + + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Avatar with Image + +```twig {"preview":true} + + + +``` + +### Avatar with Text + +```twig {"preview":true} +
    + + FP + + + FP + +
    +``` + +### Avatar Group + +```twig {"preview":true} +
    + + + + + FP + + + FP + +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Badge.md b/src/Toolkit/kits/shadcn/docs/components/Badge.md new file mode 100644 index 00000000000..68c13efb739 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Badge.md @@ -0,0 +1,56 @@ +# Badge + +A small element that displays status, counts, or labels with optional icons. + +```twig {"preview":true} +Badge +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +Badge +``` + +### Secondary + +```twig {"preview":true} + + Badge + +``` + +### Outline + +```twig {"preview":true} + + Badge + +``` + +### Destructive + +```twig {"preview":true} + + Badge + +``` + +### With Icon + +```twig {"preview":true} + + + Verified + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md b/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md new file mode 100644 index 00000000000..680e7b8558f --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Breadcrumb.md @@ -0,0 +1,89 @@ +# Breadcrumb + +A navigation element that shows the current page's location in the site hierarchy with clickable links. + +```twig {"preview":true} + + + + Home + + + + Docs + + + + Components + + + + Breadcrumb + + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + + + Home + + + + Docs + + + + Components + + + + Breadcrumb + + + +``` + +### Custom Separator + +```twig {"preview":true} + + + + Home + + + + + + Docs + + + + + + Components + + + + + + Breadcrumb + + + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Button.md b/src/Toolkit/kits/shadcn/docs/components/Button.md new file mode 100644 index 00000000000..8dd9530b0ce --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Button.md @@ -0,0 +1,87 @@ +# Button + +A clickable element that triggers actions or events, supporting various styles and states. + +```twig {"preview":true} + + Click me + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + Click me + +``` + +### Primary + +```twig {"preview":true} +Button +``` + +### Secondary + +```twig {"preview":true} +Outline +``` + +### Destructive + +```twig {"preview":true} +Destructive +``` + +### Outline + +```twig {"preview":true} +Outline +``` + +### Ghost + +```twig {"preview":true} +Ghost +``` + +### Link + +```twig {"preview":true} +Link +``` + +### Icon + +```twig {"preview":true} + + + +``` + +### With Icon + +```twig {"preview":true} + + Login with Email + +``` + +### Loading + +```twig {"preview":true} + + Please wait + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Card.md b/src/Toolkit/kits/shadcn/docs/components/Card.md new file mode 100644 index 00000000000..f482b75f2e4 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Card.md @@ -0,0 +1,84 @@ +# Card + +A container that groups related content and actions into a box with optional header, content, and footer sections. + +```twig {"preview":true,"height":"300px"} + + + Card Title + Card Description + + +

    Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.

    +
    + + Cancel + Action + +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true,"height":"300px"} + + + Card Title + Card Description + + +

    Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.

    +
    + + Cancel + Action + +
    +``` + +### With Notifications + +```twig {"preview":true,"height":"400px"} +{% set notifications = [ + { title: "Your call has been confirmed.", description: "1 hour ago"}, + { title: "You have a new message!", description: "1 hour ago"}, + { title: "Your subscription is expiring soon!", description: "2 hours ago" }, +] %} + + + Notifications + You have 3 unread messages. + + + {%- for notification in notifications -%} +
    + +
    +

    + {{ notification.title }} +

    +

    + {{ notification.description }} +

    +
    +
    + {%- endfor -%} +
    + + + + Mark all as read + + +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Checkbox.md b/src/Toolkit/kits/shadcn/docs/components/Checkbox.md new file mode 100644 index 00000000000..40da5fee4d0 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Checkbox.md @@ -0,0 +1,51 @@ +# Checkbox + +A form control that allows the user to toggle between checked and unchecked states. + +```twig {"preview":true} +
    + + +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +
    + + +
    +``` + +### With Label Component + +```twig {"preview":true} +
    + + Accept terms and conditions +
    +``` + +### Disabled + +```twig {"preview":true} +
    + + Accept terms and conditions +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Input.md b/src/Toolkit/kits/shadcn/docs/components/Input.md new file mode 100644 index 00000000000..de0b816a9d6 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Input.md @@ -0,0 +1,56 @@ +# Input + +A form control that allows users to enter text, numbers, or select files. + +```twig {"preview":true} + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + +``` + +### File + +```twig {"preview":true} +
    + + +
    +``` + +### Disabled + +```twig {"preview":true} + +``` + +### With Label + +```twig {"preview":true} +
    + Email + +
    +``` + +### With Button + +```twig {"preview":true} +
    + + Subscribe +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Label.md b/src/Toolkit/kits/shadcn/docs/components/Label.md new file mode 100644 index 00000000000..ea41f854987 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Label.md @@ -0,0 +1,47 @@ +# Label + +A text element that identifies form controls and other content. + +```twig {"preview":true} +
    + + Accept terms and conditions +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +
    + + Accept terms and conditions +
    +``` + +### With Input + +```twig {"preview":true} +
    + Email + +
    +``` + +### Required Field + +```twig {"preview":true} +
    + Email + +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Pagination.md b/src/Toolkit/kits/shadcn/docs/components/Pagination.md new file mode 100644 index 00000000000..0efbea5159f --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Pagination.md @@ -0,0 +1,101 @@ +# Pagination + +A navigation component that displays page numbers and controls for moving between pages. + +```twig {"preview":true} + + + + + + + 1 + + + 2 + + + 3 + + + + + + + + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + + + + + + 1 + + + 2 + + + 3 + + + + + + + + + +``` + +### Symmetric + +```twig {"preview":true} + + + + + + + 1 + + + + + + 4 + + + 5 + + + 6 + + + + + + 9 + + + + + + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Progress.md b/src/Toolkit/kits/shadcn/docs/components/Progress.md new file mode 100644 index 00000000000..c2c89234a73 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Progress.md @@ -0,0 +1,47 @@ +# Progress + +A visual indicator that shows the completion status of a task or operation. + +```twig {"preview":true} + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + +``` + +### With Label + +```twig {"preview":true} +
    +
    + Loading + 33% +
    + +
    +``` + +### Different Values + +```twig {"preview":true} +
    + + + + + +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Select.md b/src/Toolkit/kits/shadcn/docs/components/Select.md new file mode 100644 index 00000000000..91941c0fafe --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Select.md @@ -0,0 +1,54 @@ +# Select + +A dropdown control that allows users to choose from a list of options. + +```twig {"preview":true} + + + + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + + + + + +``` + +### With Label + +```twig {"preview":true} +
    + Framework + + + + + +
    +``` + +### Disabled + +```twig {"preview":true} + + + + + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Separator.md b/src/Toolkit/kits/shadcn/docs/components/Separator.md new file mode 100644 index 00000000000..038197b45df --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Separator.md @@ -0,0 +1,65 @@ +# Separator + +A visual divider that creates space between content elements, available in horizontal and vertical orientations. + +```twig {"preview":true} +
    +
    +

    Symfony UX

    +

    + Symfony UX initiative: a JavaScript ecosystem for Symfony +

    +
    + +
    + Website + + Packages + + Source +
    +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +
    +
    +

    Symfony UX

    +

    + Symfony UX initiative: a JavaScript ecosystem for Symfony +

    +
    + +
    +
    Blog
    + +
    Docs
    + +
    Source
    +
    +
    +``` + +### Vertical + +```twig {"preview":true} +
    +
    Blog
    + +
    Docs
    + +
    Source
    +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Skeleton.md b/src/Toolkit/kits/shadcn/docs/components/Skeleton.md new file mode 100644 index 00000000000..4c16a2c64bc --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Skeleton.md @@ -0,0 +1,47 @@ +# Skeleton + +A placeholder element that displays a loading state with an animated background. + +```twig {"preview":true} +
    + +
    + + +
    +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### User + +```twig {"preview":true} +
    + +
    + + +
    +
    +``` + +### Card + +```twig {"preview":true,"height":"250px"} +
    + +
    + + +
    +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Switch.md b/src/Toolkit/kits/shadcn/docs/components/Switch.md new file mode 100644 index 00000000000..17fa899f49f --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Switch.md @@ -0,0 +1,53 @@ +# Switch + +A toggle control that switches between on and off states. + +```twig {"preview":true} +
    + + Airplane Mode +
    +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} +
    + + Airplane Mode +
    +``` + +### Form + +```twig {"preview":true,"height":"300px"} +
    +

    Email Notifications

    +
    +
    +
    + Marketing emails +

    Receive emails about new products, features, and more.

    +
    + +
    +
    +
    + Security emails +

    Receive emails about your account security.

    +
    + +
    +
    +
    +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Table.md b/src/Toolkit/kits/shadcn/docs/components/Table.md new file mode 100644 index 00000000000..302d6bc91e2 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Table.md @@ -0,0 +1,83 @@ +# Table + +A structured grid element that organizes data into rows and columns, supporting headers, captions, and footers. + +```twig {"preview":true,"height":"400px"} +{%- set invoices = [ + { invoice: "INV001", paymentStatus: "Paid", totalAmount: "$250.00", paymentMethod: "Credit Card" }, + { invoice: "INV002", paymentStatus: "Pending", totalAmount: "$150.00", paymentMethod: "PayPal" }, + { invoice: "INV003", paymentStatus: "Unpaid", totalAmount: "$350.00", paymentMethod: "Bank Transfer" }, +] -%} + + A list of your recent invoices. + + + Invoice + Status + Method + Amount + + + + {% for invoice in invoices %} + + {{ invoice.invoice }} + {{ invoice.paymentStatus }} + {{ invoice.paymentMethod }} + {{ invoice.totalAmount }} + + {% endfor %} + + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Basic Table + +```twig {"preview":true,"height":"550px"} +{%- set invoices = [ + { invoice: "INV001", paymentStatus: "Paid", totalAmount: "$250.00", paymentMethod: "Credit Card" }, + { invoice: "INV002", paymentStatus: "Pending", totalAmount: "$150.00", paymentMethod: "PayPal" }, + { invoice: "INV003", paymentStatus: "Unpaid", totalAmount: "$350.00", paymentMethod: "Bank Transfer" }, + { invoice: "INV004", paymentStatus: "Paid", totalAmount: "$450.00", paymentMethod: "Credit Card" }, + { invoice: "INV005", paymentStatus: "Paid", totalAmount: "$550.00", paymentMethod: "PayPal" }, + { invoice: "INV006", paymentStatus: "Pending", totalAmount: "$200.00", paymentMethod: "Bank Transfer" }, + { invoice: "INV007", paymentStatus: "Unpaid", totalAmount: "$300.00", paymentMethod: "Credit Card" }, +] -%} + + A list of your recent invoices. + + + Invoice + Status + Method + Amount + + + + {% for invoice in invoices %} + + {{ invoice.invoice }} + {{ invoice.paymentStatus }} + {{ invoice.paymentMethod }} + {{ invoice.totalAmount }} + + {% endfor %} + + + + Total + $1,500.00 + + + +``` diff --git a/src/Toolkit/kits/shadcn/docs/components/Textarea.md b/src/Toolkit/kits/shadcn/docs/components/Textarea.md new file mode 100644 index 00000000000..b9babc362c9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/docs/components/Textarea.md @@ -0,0 +1,38 @@ +# Textarea + +A form control for entering multiple lines of text. + +```twig {"preview":true} + +``` + +## Installation + + + +## Usage + + + +## Examples + +### Default + +```twig {"preview":true} + +``` + +### With Label + +```twig {"preview":true} +
    + + +
    +``` + +### Disabled + +```twig {"preview":true} + +``` diff --git a/src/Toolkit/kits/shadcn/manifest.json b/src/Toolkit/kits/shadcn/manifest.json new file mode 100644 index 00000000000..467d20a2786 --- /dev/null +++ b/src/Toolkit/kits/shadcn/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Shadcn UI", + "description": "Component based on the Shadcn UI library, one of the most popular design systems in JavaScript world.", + "license": "MIT", + "homepage": "https://ux.symfony.com/components" +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig b/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig new file mode 100644 index 00000000000..d5ae97b9894 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Alert.html.twig @@ -0,0 +1,18 @@ +{%- props variant = 'default' -%} +{%- set style = html_cva( + base: 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, +) -%} + + diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert.meta.json b/src/Toolkit/kits/shadcn/templates/components/Alert.meta.json new file mode 100644 index 00000000000..9e08e59b32e --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Alert.meta.json @@ -0,0 +1,17 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "twig/extra-bundle" + }, + { + "type": "php", + "package": "twig/html-extra:^3.12.0" + }, + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig b/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig new file mode 100644 index 00000000000..712d8722850 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Alert/Description.html.twig @@ -0,0 +1,6 @@ +

    + {%- block content %}{% endblock -%} +

    diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert/Description.meta.json b/src/Toolkit/kits/shadcn/templates/components/Alert/Description.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Alert/Description.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert/Title.html.twig b/src/Toolkit/kits/shadcn/templates/components/Alert/Title.html.twig new file mode 100644 index 00000000000..5a47394168a --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Alert/Title.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Alert/Title.meta.json b/src/Toolkit/kits/shadcn/templates/components/Alert/Title.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Alert/Title.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/AspectRatio.html.twig b/src/Toolkit/kits/shadcn/templates/components/AspectRatio.html.twig new file mode 100644 index 00000000000..f10b04ff765 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/AspectRatio.html.twig @@ -0,0 +1,7 @@ +{%- props ratio, style = '' -%} +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/AspectRatio.meta.json b/src/Toolkit/kits/shadcn/templates/components/AspectRatio.meta.json new file mode 100644 index 00000000000..3c7c094fde0 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/AspectRatio.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "twig/extra-bundle" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar.html.twig b/src/Toolkit/kits/shadcn/templates/components/Avatar.html.twig new file mode 100644 index 00000000000..40fd86e29fa --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar.meta.json b/src/Toolkit/kits/shadcn/templates/components/Avatar.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig b/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig new file mode 100644 index 00000000000..0b1dcac2e93 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.html.twig @@ -0,0 +1,4 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.meta.json b/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar/Image.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.html.twig b/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.html.twig new file mode 100644 index 00000000000..0422495a095 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.meta.json b/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Avatar/Text.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Badge.html.twig b/src/Toolkit/kits/shadcn/templates/components/Badge.html.twig new file mode 100644 index 00000000000..aa53c0920aa --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Badge.html.twig @@ -0,0 +1,18 @@ +{%- props variant = 'default', outline = false -%} +{%- set style = html_cva( + base: 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, +) -%} +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Badge.meta.json b/src/Toolkit/kits/shadcn/templates/components/Badge.meta.json new file mode 100644 index 00000000000..9e08e59b32e --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Badge.meta.json @@ -0,0 +1,17 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "twig/extra-bundle" + }, + { + "type": "php", + "package": "twig/html-extra:^3.12.0" + }, + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb.html.twig new file mode 100644 index 00000000000..8681b5131ee --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb.html.twig @@ -0,0 +1,3 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb.meta.json b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb.meta.json new file mode 100644 index 00000000000..079eea5bb20 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb.meta.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.html.twig new file mode 100644 index 00000000000..32aa63b39eb --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.html.twig @@ -0,0 +1,13 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.meta.json b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Ellipsis.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Item.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Item.html.twig new file mode 100644 index 00000000000..e3e28dedda9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Item.html.twig @@ -0,0 +1,6 @@ +
  • + {%- block content %}{% endblock -%} +
  • diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Item.meta.json b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Item.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Item.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Link.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Link.html.twig new file mode 100644 index 00000000000..42cfe143bb2 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Link.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Link.meta.json b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Link.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Link.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/List.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/List.html.twig new file mode 100644 index 00000000000..f21230fc2c9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/List.html.twig @@ -0,0 +1,6 @@ +
      + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/List.meta.json b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/List.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/List.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Page.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Page.html.twig new file mode 100644 index 00000000000..a1c8da7102e --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Page.html.twig @@ -0,0 +1,9 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Page.meta.json b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Page.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Page.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Separator.html.twig b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Separator.html.twig new file mode 100644 index 00000000000..d06cb0c2a3a --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Separator.html.twig @@ -0,0 +1,10 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Separator.meta.json b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Separator.meta.json new file mode 100644 index 00000000000..19987b2a1b8 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Breadcrumb/Separator.meta.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "symfony/ux-icons" + }, + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Button.html.twig b/src/Toolkit/kits/shadcn/templates/components/Button.html.twig new file mode 100644 index 00000000000..45592a2f6cf --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Button.html.twig @@ -0,0 +1,27 @@ +{%- props variant = 'default', outline = false, size = 'default', as = 'button' -%} +{%- set style = html_cva( + base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, +) -%} + +<{{ as }} + class="{{ style.apply({variant, outline, size}, attributes.render('class'))|tailwind_merge }}" + {{ attributes }} +> + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Button.meta.json b/src/Toolkit/kits/shadcn/templates/components/Button.meta.json new file mode 100644 index 00000000000..9e08e59b32e --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Button.meta.json @@ -0,0 +1,17 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "twig/extra-bundle" + }, + { + "type": "php", + "package": "twig/html-extra:^3.12.0" + }, + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Card.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card.html.twig new file mode 100644 index 00000000000..96ef038885d --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card.meta.json b/src/Toolkit/kits/shadcn/templates/components/Card.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Content.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Content.html.twig new file mode 100644 index 00000000000..8e73fefbc9d --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Content.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Content.meta.json b/src/Toolkit/kits/shadcn/templates/components/Card/Content.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Content.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Description.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Description.html.twig new file mode 100644 index 00000000000..4b8c5e669cb --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Description.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Description.meta.json b/src/Toolkit/kits/shadcn/templates/components/Card/Description.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Description.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Footer.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Footer.html.twig new file mode 100644 index 00000000000..a8e9cdc0999 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Footer.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Footer.meta.json b/src/Toolkit/kits/shadcn/templates/components/Card/Footer.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Footer.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Header.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Header.html.twig new file mode 100644 index 00000000000..171ad5752b9 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Header.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Header.meta.json b/src/Toolkit/kits/shadcn/templates/components/Card/Header.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Header.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Title.html.twig b/src/Toolkit/kits/shadcn/templates/components/Card/Title.html.twig new file mode 100644 index 00000000000..e7460a85df8 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Title.html.twig @@ -0,0 +1,6 @@ +
    + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Card/Title.meta.json b/src/Toolkit/kits/shadcn/templates/components/Card/Title.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Card/Title.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Checkbox.html.twig b/src/Toolkit/kits/shadcn/templates/components/Checkbox.html.twig new file mode 100644 index 00000000000..69730f09be1 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Checkbox.html.twig @@ -0,0 +1,5 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Checkbox.meta.json b/src/Toolkit/kits/shadcn/templates/components/Checkbox.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Checkbox.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Input.html.twig b/src/Toolkit/kits/shadcn/templates/components/Input.html.twig new file mode 100644 index 00000000000..cc3c21c4305 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Input.html.twig @@ -0,0 +1,6 @@ +{%- props type = 'text' -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Input.meta.json b/src/Toolkit/kits/shadcn/templates/components/Input.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Input.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Label.html.twig b/src/Toolkit/kits/shadcn/templates/components/Label.html.twig new file mode 100644 index 00000000000..be0d3058264 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Label.html.twig @@ -0,0 +1,6 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Label.meta.json b/src/Toolkit/kits/shadcn/templates/components/Label.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Label.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig new file mode 100644 index 00000000000..3c746f7669d --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination.html.twig @@ -0,0 +1,7 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination.meta.json b/src/Toolkit/kits/shadcn/templates/components/Pagination.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.html.twig new file mode 100644 index 00000000000..9034e2a9b72 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.html.twig @@ -0,0 +1,5 @@ +
      + {%- block content %}{% endblock -%} +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.meta.json b/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Content.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.html.twig new file mode 100644 index 00000000000..417c0baa941 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.html.twig @@ -0,0 +1,8 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.meta.json b/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.meta.json new file mode 100644 index 00000000000..19987b2a1b8 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Ellipsis.meta.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "symfony/ux-icons" + }, + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.html.twig new file mode 100644 index 00000000000..1029344f0ea --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.html.twig @@ -0,0 +1,3 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.meta.json b/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.meta.json new file mode 100644 index 00000000000..d39de6e07e6 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Item.meta.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.html.twig new file mode 100644 index 00000000000..86c8adc46fe --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.html.twig @@ -0,0 +1,10 @@ +{%- props isActive = false, size = 'icon' -%} + + {{- block(outerBlocks.content) -}} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.meta.json b/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.meta.json new file mode 100644 index 00000000000..d39de6e07e6 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Link.meta.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.html.twig new file mode 100644 index 00000000000..53bf8ade927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.html.twig @@ -0,0 +1,9 @@ + + Next + + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.meta.json b/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.meta.json new file mode 100644 index 00000000000..19987b2a1b8 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Next.meta.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "symfony/ux-icons" + }, + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.html.twig b/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.html.twig new file mode 100644 index 00000000000..e2f693c8701 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.html.twig @@ -0,0 +1,9 @@ + + + Previous + diff --git a/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.meta.json b/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.meta.json new file mode 100644 index 00000000000..19987b2a1b8 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Pagination/Previous.meta.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "symfony/ux-icons" + }, + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Progress.html.twig b/src/Toolkit/kits/shadcn/templates/components/Progress.html.twig new file mode 100644 index 00000000000..8f9bcc5ba79 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Progress.html.twig @@ -0,0 +1,12 @@ +{%- props value = 0 -%} + +
    +
    +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Progress.meta.json b/src/Toolkit/kits/shadcn/templates/components/Progress.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Progress.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Select.html.twig b/src/Toolkit/kits/shadcn/templates/components/Select.html.twig new file mode 100644 index 00000000000..b3c11aa0d8a --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Select.html.twig @@ -0,0 +1,6 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Select.meta.json b/src/Toolkit/kits/shadcn/templates/components/Select.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Select.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig b/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig new file mode 100644 index 00000000000..666c858cb7e --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Separator.html.twig @@ -0,0 +1,18 @@ +{%- props orientation = 'horizontal', decorative = true -%} +{%- set style = html_cva( + base: 'shrink-0 bg-border', + variants: { + orientation: { + horizontal: 'h-[1px] w-full', + vertical: 'h-full w-[1px]', + }, + }, +) -%} +
    +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Separator.meta.json b/src/Toolkit/kits/shadcn/templates/components/Separator.meta.json new file mode 100644 index 00000000000..9e08e59b32e --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Separator.meta.json @@ -0,0 +1,17 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "twig/extra-bundle" + }, + { + "type": "php", + "package": "twig/html-extra:^3.12.0" + }, + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig b/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig new file mode 100644 index 00000000000..22e0e3612c8 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Skeleton.html.twig @@ -0,0 +1,4 @@ +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Skeleton.meta.json b/src/Toolkit/kits/shadcn/templates/components/Skeleton.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Skeleton.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig b/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig new file mode 100644 index 00000000000..d228265d0fe --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Switch.html.twig @@ -0,0 +1,4 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Switch.meta.json b/src/Toolkit/kits/shadcn/templates/components/Switch.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Switch.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table.html.twig new file mode 100644 index 00000000000..b9f3399d446 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table.html.twig @@ -0,0 +1,8 @@ +
    + + {%- block content %}{% endblock -%} +
    +
    diff --git a/src/Toolkit/kits/shadcn/templates/components/Table.meta.json b/src/Toolkit/kits/shadcn/templates/components/Table.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig new file mode 100644 index 00000000000..a9c34280da5 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Body.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Body.meta.json b/src/Toolkit/kits/shadcn/templates/components/Table/Body.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Body.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig new file mode 100644 index 00000000000..e737398c135 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Caption.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Caption.meta.json b/src/Toolkit/kits/shadcn/templates/components/Table/Caption.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Caption.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig new file mode 100644 index 00000000000..51fe7c95af0 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Cell.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Cell.meta.json b/src/Toolkit/kits/shadcn/templates/components/Table/Cell.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Cell.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig new file mode 100644 index 00000000000..5e4a5628e6e --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Footer.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Footer.meta.json b/src/Toolkit/kits/shadcn/templates/components/Table/Footer.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Footer.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig new file mode 100644 index 00000000000..bfa630a91f3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Head.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Head.meta.json b/src/Toolkit/kits/shadcn/templates/components/Table/Head.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Head.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig new file mode 100644 index 00000000000..94aee678f4b --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Header.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Header.meta.json b/src/Toolkit/kits/shadcn/templates/components/Table/Header.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Header.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig b/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig new file mode 100644 index 00000000000..e58858a2053 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Row.html.twig @@ -0,0 +1,6 @@ + + {%- block content %}{% endblock -%} + diff --git a/src/Toolkit/kits/shadcn/templates/components/Table/Row.meta.json b/src/Toolkit/kits/shadcn/templates/components/Table/Row.meta.json new file mode 100644 index 00000000000..99e25114927 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Table/Row.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig b/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig new file mode 100644 index 00000000000..317a57e1774 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Textarea.html.twig @@ -0,0 +1,4 @@ + diff --git a/src/Toolkit/kits/shadcn/templates/components/Textarea.meta.json b/src/Toolkit/kits/shadcn/templates/components/Textarea.meta.json new file mode 100644 index 00000000000..d50410b06b3 --- /dev/null +++ b/src/Toolkit/kits/shadcn/templates/components/Textarea.meta.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../../../schemas/component.json", + "dependencies": [ + { + "type": "php", + "package": "tales-from-a-dev/twig-tailwind-extra" + } + ] +} diff --git a/src/Toolkit/phpunit.xml.dist b/src/Toolkit/phpunit.xml.dist new file mode 100644 index 00000000000..0a4c3bed992 --- /dev/null +++ b/src/Toolkit/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + tests + + + + + + + + src + + + diff --git a/src/Toolkit/schemas/component.json b/src/Toolkit/schemas/component.json new file mode 100644 index 00000000000..85749807f22 --- /dev/null +++ b/src/Toolkit/schemas/component.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Component Meta Schema", + "type": "object", + "required": ["dependencies"], + "properties": { + "dependencies": { + "type": "array", + "description": "List of dependencies required by the component", + "items": { + "oneOf": [ + { + "type": "object", + "required": ["type", "package"], + "properties": { + "type": { + "type": "string", + "enum": ["php"], + "description": "PHP package dependency" + }, + "package": { + "type": "string", + "description": "Package name and optional version constraint" + } + } + } + ] + } + } + } +} diff --git a/src/Toolkit/src/Assert.php b/src/Toolkit/src/Assert.php new file mode 100644 index 00000000000..70dcb33cc4d --- /dev/null +++ b/src/Toolkit/src/Assert.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit; + +final class Assert +{ + /** + * Assert that the kit name is valid (ex: "Shadcn", "Tailwind", "Bootstrap", etc.). + * + * @param non-empty-string $name + * + * @throws \InvalidArgumentException if the kit name is invalid + */ + public static function kitName(string $name): void + { + if (1 !== preg_match('/^[a-zA-Z0-9](?:[a-zA-Z0-9-_ ]{0,61}[a-zA-Z0-9])?$/', $name)) { + throw new \InvalidArgumentException(\sprintf('Invalid kit name "%s".', $name)); + } + } + + /** + * Assert that the component name is valid (ex: "Button", "Input", "Card", "Card:Header", etc.). + * + * @param non-empty-string $name + * + * @throws \InvalidArgumentException if the component name is invalid + */ + public static function componentName(string $name): void + { + if (1 !== preg_match('/^[A-Z][a-zA-Z0-9]*(?::[A-Z][a-zA-Z0-9]*)*$/', $name)) { + throw new \InvalidArgumentException(\sprintf('Invalid component name "%s".', $name)); + } + } + + /** + * Assert that the PHP package name is valid (ex: "twig/html-extra", "symfony/framework-bundle", etc.). + * + * @param non-empty-string $name + * + * @throws \InvalidArgumentException if the PHP package name is invalid + */ + public static function phpPackageName(string $name): void + { + // Taken from https://github.com/composer/composer/blob/main/res/composer-schema.json + if (1 !== preg_match('/^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$/', $name)) { + throw new \InvalidArgumentException(\sprintf('Invalid PHP package name "%s".', $name)); + } + } + + public static function stimulusControllerName(string $name): void + { + if (1 !== preg_match('/^[a-z][a-z0-9-]*[a-z0-9]$/', $name)) { + throw new \InvalidArgumentException(\sprintf('Invalid Stimulus controller name "%s".', $name)); + } + } +} diff --git a/src/Toolkit/src/Asset/Component.php b/src/Toolkit/src/Asset/Component.php new file mode 100644 index 00000000000..8e6ee0868af --- /dev/null +++ b/src/Toolkit/src/Asset/Component.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Asset; + +use Symfony\UX\Toolkit\Assert; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\DependencyInterface; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\File\ComponentMeta; +use Symfony\UX\Toolkit\File\Doc; +use Symfony\UX\Toolkit\File\File; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class Component +{ + /** + * @param non-empty-string $name + * @param list $files + */ + public function __construct( + public readonly string $name, + public readonly array $files, + public ?Doc $doc = null, + public ?ComponentMeta $meta = null, + private array $dependencies = [], + ) { + Assert::componentName($name); + + if ([] === $files) { + throw new \InvalidArgumentException(\sprintf('The component "%s" must have at least one file.', $name)); + } + + foreach ($this->meta?->dependencies ?? [] as $dependency) { + $this->addDependency($dependency); + } + } + + public function addDependency(DependencyInterface $dependency): void + { + foreach ($this->dependencies as $i => $existingDependency) { + if ($existingDependency instanceof PhpPackageDependency && $existingDependency->name === $dependency->name) { + if ($existingDependency->isHigherThan($dependency)) { + return; + } + + $this->dependencies[$i] = $dependency; + + return; + } + + if ($existingDependency instanceof ComponentDependency && $existingDependency->name === $dependency->name) { + return; + } + + if ($existingDependency instanceof StimulusControllerDependency && $existingDependency->name === $dependency->name) { + return; + } + } + + $this->dependencies[] = $dependency; + } + + /** + * @return list + */ + public function getDependencies(): array + { + return $this->dependencies; + } + + public function hasDependency(DependencyInterface $dependency): bool + { + foreach ($this->dependencies as $existingDependency) { + if ($existingDependency->isEquivalentTo($dependency)) { + return true; + } + } + + return false; + } +} diff --git a/src/Toolkit/src/Asset/StimulusController.php b/src/Toolkit/src/Asset/StimulusController.php new file mode 100644 index 00000000000..94db7e19a89 --- /dev/null +++ b/src/Toolkit/src/Asset/StimulusController.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Asset; + +use Symfony\UX\Toolkit\Assert; +use Symfony\UX\Toolkit\File\File; + +/** + * @internal + * + * @author Hugo Alliaume + */ +class StimulusController +{ + /** + * @param non-empty-string $name + * @param list $files + */ + public function __construct( + public readonly string $name, + public readonly array $files, + ) { + Assert::stimulusControllerName($this->name); + + if ([] === $files) { + throw new \InvalidArgumentException(\sprintf('Stimulus controller "%s" has no files.', $name)); + } + } +} diff --git a/src/Toolkit/src/Command/CreateKitCommand.php b/src/Toolkit/src/Command/CreateKitCommand.php new file mode 100644 index 00000000000..1ab720be848 --- /dev/null +++ b/src/Toolkit/src/Command/CreateKitCommand.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\UX\Toolkit\Assert; + +/** + * @author Hugo Alliaume + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:create-kit', + description: 'Create a new kit', + hidden: true, +)] +class CreateKitCommand extends Command +{ + public function __construct( + private readonly Filesystem $filesystem, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + // Get the kit name + $question = new Question("What's the name of your kit?"); + $question->setValidator(function (?string $value) { + if (empty($value)) { + throw new \RuntimeException('Kit name cannot be empty.'); + } + Assert::kitName($value); + + return $value; + }); + $kitName = $io->askQuestion($question); + + // Get the kit homepage + $question = new Question("What's the Homepage URL of your kit?"); + $question->setValidator(function (?string $value) { + if (empty($value) || !filter_var($value, \FILTER_VALIDATE_URL)) { + throw new \Exception('The homepage URL must be valid.'); + } + + return $value; + }); + $kitHomepage = $io->askQuestion($question); + + // Get the kit license + $question = new Question('What is the license of your kit?'); + $question->setValidator(function (string $value) { + if (empty($value)) { + throw new \Exception('The license cannot be empty.'); + } + + return $value; + }); + $kitLicense = $io->askQuestion($question); + + // Create the kit + $this->filesystem->dumpFile('manifest.json', json_encode([ + 'name' => $kitName, + 'homepage' => $kitHomepage, + 'license' => $kitLicense, + ], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); + $this->filesystem->dumpFile('templates/components/Button.html.twig', << + {%- block content %}{% endblock -%} + +TWIG + ); + $this->filesystem->dumpFile('docs/components/Button.md', << + Click me + +``` + +## Examples + +### Button with Variants + +```twig +Default +Secondary +``` + +MARKDOWN + ); + $this->filesystem->dumpFile('docs/components/Button.meta.json', json_encode([ + '$schema' => '../vendor/symfony/ux-toolkit/schemas/component.schema.json', + 'dependencies' => (object) [], + ], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); + + $io->success('Your kit has been scaffolded, enjoy!'); + + return self::SUCCESS; + } +} diff --git a/src/Toolkit/src/Command/DebugKitCommand.php b/src/Toolkit/src/Command/DebugKitCommand.php new file mode 100644 index 00000000000..6a79cb84d71 --- /dev/null +++ b/src/Toolkit/src/Command/DebugKitCommand.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Helper\TableSeparator; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Kit\KitFactory; + +/** + * @author Jean-François LÊpine + * @author Hugo Alliaume + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:debug-kit', + description: 'Debug a local Kit.', + hidden: true, +)] +class DebugKitCommand extends Command +{ + public function __construct( + private readonly KitFactory $kitFactory, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('kit-path', InputArgument::OPTIONAL, 'The path to the kit to debug', '.') + ->setHelp(<<<'EOF' +To debug a Kit in the current directory: + php %command.full_name% + +Or in another directory: + php %command.full_name% ./kits/shadcn + php %command.full_name% /path/to/my-kit +EOF + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $kitPath = $input->getArgument('kit-path'); + $kitPath = Path::makeAbsolute($kitPath, getcwd()); + $kit = $this->kitFactory->createKitFromAbsolutePath($kitPath); + + $io->title(\sprintf('Kit "%s"', $kit->name)); + + $io->definitionList( + ['Name' => $kit->name], + ['Homepage' => $kit->homepage], + ['License' => $kit->license], + new TableSeparator(), + ['Path' => $kit->path], + ); + + $io->section('Components'); + foreach ($kit->getComponents() as $component) { + (new Table($io)) + ->setHeaderTitle(\sprintf('Component: "%s"', $component->name)) + ->setHorizontal() + ->setHeaders([ + 'File(s)', + 'Dependencies', + ]) + ->addRow([ + implode("\n", $component->files), + implode("\n", $component->getDependencies()), + ]) + ->setColumnWidth(1, 80) + ->setColumnMaxWidth(1, 80) + ->render(); + $io->newLine(); + } + + return Command::SUCCESS; + } +} diff --git a/src/Toolkit/src/Command/InstallComponentCommand.php b/src/Toolkit/src/Command/InstallComponentCommand.php new file mode 100644 index 00000000000..6746a6d5272 --- /dev/null +++ b/src/Toolkit/src/Command/InstallComponentCommand.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\Installer\Installer; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Registry\LocalRegistry; +use Symfony\UX\Toolkit\Registry\RegistryFactory; + +/** + * @author Jean-François LÊpine + * @author Hugo Alliaume + * + * @internal + */ +#[AsCommand( + name: 'ux:toolkit:install-component', + description: 'Install a new UX Component (e.g. Alert) in your project', +)] +class InstallComponentCommand extends Command +{ + private SymfonyStyle $io; + private bool $isInteractive; + + public function __construct( + private readonly RegistryFactory $registryFactory, + private readonly Filesystem $filesystem, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('component', InputArgument::OPTIONAL, 'The component name (Ex: Button)') + ->addOption('kit', 'k', InputOption::VALUE_OPTIONAL, 'The kit name (Ex: shadcn, or github.com/user/my-ux-toolkit-kit)') + ->addOption( + 'destination', + 'd', + InputOption::VALUE_OPTIONAL, + 'The destination directory', + Path::join('templates', 'components') + ) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force the component installation, even if the component already exists') + ->setHelp( + <<%command.name% command will install a new UX Component in your project. + +To install a component from your current kit, use: + +php %command.full_name% Button + +To install a component from an official UX Toolkit kit, use the --kit option: + +php %command.full_name% Button --kit=shadcn + +To install a component from an external GitHub kit, use the --kit option: + +php %command.full_name% Button --kit=https://github.com/user/my-kit +php %command.full_name% Button --kit=https://github.com/user/my-kit:branch +EOF + ); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->io = new SymfonyStyle($input, $output); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $kitName = $input->getOption('kit'); + $componentName = $input->getArgument('component'); + + // If the kit name is not explicitly provided, we need to suggest one + if (null === $kitName) { + /** @var list $availableKits */ + $availableKits = []; + $availableKitNames = LocalRegistry::getAvailableKitsName(); + foreach ($availableKitNames as $availableKitName) { + $kit = $this->registryFactory->getForKit($availableKitName)->getKit($availableKitName); + + if (null === $componentName) { + $availableKits[] = $kit; + } elseif (null !== $kit->getComponent($componentName)) { + $availableKits[] = $kit; + } + } + // If more than one kit is available, we ask the user which one to use + if (($availableKitsCount = \count($availableKits)) > 1) { + $kitName = $io->choice(null === $componentName ? 'Which kit do you want to use?' : \sprintf('The component "%s" exists in multiple kits. Which one do you want to use?', $componentName), array_map(fn (Kit $kit) => $kit->name, $availableKits)); + + foreach ($availableKits as $availableKit) { + if ($availableKit->name === $kitName) { + $kit = $availableKit; + break; + } + } + } elseif (1 === $availableKitsCount) { + $kit = $availableKits[0]; + } else { + $io->error(null === $componentName + ? 'It seems that no local kits are available and it should not happens. Please open an issue on https://github.com/symfony/ux to report this.' + : \sprintf("The component \"%s\" does not exist in any local kits.\n\nYou can try to run one of the following commands to interactively install components:\n%s\n\nOr you can try one of the community kits https://github.com/search?q=topic:ux-toolkit&type=repositories", $componentName, implode("\n", array_map(fn (string $availableKitName) => \sprintf('$ bin/console %s --kit %s', $this->getName(), $availableKitName), $availableKitNames))) + ); + + return Command::FAILURE; + } + } else { + $registry = $this->registryFactory->getForKit($kitName); + $kit = $registry->getKit($kitName); + } + + if (null === $componentName) { + // Ask for the component name if not provided + $componentName = $io->choice('Which component do you want to install?', array_map(fn (Component $component) => $component->name, $this->getAvailableComponents($kit))); + $component = $kit->getComponent($componentName); + } elseif (null === $component = $kit->getComponent($componentName)) { + // Suggest alternatives if component does not exist + $message = \sprintf('The component "%s" does not exist.', $componentName); + + $alternativeComponents = $this->getAlternativeComponents($kit, $componentName); + $alternativeComponentsCount = \count($alternativeComponents); + + if (1 === $alternativeComponentsCount && $input->isInteractive()) { + $io->warning($message); + if ($io->confirm(\sprintf('Do you want to install the component "%s" instead?', $alternativeComponents[0]->name))) { + $component = $alternativeComponents[0]; + } else { + return Command::FAILURE; + } + } elseif ($alternativeComponentsCount > 0) { + $io->warning(\sprintf('%s'."\n".'Possible alternatives: "%s"', $message, implode('", "', array_map(fn (Component $c) => $c->name, $alternativeComponents)))); + + return Command::FAILURE; + } else { + $io->error($message); + + return Command::FAILURE; + } + } + + $io->writeln(\sprintf('Installing component %s from the %s kit...', $component->name, $kit->name)); + + $installer = new Installer($this->filesystem, fn (string $question) => $this->io->confirm($question, $input->isInteractive())); + $installationReport = $installer->installComponent($kit, $component, $destinationPath = $input->getOption('destination'), $input->getOption('force')); + + if ([] === $installationReport->newFiles) { + $this->io->warning('The component has not been installed.'); + + return Command::SUCCESS; + } + + $this->io->success('The component has been installed.'); + $this->io->writeln('The following file(s) have been added to your project:'); + $this->io->listing(array_map(fn (File $file) => Path::join($destinationPath, $file->relativePathName), $installationReport->newFiles)); + + if ([] !== $installationReport->suggestedPhpPackages) { + $this->io->writeln(\sprintf('Run composer require %s to install the required PHP dependencies.', implode(' ', $installationReport->suggestedPhpPackages))); + $this->io->newLine(); + } + + return Command::SUCCESS; + } + + /** + * @return list + */ + private function getAvailableComponents(Kit $kit): array + { + $availableComponents = []; + + foreach ($kit->getComponents() as $component) { + if (str_contains($component->name, ':')) { + continue; + } + + $availableComponents[] = $component; + } + + return $availableComponents; + } + + /** + * @return list + */ + private function getAlternativeComponents(Kit $kit, string $componentName): array + { + $alternative = []; + + foreach ($kit->getComponents() as $component) { + $lev = levenshtein($componentName, $component->name, 2, 5, 10); + if ($lev <= 8 || str_contains($component->name, $componentName)) { + $alternative[] = $component; + } + } + + return $alternative; + } +} diff --git a/src/Toolkit/src/Dependency/ComponentDependency.php b/src/Toolkit/src/Dependency/ComponentDependency.php new file mode 100644 index 00000000000..d5eb860b3a0 --- /dev/null +++ b/src/Toolkit/src/Dependency/ComponentDependency.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Dependency; + +use Symfony\UX\Toolkit\Assert; + +/** + * Represents a dependency on a component. + * + * @internal + * + * @author Hugo Alliaume + */ +final class ComponentDependency implements DependencyInterface +{ + /** + * @param non-empty-string $name The name of the component, e.g. "Table" or "Table:Body" + */ + public function __construct( + public string $name, + ) { + Assert::componentName($this->name); + } + + public function isEquivalentTo(DependencyInterface $dependency): bool + { + if (!$dependency instanceof self) { + return false; + } + + return $this->name === $dependency->name; + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/Toolkit/src/Dependency/DependencyInterface.php b/src/Toolkit/src/Dependency/DependencyInterface.php new file mode 100644 index 00000000000..c4089c4b5cd --- /dev/null +++ b/src/Toolkit/src/Dependency/DependencyInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Dependency; + +/** + * Represents a dependency. + * + * @internal + * + * @author Hugo Alliaume + */ +interface DependencyInterface extends \Stringable +{ + public function isEquivalentTo(self $dependency): bool; +} diff --git a/src/Toolkit/src/Dependency/PhpPackageDependency.php b/src/Toolkit/src/Dependency/PhpPackageDependency.php new file mode 100644 index 00000000000..ab365d1886c --- /dev/null +++ b/src/Toolkit/src/Dependency/PhpPackageDependency.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Dependency; + +use Symfony\UX\Toolkit\Assert; + +/** + * Represents a dependency on a PHP package. + * + * @internal + * + * @author Hugo Alliaume + */ +final class PhpPackageDependency implements DependencyInterface +{ + /** + * @param non-empty-string $name + */ + public function __construct( + public readonly string $name, + public readonly ?Version $constraintVersion = null, + ) { + Assert::phpPackageName($name); + } + + public function isEquivalentTo(DependencyInterface $dependency): bool + { + if (!$dependency instanceof self) { + return false; + } + + return $this->name === $dependency->name; + } + + public function isHigherThan(self $dependency): bool + { + if (null === $this->constraintVersion || null === $dependency->constraintVersion) { + return false; + } + + return $this->constraintVersion->isHigherThan($dependency->constraintVersion); + } + + public function __toString(): string + { + return $this->name.($this->constraintVersion ? ':^'.$this->constraintVersion : ''); + } +} diff --git a/src/Toolkit/src/Dependency/StimulusControllerDependency.php b/src/Toolkit/src/Dependency/StimulusControllerDependency.php new file mode 100644 index 00000000000..6fd8733c1ec --- /dev/null +++ b/src/Toolkit/src/Dependency/StimulusControllerDependency.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Dependency; + +use Symfony\UX\Toolkit\Assert; + +/** + * Represents a dependency on a Stimulus controller. + * + * @internal + * + * @author Hugo Alliaume + */ +final class StimulusControllerDependency implements DependencyInterface +{ + /** + * @param non-empty-string $name + */ + public function __construct( + public string $name, + ) { + Assert::stimulusControllerName($this->name); + } + + public function isEquivalentTo(DependencyInterface $dependency): bool + { + if (!$dependency instanceof self) { + return false; + } + + return $this->name === $dependency->name; + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/Toolkit/src/Dependency/Version.php b/src/Toolkit/src/Dependency/Version.php new file mode 100644 index 00000000000..541d936b35e --- /dev/null +++ b/src/Toolkit/src/Dependency/Version.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Dependency; + +/** + * Represents a version number, following the SemVer specification. + * + * @internal + * + * @author Hugo Alliaume + */ +final class Version implements \Stringable +{ + /** + * @param non-empty-string + */ + public function __construct( + public readonly string $value, + ) { + } + + public function isHigherThan(self $version): bool + { + return version_compare($this->value, $version->value, '>'); + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Toolkit/src/File/ComponentMeta.php b/src/Toolkit/src/File/ComponentMeta.php new file mode 100644 index 00000000000..9048ce1dd5d --- /dev/null +++ b/src/Toolkit/src/File/ComponentMeta.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\File; + +use Symfony\UX\Toolkit\Dependency\DependencyInterface; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\Version; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final class ComponentMeta +{ + public static function fromJson(string $json): self + { + $data = json_decode($json, true, flags: \JSON_THROW_ON_ERROR); + unset($data['$schema']); + + $dependencies = []; + foreach ($data['dependencies'] ?? [] as $i => $dependency) { + if (!isset($dependency['type'])) { + throw new \InvalidArgumentException(\sprintf('The dependency type is missing for dependency #%d, add "type" key.', $i)); + } + + if ('php' === $dependency['type']) { + $package = $dependency['package'] ?? throw new \InvalidArgumentException(\sprintf('The package name is missing for dependency #%d.', $i)); + if (str_contains($package, ':')) { + [$name, $version] = explode(':', $package, 2); + $dependencies[] = new PhpPackageDependency($name, new Version($version)); + } else { + $dependencies[] = new PhpPackageDependency($package); + } + } else { + throw new \InvalidArgumentException(\sprintf('The dependency type "%s" is not supported.', $dependency['type'])); + } + } + unset($data['dependencies']); + + if ([] !== $unused = array_keys($data)) { + throw new \InvalidArgumentException(\sprintf('The following key(s) are not supported: "%s".', implode('", "', $unused))); + } + + return new self( + $dependencies + ); + } + + /** + * @param list $dependencies + */ + private function __construct( + public readonly array $dependencies, + ) { + } +} diff --git a/src/Toolkit/src/File/Doc.php b/src/Toolkit/src/File/Doc.php new file mode 100644 index 00000000000..1c66f4b619e --- /dev/null +++ b/src/Toolkit/src/File/Doc.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\File; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class Doc +{ + /** + * @param non-empty-string $markdownContent + */ + public function __construct( + public readonly string $markdownContent, + ) { + } +} diff --git a/src/Toolkit/src/File/File.php b/src/Toolkit/src/File/File.php new file mode 100644 index 00000000000..dec39e05729 --- /dev/null +++ b/src/Toolkit/src/File/File.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\File; + +use Symfony\Component\Filesystem\Path; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class File implements \Stringable +{ + /** + * @param non-empty-string $relativePathNameToKit relative path from the kit root directory, example "templates/components/Table/Body.html.twig" + * @param non-empty-string $relativePathName relative path name, without any prefix, example "Table/Body.html.twig" + * + * @throws \InvalidArgumentException + */ + public function __construct( + public readonly string $relativePathNameToKit, + public readonly string $relativePathName, + ) { + if (!Path::isRelative($relativePathNameToKit)) { + throw new \InvalidArgumentException(\sprintf('The path to the kit "%s" must be relative.', $relativePathNameToKit)); + } + + if (!Path::isRelative($relativePathName)) { + throw new \InvalidArgumentException(\sprintf('The path name "%s" must be relative.', $relativePathName)); + } + + if (!str_ends_with($relativePathNameToKit, $relativePathName)) { + throw new \InvalidArgumentException(\sprintf('The relative path name "%s" must be a subpath of the relative path to the kit "%s".', $relativePathName, $relativePathNameToKit)); + } + } + + public function __toString(): string + { + return $this->relativePathNameToKit; + } +} diff --git a/src/Toolkit/src/Installer/InstallationReport.php b/src/Toolkit/src/Installer/InstallationReport.php new file mode 100644 index 00000000000..975fbe1bd75 --- /dev/null +++ b/src/Toolkit/src/Installer/InstallationReport.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Installer; + +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\File\File; + +/** + * Represents the output after an installation. + * + * @internal + * + * @author Hugo Alliaume + */ +final class InstallationReport +{ + /** + * @param array $newFiles + * @param array $suggestedPhpPackages + */ + public function __construct( + public readonly array $newFiles, + public readonly array $suggestedPhpPackages, + ) { + } +} diff --git a/src/Toolkit/src/Installer/Installer.php b/src/Toolkit/src/Installer/Installer.php new file mode 100644 index 00000000000..816576e549f --- /dev/null +++ b/src/Toolkit/src/Installer/Installer.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Installer; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\Kit\Kit; + +final class Installer +{ + private PoolResolver $poolResolver; + + /** + * @param \Closure(string):bool $askConfirmation + */ + public function __construct( + private readonly Filesystem $filesystem, + private readonly \Closure $askConfirmation, + ) { + $this->poolResolver = new PoolResolver(); + } + + public function installComponent(Kit $kit, Component $component, string $destinationPath, bool $force): InstallationReport + { + $pool = $this->poolResolver->resolveForComponent($kit, $component); + $output = $this->handlePool($pool, $kit, $destinationPath, $force); + + return $output; + } + + /** + * @param non-empty-string $destinationPath + */ + private function handlePool(Pool $pool, Kit $kit, string $destinationPath, bool $force): InstallationReport + { + $installedFiles = []; + + foreach ($pool->getFiles() as $file) { + if ($this->installFile($kit, $file, $destinationPath, $force)) { + $installedFiles[] = $file; + } + } + + return new InstallationReport(newFiles: $installedFiles, suggestedPhpPackages: $pool->getPhpPackageDependencies()); + } + + /** + * @param non-empty-string $destinationPath + */ + private function installFile(Kit $kit, File $file, string $destinationPath, bool $force): bool + { + $componentPath = Path::join($kit->path, $file->relativePathNameToKit); + $componentDestinationPath = Path::join($destinationPath, $file->relativePathName); + + if ($this->filesystem->exists($componentDestinationPath) && !$force) { + if (!($this->askConfirmation)(\sprintf('File "%s" already exists. Do you want to overwrite it?', $componentDestinationPath))) { + return false; + } + } + + $this->filesystem->copy($componentPath, $componentDestinationPath, $force); + + return true; + } +} diff --git a/src/Toolkit/src/Installer/Pool.php b/src/Toolkit/src/Installer/Pool.php new file mode 100644 index 00000000000..545b8c25f26 --- /dev/null +++ b/src/Toolkit/src/Installer/Pool.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Installer; + +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\File\File; + +/** + * Represents a pool of files and dependencies to be installed. + * + * @internal + * + * @author Hugo Alliaume + */ +final class Pool +{ + /** + * @var array + */ + private array $files = []; + + /** + * @param array $files + */ + private array $phpPackageDependencies = []; + + public function addFile(File $file): void + { + $this->files[$file->relativePathName] ??= $file; + } + + /** + * @return array + */ + public function getFiles(): array + { + return $this->files; + } + + public function addPhpPackageDependency(PhpPackageDependency $dependency): void + { + if (isset($this->phpPackageDependencies[$dependency->name]) && $dependency->isHigherThan($this->phpPackageDependencies[$dependency->name])) { + $this->phpPackageDependencies[$dependency->name] = $dependency; + + return; + } + + $this->phpPackageDependencies[$dependency->name] = $dependency; + } + + /** + * @return array + */ + public function getPhpPackageDependencies(): array + { + return $this->phpPackageDependencies; + } +} diff --git a/src/Toolkit/src/Installer/PoolResolver.php b/src/Toolkit/src/Installer/PoolResolver.php new file mode 100644 index 00000000000..00edbf661f2 --- /dev/null +++ b/src/Toolkit/src/Installer/PoolResolver.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Installer; + +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\Kit\Kit; + +final class PoolResolver +{ + public function resolveForComponent(Kit $kit, Component $component): Pool + { + $pool = new Pool(); + + // Process the component and its dependencies + $componentsStack = [$component]; + $visitedComponents = new \SplObjectStorage(); + + while (!empty($componentsStack)) { + $currentComponent = array_pop($componentsStack); + + // Skip circular references + if ($visitedComponents->contains($currentComponent)) { + continue; + } + + $visitedComponents->attach($currentComponent); + + foreach ($currentComponent->files as $file) { + $pool->addFile($file); + } + + foreach ($currentComponent->getDependencies() as $dependency) { + if ($dependency instanceof ComponentDependency) { + $componentsStack[] = $kit->getComponent($dependency->name); + } elseif ($dependency instanceof PhpPackageDependency) { + $pool->addPhpPackageDependency($dependency); + } elseif ($dependency instanceof StimulusControllerDependency) { + if (null === $stimulusController = $kit->getStimulusController($dependency->name)) { + throw new \RuntimeException(\sprintf('Stimulus controller "%s" not found.', $dependency->name)); + } + + foreach ($stimulusController->files as $file) { + $pool->addFile($file); + } + } else { + throw new \RuntimeException(\sprintf('Unknown dependency type: %s', $dependency::class)); + } + } + } + + return $pool; + } +} diff --git a/src/Toolkit/src/Kit/Kit.php b/src/Toolkit/src/Kit/Kit.php new file mode 100644 index 00000000000..dcf69290581 --- /dev/null +++ b/src/Toolkit/src/Kit/Kit.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Kit; + +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Assert; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Asset\StimulusController; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class Kit +{ + /** + * @param non-empty-string $path + * @param non-empty-string $name + * @param non-empty-string|null $homepage + * @param non-empty-string|null $license + * @param list $components + * @param list $stimulusControllers + */ + public function __construct( + public readonly string $path, + public readonly string $name, + public readonly ?string $homepage = null, + public readonly ?string $license = null, + public readonly ?string $description = null, + public readonly ?string $uxIcon = null, + public ?string $installAsMarkdown = null, + private array $components = [], + private array $stimulusControllers = [], + ) { + Assert::kitName($this->name); + + if (!Path::isAbsolute($this->path)) { + throw new \InvalidArgumentException(\sprintf('Kit path "%s" is not absolute.', $this->path)); + } + + if (null !== $this->homepage && !filter_var($this->homepage, \FILTER_VALIDATE_URL)) { + throw new \InvalidArgumentException(\sprintf('Invalid homepage URL "%s".', $this->homepage)); + } + } + + /** + * @throws \InvalidArgumentException if the component is already registered in the kit + */ + public function addComponent(Component $component): void + { + foreach ($this->components as $existingComponent) { + if ($existingComponent->name === $component->name) { + throw new \InvalidArgumentException(\sprintf('Component "%s" is already registered in the kit.', $component->name)); + } + } + + $this->components[] = $component; + } + + public function getComponents(): array + { + return $this->components; + } + + public function getComponent(string $name): ?Component + { + foreach ($this->components as $component) { + if ($component->name === $name) { + return $component; + } + } + + return null; + } + + public function addStimulusController(StimulusController $stimulusController): void + { + foreach ($this->stimulusControllers as $existingStimulusController) { + if ($existingStimulusController->name === $stimulusController->name) { + throw new \InvalidArgumentException(\sprintf('Stimulus controller "%s" is already registered in the kit.', $stimulusController->name)); + } + } + + $this->stimulusControllers[] = $stimulusController; + } + + public function getStimulusControllers(): array + { + return $this->stimulusControllers; + } + + public function getStimulusController(string $name): ?StimulusController + { + foreach ($this->stimulusControllers as $stimulusController) { + if ($stimulusController->name === $name) { + return $stimulusController; + } + } + + return null; + } +} diff --git a/src/Toolkit/src/Kit/KitContextRunner.php b/src/Toolkit/src/Kit/KitContextRunner.php new file mode 100644 index 00000000000..e55667c19bb --- /dev/null +++ b/src/Toolkit/src/Kit/KitContextRunner.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Kit; + +use Symfony\Component\Filesystem\Path; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentTemplateFinderInterface; +use Twig\Loader\ChainLoader; +use Twig\Loader\FilesystemLoader; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final class KitContextRunner +{ + public function __construct( + private readonly \Twig\Environment $twig, + private readonly ComponentFactory $componentFactory, + ) { + } + + /** + * @template TResult of mixed + * + * @param callable(Kit): TResult $callback + * + * @return TResult + */ + public function runForKit(Kit $kit, callable $callback): mixed + { + $resetServices = $this->contextualizeServicesForKit($kit); + + try { + return $callback($kit); + } finally { + $resetServices(); + } + } + + /** + * @return callable(): void Reset the services when called + */ + private function contextualizeServicesForKit(Kit $kit): callable + { + // Configure Twig + $initialTwigLoader = $this->twig->getLoader(); + $this->twig->setLoader(new ChainLoader([ + new FilesystemLoader(Path::join($kit->path, 'templates/components')), + $initialTwigLoader, + ])); + + // Configure Twig Components + $reflComponentFactory = new \ReflectionClass($this->componentFactory); + + $reflComponentFactoryConfig = $reflComponentFactory->getProperty('config'); + $initialComponentFactoryConfig = $reflComponentFactoryConfig->getValue($this->componentFactory); + $reflComponentFactoryConfig->setValue($this->componentFactory, []); + + $reflComponentFactoryComponentTemplateFinder = $reflComponentFactory->getProperty('componentTemplateFinder'); + $initialComponentFactoryComponentTemplateFinder = $reflComponentFactoryComponentTemplateFinder->getValue($this->componentFactory); + $reflComponentFactoryComponentTemplateFinder->setValue($this->componentFactory, $this->createComponentTemplateFinder($kit)); + + return function () use ($initialTwigLoader, $reflComponentFactoryConfig, $initialComponentFactoryConfig, $reflComponentFactoryComponentTemplateFinder, $initialComponentFactoryComponentTemplateFinder) { + $this->twig->setLoader($initialTwigLoader); + $reflComponentFactoryConfig->setValue($this->componentFactory, $initialComponentFactoryConfig); + $reflComponentFactoryComponentTemplateFinder->setValue($this->componentFactory, $initialComponentFactoryComponentTemplateFinder); + }; + } + + private function createComponentTemplateFinder(Kit $kit): ComponentTemplateFinderInterface + { + static $instances = []; + + return $instances[$kit->name] ?? new class($kit) implements ComponentTemplateFinderInterface { + public function __construct(private readonly Kit $kit) + { + } + + public function findAnonymousComponentTemplate(string $name): ?string + { + if (null === $component = $this->kit->getComponent($name)) { + throw new \RuntimeException(\sprintf('Component "%s" does not exist in kit "%s".', $name, $this->kit->name)); + } + + foreach ($component->files as $file) { + return $file->relativePathName; + } + + throw new \LogicException(\sprintf('No Twig files found for component "%s" in kit "%s", it should not happens.', $name, $this->kit->name)); + } + }; + } +} diff --git a/src/Toolkit/src/Kit/KitFactory.php b/src/Toolkit/src/Kit/KitFactory.php new file mode 100644 index 00000000000..3e20df1c6ca --- /dev/null +++ b/src/Toolkit/src/Kit/KitFactory.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Kit; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class KitFactory +{ + public function __construct( + private readonly Filesystem $filesystem, + private readonly KitSynchronizer $kitSynchronizer, + ) { + } + + /** + * @throws \InvalidArgumentException if the manifest file is missing a required key + * @throws \JsonException if the manifest file is not valid JSON + */ + public function createKitFromAbsolutePath(string $absolutePath): Kit + { + if (!Path::isAbsolute($absolutePath)) { + throw new \InvalidArgumentException(\sprintf('Path "%s" is not absolute.', $absolutePath)); + } + + if (!$this->filesystem->exists($absolutePath)) { + throw new \InvalidArgumentException(\sprintf('Path "%s" does not exist.', $absolutePath)); + } + + if (!$this->filesystem->exists($manifestPath = Path::join($absolutePath, 'manifest.json'))) { + throw new \InvalidArgumentException(\sprintf('File "%s" not found.', $manifestPath)); + } + + $manifest = json_decode(file_get_contents($manifestPath), true, flags: \JSON_THROW_ON_ERROR); + + $kit = new Kit( + path: $absolutePath, + name: $manifest['name'] ?? throw new \InvalidArgumentException('Manifest file is missing "name" key.'), + homepage: $manifest['homepage'] ?? throw new \InvalidArgumentException('Manifest file is missing "homepage" key.'), + license: $manifest['license'] ?? throw new \InvalidArgumentException('Manifest file is missing "license" key.'), + description: $manifest['description'] ?? null, + ); + + $this->kitSynchronizer->synchronize($kit); + + return $kit; + } +} diff --git a/src/Toolkit/src/Kit/KitSynchronizer.php b/src/Toolkit/src/Kit/KitSynchronizer.php new file mode 100644 index 00000000000..0aaf95840e6 --- /dev/null +++ b/src/Toolkit/src/Kit/KitSynchronizer.php @@ -0,0 +1,205 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Kit; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Asset\StimulusController; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\File\ComponentMeta; +use Symfony\UX\Toolkit\File\Doc; +use Symfony\UX\Toolkit\File\File; + +/** + * @internal + * + * @author Hugo Alliaume + */ +final class KitSynchronizer +{ + /** + * @see https://regex101.com/r/WasRGf/1 + */ + private const RE_TWIG_COMPONENT_REFERENCES = '/[a-zA-Z0-9:_-]+)/'; + + /** + * @see https://regex101.com/r/inIBID/1 + */ + private const RE_STIMULUS_CONTROLLER_REFERENCES = '/data-controller=(["\'])(?P.+?)\1/'; + + private const UX_COMPONENTS_PACKAGES = [ + 'ux:icon' => 'symfony/ux-icons', + 'ux:map' => 'symfony/ux-map', + ]; + + public function __construct( + private readonly Filesystem $filesystem, + ) { + } + + public function synchronize(Kit $kit): void + { + $this->synchronizeComponents($kit); + $this->synchronizeStimulusControllers($kit); + $this->synchronizeDocumentation($kit); + } + + private function synchronizeComponents(Kit $kit): void + { + $componentsPath = Path::join('templates', 'components'); + $finder = (new Finder()) + ->in($kit->path) + ->files() + ->path($componentsPath) + ->sortByName() + ->name('*.html.twig') + ; + + foreach ($finder as $file) { + $relativePathNameToKit = $file->getRelativePathname(); + $relativePathName = str_replace($componentsPath.\DIRECTORY_SEPARATOR, '', $relativePathNameToKit); + $componentName = $this->extractComponentName($relativePathName); + + $meta = null; + if ($this->filesystem->exists($metaJsonFile = Path::join($file->getPath(), str_replace('.html.twig', '.meta.json', $file->getBasename())))) { + $metaJson = file_get_contents($metaJsonFile) ?: throw new \RuntimeException(\sprintf('Unable to get contents from file "%s".', $metaJsonFile)); + try { + $meta = ComponentMeta::fromJson($metaJson); + } catch (\Throwable $e) { + throw new \RuntimeException(\sprintf('Unable to parse component "%s" meta from JSON file "%s".', $componentName, $metaJsonFile), previous: $e); + } + } + + $component = new Component( + name: $componentName, + files: [new File( + relativePathNameToKit: $relativePathNameToKit, + relativePathName: $relativePathName, + )], + meta: $meta, + ); + + $kit->addComponent($component); + } + + foreach ($kit->getComponents() as $component) { + $this->resolveComponentDependencies($kit, $component); + } + } + + private function resolveComponentDependencies(Kit $kit, Component $component): void + { + // Find dependencies based on component name + foreach ($kit->getComponents() as $otherComponent) { + if ($component->name === $otherComponent->name) { + continue; + } + + // Find components with the component name as a prefix + if (str_starts_with($otherComponent->name, $component->name.':')) { + $component->addDependency(new ComponentDependency($otherComponent->name)); + } + } + + // Find dependencies based on file content + foreach ($component->files as $file) { + if (!$this->filesystem->exists($filePath = Path::join($kit->path, $file->relativePathNameToKit))) { + throw new \RuntimeException(\sprintf('File "%s" not found', $filePath)); + } + + $fileContent = file_get_contents($filePath); + + if (str_contains($fileContent, 'name) { + continue; + } + + if (null !== $package = self::UX_COMPONENTS_PACKAGES[strtolower($componentReferenceName)] ?? null) { + if (!$component->hasDependency(new PhpPackageDependency($package))) { + throw new \RuntimeException(\sprintf('Component "%s" uses "%s" UX Twig component, but the composer package "%s" is not listed as a dependency in meta file.', $component->name, $componentReferenceName, $package)); + } + } elseif (null === $componentReference = $kit->getComponent($componentReferenceName)) { + throw new \RuntimeException(\sprintf('Component "%s" not found in component "%s" (file "%s")', $componentReferenceName, $component->name, $file->relativePathNameToKit)); + } else { + $component->addDependency(new ComponentDependency($componentReference->name)); + } + } + } + + if (str_contains($fileContent, 'data-controller=') && preg_match_all(self::RE_STIMULUS_CONTROLLER_REFERENCES, $fileContent, $matches)) { + $controllersName = array_filter(array_map(fn (string $name) => trim($name), explode(' ', $matches['controllersName'][0]))); + foreach ($controllersName as $controllerReferenceName) { + $component->addDependency(new StimulusControllerDependency($controllerReferenceName)); + } + } + } + } + + private function synchronizeStimulusControllers(Kit $kit): void + { + $controllersPath = Path::join('assets', 'controllers'); + $finder = (new Finder()) + ->in($kit->path) + ->files() + ->path($controllersPath) + ->sortByName() + ->name('*.js') + ; + + foreach ($finder as $file) { + $relativePathNameToKit = $file->getRelativePathname(); + $relativePathName = str_replace($controllersPath.\DIRECTORY_SEPARATOR, '', $relativePathNameToKit); + $controllerName = $this->extractStimulusControllerName($relativePathName); + $controller = new StimulusController( + name: $controllerName, + files: [new File( + relativePathNameToKit: $relativePathNameToKit, + relativePathName: $relativePathName, + )], + ); + + $kit->addStimulusController($controller); + } + } + + private function synchronizeDocumentation(Kit $kit): void + { + // Read INSTALL.md if exists + $fileInstall = Path::join($kit->path, 'INSTALL.md'); + if ($this->filesystem->exists($fileInstall)) { + $kit->installAsMarkdown = file_get_contents($fileInstall); + } + + // Iterate over Component and find their documentation + foreach ($kit->getComponents() as $component) { + $docPath = Path::join($kit->path, 'docs', 'components', $component->name.'.md'); + if ($this->filesystem->exists($docPath)) { + $component->doc = new Doc(file_get_contents($docPath)); + } + } + } + + private static function extractComponentName(string $pathnameRelativeToKit): string + { + return str_replace(['.html.twig', '/'], ['', ':'], $pathnameRelativeToKit); + } + + private static function extractStimulusControllerName(string $pathnameRelativeToKit): string + { + return str_replace(['_controller.js', '-controller.js', '/', '_'], ['', '', '--', '-'], $pathnameRelativeToKit); + } +} diff --git a/src/Toolkit/src/Registry/GitHubRegistry.php b/src/Toolkit/src/Registry/GitHubRegistry.php new file mode 100644 index 00000000000..43f3104b7b0 --- /dev/null +++ b/src/Toolkit/src/Registry/GitHubRegistry.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitFactory; + +/** + * @internal + * + * @author Jean-François LÊpine + * @author Hugo Alliaume + */ +final class GitHubRegistry implements RegistryInterface +{ + public function __construct( + private readonly KitFactory $kitFactory, + private readonly Filesystem $filesystem, + private ?HttpClientInterface $httpClient = null, + ) { + if (null === $httpClient) { + if (!class_exists(HttpClient::class)) { + throw new \LogicException('You must install "symfony/http-client" to use the UX Toolkit with remote components. Try running "composer require symfony/http-client".'); + } + + $this->httpClient = HttpClient::create(); + } + + if (!class_exists(\ZipArchive::class)) { + throw new \LogicException('You must have the Zip extension installed to use UX Toolkit with remote registry.'); + } + } + + /** + * @see https://regex101.com/r/0BoRNX/1 + */ + public const RE_GITHUB_KIT = '/^(?:https:\/\/)?(github\.com)\/(?[\w-]+)\/(?[\w-]+)(?::(?[\w._-]+))?$/'; + + public static function supports(string $kitName): bool + { + return 1 === preg_match(self::RE_GITHUB_KIT, $kitName); + } + + public function getKit(string $kitName): Kit + { + $repositoryDir = $this->downloadRepository(GitHubRegistryIdentity::fromUrl($kitName)); + + return $this->kitFactory->createKitFromAbsolutePath($repositoryDir); + } + + /** + * @throws \RuntimeException + */ + private function downloadRepository(GitHubRegistryIdentity $identity): string + { + $zipUrl = \sprintf( + 'https://github.com/%s/%s/archive/%s.zip', + $identity->authorName, + $identity->repositoryName, + $identity->version, + ); + + $tmpDir = $this->createTmpDir(); + $archiveExtractedName = \sprintf('%s-%s', $identity->repositoryName, $identity->version); + $archiveName = \sprintf('%s.zip', $archiveExtractedName); + $archivePath = Path::join($tmpDir, $archiveName); + $archiveExtractedDir = Path::join($tmpDir, $archiveExtractedName); + + // Download and stream the archive + $response = $this->httpClient->request('GET', $zipUrl); + if (200 !== $response->getStatusCode()) { + throw new \RuntimeException(\sprintf('Unable to download the archive from "%s", ensure the repository exists and the version is valid.', $zipUrl)); + } + + $archiveResource = fopen($archivePath, 'w'); + foreach ($this->httpClient->stream($response) as $chunk) { + fwrite($archiveResource, $chunk->getContent()); + } + fclose($archiveResource); + + // Extract the archive + $zip = new \ZipArchive(); + $zip->open($archivePath); + $zip->extractTo($tmpDir); + $zip->close(); + + if (!$this->filesystem->exists($archiveExtractedDir)) { + throw new \RuntimeException(\sprintf('Unable to extract the archive from "%s", ensure the repository exists and the version is valid.', $zipUrl)); + } + + return $archiveExtractedDir; + } + + private function createTmpDir(): string + { + $dir = $this->filesystem->tempnam(sys_get_temp_dir(), 'ux_toolkit_github_'); + $this->filesystem->remove($dir); + $this->filesystem->mkdir($dir); + + return $dir; + } +} diff --git a/src/Toolkit/src/Registry/GitHubRegistryIdentity.php b/src/Toolkit/src/Registry/GitHubRegistryIdentity.php new file mode 100644 index 00000000000..58beeddd40e --- /dev/null +++ b/src/Toolkit/src/Registry/GitHubRegistryIdentity.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +/** + * @internal + * + * @author Jean-François LÊpine + * @author Hugo Alliaume + */ +final class GitHubRegistryIdentity +{ + /** + * @param non-empty-string $authorName + * @param non-empty-string $repositoryName + * @param non-empty-string $version + */ + private function __construct( + public readonly string $authorName, + public readonly string $repositoryName, + public readonly string $version, + ) { + } + + public static function fromUrl(string $url): self + { + $matches = []; + if (1 !== preg_match(GitHubRegistry::RE_GITHUB_KIT, $url, $matches)) { + throw new \InvalidArgumentException('The kit name is invalid, it must be a valid GitHub kit name.'); + } + + return new self( + $matches['authorName'] ?: throw new \InvalidArgumentException('Unable to extract the author name from the URL.'), + $matches['repositoryName'] ?: throw new \InvalidArgumentException('Unable to extract the repository name from the URL.'), + $matches['version'] ?? 'main', + ); + } +} diff --git a/src/Toolkit/src/Registry/LocalRegistry.php b/src/Toolkit/src/Registry/LocalRegistry.php new file mode 100644 index 00000000000..dc8577ce955 --- /dev/null +++ b/src/Toolkit/src/Registry/LocalRegistry.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitFactory; + +/** + * @internal + * + * @author Jean-François LÊpine + * @author Hugo Alliaume + */ +final class LocalRegistry implements RegistryInterface +{ + private static string $kitsDir = __DIR__.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'..'.\DIRECTORY_SEPARATOR.'kits'; + + public static function supports(string $kitName): bool + { + return 1 === preg_match('/^[a-zA-Z0-9_-]+$/', $kitName); + } + + public function __construct( + private readonly KitFactory $kitFactory, + private readonly Filesystem $filesystem, + ) { + } + + public function getKit(string $kitName): Kit + { + $kitDir = Path::join(self::$kitsDir, $kitName); + if ($this->filesystem->exists($kitDir)) { + return $this->kitFactory->createKitFromAbsolutePath($kitDir); + } + + throw new \RuntimeException(\sprintf('Unable to find the kit "%s" in the following directories: "%s"', $kitName, implode('", "', $possibleKitDirs))); + } + + /** + * @return array + */ + public static function getAvailableKitsName(): array + { + $availableKitsName = []; + $finder = (new Finder())->directories()->in(self::$kitsDir)->depth(0); + + foreach ($finder as $directory) { + $kitName = $directory->getRelativePathname(); + if (self::supports($kitName)) { + $availableKitsName[] = $kitName; + } + } + + return $availableKitsName; + } +} diff --git a/src/Toolkit/src/Registry/RegistryFactory.php b/src/Toolkit/src/Registry/RegistryFactory.php new file mode 100644 index 00000000000..e9a0e50ecda --- /dev/null +++ b/src/Toolkit/src/Registry/RegistryFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +use Psr\Container\ContainerInterface; + +/** + * @internal + * + * @author Jean-François LÊpine + * @author Hugo Alliaume + */ +final class RegistryFactory +{ + public function __construct( + private readonly ContainerInterface $registries, + ) { + } + + /** + * @throws \InvalidArgumentException + */ + public function getForKit(string $kit): RegistryInterface + { + $type = match (true) { + GitHubRegistry::supports($kit) => Type::GitHub, + LocalRegistry::supports($kit) => Type::Local, + default => throw new \InvalidArgumentException(\sprintf('The kit "%s" is not valid.', $kit)), + }; + + if (!$this->registries->has($type->value)) { + throw new \LogicException(\sprintf('The registry for the kit "%s" is not registered.', $kit)); + } + + return $this->registries->get($type->value); + } +} diff --git a/src/Toolkit/src/Registry/RegistryInterface.php b/src/Toolkit/src/Registry/RegistryInterface.php new file mode 100644 index 00000000000..246ddadac99 --- /dev/null +++ b/src/Toolkit/src/Registry/RegistryInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +use Symfony\UX\Toolkit\Kit\Kit; + +/** + * @internal + * + * @author Jean-François LÊpine + * @author Hugo Alliaume + */ +interface RegistryInterface +{ + public static function supports(string $kitName): bool; + + /** + * @throws \RuntimeException if the kit does not exist + */ + public function getKit(string $kitName): Kit; +} diff --git a/src/Toolkit/src/Registry/Type.php b/src/Toolkit/src/Registry/Type.php new file mode 100644 index 00000000000..e0b4ef79e8b --- /dev/null +++ b/src/Toolkit/src/Registry/Type.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Registry; + +/** + * @internal + * + * @author Jean-François LÊpine + * @author Hugo Alliaume + */ +enum Type: string +{ + case Local = 'local'; + case GitHub = 'github'; +} diff --git a/src/Toolkit/src/UXToolkitBundle.php b/src/Toolkit/src/UXToolkitBundle.php new file mode 100644 index 00000000000..0e38f2a251d --- /dev/null +++ b/src/Toolkit/src/UXToolkitBundle.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; + +/** + * @author Jean-François LÊpine + * @author Hugo Alliaume + */ +class UXToolkitBundle extends AbstractBundle +{ + protected string $extensionAlias = 'ux_toolkit'; + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->import('../config/services.php'); + } +} diff --git a/src/Toolkit/tests/AssertTest.php b/src/Toolkit/tests/AssertTest.php new file mode 100644 index 00000000000..5294e7dd83d --- /dev/null +++ b/src/Toolkit/tests/AssertTest.php @@ -0,0 +1,221 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Assert; + +class AssertTest extends TestCase +{ + /** + * @dataProvider provideValidKitNames + */ + public function testValidKitName(string $name): void + { + $this->expectNotToPerformAssertions(); + + Assert::kitName($name); + } + + public static function provideValidKitNames(): \Generator + { + yield ['my-kit']; + yield ['my-kit-with-dashes']; + yield ['1-my-kit']; + yield ['my-kit-1']; + yield ['my-kit-1-with-dashes']; + yield ['Shadcn UI']; + yield ['Shadcn UI-1']; + // Single character + yield ['a']; + yield ['1']; + // Maximum length (63 chars) + yield ['a'.str_repeat('-', 61).'a']; + // Various valid patterns + yield ['abc123']; + yield ['123abc']; + yield ['a1b2c3']; + yield ['a-b-c']; + yield ['a1-b2-c3']; + yield ['A1-B2-C3']; + yield ['my_kit']; + } + + /** + * @dataProvider provideInvalidKitNames + */ + public function testInvalidKitName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Invalid kit name "%s".', $name)); + + Assert::kitName($name); + } + + public static function provideInvalidKitNames(): \Generator + { + yield ['my-kit-']; + yield ['my-kit/qsd']; + // Empty string + yield ['']; + // Starting with hyphen + yield ['-my-kit']; + // Ending with hyphen + yield ['my-kit-']; + // Invalid characters + yield ['my.kit']; + yield ['my@kit']; + // Too long (64 chars) + yield ['a'.str_repeat('-', 62).'a']; + // Starting with invalid character + yield ['-abc']; + yield ['@abc']; + yield ['.abc']; + } + + /** + * @dataProvider provideValidComponentNames + */ + public function testValidComponentName(string $name): void + { + $this->expectNotToPerformAssertions(); + + Assert::componentName($name); + } + + public static function provideValidComponentNames(): iterable + { + yield ['Table']; + yield ['TableBody']; + yield ['Table:Body']; + yield ['Table:Body:Header']; + yield ['MyComponent']; + yield ['MyComponent:SubComponent']; + yield ['A']; + yield ['A:B']; + yield ['Component123']; + yield ['Component123:Sub456']; + } + + /** + * @dataProvider provideInvalidComponentNames + */ + public function testInvalidComponentName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Invalid component name "%s".', $name)); + + Assert::componentName($name); + } + + public static function provideInvalidComponentNames(): iterable + { + // Empty string + yield ['']; + // Invalid characters + yield ['table-body']; + yield ['table_body']; + yield ['table.body']; + yield ['table@body']; + yield ['table/body']; + // Starting with invalid characters + yield [':Table']; + yield ['123Table']; + yield ['@Table']; + // Invalid colon usage + yield ['Table:']; + yield ['Table::Body']; + yield [':Table:Body']; + // Lowercase start + yield ['table']; + yield ['table:Body']; + // Numbers only + yield ['123']; + yield ['123:456']; + } + + /** + * @dataProvider provideValidPhpPackageNames + */ + public function testValidPhpPackageName(string $name): void + { + $this->expectNotToPerformAssertions(); + + Assert::phpPackageName($name); + } + + public static function provideValidPhpPackageNames(): iterable + { + yield ['twig/html-extra']; + yield ['tales-from-a-dev/twig-tailwind-extra']; + } + + /** + * @dataProvider provideInvalidPhpPackageNames + */ + public function testInvalidPhpPackageName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Invalid PHP package name "%s".', $name)); + + Assert::phpPackageName($name); + } + + public static function provideInvalidPhpPackageNames(): iterable + { + yield ['']; + yield ['twig']; + yield ['twig/html-extra/']; + yield ['twig/html-extra/twig']; + } + + /** + * @dataProvider provideValidStimulusControllerNames + */ + public function testValidStimulusControllerName(string $name): void + { + $this->expectNotToPerformAssertions(); + + Assert::stimulusControllerName($name); + } + + public static function provideValidStimulusControllerNames(): iterable + { + yield ['my-controller']; + yield ['users--list-item']; + yield ['controller']; + yield ['controller-with-numbers-123']; + } + + /** + * @dataProvider provideInvalidStimulusControllerNames + */ + public function testInvalidStimulusControllerName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Invalid Stimulus controller name "%s".', $name)); + + Assert::stimulusControllerName($name); + } + + public static function provideInvalidStimulusControllerNames(): iterable + { + yield ['']; + yield ['my_controller']; + yield ['my-controller-']; + yield ['-my-controller']; + yield ['my-controller/qsd']; + yield ['my-controller@qsd']; + yield ['my-controller.qsd']; + yield ['my-controller:qsd']; + } +} diff --git a/src/Toolkit/tests/Asset/ComponentTest.php b/src/Toolkit/tests/Asset/ComponentTest.php new file mode 100644 index 00000000000..0b0c903126e --- /dev/null +++ b/src/Toolkit/tests/Asset/ComponentTest.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Asset; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\Version; +use Symfony\UX\Toolkit\File\File; + +final class ComponentTest extends TestCase +{ + public function testCanBeInstantiated(): void + { + $component = new Component('Button', [ + new File('templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $this->assertSame('Button', $component->name); + $this->assertCount(1, $component->files); + $this->assertInstanceOf(File::class, $component->files[0]); + $this->assertNull($component->doc); + $this->assertCount(0, $component->getDependencies()); + } + + public function testShouldFailIfComponentNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid component name "foobar".'); + + new Component('foobar', [ + new File('templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + } + + public function testShouldFailIfComponentHasNoFiles(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The component "Button" must have at least one file.'); + + new Component('Button', []); + } + + public function testCanAddAndGetDependencies(): void + { + $component = new Component('Button', [ + new File('templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $component->addDependency($dependency1 = new ComponentDependency('Icon')); + $component->addDependency($dependency2 = new ComponentDependency('Label')); + $component->addDependency($dependency3 = new PhpPackageDependency('symfony/twig-component', new Version('2.24.0'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); + } + + public function testShouldNotAddDuplicateComponentDependencies(): void + { + $component = new Component('Button', [ + new File('templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $component->addDependency($dependency1 = new ComponentDependency('Icon')); + $component->addDependency($dependency2 = new ComponentDependency('Label')); + $component->addDependency($dependency3 = new ComponentDependency('Icon')); + $component->addDependency($dependency4 = new PhpPackageDependency('symfony/twig-component', new Version('2.24.0'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency4], $component->getDependencies()); + } + + public function testShouldReplacePhpPackageDependencyIfVersionIsHigher(): void + { + $component = new Component('Button', [ + new File('templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $component->addDependency($dependency1 = new ComponentDependency('Icon')); + $component->addDependency($dependency2 = new ComponentDependency('Label')); + $component->addDependency($dependency3 = new PhpPackageDependency('symfony/twig-component', new Version('2.24.0'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); + + $component->addDependency($dependency4 = new PhpPackageDependency('symfony/twig-component', new Version('2.25.0'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency4], $component->getDependencies()); + } + + public function testShouldNotReplacePhpPackageDependencyIfVersionIsLower(): void + { + $component = new Component('Button', [ + new File('templates/components/Button/Button.html.twig', 'Button.html.twig'), + ]); + + $component->addDependency($dependency1 = new ComponentDependency('Icon')); + $component->addDependency($dependency2 = new ComponentDependency('Label')); + $component->addDependency($dependency3 = new PhpPackageDependency('symfony/twig-component', new Version('2.24.0'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); + + $component->addDependency(new PhpPackageDependency('symfony/twig-component', new Version('2.23.0'))); + + self::assertCount(3, $component->getDependencies()); + self::assertEquals([$dependency1, $dependency2, $dependency3], $component->getDependencies()); + } +} diff --git a/src/Toolkit/tests/Asset/StimulusControllerTest.php b/src/Toolkit/tests/Asset/StimulusControllerTest.php new file mode 100644 index 00000000000..7965f71212a --- /dev/null +++ b/src/Toolkit/tests/Asset/StimulusControllerTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Asset; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Asset\StimulusController; +use Symfony\UX\Toolkit\File\File; + +final class StimulusControllerTest extends TestCase +{ + public function testCanBeInstantiated(): void + { + $stimulusController = new StimulusController('clipboard', [ + new File('assets/controllers/clipboard_controller.js', 'clipboard_controller.js'), + ]); + + $this->assertSame('clipboard', $stimulusController->name); + } + + public function testShouldFailIfStimulusControllerNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid Stimulus controller name "invalid_controller".'); + + new StimulusController('invalid_controller', [new File('assets/controllers/invalid_controller.js', 'invalid_controller.js')]); + } + + public function testShouldFailIfStimulusControllerHasNoFiles(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Stimulus controller "clipboard" has no files.'); + + new StimulusController('clipboard', []); + } +} diff --git a/src/Toolkit/tests/Command/DebugKitCommandTest.php b/src/Toolkit/tests/Command/DebugKitCommandTest.php new file mode 100644 index 00000000000..406d237d05a --- /dev/null +++ b/src/Toolkit/tests/Command/DebugKitCommandTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Command; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Console\Test\InteractsWithConsole; + +class DebugKitCommandTest extends KernelTestCase +{ + use InteractsWithConsole; + + public function testShouldBeAbleToDebug(): void + { + $this->bootKernel(); + $this->consoleCommand(\sprintf('ux:toolkit:debug-kit %s', __DIR__.'/../../kits/shadcn')) + ->execute() + ->assertSuccessful() + // Kit details + ->assertOutputContains('Name Shadcn') + ->assertOutputContains('Homepage https://ux.symfony.com/components') + ->assertOutputContains('License MIT') + // Components details + ->assertOutputContains(<<<'EOF' ++--------------+----------------------- Component: "Avatar" --------------------------------------+ +| File(s) | templates/components/Avatar.html.twig | +| Dependencies | tales-from-a-dev/twig-tailwind-extra | +| | Avatar:Image | +| | Avatar:Text | ++--------------+----------------------------------------------------------------------------------+ +EOF + ) + ->assertOutputContains(<<<'EOF' ++--------------+----------------------- Component: "Table" ---------------------------------------+ +| File(s) | templates/components/Table.html.twig | +| Dependencies | tales-from-a-dev/twig-tailwind-extra | +| | Table:Body | +| | Table:Caption | +| | Table:Cell | +| | Table:Footer | +| | Table:Head | +| | Table:Header | +| | Table:Row | ++--------------+----------------------------------------------------------------------------------+ +EOF + ); + } +} diff --git a/src/Toolkit/tests/Command/InstallComponentCommandTest.php b/src/Toolkit/tests/Command/InstallComponentCommandTest.php new file mode 100644 index 00000000000..79e7e811c50 --- /dev/null +++ b/src/Toolkit/tests/Command/InstallComponentCommandTest.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Command; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Zenstruck\Console\Test\InteractsWithConsole; + +class InstallComponentCommandTest extends KernelTestCase +{ + use InteractsWithConsole; + + private Filesystem $filesystem; + private string $tmpDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->bootKernel(); + $this->filesystem = self::getContainer()->get('filesystem'); + $this->tmpDir = $this->filesystem->tempnam(sys_get_temp_dir(), 'ux_toolkit_test_'); + $this->filesystem->remove($this->tmpDir); + $this->filesystem->mkdir($this->tmpDir); + } + + public function testShouldAbleToInstallComponentTableAndItsDependencies(): void + { + $expectedFiles = [ + 'Table.html.twig' => $this->tmpDir.'/Table.html.twig', + 'Table/Body.html.twig' => $this->tmpDir.'/Table/Body.html.twig', + 'Table/Caption.html.twig' => $this->tmpDir.'/Table/Caption.html.twig', + 'Table/Cell.html.twig' => $this->tmpDir.'/Table/Cell.html.twig', + 'Table/Footer.html.twig' => $this->tmpDir.'/Table/Footer.html.twig', + 'Table/Head.html.twig' => $this->tmpDir.'/Table/Head.html.twig', + 'Table/Header.html.twig' => $this->tmpDir.'/Table/Header.html.twig', + 'Table/Row.html.twig' => $this->tmpDir.'/Table/Row.html.twig', + ]; + + foreach ($expectedFiles as $expectedFile) { + $this->assertFileDoesNotExist($expectedFile); + } + + $testCommand = $this->consoleCommand('ux:toolkit:install-component Table --destination='.$this->tmpDir) + ->execute() + ->assertSuccessful() + ->assertOutputContains('Installing component Table from the Shadcn UI kit...') + ->assertOutputContains('[OK] The component has been installed.') + ; + + // Files should be created + foreach ($expectedFiles as $fileName => $expectedFile) { + $testCommand->assertOutputContains($fileName); + $this->assertFileExists($expectedFile); + $this->assertEquals(file_get_contents(__DIR__.'/../../kits/shadcn/templates/components/'.$fileName), file_get_contents($expectedFile)); + } + } + + public function testShouldFailAndSuggestAlternativeComponentsWhenKitIsExplicit(): void + { + $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); + mkdir($destination); + + $this->bootKernel(); + $this->consoleCommand('ux:toolkit:install-component Table: --kit=shadcn --destination='.$destination) + ->execute() + ->assertFaulty() + ->assertOutputContains('[WARNING] The component "Table:" does not exist') + ->assertOutputContains('Possible alternatives: ') + ->assertOutputContains('"Table:Body"') + ->assertOutputContains('"Table:Caption"') + ->assertOutputContains('"Table:Cell"') + ->assertOutputContains('"Table:Footer"') + ->assertOutputContains('"Table:Head"') + ->assertOutputContains('"Table:Header"') + ->assertOutputContains('"Table:Row"') + ; + } + + public function testShouldFailWhenComponentDoesNotExist(): void + { + $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); + mkdir($destination); + + $this->bootKernel(); + $this->consoleCommand('ux:toolkit:install-component Unknown --destination='.$destination) + ->execute() + ->assertFaulty() + ->assertOutputContains('The component "Unknown" does not exist'); + } + + public function testShouldWarnWhenComponentFileAlreadyExistsInNonInteractiveMode(): void + { + $destination = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid(); + mkdir($destination); + + $this->bootKernel(); + $this->consoleCommand('ux:toolkit:install-component Badge --destination='.$destination) + ->execute() + ->assertSuccessful(); + + $this->consoleCommand('ux:toolkit:install-component Badge --destination='.$destination) + ->execute() + ->assertFaulty() + ->assertOutputContains('[WARNING] The component has not been installed.') + ; + } +} diff --git a/src/Toolkit/tests/Dependency/ComponentDependencyTest.php b/src/Toolkit/tests/Dependency/ComponentDependencyTest.php new file mode 100644 index 00000000000..54dc3a42eb3 --- /dev/null +++ b/src/Toolkit/tests/Dependency/ComponentDependencyTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Dependency; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; + +final class ComponentDependencyTest extends TestCase +{ + public function testShouldBeInstantiable(): void + { + $dependency = new ComponentDependency('Table:Body'); + + $this->assertSame('Table:Body', $dependency->name); + $this->assertSame('Table:Body', (string) $dependency); + } + + public function testShouldFailIfComponentNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid component name "foobar".'); + + new ComponentDependency('foobar'); + } +} diff --git a/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php b/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php new file mode 100644 index 00000000000..8efb9f75501 --- /dev/null +++ b/src/Toolkit/tests/Dependency/PhpPackageDependencyTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Dependency; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\Version; + +final class PhpPackageDependencyTest extends TestCase +{ + public function testShouldBeInstantiable(): void + { + $dependency = new PhpPackageDependency('twig/html-extra'); + $this->assertSame('twig/html-extra', $dependency->name); + $this->assertNull($dependency->constraintVersion); + $this->assertSame('twig/html-extra', (string) $dependency); + + $dependency = new PhpPackageDependency('twig/html-extra', new Version('3.2.1')); + $this->assertSame('twig/html-extra', $dependency->name); + $this->assertSame('twig/html-extra:^3.2.1', (string) $dependency); + } + + public function testShouldFailIfPackageNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PHP package name "/foo".'); + + new PhpPackageDependency('/foo'); + } +} diff --git a/src/Toolkit/tests/Dependency/StimulusControllerDependencyTest.php b/src/Toolkit/tests/Dependency/StimulusControllerDependencyTest.php new file mode 100644 index 00000000000..2e6b6feb80b --- /dev/null +++ b/src/Toolkit/tests/Dependency/StimulusControllerDependencyTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Dependency; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; + +final class StimulusControllerDependencyTest extends TestCase +{ + public function testShouldBeInstantiable(): void + { + $dependency = new StimulusControllerDependency('clipboard'); + + $this->assertSame('clipboard', $dependency->name); + $this->assertSame('clipboard', (string) $dependency); + } + + public function testShouldFailIfComponentNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid Stimulus controller name "my_Controller".'); + + new StimulusControllerDependency('my_Controller'); + } +} diff --git a/src/Toolkit/tests/Dependency/VersionTest.php b/src/Toolkit/tests/Dependency/VersionTest.php new file mode 100644 index 00000000000..cc0c50f52ca --- /dev/null +++ b/src/Toolkit/tests/Dependency/VersionTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Dependency; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Dependency\Version; + +final class VersionTest extends TestCase +{ + public function testCanBeInstantiated(): void + { + $version = new Version('1.2.3'); + + $this->assertSame('1.2.3', (string) $version); + } + + public function testCanBeCompared(): void + { + $this->assertTrue((new Version('1.2.3'))->isHigherThan(new Version('1.2.2'))); + $this->assertFalse((new Version('1.2.3'))->isHigherThan(new Version('1.2.4'))); + $this->assertTrue((new Version('1.2.3'))->isHigherThan(new Version('1.1.99'))); + $this->assertFalse((new Version('1.2.3'))->isHigherThan(new Version('1.2.3'))); + $this->assertTrue((new Version('1.2.3'))->isHigherThan(new Version('0.99.99'))); + $this->assertFalse((new Version('1.2.3'))->isHigherThan(new Version('2.0.0'))); + } +} diff --git a/src/Toolkit/tests/File/DocTest.php b/src/Toolkit/tests/File/DocTest.php new file mode 100644 index 00000000000..2cfb82df4b8 --- /dev/null +++ b/src/Toolkit/tests/File/DocTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\File; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\File\Doc; + +final class DocTest extends TestCase +{ + public function testCanBeInstantiated(): void + { + $doc = new Doc( + '# Basic Button + +```twig + + Click me + +```' + ); + + self::assertEquals('# Basic Button + +```twig + + Click me + +```', $doc->markdownContent); + } +} diff --git a/src/Toolkit/tests/File/FileTest.php b/src/Toolkit/tests/File/FileTest.php new file mode 100644 index 00000000000..d92958ed2f5 --- /dev/null +++ b/src/Toolkit/tests/File/FileTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\File; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\File\File; + +final class FileTest extends TestCase +{ + public function testShouldFailIfPathIsNotRelative(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The path to the kit "%s" must be relative.', __FILE__.'/templates/components/Button.html.twig')); + + new File(__FILE__.'/templates/components/Button.html.twig', __FILE__.'Button.html.twig'); + } + + public function testShouldFailIfPathNameIsNotRelative(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The path name "%s" must be relative.', __FILE__.'Button.html.twig')); + + new File('templates/components/Button.html.twig', __FILE__.'Button.html.twig'); + } + + public function testShouldFailIfPathNameIsNotASubpathOfPathToKit(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The relative path name "%s" must be a subpath of the relative path to the kit "%s".', 'foo/bar/Button.html.twig', 'templates/components/Button.html.twig')); + + new File('templates/components/Button.html.twig', 'foo/bar/Button.html.twig'); + } + + public function testCanInstantiateFile(): void + { + $file = new File('templates/components/Button.html.twig', 'Button.html.twig'); + + $this->assertSame('templates/components/Button.html.twig', $file->relativePathNameToKit); + $this->assertSame('Button.html.twig', $file->relativePathName); + $this->assertSame('templates/components/Button.html.twig', (string) $file); + } + + public function testCanInstantiateFileWithSubComponent(): void + { + $file = new File('templates/components/Table/Body.html.twig', 'Table/Body.html.twig'); + + $this->assertSame('templates/components/Table/Body.html.twig', $file->relativePathNameToKit); + $this->assertSame('Table/Body.html.twig', $file->relativePathName); + $this->assertSame('templates/components/Table/Body.html.twig', (string) $file); + } +} diff --git a/src/Toolkit/tests/Fixtures/Kernel.php b/src/Toolkit/tests/Fixtures/Kernel.php new file mode 100644 index 00000000000..6734bb54ea5 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/Kernel.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Fixtures; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Kernel as BaseKernel; +use Symfony\UX\Icons\UXIconsBundle; +use Symfony\UX\Toolkit\UXToolkitBundle; +use Symfony\UX\TwigComponent\TwigComponentBundle; +use TalesFromADev\Twig\Extra\Tailwind\Bridge\Symfony\Bundle\TalesFromADevTwigExtraTailwindBundle; +use Twig\Extra\TwigExtraBundle\TwigExtraBundle; + +final class Kernel extends BaseKernel +{ + use MicroKernelTrait; + + public function registerBundles(): iterable + { + return [ + new FrameworkBundle(), + new TwigBundle(), + new TwigComponentBundle(), + new TwigExtraBundle(), + new UXIconsBundle(), + new TalesFromADevTwigExtraTailwindBundle(), + new UXToolkitBundle(), + ]; + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', [ + 'secret' => 'S3CRET', + 'test' => true, + 'router' => ['utf8' => true], + 'secrets' => false, + 'http_method_override' => false, + 'php_errors' => ['log' => true], + 'property_access' => true, + 'http_client' => true, + 'handle_all_throwables' => true, + + ...(self::VERSION_ID >= 70300 ? [ + 'property_info' => ['with_constructor_extractor' => false], + ] : []), + ]); + + $container->extension('twig', [ + 'default_path' => __DIR__.'/../../kits', + ]); + + $container->extension('twig_component', [ + 'anonymous_template_directory' => 'components/', + 'defaults' => [], + ]); + + $container->services() + ->alias('ux_toolkit.kit.kit_factory', '.ux_toolkit.kit.kit_factory') + ->public() + + ->alias('ux_toolkit.kit.kit_synchronizer', '.ux_toolkit.kit.kit_synchronizer') + ->public() + + ->alias('ux_toolkit.registry.registry_factory', '.ux_toolkit.registry.registry_factory') + ->public() + + ->alias('ux_toolkit.registry.local', '.ux_toolkit.registry.local') + ->public() + ; + } +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json new file mode 100644 index 00000000000..f23837787ff --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "With Circular Components Dependencies", + "description": "Kit used as a test fixture.", + "license": "MIT", + "homepage": "https://ux.symfony.com/" +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/A.html.twig b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/A.html.twig new file mode 100644 index 00000000000..170566e3300 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/A.html.twig @@ -0,0 +1 @@ + diff --git a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/B.html.twig b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/B.html.twig new file mode 100644 index 00000000000..ee363988603 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/B.html.twig @@ -0,0 +1 @@ + diff --git a/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/C.html.twig b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/C.html.twig new file mode 100644 index 00000000000..bb94edf19d0 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-circular-components-dependencies/templates/components/C.html.twig @@ -0,0 +1,4 @@ +{% props render_child = false %} +{% if render_child %} + +{% endif %} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/clipboard_controller.js b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/clipboard_controller.js new file mode 100644 index 00000000000..3e2f4a2f79b --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/clipboard_controller.js @@ -0,0 +1,5 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // â€Ļ +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/date_picker_controller.js b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/date_picker_controller.js new file mode 100644 index 00000000000..3e2f4a2f79b --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/date_picker_controller.js @@ -0,0 +1,5 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // â€Ļ +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/local-time-controller.js b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/local-time-controller.js new file mode 100644 index 00000000000..3e2f4a2f79b --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/local-time-controller.js @@ -0,0 +1,5 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // â€Ļ +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/users/list_item_controller.js b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/users/list_item_controller.js new file mode 100644 index 00000000000..3e2f4a2f79b --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/assets/controllers/users/list_item_controller.js @@ -0,0 +1,5 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + // â€Ļ +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json new file mode 100644 index 00000000000..4589ccfdc23 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "With Stimulus Controllers", + "description": "Kit used as a test fixture.", + "license": "MIT", + "homepage": "https://ux.symfony.com/" +} diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/Clipboard.html.twig b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/Clipboard.html.twig new file mode 100644 index 00000000000..171027bc04e --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/Clipboard.html.twig @@ -0,0 +1 @@ +
    {% block content %}
    diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/DatePicker.html.twig b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/DatePicker.html.twig new file mode 100644 index 00000000000..7b4e9a8e332 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/DatePicker.html.twig @@ -0,0 +1 @@ +
    {% block content %}
    diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/LocalTime.html.twig b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/LocalTime.html.twig new file mode 100644 index 00000000000..37995d5bff0 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/LocalTime.html.twig @@ -0,0 +1 @@ +
    {% block content %}
    diff --git a/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/UsersListItem.html.twig b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/UsersListItem.html.twig new file mode 100644 index 00000000000..7c7a15bde17 --- /dev/null +++ b/src/Toolkit/tests/Fixtures/kits/with-stimulus-controllers/templates/components/UsersListItem.html.twig @@ -0,0 +1 @@ +
    {% block content %}
    diff --git a/src/Toolkit/tests/Functional/ComponentsRenderingTest.php b/src/Toolkit/tests/Functional/ComponentsRenderingTest.php new file mode 100644 index 00000000000..39978c8a5cd --- /dev/null +++ b/src/Toolkit/tests/Functional/ComponentsRenderingTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Functional; + +use Spatie\Snapshots\Drivers\HtmlDriver; +use Spatie\Snapshots\MatchesSnapshots; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitFactory; +use Symfony\UX\Toolkit\Registry\LocalRegistry; + +class ComponentsRenderingTest extends WebTestCase +{ + use MatchesSnapshots; + + private const KITS_DIR = __DIR__.'/../../kits'; + + /** + * @return iterable + */ + public static function provideTestComponentRendering(): iterable + { + foreach (LocalRegistry::getAvailableKitsName() as $kitName) { + $kitDir = Path::join(__DIR__, '../../kits', $kitName, 'docs/components'); + $docsFinder = (new Finder())->files()->name('*.md')->in($kitDir)->depth(0); + + foreach ($docsFinder as $docFile) { + $componentName = $docFile->getFilenameWithoutExtension(); + + $codeBlockMatchesResult = preg_match_all('/```twig.*?\n(?P.+?)```/s', $docFile->getContents(), $codeBlockMatches); + if (false === $codeBlockMatchesResult || 0 === $codeBlockMatchesResult) { + throw new \RuntimeException(\sprintf('No Twig code blocks found in file "%s"', $docFile->getRelativePathname())); + } + + foreach ($codeBlockMatches['code'] as $i => $code) { + yield \sprintf('Kit %s, component %s, code #%d', $kitName, $componentName, $i + 1) => [$kitName, $componentName, $code]; + } + } + } + } + + /** + * @dataProvider provideTestComponentRendering + */ + public function testComponentRendering(string $kitName, string $componentName, string $code): void + { + $twig = self::getContainer()->get('twig'); + $kitContextRunner = self::getContainer()->get('ux_toolkit.kit.kit_context_runner'); + + $kit = $this->instantiateKit($kitName); + $template = $twig->createTemplate($code); + $renderedCode = $kitContextRunner->runForKit($kit, fn () => $template->render()); + + $this->assertCodeRenderedMatchesHtmlSnapshot($kit, $kit->getComponent($componentName), $code, $renderedCode); + } + + private function instantiateKit(string $kitName): Kit + { + $kitFactory = self::getContainer()->get('ux_toolkit.kit.kit_factory'); + + self::assertInstanceOf(KitFactory::class, $kitFactory); + + return $kitFactory->createKitFromAbsolutePath(Path::join(__DIR__, '../../kits', $kitName)); + } + + private function assertCodeRenderedMatchesHtmlSnapshot(Kit $kit, Component $component, string $code, string $renderedCode): void + { + $info = \sprintf(<< + HTML, + $kit->name, + $component->name, + trim($code) + ); + + $this->assertMatchesSnapshot($renderedCode, new class($info) extends HtmlDriver { + public function __construct(private string $info) + { + } + + public function serialize($data): string + { + $serialized = parent::serialize($data); + $serialized = str_replace(['', ''], '', $serialized); + $serialized = trim($serialized); + + return $this->info."\n".$serialized; + } + }); + } +} diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 1__1.html new file mode 100644 index 00000000000..c3586e297f7 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 1__1.html @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 2__1.html new file mode 100644 index 00000000000..c3586e297f7 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 2__1.html @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 3__1.html new file mode 100644 index 00000000000..87ea2e3ad78 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Alert, code 3__1.html @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 1__1.html new file mode 100644 index 00000000000..4a79c14ce86 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 1__1.html @@ -0,0 +1,17 @@ + +
    +Landscape photograph by Tobias Tullius +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 2__1.html new file mode 100644 index 00000000000..f644e90cb4e --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 2__1.html @@ -0,0 +1,17 @@ + +
    +Landscape photograph by Tobias Tullius +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 3__1.html new file mode 100644 index 00000000000..f1dbc69529a --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component AspectRatio, code 3__1.html @@ -0,0 +1,17 @@ + +
    +Landscape photograph by Tobias Tullius +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 1__1.html new file mode 100644 index 00000000000..e1c7e6eed56 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 1__1.html @@ -0,0 +1,13 @@ + +@symfony + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 2__1.html new file mode 100644 index 00000000000..e1c7e6eed56 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 2__1.html @@ -0,0 +1,13 @@ + +@symfony + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 3__1.html new file mode 100644 index 00000000000..7ef48de0fd2 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 3__1.html @@ -0,0 +1,21 @@ + +
    + FP + + FP + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 4__1.html new file mode 100644 index 00000000000..c16030c2697 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Avatar, code 4__1.html @@ -0,0 +1,27 @@ + +
    + @symfony + + + FP + + FP + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 1__1.html new file mode 100644 index 00000000000..7b3a1ebdbad --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 1__1.html @@ -0,0 +1,9 @@ + +
    Badge
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 2__1.html new file mode 100644 index 00000000000..7b3a1ebdbad --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 2__1.html @@ -0,0 +1,9 @@ + +
    Badge
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 3__1.html new file mode 100644 index 00000000000..a32c304ab18 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 3__1.html @@ -0,0 +1,12 @@ + +
    Badge +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 4__1.html new file mode 100644 index 00000000000..18c1b14ff9a --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 4__1.html @@ -0,0 +1,12 @@ + +
    Badge +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 5__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 5__1.html new file mode 100644 index 00000000000..5cdce6f87c3 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 5__1.html @@ -0,0 +1,12 @@ + +
    Badge +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 6__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 6__1.html new file mode 100644 index 00000000000..c2dbe83612a --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Badge, code 6__1.html @@ -0,0 +1,15 @@ + +
    + + Verified +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 1__1.html new file mode 100644 index 00000000000..a7cb9999539 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 1__1.html @@ -0,0 +1,47 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 2__1.html new file mode 100644 index 00000000000..a7cb9999539 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 2__1.html @@ -0,0 +1,47 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 3__1.html new file mode 100644 index 00000000000..d55f2526493 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Breadcrumb, code 3__1.html @@ -0,0 +1,56 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 10__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 10__1.html new file mode 100644 index 00000000000..cccc55500e1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 10__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 11__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 11__1.html new file mode 100644 index 00000000000..3268a717761 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 11__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 1__1.html new file mode 100644 index 00000000000..adc6db76012 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 1__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 2__1.html new file mode 100644 index 00000000000..adc6db76012 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 2__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 3__1.html new file mode 100644 index 00000000000..8d5d34c2f1b --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 3__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 4__1.html new file mode 100644 index 00000000000..51fee13723d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 4__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 5__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 5__1.html new file mode 100644 index 00000000000..609ce7dc802 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 5__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 6__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 6__1.html new file mode 100644 index 00000000000..51fee13723d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 6__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 7__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 7__1.html new file mode 100644 index 00000000000..b787ff13452 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 7__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 8__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 8__1.html new file mode 100644 index 00000000000..0bdafc6e3f0 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 8__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 9__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 9__1.html new file mode 100644 index 00000000000..331e9217ac5 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Button, code 9__1.html @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 1__1.html new file mode 100644 index 00000000000..0adf24f033d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 1__1.html @@ -0,0 +1,33 @@ + +
    +
    +
    Card Title
    +
    Card Description
    +
    +
    +

    Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.

    +
    +
    + + +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 2__1.html new file mode 100644 index 00000000000..0adf24f033d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 2__1.html @@ -0,0 +1,33 @@ + +
    +
    +
    Card Title
    +
    Card Description
    +
    +
    +

    Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.

    +
    +
    + + +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 3__1.html new file mode 100644 index 00000000000..b418978a959 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Card, code 3__1.html @@ -0,0 +1,85 @@ + +
    +
    +
    Notifications
    +
    You have 3 unread messages.
    +
    +
    +
    + +
    +

    + Your call has been confirmed. +

    +

    + 1 hour ago +

    +
    +
    +
    + +
    +

    + You have a new message! +

    +

    + 1 hour ago +

    +
    +
    +
    + +
    +

    + Your subscription is expiring soon! +

    +

    + 2 hours ago +

    +
    +
    +
    +
    + +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 1__1.html new file mode 100644 index 00000000000..543639dbff1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 1__1.html @@ -0,0 +1,20 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 2__1.html new file mode 100644 index 00000000000..543639dbff1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 2__1.html @@ -0,0 +1,20 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 3__1.html new file mode 100644 index 00000000000..169aee1851c --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 3__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 4__1.html new file mode 100644 index 00000000000..70ca0d838fb --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Checkbox, code 4__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 1__1.html new file mode 100644 index 00000000000..1e51cbcdbd4 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 1__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 2__1.html new file mode 100644 index 00000000000..1e51cbcdbd4 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 2__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 3__1.html new file mode 100644 index 00000000000..426ba17ddf4 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 3__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 4__1.html new file mode 100644 index 00000000000..9ef448ac461 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 4__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 5__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 5__1.html new file mode 100644 index 00000000000..3ee2727d1b8 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 5__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 6__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 6__1.html new file mode 100644 index 00000000000..1811212cd22 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Input, code 6__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 1__1.html new file mode 100644 index 00000000000..c085f9dbf88 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 1__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 2__1.html new file mode 100644 index 00000000000..c085f9dbf88 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 2__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 3__1.html new file mode 100644 index 00000000000..2ac19e5f059 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 3__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 4__1.html new file mode 100644 index 00000000000..ccf0432e0ef --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Label, code 4__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 1__1.html new file mode 100644 index 00000000000..54da00b18ac --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 1__1.html @@ -0,0 +1,60 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 2__1.html new file mode 100644 index 00000000000..54da00b18ac --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 2__1.html @@ -0,0 +1,60 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 3__1.html new file mode 100644 index 00000000000..cbdcec499aa --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Pagination, code 3__1.html @@ -0,0 +1,82 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 1__1.html new file mode 100644 index 00000000000..0731fbc07ce --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 1__1.html @@ -0,0 +1,11 @@ + +
    +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 2__1.html new file mode 100644 index 00000000000..0731fbc07ce --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 2__1.html @@ -0,0 +1,11 @@ + +
    +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 3__1.html new file mode 100644 index 00000000000..2fcf5c28d83 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 3__1.html @@ -0,0 +1,24 @@ + +
    +
    + + 33% +
    +
    +
    +
    + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 4__1.html new file mode 100644 index 00000000000..0256b1db386 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Progress, code 4__1.html @@ -0,0 +1,36 @@ + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 1__1.html new file mode 100644 index 00000000000..4d34ca5723c --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 1__1.html @@ -0,0 +1,16 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 2__1.html new file mode 100644 index 00000000000..4d34ca5723c --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 2__1.html @@ -0,0 +1,16 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 3__1.html new file mode 100644 index 00000000000..c989fe3eec4 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 3__1.html @@ -0,0 +1,22 @@ + +
    + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 4__1.html new file mode 100644 index 00000000000..f82b4538605 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Select, code 4__1.html @@ -0,0 +1,16 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 1__1.html new file mode 100644 index 00000000000..565b8f66e20 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 1__1.html @@ -0,0 +1,45 @@ + +
    +
    +

    Symfony UX

    +

    + Symfony UX initiative: a JavaScript ecosystem for Symfony +

    +
    +
    +
    + +
    + Website +
    +
    + + Packages +
    +
    + + Source +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 2__1.html new file mode 100644 index 00000000000..c1a0775bffe --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 2__1.html @@ -0,0 +1,45 @@ + +
    +
    +

    Symfony UX

    +

    + Symfony UX initiative: a JavaScript ecosystem for Symfony +

    +
    +
    +
    + +
    +
    Blog
    +
    +
    + +
    Docs
    +
    +
    + +
    Source
    +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 3__1.html new file mode 100644 index 00000000000..dbd412538bf --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Separator, code 3__1.html @@ -0,0 +1,25 @@ + +
    +
    Blog
    +
    +
    + +
    Docs
    +
    +
    + +
    Source
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 1__1.html new file mode 100644 index 00000000000..499706948a1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 1__1.html @@ -0,0 +1,24 @@ + +
    +
    + +
    +
    + +
    + +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 2__1.html new file mode 100644 index 00000000000..499706948a1 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 2__1.html @@ -0,0 +1,24 @@ + +
    +
    + +
    +
    + +
    + +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 3__1.html new file mode 100644 index 00000000000..dd9e56d778d --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Skeleton, code 3__1.html @@ -0,0 +1,24 @@ + +
    +
    + +
    +
    + +
    + +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 1__1.html new file mode 100644 index 00000000000..d8b44e43b80 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 1__1.html @@ -0,0 +1,19 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 2__1.html new file mode 100644 index 00000000000..d8b44e43b80 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 2__1.html @@ -0,0 +1,19 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 3__1.html new file mode 100644 index 00000000000..69fc94946cc --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Switch, code 3__1.html @@ -0,0 +1,53 @@ + +
    +

    Email Notifications

    +
    +
    +
    + +

    Receive emails about new products, features, and more.

    +
    + + +
    +
    +
    + +

    Receive emails about your account security.

    +
    + + +
    +
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 1__1.html new file mode 100644 index 00000000000..4b75f309575 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 1__1.html @@ -0,0 +1,65 @@ + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    A list of your recent invoices.
    InvoiceStatusMethodAmount
    INV001PaidCredit Card$250.00
    INV002PendingPayPal$150.00
    INV003UnpaidBank Transfer$350.00
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 2__1.html new file mode 100644 index 00000000000..b9f6244ffce --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Table, code 2__1.html @@ -0,0 +1,105 @@ + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    A list of your recent invoices.
    InvoiceStatusMethodAmount
    INV001PaidCredit Card$250.00
    INV002PendingPayPal$150.00
    INV003UnpaidBank Transfer$350.00
    INV004PaidCredit Card$450.00
    INV005PaidPayPal$550.00
    INV006PendingBank Transfer$200.00
    INV007UnpaidCredit Card$300.00
    Total$1,500.00
    +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 1__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 1__1.html new file mode 100644 index 00000000000..49c8bdd6848 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 1__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 2__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 2__1.html new file mode 100644 index 00000000000..49c8bdd6848 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 2__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 3__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 3__1.html new file mode 100644 index 00000000000..61f1f01f337 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 3__1.html @@ -0,0 +1,16 @@ + +
    + + + +
    \ No newline at end of file diff --git a/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 4__1.html b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 4__1.html new file mode 100644 index 00000000000..ff0e2502d34 --- /dev/null +++ b/src/Toolkit/tests/Functional/__snapshots__/ComponentsRenderingTest__testComponentRendering with data set Kit shadcn, component Textarea, code 4__1.html @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/src/Toolkit/tests/Installer/InstallerTest.php b/src/Toolkit/tests/Installer/InstallerTest.php new file mode 100644 index 00000000000..7386d581f63 --- /dev/null +++ b/src/Toolkit/tests/Installer/InstallerTest.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Installer; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Installer\Installer; +use Symfony\UX\Toolkit\Kit\Kit; + +final class InstallerTest extends KernelTestCase +{ + private Filesystem $filesystem; + private string $tmpDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->bootKernel(); + $this->filesystem = self::getContainer()->get('filesystem'); + $this->tmpDir = $this->filesystem->tempnam(sys_get_temp_dir(), 'ux_toolkit_test_'); + $this->filesystem->remove($this->tmpDir); + $this->filesystem->mkdir($this->tmpDir); + } + + public function testCanInstallComponent(): void + { + $componentInstaller = new Installer(self::getContainer()->get('filesystem'), fn () => throw new \BadFunctionCallException('The installer should not ask for confirmation since the file does not exist.')); + $kit = $this->createKit('shadcn'); + + $this->assertFileDoesNotExist($this->tmpDir.'/Button.html.twig'); + + $component = $kit->getComponent('Button'); + $this->assertNotNull($component); + + $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); + + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $this->assertSame(file_get_contents($this->tmpDir.'/Button.html.twig'), file_get_contents(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + } + + public function testShouldAskIfFileAlreadyExists(): void + { + $askedCount = 0; + $componentInstaller = new Installer(self::getContainer()->get('filesystem'), function () use (&$askedCount) { + ++$askedCount; + + return true; + }); + $kit = $this->createKit('shadcn'); + + $component = $kit->getComponent('Button'); + $this->assertNotNull($component); + + $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); + + $this->assertSame(0, $askedCount); + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $this->assertSame(file_get_contents($this->tmpDir.'/Button.html.twig'), file_get_contents(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + + $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); + $this->assertSame(1, $askedCount); + } + + public function testCanInstallComponentIfForced(): void + { + $componentInstaller = new Installer(self::getContainer()->get('filesystem'), fn () => throw new \BadFunctionCallException('The installer should not ask for confirmation since the file does not exist.')); + $kit = $this->createKit('shadcn'); + + $component = $kit->getComponent('Button'); + $this->assertNotNull($component); + + $componentInstaller->installComponent($kit, $component, $this->tmpDir, false); + + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $this->assertSame(file_get_contents($this->tmpDir.'/Button.html.twig'), file_get_contents(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + + $componentInstaller->installComponent($kit, $component, $this->tmpDir, true); + + $this->assertFileExists($this->tmpDir.'/Button.html.twig'); + $this->assertSame(file_get_contents($this->tmpDir.'/Button.html.twig'), file_get_contents(\sprintf('%s/templates/components/Button.html.twig', $kit->path))); + } + + public function testCanInstallComponentAndItsComponentDependencies(): void + { + $componentInstaller = new Installer(self::getContainer()->get('filesystem'), fn () => throw new \BadFunctionCallException('The installer should not ask for confirmation since the file does not exist.')); + $kit = $this->createKit('shadcn'); + + $expectedFiles = [ + 'Table.html.twig' => $this->tmpDir.'/Table.html.twig', + 'Table/Body.html.twig' => $this->tmpDir.'/Table/Body.html.twig', + 'Table/Caption.html.twig' => $this->tmpDir.'/Table/Caption.html.twig', + 'Table/Cell.html.twig' => $this->tmpDir.'/Table/Cell.html.twig', + 'Table/Footer.html.twig' => $this->tmpDir.'/Table/Footer.html.twig', + 'Table/Head.html.twig' => $this->tmpDir.'/Table/Head.html.twig', + 'Table/Header.html.twig' => $this->tmpDir.'/Table/Header.html.twig', + 'Table/Row.html.twig' => $this->tmpDir.'/Table/Row.html.twig', + 'Button.html.twig' => $this->tmpDir.'/Button.html.twig', + 'Input.html.twig' => $this->tmpDir.'/Input.html.twig', + ]; + + foreach ($expectedFiles as $expectedFile) { + $this->assertFileDoesNotExist($expectedFile); + } + + $componentInstaller->installComponent($kit, $kit->getComponent('Table'), $this->tmpDir, false); + $componentInstaller->installComponent($kit, $kit->getComponent('Button'), $this->tmpDir, false); + $componentInstaller->installComponent($kit, $kit->getComponent('Input'), $this->tmpDir, false); + + foreach ($expectedFiles as $fileName => $expectedFile) { + $this->assertFileExists($expectedFile); + $this->assertSame(file_get_contents($expectedFile), file_get_contents(\sprintf('%s/templates/components/%s', $kit->path, $fileName))); + } + } + + private function createKit(string $kitName): Kit + { + return self::getContainer()->get('ux_toolkit.kit.kit_factory')->createKitFromAbsolutePath(Path::join(__DIR__, '../../kits', $kitName)); + } +} diff --git a/src/Toolkit/tests/Installer/PoolResolverTest.php b/src/Toolkit/tests/Installer/PoolResolverTest.php new file mode 100644 index 00000000000..deded51aae0 --- /dev/null +++ b/src/Toolkit/tests/Installer/PoolResolverTest.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Installer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\Installer\PoolResolver; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitSynchronizer; + +final class PoolResolverTest extends TestCase +{ + public function testCanResolveDependencies(): void + { + $kitSynchronizer = new KitSynchronizer(new Filesystem()); + $kit = new Kit(Path::join(__DIR__, '../../kits/shadcn'), 'shadcn'); + $kitSynchronizer->synchronize($kit); + + $poolResolver = new PoolResolver(); + + $pool = $poolResolver->resolveForComponent($kit, $kit->getComponent('Button')); + + $this->assertCount(1, $pool->getFiles()); + $this->assertArrayHasKey('Button.html.twig', $pool->getFiles()); + $this->assertCount(3, $pool->getPhpPackageDependencies()); + + $pool = $poolResolver->resolveForComponent($kit, $kit->getComponent('Table')); + + $this->assertCount(8, $pool->getFiles()); + $this->assertArrayHasKey('Table.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Row.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Cell.html.twig', $pool->getFiles()); + $this->assertInstanceOf(File::class, $pool->getFiles()['Table/Head.html.twig']); + $this->assertArrayHasKey('Table/Header.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Footer.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Caption.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('Table/Body.html.twig', $pool->getFiles()); + $this->assertCount(1, $pool->getPhpPackageDependencies()); + } + + public function testCanHandleCircularComponentDependencies(): void + { + $kitSynchronizer = new KitSynchronizer(new Filesystem()); + $kit = new Kit(Path::join(__DIR__, '../Fixtures/kits/with-circular-components-dependencies'), 'with-circular-components-dependencies'); + $kitSynchronizer->synchronize($kit); + + $poolResolver = new PoolResolver(); + + $pool = $poolResolver->resolveForComponent($kit, $kit->getComponent('A')); + + $this->assertCount(3, $pool->getFiles()); + $this->assertArrayHasKey('A.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('B.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('C.html.twig', $pool->getFiles()); + $this->assertCount(0, $pool->getPhpPackageDependencies()); + + $pool = $poolResolver->resolveForComponent($kit, $kit->getComponent('B')); + + $this->assertCount(3, $pool->getFiles()); + $this->assertArrayHasKey('A.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('B.html.twig', $pool->getFiles()); + $this->assertArrayHasKey('C.html.twig', $pool->getFiles()); + $this->assertCount(0, $pool->getPhpPackageDependencies()); + } +} diff --git a/src/Toolkit/tests/Installer/PoolTest.php b/src/Toolkit/tests/Installer/PoolTest.php new file mode 100644 index 00000000000..a5dc227689e --- /dev/null +++ b/src/Toolkit/tests/Installer/PoolTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Installer; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\Version; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\Installer\Pool; + +final class PoolTest extends TestCase +{ + public function testCanAddFiles(): void + { + $pool = new Pool(); + + $this->assertCount(0, $pool->getFiles()); + + $pool->addFile(new File('path/to/file.html.twig', 'file.html.twig')); + $pool->addFile(new File('path/to/another-file.html.twig', 'another-file.html.twig')); + + $this->assertCount(2, $pool->getFiles()); + } + + public function testCantAddSameFileTwice(): void + { + $pool = new Pool(); + + $pool->addFile(new File('path/to/file.html.twig', 'file.html.twig')); + $pool->addFile(new File('path/to/file.html.twig', 'file.html.twig')); + + $this->assertCount(1, $pool->getFiles()); + } + + public function testCanAddPhpPackageDependencies(): void + { + $pool = new Pool(); + + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra')); + + $this->assertCount(1, $pool->getPhpPackageDependencies()); + } + + public function testCantAddSamePhpPackageDependencyTwice(): void + { + $pool = new Pool(); + + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra')); + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra')); + + $this->assertCount(1, $pool->getPhpPackageDependencies()); + } + + public function testCanAddPhpPackageDependencyWithHigherVersion(): void + { + $pool = new Pool(); + + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra', new Version('3.11.0'))); + + $this->assertCount(1, $pool->getPhpPackageDependencies()); + $this->assertEquals('twig/html-extra:^3.11.0', (string) $pool->getPhpPackageDependencies()['twig/html-extra']); + + $pool->addPhpPackageDependency(new PhpPackageDependency('twig/html-extra', new Version('3.12.0'))); + + $this->assertCount(1, $pool->getPhpPackageDependencies()); + $this->assertEquals('twig/html-extra:^3.12.0', (string) $pool->getPhpPackageDependencies()['twig/html-extra']); + } +} diff --git a/src/Toolkit/tests/Kit/KitContextRunnerTest.php b/src/Toolkit/tests/Kit/KitContextRunnerTest.php new file mode 100644 index 00000000000..7cfe77c4094 --- /dev/null +++ b/src/Toolkit/tests/Kit/KitContextRunnerTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Kit; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\Kit\KitContextRunner; +use Symfony\UX\TwigComponent\ComponentFactory; +use Symfony\UX\TwigComponent\ComponentTemplateFinder; +use Symfony\UX\TwigComponent\ComponentTemplateFinderInterface; + +class KitContextRunnerTest extends KernelTestCase +{ + public function testRunForKitShouldConfigureThenResetServices(): void + { + $twig = self::getContainer()->get('twig'); + $initialTwigLoader = $twig->getLoader(); + + $componentFactory = self::getContainer()->get('ux.twig_component.component_factory'); + $initialComponentFactoryState = $this->extractComponentFactoryState($componentFactory); + $this->assertInstanceOf(ComponentTemplateFinder::class, $initialComponentFactoryState['componentTemplateFinder']); + $this->assertIsArray($initialComponentFactoryState['config']); + + $executed = false; + $kitContextRunner = self::getContainer()->get('ux_toolkit.kit.kit_context_runner'); + $kitContextRunner->runForKit(self::getContainer()->get('ux_toolkit.registry.local')->getKit('shadcn'), function () use (&$executed, $twig, $initialTwigLoader, $componentFactory, $initialComponentFactoryState) { + $executed = true; + + $this->assertNotEquals($initialTwigLoader, $twig->getLoader(), 'The Twig loader must be different in this current kit-aware context.'); + $this->assertNotEquals($initialComponentFactoryState, $this->extractComponentFactoryState($componentFactory), 'The ComponentFactory state must be different in this current kit-aware context.'); + + $template = $twig->createTemplate('Hello world'); + $renderedTemplate = $template->render(); + + $this->assertNotEmpty($renderedTemplate); + $this->assertStringContainsString('Hello world', $renderedTemplate); + $this->assertStringContainsString('style="aspect-ratio:', $renderedTemplate); + }); + $this->assertTrue($executed, \sprintf('The callback passed to %s::runForKit() has not been executed.', KitContextRunner::class)); + + $this->assertEquals($initialTwigLoader, $twig->getLoader(), 'The Twig loader must be back to its original implementation.'); + $this->assertEquals($initialComponentFactoryState, $this->extractComponentFactoryState($componentFactory), 'The ComponentFactory must be back to its original state.'); + } + + /** + * @return array{componentTemplateFinder: ComponentTemplateFinderInterface::class, config: array} + */ + private function extractComponentFactoryState(ComponentFactory $componentFactory): array + { + $componentTemplateFinder = \Closure::bind(fn (ComponentFactory $componentFactory) => $componentFactory->componentTemplateFinder, null, $componentFactory)($componentFactory); + $config = \Closure::bind(fn (ComponentFactory $componentFactory) => $componentFactory->config, null, $componentFactory)($componentFactory); + + return ['componentTemplateFinder' => $componentTemplateFinder, 'config' => $config]; + } +} diff --git a/src/Toolkit/tests/Kit/KitFactoryTest.php b/src/Toolkit/tests/Kit/KitFactoryTest.php new file mode 100644 index 00000000000..07f4144ca37 --- /dev/null +++ b/src/Toolkit/tests/Kit/KitFactoryTest.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Kit; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\Asset\StimulusController; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\Kit\KitFactory; + +final class KitFactoryTest extends KernelTestCase +{ + public function testShouldFailIfPathIsNotAbsolute(): void + { + $kitFactory = $this->createKitFactory(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Path "shadcn" is not absolute.'); + + $kitFactory->createKitFromAbsolutePath('shadcn'); + } + + public function testShouldFailIfKitDoesNotExist(): void + { + $kitFactory = $this->createKitFactory(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Path "%s" does not exist.', __DIR__.'/../../kits/does-not-exist')); + + $kitFactory->createKitFromAbsolutePath(__DIR__.'/../../kits/does-not-exist'); + } + + public function testCanCreateShadKit(): void + { + $kitFactory = $this->createKitFactory(); + + $kit = $kitFactory->createKitFromAbsolutePath(__DIR__.'/../../kits/shadcn'); + + $this->assertNotNull($kit); + $this->assertNotEmpty($kit->getComponents()); + + $table = $kit->getComponent('Table'); + + $this->assertNotNull($table); + $this->assertNotEmpty($table->files); + $this->assertEquals([ + new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), + new ComponentDependency('Table:Body'), + new ComponentDependency('Table:Caption'), + new ComponentDependency('Table:Cell'), + new ComponentDependency('Table:Footer'), + new ComponentDependency('Table:Head'), + new ComponentDependency('Table:Header'), + new ComponentDependency('Table:Row'), + ], $table->getDependencies()); + $this->assertNotNull($table->doc); + $this->assertStringContainsString(<<<'EOF' +# Table + +A structured grid element that organizes data into rows and columns, supporting headers, captions, and footers. +EOF + , $table->doc->markdownContent); + } + + public function testCanHandleStimulusControllers(): void + { + $kitFactory = $this->createKitFactory(); + + $kit = $kitFactory->createKitFromAbsolutePath(__DIR__.'/../Fixtures/kits/with-stimulus-controllers'); + + $this->assertNotEmpty($kit->getComponents()); + + // Assert Stimulus Controllers are registered in the Kit + $this->assertNotEmpty($kit->getStimulusControllers()); + $this->assertEquals([ + $clipboard = new StimulusController('clipboard', [new File('assets/controllers/clipboard_controller.js', 'clipboard_controller.js')]), + $datePicker = new StimulusController('date-picker', [new File('assets/controllers/date_picker_controller.js', 'date_picker_controller.js')]), + $localTime = new StimulusController('local-time', [new File('assets/controllers/local-time-controller.js', 'local-time-controller.js')]), + $usersListItem = new StimulusController('users--list-item', [new File('assets/controllers/users/list_item_controller.js', 'users/list_item_controller.js')]), + ], $kit->getStimulusControllers()); + $this->assertEquals($clipboard, $kit->getStimulusController('clipboard')); + $this->assertEquals($datePicker, $kit->getStimulusController('date-picker')); + $this->assertEquals($localTime, $kit->getStimulusController('local-time')); + $this->assertEquals($usersListItem, $kit->getStimulusController('users--list-item')); + + // Assert Stimulus Controllers are marked as Component dependencies + $this->assertEquals([new StimulusControllerDependency('clipboard')], $kit->getComponent('Clipboard')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('date-picker')], $kit->getComponent('DatePicker')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('local-time')], $kit->getComponent('LocalTime')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('users--list-item'), new StimulusControllerDependency('clipboard')], $kit->getComponent('UsersListItem')->getDependencies()); + } + + private function createKitFactory(): KitFactory + { + return new KitFactory(self::getContainer()->get('filesystem'), self::getContainer()->get('ux_toolkit.kit.kit_synchronizer')); + } +} diff --git a/src/Toolkit/tests/Kit/KitSynchronizerTest.php b/src/Toolkit/tests/Kit/KitSynchronizerTest.php new file mode 100644 index 00000000000..05829ea1133 --- /dev/null +++ b/src/Toolkit/tests/Kit/KitSynchronizerTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Kit; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\UX\Toolkit\Dependency\ComponentDependency; +use Symfony\UX\Toolkit\Dependency\PhpPackageDependency; +use Symfony\UX\Toolkit\Dependency\StimulusControllerDependency; +use Symfony\UX\Toolkit\Dependency\Version; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Kit\KitSynchronizer; + +final class KitSynchronizerTest extends KernelTestCase +{ + private Filesystem $filesystem; + + protected function setUp(): void + { + parent::setUp(); + + $this->bootKernel(); + $this->filesystem = self::getContainer()->get('filesystem'); + } + + public function testCanResolveDependencies(): void + { + $kitSynchronizer = new KitSynchronizer($this->filesystem); + $kit = new Kit(Path::join(__DIR__, '../../kits/shadcn'), 'shadcn'); + + $kitSynchronizer->synchronize($kit); + + $this->assertEquals([ + new PhpPackageDependency('twig/extra-bundle'), + new PhpPackageDependency('twig/html-extra', new Version('^3.12.0')), + new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), + ], $kit->getComponent('Button')->getDependencies()); + + $this->assertEquals([ + new PhpPackageDependency('tales-from-a-dev/twig-tailwind-extra'), + new ComponentDependency('Table:Body'), + new ComponentDependency('Table:Caption'), + new ComponentDependency('Table:Cell'), + new ComponentDependency('Table:Footer'), + new ComponentDependency('Table:Head'), + new ComponentDependency('Table:Header'), + new ComponentDependency('Table:Row'), + ], $kit->getComponent('Table')->getDependencies()); + } + + public function testCanResolveStimulusDependencies(): void + { + $kitSynchronizer = new KitSynchronizer($this->filesystem); + $kit = new Kit(Path::join(__DIR__, '../Fixtures/kits/with-stimulus-controllers'), 'kit'); + + $kitSynchronizer->synchronize($kit); + + $this->assertEquals([new StimulusControllerDependency('clipboard')], $kit->getComponent('Clipboard')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('date-picker')], $kit->getComponent('DatePicker')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('local-time')], $kit->getComponent('LocalTime')->getDependencies()); + $this->assertEquals([new StimulusControllerDependency('users--list-item'), new StimulusControllerDependency('clipboard')], $kit->getComponent('UsersListItem')->getDependencies()); + } +} diff --git a/src/Toolkit/tests/Kit/KitTest.php b/src/Toolkit/tests/Kit/KitTest.php new file mode 100644 index 00000000000..7f023a48474 --- /dev/null +++ b/src/Toolkit/tests/Kit/KitTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Kit; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Toolkit\Asset\Component; +use Symfony\UX\Toolkit\File\File; +use Symfony\UX\Toolkit\Kit\Kit; + +final class KitTest extends TestCase +{ + public function testShouldFailIfKitNameIsInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid kit name "-foobar".'); + + new Kit(__DIR__, '-foobar', 'https://example.com', 'MIT'); + } + + public function testShouldFailIfKitPathIsNotAbsolute(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Kit path "./%s" is not absolute.', __DIR__)); + + new Kit(\sprintf('./%s', __DIR__), 'foo', 'https://example.com', 'MIT'); + } + + public function testCanAddComponentsToTheKit(): void + { + $kit = new Kit(__DIR__, 'foo', 'https://example.com', 'MIT'); + $kit->addComponent(new Component('Table', [new File('Table.html.twig', 'Table.html.twig')], null)); + $kit->addComponent(new Component('Table:Row', [new File('Table/Row.html.twig', 'Table/Row.html.twig')], null)); + + $this->assertCount(2, $kit->getComponents()); + } + + public function testShouldFailIfComponentIsAlreadyRegisteredInTheKit(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Component "Table" is already registered in the kit.'); + + $kit = new Kit(__DIR__, 'foo', 'https://example.com', 'MIT'); + $kit->addComponent(new Component('Table', [new File('Table.html.twig', 'Table.html.twig')], null)); + $kit->addComponent(new Component('Table', [new File('Table.html.twig', 'Table.html.twig')], null)); + } + + public function testCanGetComponentByName(): void + { + $kit = new Kit(__DIR__, 'foo', 'https://example.com', 'MIT'); + $kit->addComponent(new Component('Table', [new File('Table.html.twig', 'Table.html.twig')], null)); + $kit->addComponent(new Component('Table:Row', [new File('Table/Row.html.twig', 'Table/Row.html.twig')], null)); + + $this->assertSame('Table', $kit->getComponent('Table')->name); + $this->assertSame('Table:Row', $kit->getComponent('Table:Row')->name); + } + + public function testShouldReturnNullIfComponentIsNotFound(): void + { + $kit = new Kit(__DIR__, 'foo', 'https://example.com', 'MIT'); + + $this->assertNull($kit->getComponent('Table:Cell')); + } +} diff --git a/src/Toolkit/tests/Registry/GitHubRegistryTest.php b/src/Toolkit/tests/Registry/GitHubRegistryTest.php new file mode 100644 index 00000000000..cffc454e602 --- /dev/null +++ b/src/Toolkit/tests/Registry/GitHubRegistryTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Registry; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\Finder\Finder; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\UX\Toolkit\Registry\GitHubRegistry; + +final class GitHubRegistryTest extends KernelTestCase +{ + private Filesystem $filesystem; + private string $tmpDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->filesystem = self::getContainer()->get('filesystem'); + $this->tmpDir = $this->filesystem->tempnam(sys_get_temp_dir(), 'ux_toolkit_test_'); + $this->filesystem->remove($this->tmpDir); + $this->filesystem->mkdir($this->tmpDir); + } + + public function testCanGetKitFromGithub(): void + { + $isHttpClientCalled = false; + $zipShadcnMain = $this->createZip('repo', 'shadcn', 'main'); + + $httpClient = new MockHttpClient(function (string $method, string $url) use ($zipShadcnMain, &$isHttpClientCalled) { + if ('GET' === $method && 'https://github.com/user/repo/archive/main.zip' === $url) { + $isHttpClientCalled = true; + + return new MockResponse( + file_get_contents($zipShadcnMain), + [ + 'http_code' => 200, + 'response_headers' => [ + 'content-type' => 'application/zip', + ], + ] + ); + } + }); + + $githubRegistry = new GitHubRegistry( + self::getContainer()->get('ux_toolkit.kit.kit_factory'), + $this->filesystem, + $httpClient, + ); + + $kit = $githubRegistry->getKit('github.com/user/repo'); + + $this->assertTrue($isHttpClientCalled); + $this->assertSame('Shadcn UI', $kit->name); + $this->assertNotEmpty($kit->getComponents()); + $this->assertFileExists($kit->path); + $this->assertFileExists(Path::join($kit->path, 'templates/components/Button.html.twig')); + $this->assertFileExists(Path::join($kit->path, 'docs/components/Button.md')); + } + + public function testShouldThrowExceptionIfKitNotFound(): void + { + $githubRegistry = new GitHubRegistry( + self::getContainer()->get('ux_toolkit.kit.kit_factory'), + $this->filesystem, + new MockHttpClient(fn () => new MockResponse( + 'Not found', + [ + 'http_code' => 404, + ] + )), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to download the archive from "https://github.com/user/repo/archive/main.zip", ensure the repository exists and the version is valid.'); + + $githubRegistry->getKit('github.com/user/repo'); + } + + private function createZip(string $repo, string $kitName, string $version): string + { + $kitPath = Path::join(__DIR__, '..', '..', 'kits', $kitName); + if (!$this->filesystem->exists($kitPath)) { + throw new \RuntimeException(\sprintf('Kit "%s" not found in "%s".', $kitName, $kitPath)); + } + + $folderName = \sprintf('%s-%s', $repo, $version); + $zip = new \ZipArchive(); + $zip->open($zipPath = \sprintf('%s/%s.zip', $this->tmpDir, $folderName), \ZipArchive::CREATE); + foreach ((new Finder())->files()->in($kitPath) as $file) { + $zip->addFile($file->getPathname(), Path::join($folderName, $file->getRelativePathname())); + } + $zip->close(); + + return $zipPath; + } +} diff --git a/src/Toolkit/tests/Registry/LocalRegistryTest.php b/src/Toolkit/tests/Registry/LocalRegistryTest.php new file mode 100644 index 00000000000..b99cf8e8b70 --- /dev/null +++ b/src/Toolkit/tests/Registry/LocalRegistryTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Registry; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\Kit\Kit; +use Symfony\UX\Toolkit\Registry\LocalRegistry; + +final class LocalRegistryTest extends KernelTestCase +{ + public function testCanGetKit(): void + { + $localRegistry = new LocalRegistry( + self::getContainer()->get('ux_toolkit.kit.kit_factory'), + self::getContainer()->get('filesystem'), + self::getContainer()->getParameter('kernel.project_dir'), + ); + + $kit = $localRegistry->getKit('shadcn'); + + $this->assertInstanceOf(Kit::class, $kit); + $this->assertSame('Shadcn UI', $kit->name); + } +} diff --git a/src/Toolkit/tests/Registry/RegistryFactoryTest.php b/src/Toolkit/tests/Registry/RegistryFactoryTest.php new file mode 100644 index 00000000000..71ad4c4bf38 --- /dev/null +++ b/src/Toolkit/tests/Registry/RegistryFactoryTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests\Registry; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\Registry\GitHubRegistry; +use Symfony\UX\Toolkit\Registry\LocalRegistry; + +final class RegistryFactoryTest extends KernelTestCase +{ + public static function provideRegistryNames(): array + { + return [ + ['shadcn', LocalRegistry::class], + ['foo-bar', LocalRegistry::class], + ['https://github.com/user/repo', GitHubRegistry::class], + ['https://github.com/user/repo:1.0.0', GitHubRegistry::class], + ['https://github.com/user/repo:2.x', GitHubRegistry::class], + ['github.com/user/repo', GitHubRegistry::class], + ['github.com/user/repo:1.0.0', GitHubRegistry::class], + ['github.com/user/repo:2.x', GitHubRegistry::class], + ]; + } + + /** + * @dataProvider provideRegistryNames + */ + public function testCanCreateRegistry(string $registryName, string $expectedRegistryClass): void + { + $registryFactory = self::getContainer()->get('ux_toolkit.registry.registry_factory'); + + $registry = $registryFactory->getForKit($registryName); + + $this->assertInstanceOf($expectedRegistryClass, $registry); + } + + public static function provideInvalidRegistryNames(): array + { + return [ + [''], + ['httpppps://github.com/user/repo@kit-name:2.x'], + ['github.com/user/repo:kit-name@1.0.0'], + ['github.com/user/repo@2.1'], + ]; + } + + /** + * @dataProvider provideInvalidRegistryNames + */ + public function testShouldFailIfRegistryIsNotFound(string $registryName): void + { + $registryFactory = self::getContainer()->get('ux_toolkit.registry.registry_factory'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The kit "%s" is not valid.', $registryName)); + + $registryFactory->getForKit($registryName); + } +} diff --git a/src/Toolkit/tests/UXToolkitBundleTest.php b/src/Toolkit/tests/UXToolkitBundleTest.php new file mode 100644 index 00000000000..f7b19becb83 --- /dev/null +++ b/src/Toolkit/tests/UXToolkitBundleTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Toolkit\Tests; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\Toolkit\UXToolkitBundle; + +class UXToolkitBundleTest extends KernelTestCase +{ + public function testBundleBuildsSuccessfully(): void + { + self::bootKernel(); + $container = self::$kernel->getContainer(); + + $this->assertInstanceOf(UXToolkitBundle::class, $container->get('kernel')->getBundles()['UXToolkitBundle']); + } +} diff --git a/src/Toolkit/tests/bootstrap.php b/src/Toolkit/tests/bootstrap.php new file mode 100644 index 00000000000..519f959c9fa --- /dev/null +++ b/src/Toolkit/tests/bootstrap.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\ErrorHandler\ErrorHandler; +use Symfony\Component\Filesystem\Filesystem; + +require __DIR__.'/../vendor/autoload.php'; + +(new Filesystem())->remove(__DIR__.'/../var'); + +// @see https://github.com/symfony/symfony/issues/53812 +ErrorHandler::register(null, false); diff --git a/src/Translator/.gitattributes b/src/Translator/.gitattributes index 97734d35229..6dfda816e4b 100644 --- a/src/Translator/.gitattributes +++ b/src/Translator/.gitattributes @@ -1,7 +1,7 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /phpunit.xml.dist export-ignore /assets/src export-ignore /assets/test export-ignore +/doc export-ignore /tests export-ignore diff --git a/src/Translator/.github/PULL_REQUEST_TEMPLATE.md b/src/Translator/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Translator/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Translator/.github/workflows/close-pull-request.yml b/src/Translator/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Translator/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Translator/.gitignore b/src/Translator/.gitignore index 30282084317..2cc9f0231c3 100644 --- a/src/Translator/.gitignore +++ b/src/Translator/.gitignore @@ -1,4 +1,5 @@ -vendor -composer.lock -.php_cs.cache -.phpunit.result.cache +/assets/node_modules/ +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.result.cache diff --git a/src/Translator/CHANGELOG.md b/src/Translator/CHANGELOG.md index 789af7f2eba..34532d510ef 100644 --- a/src/Translator/CHANGELOG.md +++ b/src/Translator/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.22.0 + +- Support both the Symfony format (`fr_FR`) and W3C specification (`fr-FR`) for locale subcodes. + ## 2.20.0 - Add `throwWhenNotFound` function to configure the behavior when a translation is not found. diff --git a/src/Translator/assets/LICENSE b/src/Translator/assets/LICENSE new file mode 100644 index 00000000000..3ed9f412ce5 --- /dev/null +++ b/src/Translator/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Translator/assets/README.md b/src/Translator/assets/README.md new file mode 100644 index 00000000000..c5d5d125068 --- /dev/null +++ b/src/Translator/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-translator + +JavaScript assets of the [symfony/ux-translator](https://packagist.org/packages/symfony/ux-translator) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-translator](https://packagist.org/packages/symfony/ux-translator) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-translator](https://packagist.org/packages/symfony/ux-translator) PHP package version: +```shell +composer require symfony/ux-translator:2.23.0 +npm add @symfony/ux-translator@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-translator/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Translator/assets/dist/formatters/formatter.d.ts b/src/Translator/assets/dist/formatters/formatter.d.ts index c3403c041a5..e55715f0960 100644 --- a/src/Translator/assets/dist/formatters/formatter.d.ts +++ b/src/Translator/assets/dist/formatters/formatter.d.ts @@ -1 +1 @@ -export declare function format(id: string, parameters: Record, locale: string): string; +export declare function format(id: string, parameters: Record, locale: string): string; diff --git a/src/Translator/assets/dist/formatters/intl-formatter.d.ts b/src/Translator/assets/dist/formatters/intl-formatter.d.ts index e22d7481ac6..9892f4bd0d4 100644 --- a/src/Translator/assets/dist/formatters/intl-formatter.d.ts +++ b/src/Translator/assets/dist/formatters/intl-formatter.d.ts @@ -1 +1 @@ -export declare function formatIntl(id: string, parameters: Record, locale: string): string; +export declare function formatIntl(id: string, parameters: Record, locale: string): string; diff --git a/src/Translator/assets/dist/translator_controller.js b/src/Translator/assets/dist/translator_controller.js index 76292583bfb..c4504b75e3c 100644 --- a/src/Translator/assets/dist/translator_controller.js +++ b/src/Translator/assets/dist/translator_controller.js @@ -1,20 +1,5 @@ import { IntlMessageFormat } from 'intl-messageformat'; -function formatIntl(id, parameters, locale) { - if (id === '') { - return ''; - } - const intlMessage = new IntlMessageFormat(id, [locale.replace('_', '-')], undefined, { ignoreTag: true }); - parameters = { ...parameters }; - Object.entries(parameters).forEach(([key, value]) => { - if (key.includes('%') || key.includes('{')) { - delete parameters[key]; - parameters[key.replace(/[%{} ]/g, '').trim()] = value; - } - }); - return intlMessage.format(parameters); -} - function strtr(string, replacePairs) { const regex = Object.entries(replacePairs).map(([from]) => { return from.replace(/([-[\]{}()*+?.\\^$|#,])/g, '\\$1'); @@ -218,6 +203,21 @@ function getPluralizationRule(number, locale) { } } +function formatIntl(id, parameters, locale) { + if (id === '') { + return ''; + } + const intlMessage = new IntlMessageFormat(id, [locale.replace('_', '-')], undefined, { ignoreTag: true }); + parameters = { ...parameters }; + Object.entries(parameters).forEach(([key, value]) => { + if (key.includes('%') || key.includes('{')) { + delete parameters[key]; + parameters[key.replace(/[%{} ]/g, '').trim()] = value; + } + }); + return intlMessage.format(parameters); +} + let _locale = null; let _localeFallbacks = {}; let _throwWhenNotFound = false; @@ -227,7 +227,7 @@ function setLocale(locale) { function getLocale() { return (_locale || document.documentElement.getAttribute('data-symfony-ux-translator-locale') || - document.documentElement.lang || + (document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : null) || 'en'); } function throwWhenNotFound(enabled) { diff --git a/src/Translator/assets/dist/utils.d.ts b/src/Translator/assets/dist/utils.d.ts index b6621dfd66c..fd3ca78fbeb 100644 --- a/src/Translator/assets/dist/utils.d.ts +++ b/src/Translator/assets/dist/utils.d.ts @@ -1 +1 @@ -export declare function strtr(string: string, replacePairs: Record): string; +export declare function strtr(string: string, replacePairs: Record): string; diff --git a/src/Translator/assets/package.json b/src/Translator/assets/package.json index 53c12bbcac4..4c904c12ccc 100644 --- a/src/Translator/assets/package.json +++ b/src/Translator/assets/package.json @@ -2,9 +2,25 @@ "name": "@symfony/ux-translator", "description": "Symfony Translator for JavaScript", "license": "MIT", - "version": "1.0.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://ux.symfony.com/translator", + "repository": "https://github.com/symfony/ux-translator", + "type": "module", + "files": [ + "dist" + ], "main": "dist/translator_controller.js", "types": "dist/translator_controller.d.ts", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "importmap": { "intl-messageformat": "^10.5.11", diff --git a/src/Translator/assets/src/formatters/formatter.ts b/src/Translator/assets/src/formatters/formatter.ts index 80d99b828a6..58e18ce1111 100644 --- a/src/Translator/assets/src/formatters/formatter.ts +++ b/src/Translator/assets/src/formatters/formatter.ts @@ -47,7 +47,7 @@ import { strtr } from '../utils'; * @param parameters An array of parameters for the message * @param locale The locale */ -export function format(id: string, parameters: Record, locale: string): string { +export function format(id: string, parameters: Record, locale: string): string { if (null === id || '' === id) { return ''; } diff --git a/src/Translator/assets/src/formatters/intl-formatter.ts b/src/Translator/assets/src/formatters/intl-formatter.ts index 07bc5a60795..a092e54f811 100644 --- a/src/Translator/assets/src/formatters/intl-formatter.ts +++ b/src/Translator/assets/src/formatters/intl-formatter.ts @@ -7,7 +7,7 @@ import { IntlMessageFormat } from 'intl-messageformat'; * @param parameters An array of parameters for the message * @param locale The locale */ -export function formatIntl(id: string, parameters: Record, locale: string): string { +export function formatIntl(id: string, parameters: Record, locale: string): string { if (id === '') { return ''; } @@ -23,5 +23,5 @@ export function formatIntl(id: string, parameters: Record = {}; @@ -47,8 +47,8 @@ export function setLocale(locale: LocaleType | null) { export function getLocale(): LocaleType { return ( _locale || - document.documentElement.getAttribute('data-symfony-ux-translator-locale') || // - document.documentElement.lang || // + document.documentElement.getAttribute('data-symfony-ux-translator-locale') || // + (document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : null) || // 'en' ); } diff --git a/src/Translator/assets/src/utils.ts b/src/Translator/assets/src/utils.ts index 0cb448da203..29d035e1283 100644 --- a/src/Translator/assets/src/utils.ts +++ b/src/Translator/assets/src/utils.ts @@ -6,7 +6,7 @@ * @param string The string to replace in * @param replacePairs The pairs of characters to replace */ -export function strtr(string: string, replacePairs: Record): string { +export function strtr(string: string, replacePairs: Record): string { const regex: Array = Object.entries(replacePairs).map(([from]) => { return from.replace(/([-[\]{}()*+?.\\^$|#,])/g, '\\$1'); }); diff --git a/src/Translator/assets/test/translator.test.ts b/src/Translator/assets/test/translator.test.ts index 6df854a1be9..2ca0ee28399 100644 --- a/src/Translator/assets/test/translator.test.ts +++ b/src/Translator/assets/test/translator.test.ts @@ -1,7 +1,7 @@ import { - getLocale, type Message, type NoParametersType, + getLocale, setLocale, setLocaleFallbacks, throwWhenNotFound, @@ -35,6 +35,18 @@ describe('Translator', () => { }); }); + describe('getLocale', () => { + test('with subcode', () => { + // allow format according to W3C + document.documentElement.lang = 'de-AT'; + expect(getLocale()).toEqual('de_AT'); + + // or "incorrect" Symfony locale format + document.documentElement.lang = 'de_AT'; + expect(getLocale()).toEqual('de_AT'); + }); + }); + describe('setLocale', () => { test('custom locale', () => { setLocale('fr'); diff --git a/src/Translator/doc/index.rst b/src/Translator/doc/index.rst index 6d194ce4a53..0c0da4facc1 100644 --- a/src/Translator/doc/index.rst +++ b/src/Translator/doc/index.rst @@ -35,9 +35,9 @@ needed if you're using AssetMapper): $ npm install --force $ npm run watch - # or use yarn - $ yarn install --force - $ yarn watch +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-translator npm package`_ After installing the bundle, the following file should be created, thanks to the Symfony Flex recipe: @@ -94,9 +94,9 @@ Configuring the default locale By default, the default locale is ``en`` (English) that you can configure through many ways (in order of priority): -#. With ``setLocale('your-locale')`` from ``@symfony/ux-translator`` package -#. Or with ```` attribute -#. Or with ```` attribute +#. With ``setLocale('de')`` or ``setLocale('de_AT')`` from ``@symfony/ux-translator`` package +#. Or with ```` attribute (e.g., ``de_AT`` or ``de`` using Symfony locale format) +#. Or with ```` attribute (e.g., ``de-AT`` or ``de`` following the `W3C specification on language codes`_) Detecting missing translations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -195,3 +195,5 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`the Symfony UX initiative`: https://ux.symfony.com/ .. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html .. _`ICU Message Format`: https://symfony.com/doc/current/reference/formats/message_format.html +.. _`W3C specification on language codes`: https://www.w3.org/TR/html401/struct/dirlang.html#h-8.1.1 +.. _`@symfony/ux-translator npm package`: https://www.npmjs.com/package/@symfony/ux-translator diff --git a/src/Translator/src/DependencyInjection/UxTranslatorExtension.php b/src/Translator/src/DependencyInjection/UxTranslatorExtension.php index 9ba3a02926c..3ebfbe518e9 100644 --- a/src/Translator/src/DependencyInjection/UxTranslatorExtension.php +++ b/src/Translator/src/DependencyInjection/UxTranslatorExtension.php @@ -27,7 +27,7 @@ */ class UxTranslatorExtension extends Extension implements PrependExtensionInterface { - public function load(array $configs, ContainerBuilder $container) + public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); @@ -46,7 +46,7 @@ public function load(array $configs, ContainerBuilder $container) } } - public function prepend(ContainerBuilder $container) + public function prepend(ContainerBuilder $container): void { if (!$this->isAssetMapperAvailable($container)) { return; diff --git a/src/Translator/src/Intl/ErrorKind.php b/src/Translator/src/Intl/ErrorKind.php index cabe240e265..94066bba419 100644 --- a/src/Translator/src/Intl/ErrorKind.php +++ b/src/Translator/src/Intl/ErrorKind.php @@ -14,7 +14,7 @@ /** * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/error.ts#L9-L77. * - * @experimental + * @internal */ final class ErrorKind { diff --git a/src/Translator/src/Intl/IntlMessageParser.php b/src/Translator/src/Intl/IntlMessageParser.php index 839de42fbf4..560b883e668 100644 --- a/src/Translator/src/Intl/IntlMessageParser.php +++ b/src/Translator/src/Intl/IntlMessageParser.php @@ -11,16 +11,21 @@ namespace Symfony\UX\Translator\Intl; +use Symfony\Component\String\AbstractString; + use function Symfony\Component\String\s; /** * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/parser.ts. * - * @experimental + * @internal */ -class IntlMessageParser +final class IntlMessageParser { - private string $message; + private readonly AbstractString $message; + // Minor optimization, this avoid a lot of calls to `$this->message->length()` + private readonly int $messageLength; + private Position $position; private bool $ignoreTag; private bool $requiresOtherClause; @@ -28,7 +33,8 @@ class IntlMessageParser public function __construct( string $message, ) { - $this->message = $message; + $this->message = s($message); + $this->messageLength = $this->message->length(); $this->position = new Position(0, 1, 1); $this->ignoreTag = true; $this->requiresOtherClause = true; @@ -112,14 +118,14 @@ private function parseMessage(int $nestingLevel, mixed $parentArgType, bool $exp */ private function parseTagName(): string { - $startOffset = $this->offset(); + $startOffset = $this->position->offset; $this->bump(); // the first tag name character while (!$this->isEOF() && Utils::isPotentialElementNameChar($this->char())) { $this->bump(); } - return s($this->message)->slice($startOffset, $this->offset() - $startOffset)->toString(); + return $this->message->slice($startOffset, $this->position->offset - $startOffset)->toString(); } /** @@ -365,7 +371,7 @@ private function parseIdentifierIfPossible(): array { $startingPosition = clone $this->position; - $startOffset = $this->offset(); + $startOffset = $this->position->offset; $value = Utils::matchIdentifierAtIndex($this->message, $startOffset); $endOffset = $startOffset + s($value)->length(); @@ -445,9 +451,9 @@ private function parseArgumentOptions( ); // Extract style or skeleton - if ($styleAndLocation && s($styleAndLocation['style'] ?? '')->startsWith('::')) { + if ($styleAndLocation && ($style = s($styleAndLocation['style'] ?? ''))->startsWith('::')) { // Skeleton starts with `::`. - $skeleton = s($styleAndLocation['style'])->slice(2)->trimStart()->toString(); + $skeleton = $style->slice(2)->trimStart()->toString(); if ('number' === $argType) { $result = $this->parseNumberSkeletonFromString( @@ -663,7 +669,7 @@ private function parseSimpleArgStyleIfPossible(): array --$nestedBraces; } else { return [ - 'val' => s($this->message)->slice($startPosition->offset, $this->offset() - $startPosition->offset)->toString(), + 'val' => $this->message->slice($startPosition->offset, $this->position->offset - $startPosition->offset)->toString(), 'err' => null, ]; } @@ -676,7 +682,7 @@ private function parseSimpleArgStyleIfPossible(): array } return [ - 'val' => s($this->message)->slice($startPosition->offset, $this->offset() - $startPosition->offset)->toString(), + 'val' => $this->message->slice($startPosition->offset, $this->position->offset - $startPosition->offset)->toString(), 'err' => null, ]; } @@ -735,7 +741,7 @@ private function tryParsePluralOrSelectOptions( return $result; } $selectorLocation = new Location($startPosition, clone $this->position); - $selector = s($this->message)->slice($startPosition->offset, $this->offset() - $startPosition->offset)->toString(); + $selector = $this->message->slice($startPosition->offset, $this->position->offset - $startPosition->offset)->toString(); } else { break; } @@ -864,14 +870,9 @@ private function tryParseDecimalInteger( ]; } - private function offset(): int - { - return $this->position->offset; - } - private function isEOF(): bool { - return $this->offset() === s($this->message)->length(); + return $this->position->offset === $this->messageLength; } /** @@ -882,16 +883,14 @@ private function isEOF(): bool */ private function char(): int { - $message = s($this->message); - $offset = $this->position->offset; - if ($offset >= $message->length()) { + if ($offset >= $this->messageLength) { throw new \OutOfBoundsException(); } - $code = $message->slice($offset, 1)->codePointsAt(0)[0] ?? null; + $code = $this->message->codePointsAt($offset)[0] ?? null; if (null === $code) { - throw new \Exception("Offset {$offset} is at invalid UTF-16 code unit boundary"); + throw new \Exception("Offset {$offset} is at invalid UTF-16 code unit boundary."); } return $code; @@ -909,7 +908,7 @@ private function error(string $kind, Location $location): array 'err' => [ 'kind' => $kind, 'location' => $location, - 'message' => $this->message, + 'message' => $this->message->toString(), ], ]; } @@ -941,7 +940,7 @@ private function bump(): void */ private function bumpIf(string $prefix): bool { - if (s($this->message)->slice($this->offset())->startsWith($prefix)) { + if ($this->message->slice($this->position->offset)->startsWith($prefix)) { for ($i = 0, $len = \strlen($prefix); $i < $len; ++$i) { $this->bump(); } @@ -958,14 +957,13 @@ private function bumpIf(string $prefix): bool */ private function bumpUntil(string $pattern): bool { - $currentOffset = $this->offset(); - $index = s($this->message)->indexOf($pattern, $currentOffset); + $index = $this->message->indexOf($pattern, $this->position->offset); if ($index >= 0) { $this->bumpTo($index); return true; } else { - $this->bumpTo(s($this->message)->length()); + $this->bumpTo($this->messageLength); return false; } @@ -979,18 +977,18 @@ private function bumpUntil(string $pattern): bool */ private function bumpTo(int $targetOffset) { - if ($this->offset() > $targetOffset) { - throw new \Exception(\sprintf('targetOffset %s must be greater than or equal to the current offset %d', $targetOffset, $this->offset())); + if ($this->position->offset > $targetOffset) { + throw new \Exception(\sprintf('targetOffset "%s" must be greater than or equal to the current offset %d', $targetOffset, $this->position->offset)); } - $targetOffset = min($targetOffset, s($this->message)->length()); + $targetOffset = min($targetOffset, $this->messageLength); while (true) { - $offset = $this->offset(); + $offset = $this->position->offset; if ($offset === $targetOffset) { break; } if ($offset > $targetOffset) { - throw new \Exception("targetOffset {$targetOffset} is at invalid UTF-16 code unit boundary"); + throw new \Exception("targetOffset {$targetOffset} is at invalid UTF-16 code unit boundary."); } $this->bump(); @@ -1019,8 +1017,7 @@ private function peek(): ?int } $code = $this->char(); - $offset = $this->offset(); - $nextCodes = s($this->message)->codePointsAt($offset + ($code >= 0x10000 ? 2 : 1)); + $nextCodes = $this->message->codePointsAt($this->position->offset + ($code >= 0x10000 ? 2 : 1)); return $nextCodes[0] ?? null; } diff --git a/src/Translator/src/Intl/Location.php b/src/Translator/src/Intl/Location.php index e342a2ff454..270a1bfe0a1 100644 --- a/src/Translator/src/Intl/Location.php +++ b/src/Translator/src/Intl/Location.php @@ -14,7 +14,7 @@ /** * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L58-L61. * - * @experimental + * @internal */ final class Location { diff --git a/src/Translator/src/Intl/Position.php b/src/Translator/src/Intl/Position.php index bc633622025..0772508cffb 100644 --- a/src/Translator/src/Intl/Position.php +++ b/src/Translator/src/Intl/Position.php @@ -14,7 +14,7 @@ /** * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L53-L57. * - * @experimental + * @internal */ final class Position { diff --git a/src/Translator/src/Intl/SkeletonType.php b/src/Translator/src/Intl/SkeletonType.php index b10daeb0384..089c87fe496 100644 --- a/src/Translator/src/Intl/SkeletonType.php +++ b/src/Translator/src/Intl/SkeletonType.php @@ -14,7 +14,7 @@ /** * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L48-L51. * - * @experimental + * @internal */ final class SkeletonType { diff --git a/src/Translator/src/Intl/Type.php b/src/Translator/src/Intl/Type.php index 12927c342dd..d02bab4aff8 100644 --- a/src/Translator/src/Intl/Type.php +++ b/src/Translator/src/Intl/Type.php @@ -14,7 +14,7 @@ /** * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L8-L46. * - * @experimental + * @internal */ final class Type { diff --git a/src/Translator/src/Intl/Utils.php b/src/Translator/src/Intl/Utils.php index af363073c92..b5c44a5f192 100644 --- a/src/Translator/src/Intl/Utils.php +++ b/src/Translator/src/Intl/Utils.php @@ -11,10 +11,10 @@ namespace Symfony\UX\Translator\Intl; -use function Symfony\Component\String\s; +use Symfony\Component\String\AbstractString; /** - * @experimental + * @internal */ final class Utils { @@ -355,12 +355,12 @@ public static function fromCodePoint(int ...$codePoints): string return $elements; } - public static function matchIdentifierAtIndex(string $s, int $index): string + public static function matchIdentifierAtIndex(AbstractString $s, int $index): string { $match = []; while (true) { - $c = s($s)->codePointsAt($index)[0] ?? null; + $c = $s->codePointsAt($index)[0] ?? null; if (null === $c || self::isWhiteSpace($c) || self::isPatternSyntax($c)) { break; } diff --git a/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php b/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php index 93d2b1d5129..18c709c3468 100644 --- a/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php +++ b/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php @@ -14,7 +14,7 @@ /** * @author Hugo Alliaume * - * @experimental + * @internal */ interface ExtractorInterface { diff --git a/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php b/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php index 6722f882836..091e27892d9 100644 --- a/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php +++ b/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php @@ -17,7 +17,7 @@ /** * @author Hugo Alliaume * - * @experimental + * @internal */ final class IntlMessageParametersExtractor implements ExtractorInterface { diff --git a/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php b/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php index 289c32db171..38988ea7a5f 100644 --- a/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php +++ b/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php @@ -14,7 +14,7 @@ /** * @author Hugo Alliaume * - * @experimental + * @internal */ final class MessageParametersExtractor implements ExtractorInterface { diff --git a/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php b/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php index 4eaf9c89b91..2981142e2ab 100644 --- a/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php +++ b/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php @@ -14,7 +14,7 @@ /** * @author Hugo Alliaume * - * @experimental + * @internal */ final class TypeScriptMessageParametersPrinter { diff --git a/src/Translator/src/TranslationsDumper.php b/src/Translator/src/TranslationsDumper.php index cba611171be..a986c7c05c9 100644 --- a/src/Translator/src/TranslationsDumper.php +++ b/src/Translator/src/TranslationsDumper.php @@ -155,21 +155,18 @@ private function getTranslationsTypeScriptTypeDefinition(array $translationsByDo } $parametersTypes[$domain] = $this->typeScriptMessageParametersPrinter->print($parameters); - $locales[] = $locale; } } + $typeScriptParametersType = []; + foreach ($parametersTypes as $domain => $parametersType) { + $typeScriptParametersType[] = \sprintf("'%s': { parameters: %s }", $domain, $parametersType); + } + return \sprintf( 'Message<{ %s }, %s>', - implode(', ', array_reduce( - array_keys($parametersTypes), - fn (array $carry, string $domain) => [ - ...$carry, - \sprintf("'%s': { parameters: %s }", $domain, $parametersTypes[$domain]), - ], - [], - )), + implode(', ', $typeScriptParametersType), implode('|', array_map(fn (string $locale) => "'$locale'", array_unique($locales))), ); } @@ -189,13 +186,14 @@ private function generateConstantName(string $translationId): string { static $alreadyGenerated = []; + $translationId = s($translationId)->ascii()->snake()->upper()->replaceMatches('/^(\d)/', '_$1')->toString(); $prefix = 0; do { - $constantName = s($translationId)->ascii()->snake()->upper()->replaceMatches('/^(\d)/', '_$1')->toString().($prefix > 0 ? '_'.$prefix : ''); + $constantName = $translationId.($prefix > 0 ? '_'.$prefix : ''); ++$prefix; - } while (\in_array($constantName, $alreadyGenerated, true)); + } while ($alreadyGenerated[$constantName] ?? false); - $alreadyGenerated[] = $constantName; + $alreadyGenerated[$constantName] = true; return $constantName; } diff --git a/src/Translator/tests/Kernel/EmptyAppKernel.php b/src/Translator/tests/Kernel/EmptyAppKernel.php index 648443bc735..b92997f6fef 100644 --- a/src/Translator/tests/Kernel/EmptyAppKernel.php +++ b/src/Translator/tests/Kernel/EmptyAppKernel.php @@ -29,7 +29,7 @@ public function registerBundles(): iterable return [new UxTranslatorBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { } } diff --git a/src/Translator/tests/Kernel/FrameworkAppKernel.php b/src/Translator/tests/Kernel/FrameworkAppKernel.php index 98c0ca6a703..3e61e1ef9d8 100644 --- a/src/Translator/tests/Kernel/FrameworkAppKernel.php +++ b/src/Translator/tests/Kernel/FrameworkAppKernel.php @@ -31,7 +31,7 @@ public function registerBundles(): iterable return [new FrameworkBundle(), new UxTranslatorBundle()]; } - public function registerContainerConfiguration(LoaderInterface $loader) + public function registerContainerConfiguration(LoaderInterface $loader): void { $loader->load(function (ContainerBuilder $container) { $container->loadFromExtension('framework', [ diff --git a/src/Translator/tests/TranslationsDumperTest.php b/src/Translator/tests/TranslationsDumperTest.php index c68a9fef794..10b13d90a05 100644 --- a/src/Translator/tests/TranslationsDumperTest.php +++ b/src/Translator/tests/TranslationsDumperTest.php @@ -57,6 +57,7 @@ public function testDump(): void export const NOTIFICATION_COMMENT_CREATED_DESCRIPTION = {"id":"notification.comment_created.description","translations":{"messages+intl-icu":{"en":"Your post \"{title}\" has received a new comment. You can read the comment by following this link<\/a>","fr":"Votre article \"{title}\" a re\u00e7u un nouveau commentaire. Vous pouvez lire le commentaire en suivant ce lien<\/a>"}}}; export const POST_NUM_COMMENTS = {"id":"post.num_comments","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}}","fr":"{count, plural, one {# commentaire} other {# commentaires}}"},"foobar":{"en":"There is 1 comment|There are %count% comments","fr":"Il y a 1 comment|Il y a %count% comments"}}}; export const POST_NUM_COMMENTS_1 = {"id":"post.num_comments.","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}; +export const POST_NUM_COMMENTS_2 = {"id":"post.num_comments..","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}; export const SYMFONY_GREAT = {"id":"symfony.great","translations":{"messages":{"en":"Symfony is awesome!","fr":"Symfony est g\u00e9nial !"}}}; export const SYMFONY_WHAT = {"id":"symfony.what","translations":{"messages":{"en":"Symfony is %what%!","fr":"Symfony est %what%!"}}}; export const SYMFONY_WHAT_1 = {"id":"symfony.what!","translations":{"messages":{"en":"Symfony is %what%! (should not conflict with the previous one.)","fr":"Symfony est %what%! (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}; @@ -83,6 +84,7 @@ public function testDump(): void export declare const NOTIFICATION_COMMENT_CREATED_DESCRIPTION: Message<{ 'messages+intl-icu': { parameters: { 'title': string, 'link': string } } }, 'en'|'fr'>; export declare const POST_NUM_COMMENTS: Message<{ 'messages+intl-icu': { parameters: { 'count': number } }, 'foobar': { parameters: { '%count%': number } } }, 'en'|'fr'>; export declare const POST_NUM_COMMENTS_1: Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>; +export declare const POST_NUM_COMMENTS_2: Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>; export declare const SYMFONY_GREAT: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; export declare const SYMFONY_WHAT: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>; export declare const SYMFONY_WHAT_1: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>; @@ -149,6 +151,7 @@ private static function getMessageCatalogues(): array 'notification.comment_created.description' => 'Your post "{title}" has received a new comment. You can read the comment by following this link', 'post.num_comments' => '{count, plural, one {# comment} other {# comments}}', 'post.num_comments.' => '{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)', + 'post.num_comments..' => '{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)', ], 'messages' => [ 'symfony.great' => 'Symfony is awesome!', @@ -178,6 +181,7 @@ private static function getMessageCatalogues(): array 'notification.comment_created.description' => 'Votre article "{title}" a reçu un nouveau commentaire. Vous pouvez lire le commentaire en suivant ce lien', 'post.num_comments' => '{count, plural, one {# commentaire} other {# commentaires}}', 'post.num_comments.' => '{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction prÊcÊdente)', + 'post.num_comments..' => '{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction prÊcÊdente)', ], 'messages' => [ 'symfony.great' => 'Symfony est gÊnial !', diff --git a/src/Turbo/.gitattributes b/src/Turbo/.gitattributes index b6c0beab639..34736d42f2a 100644 --- a/src/Turbo/.gitattributes +++ b/src/Turbo/.gitattributes @@ -1,9 +1,11 @@ -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.symfony.bundle.yaml export-ignore /phpunit.xml.dist export-ignore -/phpstan.neon.dist export-ignore +/phpstan.dist.neon export-ignore /assets/src export-ignore /assets/test export-ignore /assets/vitest.config.js export-ignore +/CONTRIBUTING.md export-ignore +/docker-compose.yml +/doc export-ignore /tests export-ignore diff --git a/src/Turbo/.github/PULL_REQUEST_TEMPLATE.md b/src/Turbo/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..df3b474b452 --- /dev/null +++ b/src/Turbo/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Turbo/.github/workflows/close-pull-request.yml b/src/Turbo/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..57e4e3fb074 --- /dev/null +++ b/src/Turbo/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Turbo/.gitignore b/src/Turbo/.gitignore index 264070be8bd..d365c4b922b 100644 --- a/src/Turbo/.gitignore +++ b/src/Turbo/.gitignore @@ -1,12 +1,12 @@ -/.php_cs.cache -/.php_cs -/.phpunit.result.cache -/composer.phar +/assets/node_modules/ +/vendor/ /composer.lock +/drivers/ /phpunit.xml -/vendor/ -/tests/app/var +/.phpunit.result.cache +/tests/app/assets/vendor/ +/tests/app/public/assets/ /tests/app/public/build/ +/tests/app/var/ +/vendor/ node_modules/ -package-lock.json -yarn.lock diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md index b5ad566cf65..fef22f69cf1 100644 --- a/src/Turbo/CHANGELOG.md +++ b/src/Turbo/CHANGELOG.md @@ -1,5 +1,23 @@ # CHANGELOG +## 2.24.0 + +- Add Twig Extensions for `meta` tags +- Add support for authentication to the EventSource via `turbo_stream_listen` + +## 2.22.0 + +- Add `` component +- Add `` component +- Add support for custom actions in `TurboStream` and `TurboStreamResponse` +- Add support for providing multiple mercure topics to `turbo_stream_listen` + +## 2.21.0 + +- Add `Helper/TurboStream::append()` et al. methods +- Add `TurboStreamResponse` +- Add `` components + ## 2.19.0 - Fix Doctrine proxies are not Broadcasted #3139 diff --git a/src/Turbo/CONTRIBUTING.md b/src/Turbo/CONTRIBUTING.md index 3eb5e018b6d..bc4c29ac10c 100644 --- a/src/Turbo/CONTRIBUTING.md +++ b/src/Turbo/CONTRIBUTING.md @@ -13,8 +13,6 @@ Install the test app: $ composer install $ cd tests/app - $ yarn install - $ yarn build $ php public/index.php doctrine:schema:create Start the test app: diff --git a/src/Turbo/README.md b/src/Turbo/README.md index aa855d717d7..538794fc22b 100644 --- a/src/Turbo/README.md +++ b/src/Turbo/README.md @@ -37,8 +37,6 @@ Configure test environment (working directory: `src/Turbo`): composer update docker compose up -d cd tests/app -yarn install -yarn build php public/index.php doctrine:schema:create ``` diff --git a/src/Turbo/assets/LICENSE b/src/Turbo/assets/LICENSE new file mode 100644 index 00000000000..99c6bdf356e --- /dev/null +++ b/src/Turbo/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Turbo/assets/README.md b/src/Turbo/assets/README.md new file mode 100644 index 00000000000..02a860f81c4 --- /dev/null +++ b/src/Turbo/assets/README.md @@ -0,0 +1,24 @@ +# @symfony/ux-turbo + +JavaScript assets of the [symfony/ux-turbo](https://packagist.org/packages/symfony/ux-turbo) PHP package. + +## Installation + +This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). + +We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-turbo](https://packagist.org/packages/symfony/ux-turbo) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. + +If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-turbo](https://packagist.org/packages/symfony/ux-turbo) PHP package version: +```shell +composer require symfony/ux-turbo:2.23.0 +npm add @symfony/ux-turbo@2.23.0 +``` + +**Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-turbo/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Turbo/assets/dist/turbo_stream_controller.d.ts b/src/Turbo/assets/dist/turbo_stream_controller.d.ts index a86c6796863..cc4db88a562 100644 --- a/src/Turbo/assets/dist/turbo_stream_controller.d.ts +++ b/src/Turbo/assets/dist/turbo_stream_controller.d.ts @@ -2,14 +2,19 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { static values: { topic: StringConstructor; + topics: ArrayConstructor; hub: StringConstructor; + withCredentials: BooleanConstructor; }; es: EventSource | undefined; url: string | undefined; readonly topicValue: string; + readonly topicsValue: string[]; + readonly withCredentialsValue: boolean; readonly hubValue: string; readonly hasHubValue: boolean; readonly hasTopicValue: boolean; + readonly hasTopicsValue: boolean; initialize(): void; connect(): void; disconnect(): void; diff --git a/src/Turbo/assets/dist/turbo_stream_controller.js b/src/Turbo/assets/dist/turbo_stream_controller.js index 287dbbf7719..d5962232feb 100644 --- a/src/Turbo/assets/dist/turbo_stream_controller.js +++ b/src/Turbo/assets/dist/turbo_stream_controller.js @@ -6,17 +6,24 @@ class default_1 extends Controller { const errorMessages = []; if (!this.hasHubValue) errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.'); - if (!this.hasTopicValue) - errorMessages.push('A "topic" value must be provided.'); + if (!this.hasTopicValue && !this.hasTopicsValue) + errorMessages.push('Either "topic" or "topics" value must be provided.'); if (errorMessages.length) throw new Error(errorMessages.join(' ')); const u = new URL(this.hubValue); - u.searchParams.append('topic', this.topicValue); + if (this.hasTopicValue) { + u.searchParams.append('topic', this.topicValue); + } + else { + this.topicsValue.forEach((topic) => { + u.searchParams.append('topic', topic); + }); + } this.url = u.toString(); } connect() { if (this.url) { - this.es = new EventSource(this.url); + this.es = new EventSource(this.url, { withCredentials: this.withCredentialsValue }); connectStreamSource(this.es); } } @@ -29,7 +36,9 @@ class default_1 extends Controller { } default_1.values = { topic: String, + topics: Array, hub: String, + withCredentials: Boolean, }; export { default_1 as default }; diff --git a/src/Turbo/assets/package.json b/src/Turbo/assets/package.json index e7da91bfbc5..86fae168b50 100644 --- a/src/Turbo/assets/package.json +++ b/src/Turbo/assets/package.json @@ -2,10 +2,30 @@ "name": "@symfony/ux-turbo", "description": "Hotwire Turbo integration for Symfony", "license": "MIT", - "private": true, - "version": "0.1.0", + "version": "2.26.1", + "keywords": [ + "symfony-ux", + "turbo", + "hotwire", + "javascript", + "turbo-stream", + "mercure" + ], + "homepage": "https://ux.symfony.com/turbo", + "repository": "https://github.com/symfony/ux-turbo", + "type": "module", + "files": [ + "dist" + ], "main": "dist/turbo_controller.js", "types": "dist/turbo_controller.d.ts", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, "symfony": { "controllers": { "turbo-core": { @@ -26,11 +46,12 @@ } }, "peerDependencies": { - "@hotwired/turbo": "^7.1.1 || ^8.0", - "@hotwired/stimulus": "^3.0.0" + "@hotwired/stimulus": "^3.0.0", + "@hotwired/turbo": "^7.1.1 || ^8.0" }, "devDependencies": { + "@hotwired/stimulus": "^3.0.0", "@hotwired/turbo": "^7.1.0 || ^8.0", - "@hotwired/stimulus": "^3.0.0" + "@types/hotwired__turbo": "^8.0.4" } } diff --git a/src/Turbo/assets/src/turbo_stream_controller.ts b/src/Turbo/assets/src/turbo_stream_controller.ts index c408dcfb099..aaa19c78396 100644 --- a/src/Turbo/assets/src/turbo_stream_controller.ts +++ b/src/Turbo/assets/src/turbo_stream_controller.ts @@ -16,31 +16,43 @@ import { connectStreamSource, disconnectStreamSource } from '@hotwired/turbo'; export default class extends Controller { static values = { topic: String, + topics: Array, hub: String, + withCredentials: Boolean, }; es: EventSource | undefined; url: string | undefined; declare readonly topicValue: string; + declare readonly topicsValue: string[]; + declare readonly withCredentialsValue: boolean; declare readonly hubValue: string; declare readonly hasHubValue: boolean; declare readonly hasTopicValue: boolean; + declare readonly hasTopicsValue: boolean; initialize() { const errorMessages: string[] = []; if (!this.hasHubValue) errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.'); - if (!this.hasTopicValue) errorMessages.push('A "topic" value must be provided.'); + if (!this.hasTopicValue && !this.hasTopicsValue) + errorMessages.push('Either "topic" or "topics" value must be provided.'); if (errorMessages.length) throw new Error(errorMessages.join(' ')); const u = new URL(this.hubValue); - u.searchParams.append('topic', this.topicValue); + if (this.hasTopicValue) { + u.searchParams.append('topic', this.topicValue); + } else { + this.topicsValue.forEach((topic) => { + u.searchParams.append('topic', topic); + }); + } this.url = u.toString(); } connect() { if (this.url) { - this.es = new EventSource(this.url); + this.es = new EventSource(this.url, { withCredentials: this.withCredentialsValue }); connectStreamSource(this.es); } } diff --git a/src/Turbo/assets/test/turbo_controller.test.ts b/src/Turbo/assets/test/turbo_controller.test.ts index 8b9584246b7..996ae01dfa7 100644 --- a/src/Turbo/assets/test/turbo_controller.test.ts +++ b/src/Turbo/assets/test/turbo_controller.test.ts @@ -8,8 +8,8 @@ */ import { Application } from '@hotwired/stimulus'; -import { getByTestId } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId } from '@testing-library/dom'; import TurboController from '../src/turbo_controller'; const startStimulus = () => { diff --git a/src/Turbo/assets/test/turbo_stream_controller.test.ts b/src/Turbo/assets/test/turbo_stream_controller.test.ts index 66c33f9b254..81042b140f5 100644 --- a/src/Turbo/assets/test/turbo_stream_controller.test.ts +++ b/src/Turbo/assets/test/turbo_stream_controller.test.ts @@ -8,10 +8,10 @@ */ import { Application } from '@hotwired/stimulus'; -import { getByTestId } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; -import TurboStreamController from '../src/turbo_stream_controller'; +import { getByTestId } from '@testing-library/dom'; import { vi } from 'vitest'; +import TurboStreamController from '../src/turbo_stream_controller'; const startStimulus = () => { const application = Application.start(); diff --git a/src/Turbo/composer.json b/src/Turbo/composer.json index 46acc1f579c..797f2cdd93a 100644 --- a/src/Turbo/composer.json +++ b/src/Turbo/composer.json @@ -40,23 +40,25 @@ "require-dev": { "doctrine/doctrine-bundle": "^2.4.3", "doctrine/orm": "^2.8 | 3.0", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^2.1.17", + "symfony/asset-mapper": "^6.4|^7.0", "symfony/debug-bundle": "^5.4|^6.0|^7.0", "symfony/form": "^5.4|^6.0|^7.0", - "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", "symfony/mercure-bundle": "^0.3.7", "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/panther": "^1.0|^2.0", + "symfony/panther": "^2.2", "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", "symfony/process": "^5.4|6.3.*|^7.0", "symfony/property-access": "^5.4|^6.0|^7.0", "symfony/security-core": "^5.4|^6.0|^7.0", "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/ux-twig-component": "^2.21", + "symfony/twig-bundle": "^6.4|^7.0", "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0", - "symfony/webpack-encore-bundle": "^2.1.1", "symfony/expression-language": "^5.4|^6.0|^7.0", - "dbrekelmans/bdi": "dev-main" + "dbrekelmans/bdi": "dev-main", + "php-webdriver/webdriver": "^1.15" }, "conflict": { "symfony/flex": "<1.13" diff --git a/src/Turbo/config/services.php b/src/Turbo/config/services.php index 0cd1e6a1d5f..e3a3fabcde5 100644 --- a/src/Turbo/config/services.php +++ b/src/Turbo/config/services.php @@ -11,11 +11,14 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\HttpKernel\KernelEvents; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Symfony\UX\Turbo\Broadcaster\IdAccessor; use Symfony\UX\Turbo\Broadcaster\ImuxBroadcaster; use Symfony\UX\Turbo\Broadcaster\TwigBroadcaster; use Symfony\UX\Turbo\Doctrine\BroadcastListener; +use Symfony\UX\Turbo\Request\RequestListener; +use Symfony\UX\Turbo\Twig\TurboRuntime; use Symfony\UX\Turbo\Twig\TwigExtension; /* @@ -45,9 +48,15 @@ ->decorate('turbo.broadcaster.imux') ->set('turbo.twig.extension', TwigExtension::class) - ->args([tagged_locator('turbo.renderer.stream_listen', 'transport'), abstract_arg('default')]) ->tag('twig.extension') + ->set('turbo.twig.runtime', TurboRuntime::class) + ->args([ + tagged_locator('turbo.renderer.stream_listen', 'transport'), + abstract_arg('default_transport'), + ]) + ->tag('twig.runtime') + ->set('turbo.doctrine.event_listener', BroadcastListener::class) ->args([ service('turbo.broadcaster.imux'), @@ -55,5 +64,8 @@ ]) ->tag('doctrine.event_listener', ['event' => 'onFlush']) ->tag('doctrine.event_listener', ['event' => 'postFlush']) + + ->set('turbo.kernel.request_listener', RequestListener::class) + ->tag('kernel.event_listener', ['event' => KernelEvents::REQUEST, 'priority' => 256]) ; }; diff --git a/src/Turbo/doc/index.rst b/src/Turbo/doc/index.rst index d20f6c73ed0..e198d70647b 100644 --- a/src/Turbo/doc/index.rst +++ b/src/Turbo/doc/index.rst @@ -19,10 +19,6 @@ Or watch the `Turbo Screencast on SymfonyCasts`_. Installation ------------ -.. caution:: - - Before you start, make sure you have `StimulusBundle configured in your app`_. - Install the bundle using Composer and Symfony Flex: .. code-block:: terminal @@ -37,9 +33,9 @@ needed if you're using AssetMapper): $ npm install --force $ npm run watch - # or use yarn - $ yarn install --force - $ yarn watch +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-turbo npm package`_ Usage ----- @@ -258,6 +254,44 @@ a Turbo Frame, and retrieve the ID of this frame:: } } + Twig Component +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. versionadded:: 2.22 + + The ```` Twig Component was added in Turbo 2.22. + +Simple example: + +.. code-block:: html+twig + + + + {# renders as: #} + + +With a HTML attribute: + +.. code-block:: html+twig + + + + {# renders as: #} + + +With content: + +.. code-block:: html+twig + + + A placeholder. + + + {# renders as: #} + + A placeholder. + + Writing Tests ^^^^^^^^^^^^^ @@ -284,7 +318,7 @@ Symfony. $client->request('GET', '/'); $client->clickLink('This block is scoped, the rest of the page will not change if you click here!'); - $this->assertSelectorTextContains('body', 'This will replace the content of the Turbo Frame!'); + $this->assertSelectorWillContain('body', 'This will replace the content of the Turbo Frame!'); } } @@ -312,11 +346,6 @@ clients. There are two main ways to receive the updates: Forms ^^^^^ -.. versionadded:: 2.1 - - Prior to 2.1, ``TurboStreamResponse::STREAM_FORMAT`` was used instead of ``TurboBundle::STREAM_FORMAT``. - Also, one had to return a new ``TurboStreamResponse()`` object as the third argument to ``$this->render()``. - Let's discover how to use Turbo Streams to enhance your `Symfony forms`_:: // src/Controller/TaskController.php @@ -353,7 +382,6 @@ Let's discover how to use Turbo Streams to enhance your `Symfony forms`_:: return $this->redirectToRoute('task_success', [], Response::HTTP_SEE_OTHER); } - // Symfony 6.2+ return $this->render('task/new.html.twig', [ 'form' => $form, ]); @@ -364,19 +392,177 @@ Let's discover how to use Turbo Streams to enhance your `Symfony forms`_:: {# bottom of new.html.twig #} {% block success_stream %} - - + {% endblock %} Supported actions are ``append``, ``prepend``, ``replace``, ``update``, -``remove``, ``before`` and ``after``. +``remove``, ``before``, ``after`` and ``refresh``. `Read the Turbo Streams documentation for more details`_. +Stream Messages and Actions +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To render a ```` element, this bundle provides a set of ```` Twig Components. These components make it easy to inject content directly into the ``