Compare commits

..

32 Commits

Author SHA1 Message Date
3d1ddb9f26 chore(master): release 0.20.3 2026-01-10 16:42:27 +02:00
a286bbdfe9 test: add incremental db test 2026-01-10 16:39:18 +02:00
a8e158d35b test: add db integration tests 2026-01-10 15:52:21 +02:00
c3d267f122 fix: db seed transactions logic 2026-01-10 15:34:18 +02:00
c2e4ebe242 chore(deps): update dependencies 2026-01-10 15:05:21 +02:00
Nextcloud bot
679abe3fb6 fix(l10n): Update translations from Transifex
Signed-off-by: Nextcloud bot <bot@nextcloud.com>
2026-01-10 02:15:18 +00:00
Nextcloud bot
043af15809 fix(l10n): Update translations from Transifex
Signed-off-by: Nextcloud bot <bot@nextcloud.com>
2026-01-09 02:05:21 +00:00
407df1d423 chore(master): release 0.20.2 2026-01-09 00:53:18 +02:00
e2dcebc6ee fix: bbcode cursor positions after inserting 2026-01-08 10:30:25 +02:00
a905ce3b4c chore(master): release 0.20.1 2026-01-08 10:14:02 +02:00
c017bb3d09 fix: db seed migrations 2026-01-08 09:32:40 +02:00
Nextcloud bot
67c92c05a3 fix(l10n): Update translations from Transifex
Signed-off-by: Nextcloud bot <bot@nextcloud.com>
2026-01-08 02:01:13 +00:00
e94ca2dec1 chore(deps): update dependencies 2026-01-07 22:51:20 +02:00
975744ec6f chore(master): release 0.20.0 2026-01-07 22:44:52 +02:00
cb7a03c1d5 feat: add preference to auto subscribe to replied threads 2026-01-07 22:41:00 +02:00
00e5d6d3b2 feat: add max-width to post/signature images 2026-01-07 22:40:43 +02:00
8b489b9cc3 fix: forum users tables migrations 2026-01-07 22:40:43 +02:00
9f904a7e48 chore(master): release 0.19.7 2026-01-06 01:31:16 +02:00
886c51fdca build: update workflows 2026-01-06 01:26:06 +02:00
919a13fdd3 fix: bbcode text insertion/selection logic 2026-01-06 01:10:36 +02:00
370eed1286 refactor: extract bbcode insertion/wrapping logic and add tests 2026-01-06 00:53:44 +02:00
1ff6349337 test: add view tests 2026-01-05 20:06:23 +02:00
7732f22f4e test: add comprehensive component tests 2026-01-05 20:06:16 +02:00
a07c8e452f refactor: move components to own subdirs 2026-01-05 18:27:17 +02:00
57642efc7b chore: update gen templates 2026-01-05 18:19:20 +02:00
18a2918446 test: add basic frontend tests 2026-01-05 18:19:19 +02:00
3e7cebc8c3 fix: main content size on mobile 2026-01-05 17:01:54 +02:00
eb1b2f86df fix: aggressive scroll-behavior interferes with mobile apps popover menu 2026-01-05 16:58:17 +02:00
c72c8b3eed chore(master): release 0.19.6 2026-01-05 11:54:32 +02:00
ec49855173 fix: bbcode editor 2026-01-05 11:50:13 +02:00
cdca135f7d chore(deps): update dependencies 2026-01-04 12:24:02 +02:00
145e6d8f81 fix: bbcode text wrapping 2026-01-04 12:19:03 +02:00
161 changed files with 11229 additions and 983 deletions

View File

@@ -1,10 +1,5 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# Use lint-eslint together with lint-eslint-when-unrelated to make eslint a required check for GitHub actions
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
# SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
# SPDX-License-Identifier: AGPL-3.0-or-later
name: Lint eslint
@@ -20,40 +15,9 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
outputs:
src: ${{ steps.changes.outputs.src}}
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/**'
- 'src/**'
- 'appinfo/info.xml'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
- '.eslintrc.*'
- '.eslintignore'
- '**.js'
- '**.ts'
- '**.vue'
lint:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.src != 'false'
name: NPM lint
steps:
@@ -75,13 +39,12 @@ jobs:
permissions:
contents: none
runs-on: ubuntu-latest
needs: [changes, lint]
needs: lint
if: always()
# This is the summary, we just avoid to rename it so that branch protection rules still match
name: eslint
steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi
run: if ${{ needs.lint.result != 'success' }}; then exit 1; fi

View File

@@ -0,0 +1,339 @@
# Incremental migration test workflow
#
# This workflow tests that migrations work correctly when upgrading from an older version.
# It first installs the app at v0.14.0 (last version before forum_user_stats -> forum_users rename),
# then upgrades to the current version and verifies all migrations run successfully.
#
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: PHPUnit Incremental Migration
on: pull_request
permissions:
contents: read
concurrency:
group: phpunit-incremental-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
src: ${{ steps.changes.outputs.src }}
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/**'
- 'appinfo/**'
- 'lib/**'
- 'tests/**'
- 'composer.json'
- 'composer.lock'
incremental-pgsql:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.src != 'false'
name: Incremental Migration (PostgreSQL)
services:
postgres:
image: ghcr.io/nextcloud/continuous-integration-postgres-16:latest # zizmor: ignore[unpinned-images]
ports:
- 4444:5432/tcp
env:
POSTGRES_USER: root
POSTGRES_PASSWORD: rootpassword
POSTGRES_DB: nextcloud
options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5
steps:
- name: Checkout app (current)
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
path: app-current
fetch-depth: 0
- name: Detect app ID from appinfo/info.xml
run: |
APP_ID=$(grep -oP '(?<=<id>)[^<]+' app-current/appinfo/info.xml | head -1)
echo "APP_NAME=$APP_ID" >> $GITHUB_ENV
echo "Detected app ID: $APP_ID"
- name: Get supported server versions
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: app-current/appinfo/info.xml
- name: Save current app for later
run: |
mkdir -p /tmp/app-backup
cp -r app-current /tmp/app-backup/
- name: Checkout server
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
submodules: true
repository: nextcloud/server
ref: ${{ fromJson(steps.versions.outputs.branches)[0] }}
- name: Checkout app at v0.14.0 (pre-rename baseline)
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
ref: v0.14.0
path: apps/${{ env.APP_NAME }}
- name: Set up php 8.3
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2.35.5
with:
php-version: '8.3'
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, pgsql, pdo_pgsql
coverage: none
ini-file: development
ini-values: disable_functions=
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set up dependencies (v0.14.0)
working-directory: apps/${{ env.APP_NAME }}
run: |
composer remove nextcloud/ocp --dev --no-scripts || true
composer i --no-scripts || composer i
- name: Set up Nextcloud and install app at v0.14.0
env:
DB_PORT: 4444
run: |
mkdir data
./occ maintenance:install --verbose --database=pgsql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
echo "::group::Installing app at v0.14.0"
./occ app:enable --force ${{ env.APP_NAME }}
echo "::endgroup::"
echo "::group::Database tables after v0.14.0 install"
./occ db:convert-filecache-bigint --no-interaction || true
PGPASSWORD=rootpassword psql -h 127.0.0.1 -p $DB_PORT -U root -d nextcloud -c "\dt oc_forum_*"
echo "::endgroup::"
- name: Upgrade app to current version
run: |
echo "::group::Replacing app with current version"
rm -rf apps/${{ env.APP_NAME }}
cp -r /tmp/app-backup/app-current apps/${{ env.APP_NAME }}
echo "::endgroup::"
- name: Set up dependencies (current)
working-directory: apps/${{ env.APP_NAME }}
run: |
composer remove nextcloud/ocp --dev --no-scripts || true
composer i
- name: Run migrations to current version
env:
DB_PORT: 4444
run: |
echo "::group::Running upgrade migrations"
# Disable maintenance mode if it was left on
./occ maintenance:mode --off || true
# Disable and re-enable the app to trigger migrations
# This simulates what happens when a user upgrades the app
./occ app:disable ${{ env.APP_NAME }}
./occ app:enable ${{ env.APP_NAME }}
echo "::endgroup::"
echo "::group::Database tables after upgrade"
PGPASSWORD=rootpassword psql -h 127.0.0.1 -p $DB_PORT -U root -d nextcloud -c "\dt oc_forum_*"
echo "::endgroup::"
echo "::group::Checking forum_users table exists"
PGPASSWORD=rootpassword psql -h 127.0.0.1 -p $DB_PORT -U root -d nextcloud -c "SELECT COUNT(*) FROM oc_forum_users" || exit 1
echo "::endgroup::"
- name: Check PHPUnit integration script is defined
id: check_integration
continue-on-error: true
working-directory: apps/${{ env.APP_NAME }}
run: |
composer run --list | grep '^ test:integration ' | wc -l | grep 1
- name: Run Nextcloud
if: steps.check_integration.outcome == 'success'
run: php -S localhost:8080 &
- name: PHPUnit integration
if: steps.check_integration.outcome == 'success'
working-directory: apps/${{ env.APP_NAME }}
run: composer run test:integration
- name: Print logs
if: always()
run: |
cat data/nextcloud.log
incremental-mysql:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.src != 'false'
name: Incremental Migration (MySQL)
services:
mysql:
image: ghcr.io/nextcloud/continuous-integration-mariadb-10.11:latest # zizmor: ignore[unpinned-images]
ports:
- 4444:3306/tcp
env:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: nextcloud
options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 5
steps:
- name: Checkout app (current)
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
path: app-current
fetch-depth: 0
- name: Detect app ID from appinfo/info.xml
run: |
APP_ID=$(grep -oP '(?<=<id>)[^<]+' app-current/appinfo/info.xml | head -1)
echo "APP_NAME=$APP_ID" >> $GITHUB_ENV
echo "Detected app ID: $APP_ID"
- name: Get supported server versions
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
with:
filename: app-current/appinfo/info.xml
- name: Save current app for later
run: |
mkdir -p /tmp/app-backup
cp -r app-current /tmp/app-backup/
- name: Checkout server
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
submodules: true
repository: nextcloud/server
ref: ${{ fromJson(steps.versions.outputs.branches)[0] }}
- name: Checkout app at v0.14.0 (pre-rename baseline)
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
ref: v0.14.0
path: apps/${{ env.APP_NAME }}
- name: Set up php 8.3
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2.35.5
with:
php-version: '8.3'
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, mysql, pdo_mysql
coverage: none
ini-file: development
ini-values: disable_functions=
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set up dependencies (v0.14.0)
working-directory: apps/${{ env.APP_NAME }}
run: |
composer remove nextcloud/ocp --dev --no-scripts || true
composer i --no-scripts || composer i
- name: Set up Nextcloud and install app at v0.14.0
env:
DB_PORT: 4444
run: |
mkdir data
./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
echo "::group::Installing app at v0.14.0"
./occ app:enable --force ${{ env.APP_NAME }}
echo "::endgroup::"
echo "::group::Database tables after v0.14.0 install"
mysql -h 127.0.0.1 -P $DB_PORT -u root -prootpassword nextcloud -e "SHOW TABLES LIKE 'oc_forum_%'"
echo "::endgroup::"
- name: Upgrade app to current version
run: |
echo "::group::Replacing app with current version"
rm -rf apps/${{ env.APP_NAME }}
cp -r /tmp/app-backup/app-current apps/${{ env.APP_NAME }}
echo "::endgroup::"
- name: Set up dependencies (current)
working-directory: apps/${{ env.APP_NAME }}
run: |
composer remove nextcloud/ocp --dev --no-scripts || true
composer i
- name: Run migrations to current version
env:
DB_PORT: 4444
run: |
echo "::group::Running upgrade migrations"
# Disable maintenance mode if it was left on
./occ maintenance:mode --off || true
# Disable and re-enable the app to trigger migrations
# This simulates what happens when a user upgrades the app
./occ app:disable ${{ env.APP_NAME }}
./occ app:enable ${{ env.APP_NAME }}
echo "::endgroup::"
echo "::group::Database tables after upgrade"
mysql -h 127.0.0.1 -P $DB_PORT -u root -prootpassword nextcloud -e "SHOW TABLES LIKE 'oc_forum_%'"
echo "::endgroup::"
echo "::group::Checking forum_users table exists"
mysql -h 127.0.0.1 -P $DB_PORT -u root -prootpassword nextcloud -e "SELECT COUNT(*) FROM oc_forum_users" || exit 1
echo "::endgroup::"
- name: Check PHPUnit integration script is defined
id: check_integration
continue-on-error: true
working-directory: apps/${{ env.APP_NAME }}
run: |
composer run --list | grep '^ test:integration ' | wc -l | grep 1
- name: Run Nextcloud
if: steps.check_integration.outcome == 'success'
run: php -S localhost:8080 &
- name: PHPUnit integration
if: steps.check_integration.outcome == 'success'
working-directory: apps/${{ env.APP_NAME }}
run: composer run test:integration
- name: Print logs
if: always()
run: |
cat data/nextcloud.log
summary:
permissions:
contents: none
runs-on: ubuntu-latest
needs: [changes, incremental-pgsql, incremental-mysql]
if: always()
name: incremental-migration-summary
steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && (needs.incremental-pgsql.result != 'success' || needs.incremental-mysql.result != 'success') }}; then exit 1; fi

View File

@@ -174,16 +174,34 @@ jobs:
working-directory: apps/${{ env.APP_NAME }}
run: composer run test:unit
- name: Check PHPUnit integration script is defined
id: check_integration
continue-on-error: true
working-directory: apps/${{ env.APP_NAME }}
run: |
composer run --list | grep '^ test:integration ' | wc -l | grep 1
- name: Run Nextcloud
# Only run if phpunit integration config file exists
if: steps.check_integration.outcome == 'success'
run: php -S localhost:8080 &
- name: PHPUnit integration
# Only run if phpunit integration config file exists
if: steps.check_integration.outcome == 'success'
working-directory: apps/${{ env.APP_NAME }}
run: composer run test:integration
- name: Print logs
if: always()
run: |
cat data/nextcloud.log
- name: Skipped
# Fail the action when unit tests are not specified
if: steps.check_phpunit.outcome == 'failure'
# Fail the action when neither unit nor integration tests ran
if: steps.check_phpunit.outcome == 'failure' && steps.check_integration.outcome == 'failure'
run: |
echo 'PHPUnit tests are not specified in composer.json scripts'
echo 'Neither PHPUnit nor PHPUnit integration tests are specified in composer.json scripts'
exit 1
summary:

68
.github/workflows/vitest.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
# SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
# SPDX-License-Identifier: AGPL-3.0-or-later
name: Vitest
on: pull_request
permissions:
contents: read
concurrency:
group: vitest-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
vitest:
runs-on: ubuntu-latest
name: Vitest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test:run
summary:
permissions:
contents: none
runs-on: ubuntu-latest
needs: vitest
if: always()
name: vitest-summary
steps:
- name: Summary status
run: if ${{ needs.vitest.result != 'success' }}; then exit 1; fi

View File

@@ -1,2 +1,2 @@
templates/
scaffolds/
gen/

View File

@@ -1 +1 @@
{".":"0.19.5"}
{".":"0.20.3"}

View File

@@ -1,5 +1,59 @@
# Changelog
## [0.20.3](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.2...v0.20.3) (2026-01-10)
### Bug Fixes
* db seed transactions logic ([c3d267f](https://github.com/chenasraf/nextcloud-forum/commit/c3d267f12269568e0a091cc094e043e4f1ad8cb8))
* **l10n:** Update translations from Transifex ([679abe3](https://github.com/chenasraf/nextcloud-forum/commit/679abe3fb6ce7c8545bf99875092f20cf5d468b8))
* **l10n:** Update translations from Transifex ([043af15](https://github.com/chenasraf/nextcloud-forum/commit/043af1580912d201a724313d4788d7a776e11934))
## [0.20.2](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.1...v0.20.2) (2026-01-08)
### Bug Fixes
* bbcode cursor positions after inserting ([e2dcebc](https://github.com/chenasraf/nextcloud-forum/commit/e2dcebc6ee6e4d017f7f26fc86e72e6734a1f757))
## [0.20.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.0...v0.20.1) (2026-01-08)
### Bug Fixes
* db seed migrations ([c017bb3](https://github.com/chenasraf/nextcloud-forum/commit/c017bb3d09a517c19e772420311c23a957f25cba))
* **l10n:** Update translations from Transifex ([67c92c0](https://github.com/chenasraf/nextcloud-forum/commit/67c92c05a3e7f58bbc05265087b763368653f7d3))
## [0.20.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.7...v0.20.0) (2026-01-07)
### Features
* add max-width to post/signature images ([00e5d6d](https://github.com/chenasraf/nextcloud-forum/commit/00e5d6d3b2e14939b233a80050f645ebd7b8503a))
* add preference to auto subscribe to replied threads ([cb7a03c](https://github.com/chenasraf/nextcloud-forum/commit/cb7a03c1d51f2dc5642a47ab222b07cec6e01731))
### Bug Fixes
* forum users tables migrations ([8b489b9](https://github.com/chenasraf/nextcloud-forum/commit/8b489b9cc3919dedf1463c7c7dd54e7a8009fc6f))
## [0.19.7](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.6...v0.19.7) (2026-01-05)
### Bug Fixes
* aggressive scroll-behavior interferes with mobile apps popover menu ([eb1b2f8](https://github.com/chenasraf/nextcloud-forum/commit/eb1b2f86df7e7bf75bdbd9ba8260471ec91110fb))
* bbcode text insertion/selection logic ([919a13f](https://github.com/chenasraf/nextcloud-forum/commit/919a13fdd3da0579c7d9ebdd032e3108e9da7047))
* main content size on mobile ([3e7cebc](https://github.com/chenasraf/nextcloud-forum/commit/3e7cebc8c3316dada42cf1ba81acb062d5b1d41a))
## [0.19.6](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.5...v0.19.6) (2026-01-05)
### Bug Fixes
* bbcode editor ([ec49855](https://github.com/chenasraf/nextcloud-forum/commit/ec49855173e026b683a6dd0cc29e46a72f62e98e))
* bbcode text wrapping ([145e6d8](https://github.com/chenasraf/nextcloud-forum/commit/145e6d8f814d3899ef6327eaff5637a296b6582d))
## [0.19.5](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.4...v0.19.5) (2026-01-03)

View File

@@ -17,13 +17,14 @@
# - JS build is delegated to your package.json scripts (tool-agnostic).
#
# Common recipes:
# make build → install deps & build
# make dist → build source + appstore tarballs
# make test → run PHP unit tests
# make lint → lint JS & PHP
# make openapi → generate OpenAPI JSON
# make sign → print signature for GitHub tarball
# make release → upload release to Nextcloud App Store
# make build → install deps & build
# make dist → build source + appstore tarballs
# make test → run PHP unit tests
# make test-integration → run PHP integration tests
# make lint → lint JS & PHP
# make openapi → generate OpenAPI JSON
# make sign → print signature for GitHub tarball
# make release → upload release to Nextcloud App Store
#
app_name=forum
@@ -242,10 +243,33 @@ test: composer
exit 1; \
fi; \
echo "\x1b[32mUsing Nextcloud root: $$NC_ROOT\x1b[0m"; \
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.xml; \
if [ -f tests/phpunit.integration.xml ]; then \
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.integration.xml; \
fi
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.xml
# test-integration:
# - Run only PHP integration tests (database-dependent tests)
# - These tests run against a real database and test migration/seeding
.PHONY: test-integration
test-integration: composer
@NC_ROOT="$(NEXTCLOUD_ROOT)"; \
if [ -n "$$NC_ROOT" ]; then \
NC_ROOT=$$(echo "$$NC_ROOT" | sed "s|^\\\~|$$HOME|" | sed "s|^~|$$HOME|"); \
fi; \
if [ -z "$$NC_ROOT" ]; then \
if [ -d "$(CURDIR)/../../../tests/bootstrap.php" ]; then \
NC_ROOT="$(CURDIR)/../../.."; \
fi; \
fi; \
if [ -z "$$NC_ROOT" ]; then \
echo "\x1b[33mCould not find Nextcloud installation.\x1b[0m"; \
echo "Set NEXTCLOUD_ROOT environment variable."; \
exit 1; \
fi; \
if [ ! -f tests/phpunit.integration.xml ]; then \
echo "\x1b[31mNo integration tests found (tests/phpunit.integration.xml missing)\x1b[0m"; \
exit 1; \
fi; \
echo "\x1b[32mUsing Nextcloud root: $$NC_ROOT\x1b[0m"; \
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.integration.xml
# test-docker:
# - Run PHP unit tests inside a Nextcloud Docker container

View File

@@ -37,7 +37,7 @@ This app is in early stages of development. While functional, you may encounter
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
]]></description>
<version>0.19.5</version>
<version>0.20.3</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
<namespace>Forum</namespace>

View File

@@ -26,6 +26,7 @@
"cs:fix": "php-cs-fixer fix",
"psalm": "psalm --threads=1 --no-cache",
"test:unit": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky",
"test:integration": "phpunit tests -c tests/phpunit.integration.xml --colors=always --fail-on-warning --fail-on-risky",
"openapi": "generate-spec"
},
"require": {

40
composer.lock generated
View File

@@ -180,12 +180,12 @@
"source": {
"type": "git",
"url": "https://github.com/nextcloud-deps/ocp.git",
"reference": "45612049852ea4234dd799a4a3281a397f0749c3"
"reference": "a79703d9f38e964b003ae1cc805b6531d142fa93"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/45612049852ea4234dd799a4a3281a397f0749c3",
"reference": "45612049852ea4234dd799a4a3281a397f0749c3",
"url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/a79703d9f38e964b003ae1cc805b6531d142fa93",
"reference": "a79703d9f38e964b003ae1cc805b6531d142fa93",
"shasum": ""
},
"require": {
@@ -220,7 +220,7 @@
"issues": "https://github.com/nextcloud-deps/ocp/issues",
"source": "https://github.com/nextcloud-deps/ocp/tree/stable32"
},
"time": "2025-12-16T00:55:52+00:00"
"time": "2026-01-09T00:57:52+00:00"
},
{
"name": "nikic/php-parser",
@@ -1035,12 +1035,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
"reference": "c04f8230182e06cd6b2ba948c85581a1b93887f2"
"reference": "ccfd723dc03e9864008d011603c412910180d7a6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/c04f8230182e06cd6b2ba948c85581a1b93887f2",
"reference": "c04f8230182e06cd6b2ba948c85581a1b93887f2",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ccfd723dc03e9864008d011603c412910180d7a6",
"reference": "ccfd723dc03e9864008d011603c412910180d7a6",
"shasum": ""
},
"conflict": {
@@ -1101,7 +1101,7 @@
"backpack/filemanager": "<2.0.2|>=3,<3.0.9",
"bacula-web/bacula-web": "<9.7.1",
"badaso/core": "<=2.9.11",
"bagisto/bagisto": "<=2.3.7",
"bagisto/bagisto": "<2.3.10",
"barrelstrength/sprout-base-email": "<1.2.7",
"barrelstrength/sprout-forms": "<3.9",
"barryvdh/laravel-translation-manager": "<0.6.8",
@@ -1163,7 +1163,7 @@
"codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5",
"commerceteam/commerce": ">=0.9.6,<0.9.9",
"components/jquery": ">=1.0.3,<3.5",
"composer/composer": "<1.10.27|>=2,<2.2.24|>=2.3,<2.7.7",
"composer/composer": "<1.10.27|>=2,<2.2.26|>=2.3,<2.9.3",
"concrete5/concrete5": "<9.4.3",
"concrete5/core": "<8.5.8|>=9,<9.1",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
@@ -1173,10 +1173,11 @@
"contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"coreshop/core-shop": "<=4.1.7",
"corveda/phpsandbox": "<1.3.5",
"cosenary/instagram": "<=2.3",
"couleurcitron/tarteaucitron-wp": "<0.3",
"craftcms/cms": "<=4.16.5|>=5,<=5.8.6",
"craftcms/cms": "<=4.16.16|>=5,<=5.8.20",
"croogo/croogo": "<=4.0.7",
"cuyz/valinor": "<0.12",
"czim/file-handling": "<1.5|>=2,<2.3",
@@ -1224,7 +1225,7 @@
"drupal/commerce_alphabank_redirect": "<1.0.3",
"drupal/commerce_eurobank_redirect": "<2.1.1",
"drupal/config_split": "<1.10|>=2,<2.0.2",
"drupal/core": ">=6,<6.38|>=7,<7.102|>=8,<10.4.9|>=10.5,<10.5.6|>=11,<11.1.9|>=11.2,<11.2.8",
"drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.4.9|>=10.5,<10.5.6|>=11,<11.1.9|>=11.2,<11.2.8",
"drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8",
"drupal/currency": "<3.5",
"drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8",
@@ -1288,7 +1289,7 @@
"ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15",
"ezyang/htmlpurifier": "<=4.2",
"facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2",
"facturascripts/facturascripts": "<=2022.08",
"facturascripts/facturascripts": "<=2025.4|==2025.11|==2025.41|==2025.43",
"fastly/magento2": "<1.2.26",
"feehi/cms": "<=2.1.1",
"feehi/feehicms": "<=2.1.1",
@@ -1339,7 +1340,7 @@
"geshi/geshi": "<=1.0.9.1",
"getformwork/formwork": "<2.2",
"getgrav/grav": "<1.11.0.0-beta1",
"getkirby/cms": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1|>=5,<5.1.4",
"getkirby/cms": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1|>=5,<=5.2.1",
"getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1",
"getkirby/panel": "<2.5.14",
"getkirby/starterkit": "<=3.7.0.2",
@@ -1568,7 +1569,7 @@
"october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1",
"october/october": "<3.7.5",
"october/rain": "<1.0.472|>=1.1,<1.1.2",
"october/system": "<3.7.5",
"october/system": "<=3.7.12|>=4,<=4.0.11",
"oliverklee/phpunit": "<3.5.15",
"omeka/omeka-s": "<4.0.3",
"onelogin/php-saml": "<2.21.1|>=3,<3.8.1|>=4,<4.3.1",
@@ -1595,6 +1596,7 @@
"pagekit/pagekit": "<=1.0.18",
"paragonie/ecc": "<2.0.1",
"paragonie/random_compat": "<2",
"paragonie/sodium_compat": "<1.24|>=2,<2.5",
"passbolt/passbolt_api": "<4.6.2",
"paypal/adaptivepayments-sdk-php": "<=3.9.2",
"paypal/invoice-sdk-php": "<=3.9",
@@ -1662,7 +1664,7 @@
"processwire/processwire": "<=3.0.246",
"propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7",
"propel/propel1": ">=1,<=1.7.1",
"pterodactyl/panel": "<=1.11.10",
"pterodactyl/panel": "<1.12",
"ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2",
"ptrofimov/beanstalk_console": "<1.7.14",
"pubnub/pubnub": "<6.1",
@@ -1680,7 +1682,7 @@
"rap2hpoutre/laravel-log-viewer": "<0.13",
"react/http": ">=0.7,<1.9",
"really-simple-plugins/complianz-gdpr": "<6.4.2",
"redaxo/source": "<5.20.1",
"redaxo/source": "<=5.20.1",
"remdex/livehelperchat": "<4.29",
"renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1",
"reportico-web/reportico": "<=8.1",
@@ -1839,7 +1841,7 @@
"thelia/thelia": ">=2.1,<2.1.3",
"theonedemon/phpwhois": "<=4.2.5",
"thinkcmf/thinkcmf": "<6.0.8",
"thorsten/phpmyfaq": "<=4.0.13",
"thorsten/phpmyfaq": "<4.0.16|>=4.1.0.0-alpha,<=4.1.0.0-beta2",
"tikiwiki/tiki-manager": "<=17.1",
"timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1",
"tinymce/tinymce": "<7.2",
@@ -1955,7 +1957,7 @@
"yiisoft/yii2-redis": "<2.0.20",
"yikesinc/yikes-inc-easy-mailchimp-extender": "<6.8.6",
"yoast-seo-for-typo3/yoast_seo": "<7.2.3",
"yourls/yourls": "<=1.8.2",
"yourls/yourls": "<=1.10.2",
"yuan1994/tpadmin": "<=1.3.12",
"yungifez/skuul": "<=2.6.5",
"z-push/z-push-dev": "<2.7.6",
@@ -2033,7 +2035,7 @@
"type": "tidelift"
}
],
"time": "2025-12-27T00:23:33+00:00"
"time": "2026-01-09T19:06:26+00:00"
},
{
"name": "sebastian/cli-parser",

View File

@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* Unit tests for {{pascalCase name}} component.
*
* See src/components/StatusBadge.test.ts for a complete example.
*/
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
import {{ pascalCase name }} from './{{pascalCase name}}.vue'
// ----------------------------------------------------------------------------
// Mocks - uncomment as needed
// ----------------------------------------------------------------------------
// Mock @nextcloud/l10n (if your component uses t() or n())
// vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
// Mock icon components (adjust path and name as needed)
// vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
describe('{{pascalCase name}}', () => {
// Example: Basic rendering
// it('renders correctly', () => {
// const wrapper = mount({{pascalCase name}})
// expect(wrapper.exists()).toBe(true)
// })
// Example: Testing with props
// it('renders with props', () => {
// const wrapper = mount({{pascalCase name}}, {
// props: { title: 'Hello' },
// })
// expect(wrapper.text()).toContain('Hello')
// })
// Example: Testing CSS classes
// it('applies correct CSS class', () => {
// const wrapper = mount({{pascalCase name}}, {
// props: { variant: 'primary' },
// })
// expect(wrapper.classes()).toContain('is-primary')
// })
// Example: Testing emitted events
// it('emits click event', async () => {
// const wrapper = mount({{pascalCase name}})
// await wrapper.trigger('click')
// expect(wrapper.emitted('click')).toBeTruthy()
// })
// Example: Testing computed properties
// it('computes derived value', () => {
// const wrapper = mount({{pascalCase name}}, {
// props: { count: 5 },
// })
// const vm = wrapper.vm as InstanceType<typeof {{pascalCase name}}>
// expect(vm.doubleCount).toBe(10)
// })
// Example: Testing conditional rendering
// it('shows content when condition is met', () => {
// const wrapper = mount({{pascalCase name}}, {
// props: { showDetails: true },
// })
// expect(wrapper.find('.details').exists()).toBe(true)
// })
// Example: Testing slots
// it('renders slot content', () => {
// const wrapper = mount({{pascalCase name}}, {
// slots: { default: 'Slot content' },
// })
// expect(wrapper.text()).toContain('Slot content')
// })
it.todo('add your tests here')
})

View File

@@ -40,7 +40,7 @@ class {{pascalCase name}}Mapper extends QBMapper {
}
/**
* @param string $projectId
* @param string $id
* @return array<{{pascalCase name}}>
*/
public function findAll(): array {

View File

@@ -282,6 +282,8 @@ OC.L10N.register(
"Configure how you receive notifications" : "Einrichten, wie du Benachrichtigungen erhältst",
"Auto-subscribe to threads I create" : "Themen die ich erstellt habe, automatisch abonnieren",
"When enabled, you will automatically receive notifications for replies to threads you create" : "Wenn diese Option aktiviert ist, erhältst du automatisch Benachrichtigungen für Antworten auf von dir erstellte Themen",
"Auto-subscribe to threads I reply to" : "Themen auf die ich geantwortet habe, automatisch abonnieren",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Wenn diese Option aktiviert ist, erhältst du automatisch Benachrichtigungen für Antworten in Themen, in denen du geantwortet hast",
"Files" : "Dateien",
"Configure file upload settings" : "Einstellungen für das Hochladen von Dateien",
"Upload directory" : "Hochladeverzeichnis",

View File

@@ -280,6 +280,8 @@
"Configure how you receive notifications" : "Einrichten, wie du Benachrichtigungen erhältst",
"Auto-subscribe to threads I create" : "Themen die ich erstellt habe, automatisch abonnieren",
"When enabled, you will automatically receive notifications for replies to threads you create" : "Wenn diese Option aktiviert ist, erhältst du automatisch Benachrichtigungen für Antworten auf von dir erstellte Themen",
"Auto-subscribe to threads I reply to" : "Themen auf die ich geantwortet habe, automatisch abonnieren",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Wenn diese Option aktiviert ist, erhältst du automatisch Benachrichtigungen für Antworten in Themen, in denen du geantwortet hast",
"Files" : "Dateien",
"Configure file upload settings" : "Einstellungen für das Hochladen von Dateien",
"Upload directory" : "Hochladeverzeichnis",

View File

@@ -282,6 +282,8 @@ OC.L10N.register(
"Configure how you receive notifications" : "Einrichten, wie Sie Benachrichtigungen erhalten",
"Auto-subscribe to threads I create" : "Themen die ich erstellt habe, automatisch abonnieren",
"When enabled, you will automatically receive notifications for replies to threads you create" : "Wenn diese Option aktiviert ist, erhalten Sie automatisch Benachrichtigungen für Antworten auf von Ihnen erstellte Themen",
"Auto-subscribe to threads I reply to" : "Themen auf die ich geantwortet habe, automatisch abonnieren",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Wenn diese Option aktiviert ist, erhalten Sie automatisch Benachrichtigungen für Antworten in Themen, in denen Sie geantwortet haben",
"Files" : "Dateien",
"Configure file upload settings" : "Einstellungen für das Hochladen von Dateien",
"Upload directory" : "Hochladeverzeichnis",

View File

@@ -280,6 +280,8 @@
"Configure how you receive notifications" : "Einrichten, wie Sie Benachrichtigungen erhalten",
"Auto-subscribe to threads I create" : "Themen die ich erstellt habe, automatisch abonnieren",
"When enabled, you will automatically receive notifications for replies to threads you create" : "Wenn diese Option aktiviert ist, erhalten Sie automatisch Benachrichtigungen für Antworten auf von Ihnen erstellte Themen",
"Auto-subscribe to threads I reply to" : "Themen auf die ich geantwortet habe, automatisch abonnieren",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Wenn diese Option aktiviert ist, erhalten Sie automatisch Benachrichtigungen für Antworten in Themen, in denen Sie geantwortet haben",
"Files" : "Dateien",
"Configure file upload settings" : "Einstellungen für das Hochladen von Dateien",
"Upload directory" : "Hochladeverzeichnis",

View File

@@ -2,11 +2,15 @@ OC.L10N.register(
"forum",
{
"Admin" : "Administrador",
"Administrator role with full permissions" : "Rol de administrador con permisos completos",
"Moderator" : "Moderador",
"Moderator role with elevated permissions" : "Rol de moderador con permisos elevados",
"User" : "Usuario",
"Default user role with basic permissions" : "Rol de usuario por defecto con permisos básicos",
"Guest" : "Invitado",
"Guest role for unauthenticated users with read-only access" : "Rol de invitado para usuarios sin autenticar con acceso de solo lectura",
"General" : "General",
"General discussion categories" : "Categorías de discusión general",
"Support" : "Soporte",
"Bold text" : "Texto en negrita",
"Underlined text" : "Texto subrayado",

View File

@@ -1,10 +1,14 @@
{ "translations": {
"Admin" : "Administrador",
"Administrator role with full permissions" : "Rol de administrador con permisos completos",
"Moderator" : "Moderador",
"Moderator role with elevated permissions" : "Rol de moderador con permisos elevados",
"User" : "Usuario",
"Default user role with basic permissions" : "Rol de usuario por defecto con permisos básicos",
"Guest" : "Invitado",
"Guest role for unauthenticated users with read-only access" : "Rol de invitado para usuarios sin autenticar con acceso de solo lectura",
"General" : "General",
"General discussion categories" : "Categorías de discusión general",
"Support" : "Soporte",
"Bold text" : "Texto en negrita",
"Underlined text" : "Texto subrayado",

View File

@@ -281,6 +281,8 @@ OC.L10N.register(
"Configure how you receive notifications" : "Seadista endale teavituste saatmise viisi",
"Auto-subscribe to threads I create" : "Telli minu loodud jutulõngad automaatselt",
"When enabled, you will automatically receive notifications for replies to threads you create" : "Kui see eelistus on kasutusel, siis saad automaatselt teavituse sinu loodud jutulõngade vastuste puhul",
"Auto-subscribe to threads I reply to" : "Telli minu poolt vastatud jutulõngad automaatselt",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Kui see eelistus on kasutusel, siis saad automaatselt teavituse nende jutulõngade kohta, kuhu oled vastuse kirjutanud",
"Files" : "Failid",
"Configure file upload settings" : "Seadista failide üleslaadimise seadistusi",
"Upload directory" : "Üleslaadimiskaust",

View File

@@ -279,6 +279,8 @@
"Configure how you receive notifications" : "Seadista endale teavituste saatmise viisi",
"Auto-subscribe to threads I create" : "Telli minu loodud jutulõngad automaatselt",
"When enabled, you will automatically receive notifications for replies to threads you create" : "Kui see eelistus on kasutusel, siis saad automaatselt teavituse sinu loodud jutulõngade vastuste puhul",
"Auto-subscribe to threads I reply to" : "Telli minu poolt vastatud jutulõngad automaatselt",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Kui see eelistus on kasutusel, siis saad automaatselt teavituse nende jutulõngade kohta, kuhu oled vastuse kirjutanud",
"Files" : "Failid",
"Configure file upload settings" : "Seadista failide üleslaadimise seadistusi",
"Upload directory" : "Üleslaadimiskaust",

View File

@@ -282,6 +282,8 @@ OC.L10N.register(
"Configure how you receive notifications" : "Cumraigh conas a fhaigheann tú fógraí",
"Auto-subscribe to threads I create" : "Liostáil go huathoibríoch le snáitheanna a chruthaím",
"When enabled, you will automatically receive notifications for replies to threads you create" : "Nuair a bheidh sé cumasaithe, gheobhaidh tú fógraí go huathoibríoch le haghaidh freagraí ar shnáitheanna a chruthaíonn tú",
"Auto-subscribe to threads I reply to" : "Liostáil go huathoibríoch le snáitheanna a bhfreagraím orthu",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Nuair a bheidh sé cumasaithe, gheobhaidh tú fógraí go huathoibríoch faoi fhreagraí nua i snáitheanna ar fhreagair tú iad",
"Files" : "Comhaid",
"Configure file upload settings" : "Cumraigh socruithe uaslódála comhad",
"Upload directory" : "Uaslódáil eolaire",

View File

@@ -280,6 +280,8 @@
"Configure how you receive notifications" : "Cumraigh conas a fhaigheann tú fógraí",
"Auto-subscribe to threads I create" : "Liostáil go huathoibríoch le snáitheanna a chruthaím",
"When enabled, you will automatically receive notifications for replies to threads you create" : "Nuair a bheidh sé cumasaithe, gheobhaidh tú fógraí go huathoibríoch le haghaidh freagraí ar shnáitheanna a chruthaíonn tú",
"Auto-subscribe to threads I reply to" : "Liostáil go huathoibríoch le snáitheanna a bhfreagraím orthu",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Nuair a bheidh sé cumasaithe, gheobhaidh tú fógraí go huathoibríoch faoi fhreagraí nua i snáitheanna ar fhreagair tú iad",
"Files" : "Comhaid",
"Configure file upload settings" : "Cumraigh socruithe uaslódála comhad",
"Upload directory" : "Uaslódáil eolaire",

View File

@@ -93,7 +93,7 @@ OC.L10N.register(
"Pick file from Nextcloud" : "Seleccionar un ficheiro en Nextcloud",
"Upload file to Nextcloud" : "Enviar un ficheiro a Nextcloud",
"Uploading file …" : "Enviando o ficheiro…",
"Upload failed" : "Produciuse algún fallo no envío",
"Upload failed" : "Produciuse un fallo no envío",
"Close" : "Pechar",
"Pick a file to attach" : "Escolla un ficheiro para anexar",
"Failed to upload file" : "Produciuse un fallo ao enviar o ficheiro",

View File

@@ -91,7 +91,7 @@
"Pick file from Nextcloud" : "Seleccionar un ficheiro en Nextcloud",
"Upload file to Nextcloud" : "Enviar un ficheiro a Nextcloud",
"Uploading file …" : "Enviando o ficheiro…",
"Upload failed" : "Produciuse algún fallo no envío",
"Upload failed" : "Produciuse un fallo no envío",
"Close" : "Pechar",
"Pick a file to attach" : "Escolla un ficheiro para anexar",
"Failed to upload file" : "Produciuse un fallo ao enviar o ficheiro",

View File

@@ -16,6 +16,7 @@ OC.L10N.register(
"Dashboard" : "Skydelis",
"Users" : "Naudotojai",
"Categories" : "Kategorijos",
"Expand" : "Išskleisti",
"Collapse" : "Suskleisti",
"Hello world!" : "Sveikas, pasauli!",
"Code" : "Kodas",
@@ -56,6 +57,7 @@ OC.L10N.register(
"Edit title" : "Taisyti pavadinimą",
"Move thread" : "Perkelti giją",
"Preferences" : "Nuostatos",
"Notifications" : "Pranešimai",
"Files" : "Failai",
"Browse" : "Naršyti",
"Signature" : "Parašas",

View File

@@ -14,6 +14,7 @@
"Dashboard" : "Skydelis",
"Users" : "Naudotojai",
"Categories" : "Kategorijos",
"Expand" : "Išskleisti",
"Collapse" : "Suskleisti",
"Hello world!" : "Sveikas, pasauli!",
"Code" : "Kodas",
@@ -54,6 +55,7 @@
"Edit title" : "Taisyti pavadinimą",
"Move thread" : "Perkelti giją",
"Preferences" : "Nuostatos",
"Notifications" : "Pranešimai",
"Files" : "Failai",
"Browse" : "Naršyti",
"Signature" : "Parašas",

View File

@@ -282,6 +282,8 @@ OC.L10N.register(
"Configure how you receive notifications" : "設定你接收通知的方式",
"Auto-subscribe to threads I create" : "自動訂閱由我建立的主題",
"When enabled, you will automatically receive notifications for replies to threads you create" : "啟用後,你會自動收到針對你所建立主題之回覆通知。",
"Auto-subscribe to threads I reply to" : "自動訂閱我回覆的討論串",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "啟用後,您將會自動收到您回覆過的討論串中的新回覆通知",
"Files" : "檔案",
"Configure file upload settings" : "配置檔案上傳設定",
"Upload directory" : "上載目錄",

View File

@@ -280,6 +280,8 @@
"Configure how you receive notifications" : "設定你接收通知的方式",
"Auto-subscribe to threads I create" : "自動訂閱由我建立的主題",
"When enabled, you will automatically receive notifications for replies to threads you create" : "啟用後,你會自動收到針對你所建立主題之回覆通知。",
"Auto-subscribe to threads I reply to" : "自動訂閱我回覆的討論串",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "啟用後,您將會自動收到您回覆過的討論串中的新回覆通知",
"Files" : "檔案",
"Configure file upload settings" : "配置檔案上傳設定",
"Upload directory" : "上載目錄",

View File

@@ -282,6 +282,8 @@ OC.L10N.register(
"Configure how you receive notifications" : "設定您要如何接收通知",
"Auto-subscribe to threads I create" : "自動訂閱我建立的討論串",
"When enabled, you will automatically receive notifications for replies to threads you create" : "啟用後,您會自動收到您所建立的討論串回覆的通知",
"Auto-subscribe to threads I reply to" : "自動訂閱我回覆的討論串",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "啟用後,您將會自動收到您回覆過的討論串中的新回覆通知",
"Files" : "檔案",
"Configure file upload settings" : "設定檔案上傳設定",
"Upload directory" : "上傳目錄",

View File

@@ -280,6 +280,8 @@
"Configure how you receive notifications" : "設定您要如何接收通知",
"Auto-subscribe to threads I create" : "自動訂閱我建立的討論串",
"When enabled, you will automatically receive notifications for replies to threads you create" : "啟用後,您會自動收到您所建立的討論串回覆的通知",
"Auto-subscribe to threads I reply to" : "自動訂閱我回覆的討論串",
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "啟用後,您將會自動收到您回覆過的討論串中的新回覆通知",
"Files" : "檔案",
"Configure file upload settings" : "設定檔案上傳設定",
"Upload directory" : "上傳目錄",

View File

@@ -87,7 +87,8 @@ class RepairSeeds extends Command {
}
};
SeedHelper::seedAll($migrationOutput);
// Pass throwOnError=true so users get proper error feedback
SeedHelper::seedAll($migrationOutput, true);
$output->writeln('');
$output->writeln('<info>Forum data repair/seed completed successfully!</info>');

View File

@@ -15,11 +15,13 @@ use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\ReactionMapper;
use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCA\Forum\Service\BBCodeService;
use OCA\Forum\Service\NotificationService;
use OCA\Forum\Service\PermissionService;
use OCA\Forum\Service\PostEnrichmentService;
use OCA\Forum\Service\PostHistoryService;
use OCA\Forum\Service\UserPreferencesService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -49,6 +51,8 @@ class PostController extends OCSController {
private PostEnrichmentService $postEnrichmentService,
private PostHistoryService $postHistoryService,
private UserService $userService,
private UserPreferencesService $userPreferencesService,
private ThreadSubscriptionMapper $threadSubscriptionMapper,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -400,6 +404,21 @@ class PostController extends OCSController {
// Don't fail the request if mention notification sending fails
}
// Auto-subscribe the user to the thread if preference is enabled and not already subscribed
try {
$autoSubscribe = $this->userPreferencesService->getPreference(
$user->getUID(),
UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS
);
if ($autoSubscribe && !$this->threadSubscriptionMapper->isUserSubscribed($user->getUID(), $threadId)) {
$this->threadSubscriptionMapper->subscribe($user->getUID(), $threadId);
}
} catch (\Exception $e) {
$this->logger->warning('Failed to auto-subscribe user to thread: ' . $e->getMessage());
// Don't fail the request if auto-subscribe fails
}
return new DataResponse($this->postEnrichmentService->enrichPost($createdPost), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating post: ' . $e->getMessage());

View File

@@ -31,7 +31,6 @@ use OCP\AppFramework\Db\Entity;
* @method void setUpdatedAt(int $updatedAt)
*/
class ForumUser extends Entity implements JsonSerializable {
public $id;
protected string $userId = '';
protected int $postCount = 0;
protected int $threadCount = 0;

View File

@@ -17,33 +17,106 @@ class SeedHelper {
* Each function checks its own state and returns early if already seeded
*
* @param \OCP\Migration\IOutput|null $output Optional output for console messages
* @param bool $throwOnError If true, throws exceptions on failure. If false (default), logs errors and continues.
* Set to false when called from migrations to avoid PostgreSQL transaction abort issues.
*/
public static function seedAll($output = null): void {
public static function seedAll($output = null, bool $throwOnError = false): void {
$logger = \OC::$server->get(\Psr\Log\LoggerInterface::class);
$db = \OC::$server->get(\OCP\IDBConnection::class);
$logger->info('Forum seeding: Starting data seed/repair');
if ($output) {
$output->info('Forum: Starting data seed/repair...');
}
$errors = [];
// Ensure forum_users table exists (handle rename from forum_user_stats if needed)
self::ensureForumUsersTable($output);
// This is critical and should fail early if it cannot be done
try {
self::ensureForumUsersTable($output);
} catch (\Exception $e) {
$errors[] = 'ensureForumUsersTable: ' . $e->getMessage();
$logger->error('Forum seeding: Failed to ensure forum_users table', ['exception' => $e->getMessage()]);
if ($output) {
$output->warning(' Failed to ensure forum_users table: ' . $e->getMessage());
}
// Try to recover connection state for PostgreSQL
self::recoverConnectionState($db, $logger);
}
// Each function checks its own state and returns early if already seeded
// They run independently so one failure doesn't block others
self::seedDefaultRoles($output);
self::seedCategoryHeaders($output);
self::seedDefaultCategories($output);
self::seedCategoryPermissions($output);
self::seedGuestRolePermissions($output);
self::seedDefaultBBCodes($output);
self::assignUserRoles($output);
self::seedWelcomeThread($output);
// They run independently so one failure does not block others
// This is especially important for PostgreSQL where a failed query aborts the transaction
$seedOperations = [
'seedDefaultRoles' => fn () => self::seedDefaultRoles($output),
'seedCategoryHeaders' => fn () => self::seedCategoryHeaders($output),
'seedDefaultCategories' => fn () => self::seedDefaultCategories($output),
'seedCategoryPermissions' => fn () => self::seedCategoryPermissions($output),
'seedGuestRolePermissions' => fn () => self::seedGuestRolePermissions($output),
'seedDefaultBBCodes' => fn () => self::seedDefaultBBCodes($output),
'assignUserRoles' => fn () => self::assignUserRoles($output),
'seedWelcomeThread' => fn () => self::seedWelcomeThread($output),
];
$logger->info('Forum seeding: Completed data seed/repair');
foreach ($seedOperations as $name => $operation) {
try {
// Before each operation, ensure connection is in a clean state
self::recoverConnectionState($db, $logger);
$operation();
} catch (\Exception $e) {
$errors[] = "$name: " . $e->getMessage();
$logger->error("Forum seeding: $name failed", ['exception' => $e->getMessage()]);
// Try to recover connection state for next operation (especially important for PostgreSQL)
self::recoverConnectionState($db, $logger);
// Continue with other operations - don't let one failure block others
}
}
if ($output) {
$output->info('Forum: Data seed/repair completed');
if (!empty($errors)) {
$errorSummary = 'Some seeding operations failed: ' . implode('; ', $errors);
$logger->warning('Forum seeding: Completed with errors', ['errors' => $errors]);
if ($output) {
$output->warning('Forum: Data seed/repair completed with errors. Run "occ forum:repair-seeds" to retry failed operations.');
}
if ($throwOnError) {
throw new \RuntimeException($errorSummary);
}
} else {
$logger->info('Forum seeding: Completed data seed/repair successfully');
if ($output) {
$output->info('Forum: Data seed/repair completed');
}
}
}
/**
* Recover database connection state after an error
* On PostgreSQL, a failed query aborts the entire transaction, and subsequent queries fail.
* This method attempts to rollback any open transactions to restore a usable connection state.
*
* @param \OCP\IDBConnection $db Database connection
* @param \Psr\Log\LoggerInterface $logger Logger instance
*/
private static function recoverConnectionState(\OCP\IDBConnection $db, \Psr\Log\LoggerInterface $logger): void {
try {
// If we're in a transaction, try to roll back to recover the connection
while ($db->inTransaction()) {
try {
$db->rollBack();
$logger->debug('Forum seeding: Rolled back transaction to recover connection state');
} catch (\Exception $e) {
// If rollback fails, the connection might be in an unrecoverable state
$logger->warning('Forum seeding: Failed to rollback transaction during recovery', ['exception' => $e->getMessage()]);
break;
}
}
} catch (\Exception $e) {
// Ignore errors when checking transaction state
$logger->debug('Forum seeding: Error checking transaction state', ['exception' => $e->getMessage()]);
}
}
@@ -125,7 +198,8 @@ class SeedHelper {
/**
* Create the forum_users table from scratch
* This mirrors the schema from Version1 + Version8 migrations
* This mirrors the final schema from Version1 + Version2 + Version8 migrations
* (id as primary key, user_id as unique, includes signature column)
*/
private static function createForumUsersTable(\OCP\IDBConnection $db): void {
$platform = $db->getDatabasePlatform();
@@ -138,6 +212,7 @@ class SeedHelper {
// MySQL and MariaDB both extend MySQLPlatform
$db->executeStatement("
CREATE TABLE `{$tableName}` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` VARCHAR(64) NOT NULL,
`post_count` INT UNSIGNED NOT NULL DEFAULT 0,
`thread_count` INT UNSIGNED NOT NULL DEFAULT 0,
@@ -146,14 +221,17 @@ class SeedHelper {
`signature` TEXT DEFAULT NULL,
`created_at` INT UNSIGNED NOT NULL,
`updated_at` INT UNSIGNED NOT NULL,
PRIMARY KEY (`user_id`),
PRIMARY KEY (`id`),
UNIQUE INDEX `forum_users_user_id_uniq` (`user_id`),
INDEX `forum_users_post_count_idx` (`post_count`),
INDEX `forum_users_thread_count_idx` (`thread_count`),
INDEX `forum_users_deleted_at_idx` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
");
} elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
$db->executeStatement("
CREATE TABLE \"{$tableName}\" (
\"id\" BIGSERIAL PRIMARY KEY,
\"user_id\" VARCHAR(64) NOT NULL,
\"post_count\" INTEGER NOT NULL DEFAULT 0,
\"thread_count\" INTEGER NOT NULL DEFAULT 0,
@@ -161,16 +239,18 @@ class SeedHelper {
\"deleted_at\" INTEGER DEFAULT NULL,
\"signature\" TEXT DEFAULT NULL,
\"created_at\" INTEGER NOT NULL,
\"updated_at\" INTEGER NOT NULL,
PRIMARY KEY (\"user_id\")
\"updated_at\" INTEGER NOT NULL
)
");
$db->executeStatement("CREATE UNIQUE INDEX \"forum_users_user_id_uniq\" ON \"{$tableName}\" (\"user_id\")");
$db->executeStatement("CREATE INDEX \"forum_users_post_count_idx\" ON \"{$tableName}\" (\"post_count\")");
$db->executeStatement("CREATE INDEX \"forum_users_thread_count_idx\" ON \"{$tableName}\" (\"thread_count\")");
$db->executeStatement("CREATE INDEX \"forum_users_deleted_at_idx\" ON \"{$tableName}\" (\"deleted_at\")");
} else {
// SQLite (and any other platform as fallback)
$db->executeStatement("
CREATE TABLE \"{$tableName}\" (
\"id\" INTEGER PRIMARY KEY AUTOINCREMENT,
\"user_id\" VARCHAR(64) NOT NULL,
\"post_count\" INTEGER NOT NULL DEFAULT 0,
\"thread_count\" INTEGER NOT NULL DEFAULT 0,
@@ -178,11 +258,12 @@ class SeedHelper {
\"deleted_at\" INTEGER DEFAULT NULL,
\"signature\" TEXT DEFAULT NULL,
\"created_at\" INTEGER NOT NULL,
\"updated_at\" INTEGER NOT NULL,
PRIMARY KEY (\"user_id\")
\"updated_at\" INTEGER NOT NULL
)
");
$db->executeStatement("CREATE UNIQUE INDEX \"forum_users_user_id_uniq\" ON \"{$tableName}\" (\"user_id\")");
$db->executeStatement("CREATE INDEX \"forum_users_post_count_idx\" ON \"{$tableName}\" (\"post_count\")");
$db->executeStatement("CREATE INDEX \"forum_users_thread_count_idx\" ON \"{$tableName}\" (\"thread_count\")");
$db->executeStatement("CREATE INDEX \"forum_users_deleted_at_idx\" ON \"{$tableName}\" (\"deleted_at\")");
}
}
@@ -262,7 +343,8 @@ class SeedHelper {
$output->info(' → Creating default roles...');
}
$db->beginTransaction();
// Note: We don't use explicit transactions here to avoid PostgreSQL transaction abort cascade.
// Each INSERT is independent and idempotent, so partial success is acceptable.
$rolesCreated = 0;
// Define roles by role_type (not hardcoded IDs)
@@ -307,26 +389,29 @@ class SeedHelper {
foreach ($rolesToCreate as $roleType => $roleData) {
if (!in_array($roleType, $existingTypes)) {
$qb = $db->getQueryBuilder();
$qb->insert('forum_roles')
->values([
'name' => $qb->createNamedParameter($roleData['name']),
'description' => $qb->createNamedParameter($roleData['description']),
'can_access_admin_tools' => $qb->createNamedParameter($roleData['can_access_admin_tools'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_edit_roles' => $qb->createNamedParameter($roleData['can_edit_roles'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_edit_categories' => $qb->createNamedParameter($roleData['can_edit_categories'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'is_system_role' => $qb->createNamedParameter($roleData['is_system_role'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'role_type' => $qb->createNamedParameter($roleData['role_type'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$rolesCreated++;
$logger->info("Forum seeding: Created role with type '$roleType'");
try {
$qb = $db->getQueryBuilder();
$qb->insert('forum_roles')
->values([
'name' => $qb->createNamedParameter($roleData['name']),
'description' => $qb->createNamedParameter($roleData['description']),
'can_access_admin_tools' => $qb->createNamedParameter($roleData['can_access_admin_tools'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_edit_roles' => $qb->createNamedParameter($roleData['can_edit_roles'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_edit_categories' => $qb->createNamedParameter($roleData['can_edit_categories'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'is_system_role' => $qb->createNamedParameter($roleData['is_system_role'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'role_type' => $qb->createNamedParameter($roleData['role_type'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$rolesCreated++;
$logger->info("Forum seeding: Created role with type '$roleType'");
} catch (\Exception $e) {
// Log but continue - other roles might succeed
$logger->warning("Forum seeding: Failed to create role '$roleType': " . $e->getMessage());
}
}
}
$db->commit();
// Validate that critical roles can be found by role_type after creation
// Note: We query directly instead of using RoleMapper to avoid MultipleObjectsReturnedException
// if duplicates somehow exist (the cleanup migration should have removed them, but be defensive)
@@ -361,9 +446,6 @@ class SeedHelper {
$output->info(" ✓ Created $rolesCreated default roles (Admin, Moderator, User, Guest)");
}
} catch (\Exception $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$logger->error('Forum seeding: Failed to create default roles', [
'exception' => $e->getMessage(),
]);
@@ -456,7 +538,7 @@ class SeedHelper {
$userAccessibleCategories = $result->fetchAll();
$result->closeCursor();
$db->beginTransaction();
// Note: No explicit transaction - each INSERT auto-commits to avoid PostgreSQL transaction abort cascade
$categoriesGranted = 0;
foreach ($userAccessibleCategories as $categoryRow) {
$categoryId = (int)$categoryRow['category_id'];
@@ -472,30 +554,31 @@ class SeedHelper {
$result->closeCursor();
if (!$permExists) {
$qb = $db->getQueryBuilder();
$qb->insert('forum_category_perms')
->values([
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'role_id' => $qb->createNamedParameter($guestRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_post' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_reply' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_moderate' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
])
->executeStatement();
$categoriesGranted++;
try {
$qb = $db->getQueryBuilder();
$qb->insert('forum_category_perms')
->values([
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'role_id' => $qb->createNamedParameter($guestRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_post' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_reply' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_moderate' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
])
->executeStatement();
$categoriesGranted++;
} catch (\Exception $e) {
// Log but continue - other categories might succeed
$logger->warning("Forum seeding: Failed to set guest permission for category $categoryId: " . $e->getMessage());
}
}
}
$db->commit();
$logger->info('Forum seeding: Set guest role view-only permissions for ' . $categoriesGranted . ' categories (matching User role access)');
if ($output) {
$output->info(' ✓ Set guest role view-only permissions for ' . $categoriesGranted . ' categories');
}
} catch (\Exception $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$logger->error('Forum seeding: Failed to set guest role permissions', [
'exception' => $e->getMessage(),
]);
@@ -539,8 +622,7 @@ class SeedHelper {
$output->info(' → Creating category headers...');
}
$db->beginTransaction();
// Note: No explicit transaction - single INSERT auto-commits
// Create "General" category header
$qb = $db->getQueryBuilder();
$qb->insert('forum_cat_headers')
@@ -552,15 +634,11 @@ class SeedHelper {
])
->executeStatement();
$db->commit();
$logger->info('Forum seeding: Created category headers');
if ($output) {
$output->info(' ✓ Created category headers');
}
} catch (\Exception $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$logger->error('Forum seeding: Failed to create category headers', [
'exception' => $e->getMessage(),
]);
@@ -606,7 +684,7 @@ class SeedHelper {
}
$headerId = (int)$header['id'];
$db->beginTransaction();
// Note: No explicit transaction - each INSERT auto-commits to avoid PostgreSQL transaction abort cascade
$categoriesCreated = 0;
// Check if "General Discussions" category exists
@@ -619,22 +697,26 @@ class SeedHelper {
$result->closeCursor();
if (!$exists) {
// Create "General Discussions" category
$qb = $db->getQueryBuilder();
$qb->insert('forum_categories')
->values([
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'name' => $qb->createNamedParameter($l->t('General discussions')),
'description' => $qb->createNamedParameter($l->t('A place for general conversations and discussions')),
'slug' => $qb->createNamedParameter('general-discussions'),
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$categoriesCreated++;
try {
// Create "General Discussions" category
$qb = $db->getQueryBuilder();
$qb->insert('forum_categories')
->values([
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'name' => $qb->createNamedParameter($l->t('General discussions')),
'description' => $qb->createNamedParameter($l->t('A place for general conversations and discussions')),
'slug' => $qb->createNamedParameter('general-discussions'),
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$categoriesCreated++;
} catch (\Exception $e) {
$logger->warning('Forum seeding: Failed to create General Discussions category: ' . $e->getMessage());
}
}
// Check if "Support" category exists
@@ -647,33 +729,33 @@ class SeedHelper {
$result->closeCursor();
if (!$exists) {
// Create "Support" category
$qb = $db->getQueryBuilder();
$qb->insert('forum_categories')
->values([
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'name' => $qb->createNamedParameter($l->t('Support')),
'description' => $qb->createNamedParameter($l->t('Ask questions about the forum, provide feedback or report issues.')),
'slug' => $qb->createNamedParameter('support'),
'sort_order' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$categoriesCreated++;
try {
// Create "Support" category
$qb = $db->getQueryBuilder();
$qb->insert('forum_categories')
->values([
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'name' => $qb->createNamedParameter($l->t('Support')),
'description' => $qb->createNamedParameter($l->t('Ask questions about the forum, provide feedback or report issues.')),
'slug' => $qb->createNamedParameter('support'),
'sort_order' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$categoriesCreated++;
} catch (\Exception $e) {
$logger->warning('Forum seeding: Failed to create Support category: ' . $e->getMessage());
}
}
$db->commit();
$logger->info("Forum seeding: Created $categoriesCreated default categories");
if ($output) {
$output->info(" ✓ Created $categoriesCreated default categories (General Discussions, Support)");
}
} catch (\Exception $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$logger->error('Forum seeding: Failed to create default categories', [
'exception' => $e->getMessage(),
]);
@@ -743,7 +825,7 @@ class SeedHelper {
$output->info(' → Creating category permissions...');
}
$db->beginTransaction();
// Note: No explicit transaction - each INSERT auto-commits to avoid PostgreSQL transaction abort cascade
$permissionsCreated = 0;
// Create permissions for Moderator and User roles (Admin has implicit permissions)
@@ -761,18 +843,22 @@ class SeedHelper {
$result->closeCursor();
if (!$exists) {
$qb = $db->getQueryBuilder();
$qb->insert('forum_category_perms')
->values([
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'role_id' => $qb->createNamedParameter($moderatorRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_moderate' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
])
->executeStatement();
$permissionsCreated++;
try {
$qb = $db->getQueryBuilder();
$qb->insert('forum_category_perms')
->values([
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'role_id' => $qb->createNamedParameter($moderatorRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_moderate' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
])
->executeStatement();
$permissionsCreated++;
} catch (\Exception $e) {
$logger->warning("Forum seeding: Failed to create moderator permission for category $categoryId: " . $e->getMessage());
}
}
// Check and create User role permissions
@@ -786,30 +872,30 @@ class SeedHelper {
$result->closeCursor();
if (!$exists) {
$qb = $db->getQueryBuilder();
$qb->insert('forum_category_perms')
->values([
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'role_id' => $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_moderate' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
])
->executeStatement();
$permissionsCreated++;
try {
$qb = $db->getQueryBuilder();
$qb->insert('forum_category_perms')
->values([
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'role_id' => $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_moderate' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
])
->executeStatement();
$permissionsCreated++;
} catch (\Exception $e) {
$logger->warning("Forum seeding: Failed to create user permission for category $categoryId: " . $e->getMessage());
}
}
}
$db->commit();
$logger->info("Forum seeding: Created $permissionsCreated category permissions");
if ($output) {
$output->info(" ✓ Created $permissionsCreated category permissions for " . count($categories) . ' categories');
}
} catch (\Exception $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$logger->error('Forum seeding: Failed to create category permissions', [
'exception' => $e->getMessage(),
]);
@@ -836,8 +922,7 @@ class SeedHelper {
$output->info(' → Creating default BBCodes...');
}
$db->beginTransaction();
// Note: No explicit transaction - each INSERT auto-commits to avoid PostgreSQL transaction abort cascade
$bbcodes = [
[
'tag' => 'icode',
@@ -880,33 +965,33 @@ class SeedHelper {
$result->closeCursor();
if (!$exists) {
$qb = $db->getQueryBuilder();
$qb->insert('forum_bbcodes')
->values([
'tag' => $qb->createNamedParameter($bbcode['tag']),
'replacement' => $qb->createNamedParameter($bbcode['replacement']),
'example' => $qb->createNamedParameter($bbcode['example']),
'description' => $qb->createNamedParameter($bbcode['description']),
'enabled' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'parse_inner' => $qb->createNamedParameter($bbcode['parse_inner'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'is_builtin' => $qb->createNamedParameter($bbcode['is_builtin'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'special_handler' => $qb->createNamedParameter($bbcode['special_handler']),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$bbcodesCreated++;
try {
$qb = $db->getQueryBuilder();
$qb->insert('forum_bbcodes')
->values([
'tag' => $qb->createNamedParameter($bbcode['tag']),
'replacement' => $qb->createNamedParameter($bbcode['replacement']),
'example' => $qb->createNamedParameter($bbcode['example']),
'description' => $qb->createNamedParameter($bbcode['description']),
'enabled' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'parse_inner' => $qb->createNamedParameter($bbcode['parse_inner'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'is_builtin' => $qb->createNamedParameter($bbcode['is_builtin'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'special_handler' => $qb->createNamedParameter($bbcode['special_handler']),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$bbcodesCreated++;
} catch (\Exception $e) {
$logger->warning("Forum seeding: Failed to create BBCode '{$bbcode['tag']}': " . $e->getMessage());
}
}
}
$db->commit();
$logger->info("Forum seeding: Created $bbcodesCreated default BBCodes");
if ($output) {
$output->info(" ✓ Created $bbcodesCreated default BBCodes (icode, spoiler, attachment)");
}
} catch (\Exception $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$logger->error('Forum seeding: Failed to create default BBCodes', [
'exception' => $e->getMessage(),
]);
@@ -1061,6 +1146,9 @@ class SeedHelper {
$logger = \OC::$server->get(\Psr\Log\LoggerInterface::class);
$timestamp = time();
// Recover connection state before starting (important for PostgreSQL)
self::recoverConnectionState($db, $logger);
try {
// Check if welcome thread already exists
$qb = $db->getQueryBuilder();
@@ -1112,6 +1200,42 @@ class SeedHelper {
}
});
// Check if slug column still exists BEFORE starting transaction
// (for backwards compatibility with old migrations)
// On PostgreSQL, a failed query inside a transaction aborts the entire transaction,
// so we must check column existence outside the transaction
$hasSlugColumn = true;
try {
$checkQb = $db->getQueryBuilder();
$checkQb->select('slug')->from('forum_posts')->setMaxResults(1);
$checkQb->executeQuery()->closeCursor();
} catch (\Exception $e) {
$hasSlugColumn = false;
// Recover connection state after the failed query (important for PostgreSQL)
self::recoverConnectionState($db, $logger);
}
// Prepare welcome post content
$welcomeContent = $l->t('Welcome to the Nextcloud Forums!') . "\n\n"
. $l->t('This is a community-driven forum built right into your Nextcloud instance. '
. 'Here you can discuss topics, share ideas and collaborate with other users.') . "\n\n"
. '[b]' . $l->t('Features:') . "[/b]\n"
. "[list]\n"
. '[*]' . $l->t('Create and reply to threads') . "\n"
. '[*]' . $l->t('Organize discussions by categories') . "\n"
. '[*]' . $l->t('Use BBCode for rich text formatting') . "\n"
. '[*]' . $l->t('Attach files from your Nextcloud storage') . "\n"
. '[*]' . $l->t('React to posts') . "\n"
. '[*]' . $l->t('Track read/unread threads') . "\n\n"
. "[/list]\n"
. '[b]' . $l->t('BBCode examples:') . "[/b]\n"
. "[list]\n"
. '[*][b]' . $l->t('Bold text') . '[/b] - ' . $l->t('Use %1$stext%2$s', ['[icode][b]', '[/b][/icode]']) . "\n"
. '[*][i]' . $l->t('Italic text') . '[/i] - ' . $l->t('Use %1$stext%2$s', ['[icode][i]', '[/i][/icode]']) . "\n"
. '[*][u]' . $l->t('Underlined text') . '[/u] - ' . $l->t('Use %1$stext%2$s', ['[icode][u]', '[/u][/icode]']) . "\n\n"
. "[/list]\n"
. $l->t('Feel free to start a new discussion or reply to existing threads. Happy posting!');
$db->beginTransaction();
// Create welcome thread
@@ -1134,38 +1258,6 @@ class SeedHelper {
->executeStatement();
$threadId = $qb->getLastInsertId();
// Create welcome post
$welcomeContent = $l->t('Welcome to the Nextcloud Forums!') . "\n\n"
. $l->t('This is a community-driven forum built right into your Nextcloud instance. '
. 'Here you can discuss topics, share ideas and collaborate with other users.') . "\n\n"
. '[b]' . $l->t('Features:') . "[/b]\n"
. "[list]\n"
. '[*]' . $l->t('Create and reply to threads') . "\n"
. '[*]' . $l->t('Organize discussions by categories') . "\n"
. '[*]' . $l->t('Use BBCode for rich text formatting') . "\n"
. '[*]' . $l->t('Attach files from your Nextcloud storage') . "\n"
. '[*]' . $l->t('React to posts') . "\n"
. '[*]' . $l->t('Track read/unread threads') . "\n\n"
. "[/list]\n"
. '[b]' . $l->t('BBCode examples:') . "[/b]\n"
. "[list]\n"
. '[*][b]' . $l->t('Bold text') . '[/b] - ' . $l->t('Use %1$stext%2$s', ['[icode][b]', '[/b][/icode]']) . "\n"
. '[*][i]' . $l->t('Italic text') . '[/i] - ' . $l->t('Use %1$stext%2$s', ['[icode][i]', '[/i][/icode]']) . "\n"
. '[*][u]' . $l->t('Underlined text') . '[/u] - ' . $l->t('Use %1$stext%2$s', ['[icode][u]', '[/u][/icode]']) . "\n\n"
. "[/list]\n"
. $l->t('Feel free to start a new discussion or reply to existing threads. Happy posting!');
// Check if slug column still exists (for backwards compatibility with old migrations)
// Use a query to check column existence since schema introspection APIs vary
$hasSlugColumn = true;
try {
$checkQb = $db->getQueryBuilder();
$checkQb->select('slug')->from('forum_posts')->setMaxResults(1);
$checkQb->executeQuery()->closeCursor();
} catch (\Exception $e) {
$hasSlugColumn = false;
}
// Build post values - slug is optional (removed in Version8)
$qb = $db->getQueryBuilder();
$postValues = [
@@ -1265,8 +1357,13 @@ class SeedHelper {
$output->info(' ✓ Created welcome thread');
}
} catch (\Exception $e) {
if ($db->inTransaction()) {
$db->rollBack();
// Try to rollback if we're in a transaction - important for PostgreSQL recovery
try {
if ($db->inTransaction()) {
$db->rollBack();
}
} catch (\Exception $rollbackEx) {
$logger->debug('Forum seeding: Failed to rollback after welcome thread error', ['exception' => $rollbackEx->getMessage()]);
}
$logger->error('Forum seeding: Failed to create welcome thread', [
'exception' => $e->getMessage(),

View File

@@ -76,7 +76,15 @@ class Version15Date20260103000000 extends SimpleMigrationStep {
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
// Re-run seeding to ensure all required data exists
SeedHelper::seedAll($output);
// Pass throwOnError=false to avoid PostgreSQL transaction abort issues
// If seeding fails, users can run "occ forum:repair-seeds" to retry
try {
SeedHelper::seedAll($output, false);
} catch (\Exception $e) {
// This should not happen with throwOnError=false, but handle it gracefully
$this->logger->error('Forum migration: Seeding failed unexpectedly', ['exception' => $e->getMessage()]);
$output->warning('Forum: Seeding failed. Run "occ forum:repair-seeds" after enabling the app to complete setup.');
}
}
/**

View File

@@ -85,12 +85,30 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$table->addIndex(['name'], 'forum_roles_name_idx');
}
/**
* Create forum_users table (formerly forum_user_stats)
* Note: On fresh installs, this creates forum_users directly with the final schema.
* For progressive installs where forum_user_stats already exists,
* SeedHelper::ensureForumUsersTable() handles the rename.
*
* The table structure matches what Version2 transforms it to:
* - id: auto-increment primary key
* - user_id: unique string
* - signature: added in Version8
*/
private function createUserStatsTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('forum_user_stats')) {
// Skip if either table already exists (handles both fresh and progressive installs)
if ($schema->hasTable('forum_users') || $schema->hasTable('forum_user_stats')) {
return;
}
$table = $schema->createTable('forum_user_stats');
// Create forum_users directly with the final schema (matching Version2's transformation)
$table = $schema->createTable('forum_users');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('user_id', 'string', [
'notnull' => true,
'length' => 64,
@@ -115,6 +133,10 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'unsigned' => true,
'default' => null,
]);
$table->addColumn('signature', 'text', [
'notnull' => false,
'default' => null,
]);
$table->addColumn('created_at', 'integer', [
'notnull' => true,
'unsigned' => true,
@@ -123,9 +145,11 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'notnull' => true,
'unsigned' => true,
]);
$table->setPrimaryKey(['user_id']);
$table->addIndex(['post_count'], 'user_stats_post_count_idx');
$table->addIndex(['deleted_at'], 'user_stats_deleted_at_idx');
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['user_id'], 'forum_users_user_id_uniq');
$table->addIndex(['post_count'], 'forum_users_post_count_idx');
$table->addIndex(['thread_count'], 'forum_users_thread_count_idx');
$table->addIndex(['deleted_at'], 'forum_users_deleted_at_idx');
}
private function createForumUserRolesTable(ISchemaWrapper $schema): void {

View File

@@ -67,18 +67,38 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
$table->addUniqueIndex(['user_id', 'thread_id'], 'thread_subs_uniq_idx');
}
/**
* Fix forum_user_stats or forum_users table structure
* Handles both old table name (progressive installs) and new table name (fresh installs)
*/
private function fixForumUserStatsTable(ISchemaWrapper $schema): void {
if (!$schema->hasTable('forum_user_stats')) {
// Determine which table exists (handles both fresh and progressive installs)
$tableName = null;
if ($schema->hasTable('forum_user_stats')) {
$tableName = 'forum_user_stats';
} elseif ($schema->hasTable('forum_users')) {
$tableName = 'forum_users';
}
if ($tableName === null) {
return;
}
$table = $schema->getTable('forum_user_stats');
$table = $schema->getTable($tableName);
// Check if already fixed (has id column)
// Note: On fresh installs, forum_users uses user_id as primary key (no id column needed)
// This fix is only needed for progressive installs with old forum_user_stats structure
if ($table->hasColumn('id')) {
return;
}
// Only add id column to forum_user_stats (old structure)
// forum_users created in Version1 uses user_id as primary key and doesn't need this fix
if ($tableName !== 'forum_user_stats') {
return;
}
// Add id column as auto-increment
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
@@ -123,8 +143,7 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
}
/**
* Rebuild user stats using the old table name (forum_user_stats)
* This is needed because Version8 hasn't renamed the table yet
* Rebuild user stats - handles both old (forum_user_stats) and new (forum_users) table names
*/
private function rebuildAllUserStatsLegacy(): array {
// Get all user IDs from Nextcloud
@@ -133,11 +152,22 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
$users[] = $user->getUID();
});
// Determine which table to use
$tableName = $this->getUserStatsTableName();
if ($tableName === null) {
// No table exists yet - this shouldn't happen but handle gracefully
return [
'users' => count($users),
'updated' => 0,
'created' => 0,
];
}
$updated = 0;
$created = 0;
foreach ($users as $userId) {
$wasCreated = $this->rebuildUserStatsLegacy($userId);
$wasCreated = $this->rebuildUserStatsLegacy($userId, $tableName);
if ($wasCreated) {
$created++;
} else {
@@ -153,9 +183,36 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
}
/**
* Rebuild stats for a single user using the old table name
* Get the user stats table name (handles both old and new names)
*/
private function rebuildUserStatsLegacy(string $userId): bool {
private function getUserStatsTableName(): ?string {
// Check forum_users first (new name, for fresh installs)
// Then check forum_user_stats (old name, for progressive installs)
try {
$qb = $this->db->getQueryBuilder();
$qb->select('user_id')->from('forum_users')->setMaxResults(1);
$qb->executeQuery()->closeCursor();
return 'forum_users';
} catch (\Exception $e) {
// Table doesn't exist, try old name
}
try {
$qb = $this->db->getQueryBuilder();
$qb->select('user_id')->from('forum_user_stats')->setMaxResults(1);
$qb->executeQuery()->closeCursor();
return 'forum_user_stats';
} catch (\Exception $e) {
// Neither table exists
}
return null;
}
/**
* Rebuild stats for a single user
*/
private function rebuildUserStatsLegacy(string $userId, string $tableName): bool {
// Count non-deleted threads created by this user
$threadQb = $this->db->getQueryBuilder();
$threadQb->select($threadQb->func()->count('*', 'count'))
@@ -194,10 +251,10 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
$lastPostAt = $lastPostResult->fetchOne();
$lastPostResult->closeCursor();
// Check if forum user record already exists (using OLD table name)
// Check if forum user record already exists
$checkQb = $this->db->getQueryBuilder();
$checkQb->select('user_id')
->from('forum_user_stats') // OLD table name!
->from($tableName)
->where($checkQb->expr()->eq('user_id', $checkQb->createNamedParameter($userId)));
$checkResult = $checkQb->executeQuery();
$exists = $checkResult->fetch();
@@ -206,9 +263,9 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
$timestamp = time();
if ($exists) {
// Update existing record (using OLD table name)
// Update existing record
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats') // OLD table name!
$updateQb->update($tableName)
->set('thread_count', $updateQb->createNamedParameter($threadCount, IQueryBuilder::PARAM_INT))
->set('post_count', $updateQb->createNamedParameter($postCount, IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))
@@ -221,9 +278,9 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
$updateQb->executeStatement();
return false;
} else {
// Create new record (using OLD table name)
// Create new record
$insertQb = $this->db->getQueryBuilder();
$insertQb->insert('forum_user_stats') // OLD table name!
$insertQb->insert($tableName)
->values([
'user_id' => $insertQb->createNamedParameter($userId),
'thread_count' => $insertQb->createNamedParameter($threadCount, IQueryBuilder::PARAM_INT),
@@ -239,7 +296,7 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
} catch (\Exception $e) {
// If insert fails (race condition), try updating instead
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats') // OLD table name!
$updateQb->update($tableName)
->set('thread_count', $updateQb->createNamedParameter($threadCount, IQueryBuilder::PARAM_INT))
->set('post_count', $updateQb->createNamedParameter($postCount, IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))

View File

@@ -43,9 +43,18 @@ class Version8Date20251128000000 extends SimpleMigrationStep {
}
}
// Add signature column to user stats
if ($schema->hasTable('forum_user_stats')) {
$table = $schema->getTable('forum_user_stats');
// Add signature column to forum_users table (handles both old and new table names)
// On fresh installs: forum_users is created with signature column in Version1
// On progressive installs: forum_user_stats may still exist and needs signature added
$userTableName = null;
if ($schema->hasTable('forum_users')) {
$userTableName = 'forum_users';
} elseif ($schema->hasTable('forum_user_stats')) {
$userTableName = 'forum_user_stats';
}
if ($userTableName !== null) {
$table = $schema->getTable($userTableName);
if (!$table->hasColumn('signature')) {
$table->addColumn('signature', 'text', [

View File

@@ -17,6 +17,9 @@ class UserPreferencesService {
/** Preference key for auto-subscribing to created threads */
public const PREF_AUTO_SUBSCRIBE_CREATED_THREADS = 'auto_subscribe_created_threads';
/** Preference key for auto-subscribing to threads when replying */
public const PREF_AUTO_SUBSCRIBE_REPLIED_THREADS = 'auto_subscribe_replied_threads';
/** Preference key for upload directory path */
public const PREF_UPLOAD_DIRECTORY = 'upload_directory';
@@ -26,6 +29,7 @@ class UserPreferencesService {
/** @var array<string, mixed> Default preference values */
private const DEFAULTS = [
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => true,
self::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS => false,
self::PREF_UPLOAD_DIRECTORY => 'Forum',
self::PREF_SIGNATURE => '',
];
@@ -33,6 +37,7 @@ class UserPreferencesService {
/** @var array<string> List of valid preference keys */
private const VALID_KEYS = [
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS,
self::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS,
self::PREF_UPLOAD_DIRECTORY,
self::PREF_SIGNATURE,
];

View File

@@ -5,15 +5,18 @@
"type": "module",
"engines": {
"node": "^22.19.0",
"pnpm": "^10.17.0"
"pnpm": "^10.27.0"
},
"packageManager": "pnpm@10.27.0",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint src",
"format": "eslint --fix src && prettier --write {vite.config.ts,src/,README.md}",
"prepare": "husky",
"gen": "simple-scaffold -c . -k"
"gen": "simple-scaffold -c . -k",
"test": "vitest",
"test:run": "vitest run"
},
"browserslist": [
"extends @nextcloud/browserslist-config"
@@ -25,7 +28,7 @@
"@nextcloud/l10n": "^3.4.1",
"@nextcloud/router": "^3.1.0",
"@nextcloud/vite-config": "2.3.5",
"@nextcloud/vue": "^9.3.1",
"@nextcloud/vue": "^9.3.3",
"date-fns": "^4.1.0",
"linkifyjs": "^4.3.2",
"vue": "^3.5.26",
@@ -36,19 +39,23 @@
"@nextcloud/browserslist-config": "^3.1.2",
"@nextcloud/eslint-config": "^8.4.2",
"@nextcloud/stylelint-config": "^3.1.1",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.2",
"happy-dom": "^20.1.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^2.8.8",
"prettier-plugin-vue": "^1.1.6",
"rollup-plugin-visualizer": "^6.0.5",
"sass": "^1.97.1",
"sass-embedded": "^1.97.1",
"sass": "^1.97.2",
"sass-embedded": "^1.97.2",
"typescript": "5.9.2",
"typescript-eslint": "^8.51.0",
"typescript-eslint": "^8.52.0",
"vite": "^6.4.1",
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.0.16",
"vue-router": "^4.6.4",
"vue-tsc": "^2.2.12"
}

1319
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,6 @@ module.exports = () => {
component: {
templates: ['gen/component'],
output: 'src/components',
subDir: false,
},
view: {
templates: ['gen/view'],

View File

@@ -23,7 +23,7 @@ import { defineComponent } from 'vue'
import NcContent from '@nextcloud/vue/components/NcContent'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppNavigation from '@/components/AppNavigation.vue'
import AppNavigation from '@/components/AppNavigation'
import { isDarkTheme } from '@nextcloud/vue/functions/isDarkTheme'
export default defineComponent({
@@ -126,10 +126,10 @@ export default defineComponent({
</style>
<style lang="scss">
// Global styles for smooth scrolling
html,
body,
* {
scroll-behavior: smooth;
// Fix content width on mobile
@media screen and (max-width: 768px) {
#content-vue.app-forum {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AdminTable from './AdminTable.vue'
describe('AdminTable', () => {
const defaultColumns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
]
const defaultRows = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
]
describe('rendering', () => {
it('should render column headers', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
expect(wrapper.find('.header-row').text()).toContain('Name')
expect(wrapper.find('.header-row').text()).toContain('Email')
})
it('should render data rows', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
const dataRows = wrapper.findAll('.data-row')
expect(dataRows).toHaveLength(2)
})
it('should render cell values', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
expect(wrapper.text()).toContain('John Doe')
expect(wrapper.text()).toContain('john@example.com')
})
})
describe('actions column', () => {
it('should not show actions column by default', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
expect(wrapper.find('.col-actions').exists()).toBe(false)
})
it('should show actions column when hasActions is true', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows, hasActions: true },
})
expect(wrapper.findAll('.col-actions').length).toBeGreaterThan(0)
})
it('should use custom actions label', () => {
const wrapper = mount(AdminTable, {
props: {
columns: defaultColumns,
rows: defaultRows,
hasActions: true,
actionsLabel: 'Operations',
},
})
expect(wrapper.find('.header-row').text()).toContain('Operations')
})
})
describe('grid style', () => {
it('should compute grid template columns', () => {
const columns = [
{ key: 'name', label: 'Name', width: '200px' },
{ key: 'email', label: 'Email', minWidth: '150px' },
]
const wrapper = mount(AdminTable, {
props: { columns, rows: defaultRows },
})
const grid = wrapper.find('.table-grid')
expect(grid.attributes('style')).toContain('grid-template-columns')
})
})
describe('row key', () => {
it('should use id as row key by default', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
})
expect(wrapper.findAll('.data-row')).toHaveLength(2)
})
it('should use custom row key', () => {
const rows = [
{ customId: 'a', name: 'Test' },
{ customId: 'b', name: 'Test 2' },
]
const wrapper = mount(AdminTable, {
props: { columns: [{ key: 'name', label: 'Name' }], rows, rowKey: 'customId' },
})
expect(wrapper.findAll('.data-row')).toHaveLength(2)
})
})
describe('slots', () => {
it('should render custom cell content via slot', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows },
slots: {
'cell-name': '<span class="custom-cell">Custom Name</span>',
},
})
expect(wrapper.findAll('.custom-cell').length).toBeGreaterThan(0)
})
it('should render actions slot', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows, hasActions: true },
slots: {
actions: '<button class="action-btn">Edit</button>',
},
})
expect(wrapper.findAll('.action-btn').length).toBeGreaterThan(0)
})
})
describe('row class', () => {
it('should apply string row class', () => {
const wrapper = mount(AdminTable, {
props: { columns: defaultColumns, rows: defaultRows, rowClass: 'custom-row' },
})
expect(wrapper.findAll('.data-row.custom-row')).toHaveLength(2)
})
it('should apply function row class', () => {
const wrapper = mount(AdminTable, {
props: {
columns: defaultColumns,
rows: defaultRows,
rowClass: (row: { id: number }) => (row.id === 1 ? 'first-row' : ''),
},
})
expect(wrapper.find('.data-row.first-row').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,2 @@
import AdminTable from './AdminTable.vue'
export default AdminTable

View File

@@ -193,7 +193,7 @@ import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import UserInfo from '@/components/UserInfo.vue'
import UserInfo from '@/components/UserInfo'
import HomeIcon from '@icons/Home.vue'
import ForumIcon from '@icons/Forum.vue'
import FolderIcon from '@icons/Folder.vue'

View File

@@ -0,0 +1,2 @@
import AppNavigation from './AppNavigation.vue'
export default AppNavigation

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AppToolbar from './AppToolbar.vue'
describe('AppToolbar', () => {
describe('rendering', () => {
it('should render toolbar container', () => {
const wrapper = mount(AppToolbar)
expect(wrapper.find('.app-toolbar').exists()).toBe(true)
})
it('should render left and right sections', () => {
const wrapper = mount(AppToolbar)
expect(wrapper.find('.toolbar-left').exists()).toBe(true)
expect(wrapper.find('.toolbar-right').exists()).toBe(true)
})
})
describe('slots', () => {
it('should render left slot content', () => {
const wrapper = mount(AppToolbar, {
slots: {
left: '<span class="left-content">Left Content</span>',
},
})
expect(wrapper.find('.toolbar-left .left-content').exists()).toBe(true)
expect(wrapper.find('.toolbar-left').text()).toBe('Left Content')
})
it('should render right slot content', () => {
const wrapper = mount(AppToolbar, {
slots: {
right: '<span class="right-content">Right Content</span>',
},
})
expect(wrapper.find('.toolbar-right .right-content').exists()).toBe(true)
expect(wrapper.find('.toolbar-right').text()).toBe('Right Content')
})
it('should render both slots simultaneously', () => {
const wrapper = mount(AppToolbar, {
slots: {
left: '<button>Action</button>',
right: '<span>Status</span>',
},
})
expect(wrapper.find('.toolbar-left button').exists()).toBe(true)
expect(wrapper.find('.toolbar-right span').exists()).toBe(true)
})
it('should render multiple elements in a slot', () => {
const wrapper = mount(AppToolbar, {
slots: {
left: '<button>One</button><button>Two</button><button>Three</button>',
},
})
const buttons = wrapper.findAll('.toolbar-left button')
expect(buttons).toHaveLength(3)
})
})
})

View File

@@ -0,0 +1,2 @@
import AppToolbar from './AppToolbar.vue'
export default AppToolbar

View File

@@ -9,6 +9,7 @@
<BBCodeToolbar
ref="toolbar"
:textarea-ref="contenteditableElement"
:model-value="modelValue"
@insert="handleBBCodeInsert"
/>
<NcRichContenteditable
@@ -42,7 +43,7 @@
import { defineComponent, type PropType } from 'vue'
import NcRichContenteditable from '@nextcloud/vue/components/NcRichContenteditable'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import BBCodeToolbar from './BBCodeToolbar.vue'
import BBCodeToolbar from '@/components/BBCodeToolbar'
import UploadIcon from '@icons/Upload.vue'
import { t } from '@nextcloud/l10n'
import { ocs } from '@/axios'

View File

@@ -0,0 +1,2 @@
import BBCodeEditor from './BBCodeEditor.vue'
export default BBCodeEditor

View File

@@ -0,0 +1,419 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import type { BBCode } from '@/types'
// Mock axios - must use factory that doesn't reference external variables
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
// Import after mock
import { ocs } from '@/axios'
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
const mockGet = vi.mocked(ocs.get)
describe('BBCodeHelpDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: [] } as never)
})
const createWrapper = (props = {}) => {
return mount(BBCodeHelpDialog, {
props: {
open: true,
showCustom: true,
...props,
},
})
}
describe('rendering', () => {
it('renders the dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('renders built-in BBCodes section', () => {
const wrapper = createWrapper()
expect(wrapper.find('.bbcode-section').exists()).toBe(true)
expect(wrapper.text()).toContain('Built-in BBCodes')
})
it('renders all built-in BBCode tags', () => {
const wrapper = createWrapper()
const tags = wrapper.findAll('.bbcode-tag')
// Check for some expected built-in tags
const tagTexts = tags.map((t) => t.text())
expect(tagTexts).toContain('[b]')
expect(tagTexts).toContain('[i]')
expect(tagTexts).toContain('[code]')
expect(tagTexts).toContain('[url]')
expect(tagTexts).toContain('[img]')
expect(tagTexts).toContain('[quote]')
})
it('renders BBCode examples', () => {
const wrapper = createWrapper()
const examples = wrapper.findAll('.example-code')
expect(examples.length).toBeGreaterThan(0)
// Check for a specific example
expect(wrapper.text()).toContain('[b]Hello world![/b]')
})
it('renders custom BBCodes section when showCustom is true', () => {
const wrapper = createWrapper({ showCustom: true })
expect(wrapper.text()).toContain('Custom BBCodes')
})
it('does not render custom BBCodes section when showCustom is false', () => {
const wrapper = createWrapper({ showCustom: false })
expect(wrapper.text()).not.toContain('Custom BBCodes')
})
})
describe('fetching builtin DB codes', () => {
it('fetches builtin codes when dialog opens', async () => {
createWrapper({ open: true })
await flushPromises()
expect(mockGet).toHaveBeenCalledWith('/bbcodes/builtin')
})
it('displays builtin DB codes', async () => {
const builtinCodes: BBCode[] = [
{
id: 1,
tag: 'spoiler',
replacement: '<span class="spoiler">{content}</span>',
example: '[spoiler]Hidden text[/spoiler]',
description: 'Spoiler text',
enabled: true,
parseInner: true,
isBuiltin: true,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes/builtin') {
return Promise.resolve({ data: builtinCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.text()).toContain('[spoiler]')
expect(wrapper.text()).toContain('Spoiler text')
})
it('silently fails when builtin codes fetch fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes/builtin') {
return Promise.reject(new Error('Network error'))
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true })
await flushPromises()
// Should not show error state for builtin codes
expect(wrapper.find('.error-state').exists()).toBe(false)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('fetching custom codes', () => {
it('fetches custom codes when dialog opens with showCustom true', async () => {
createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(mockGet).toHaveBeenCalledWith('/bbcodes')
})
it('does not fetch custom codes when showCustom is false', async () => {
createWrapper({ open: true, showCustom: false })
await flushPromises()
expect(mockGet).not.toHaveBeenCalledWith('/bbcodes')
})
it('displays loading state while fetching custom codes', async () => {
let resolvePromise: (value: unknown) => void
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return new Promise((resolve) => {
resolvePromise = resolve
}) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading custom BBCodes')
resolvePromise!({ data: [] })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(false)
})
it('displays custom codes after fetch', async () => {
const customCodes: BBCode[] = [
{
id: 10,
tag: 'highlight',
replacement: '<mark>{content}</mark>',
example: '[highlight]Important text[/highlight]',
description: 'Highlight text',
enabled: true,
parseInner: true,
isBuiltin: false,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return Promise.resolve({ data: customCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.text()).toContain('[highlight]')
expect(wrapper.text()).toContain('Highlight text')
})
it('filters out disabled custom codes', async () => {
const customCodes: BBCode[] = [
{
id: 10,
tag: 'enabled',
replacement: '<span>{content}</span>',
example: '[enabled]Text[/enabled]',
description: 'Enabled code',
enabled: true,
parseInner: true,
isBuiltin: false,
specialHandler: null,
createdAt: Date.now(),
},
{
id: 11,
tag: 'disabled',
replacement: '<span>{content}</span>',
example: '[disabled]Text[/disabled]',
description: 'Disabled code',
enabled: false,
parseInner: true,
isBuiltin: false,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return Promise.resolve({ data: customCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.text()).toContain('[enabled]')
expect(wrapper.text()).not.toContain('[disabled]')
})
it('displays empty state when no custom codes exist', async () => {
mockGet.mockResolvedValue({ data: [] } as never)
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.find('.empty-state').exists()).toBe(true)
expect(wrapper.text()).toContain('No custom BBCodes configured')
})
it('displays error state when fetch fails', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return Promise.reject(new Error('Network error'))
}
return Promise.resolve({ data: [] }) as never
})
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
expect(wrapper.find('.error-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Failed to load custom BBCodes')
})
})
describe('caching', () => {
it('does not refetch builtin codes if already loaded when reopening', async () => {
// Mock returns data
const builtinCodes: BBCode[] = [
{
id: 1,
tag: 'test',
replacement: '<span>{content}</span>',
example: '[test]Hello[/test]',
description: 'Test',
enabled: true,
parseInner: true,
isBuiltin: true,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes/builtin') {
return Promise.resolve({ data: builtinCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true })
await flushPromises()
const callCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes/builtin').length
expect(callCount).toBe(1)
// Close and reopen - since builtinDbCodes.length > 0, should not refetch
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
const newCallCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes/builtin').length
expect(newCallCount).toBe(1) // Should still be 1
})
it('does not refetch custom codes if already loaded when reopening', async () => {
// Mock returns data for custom codes
const customCodes: BBCode[] = [
{
id: 10,
tag: 'custom',
replacement: '<span>{content}</span>',
example: '[custom]Hello[/custom]',
description: 'Custom',
enabled: true,
parseInner: true,
isBuiltin: false,
specialHandler: null,
createdAt: Date.now(),
},
]
mockGet.mockImplementation((url: string) => {
if (url === '/bbcodes') {
return Promise.resolve({ data: customCodes }) as never
}
return Promise.resolve({ data: [] }) as never
})
const wrapper = createWrapper({ open: true, showCustom: true })
await flushPromises()
const callCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes').length
expect(callCount).toBe(1)
// Close and reopen - since customCodes.length > 0, should not refetch
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
const newCallCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes').length
expect(newCallCount).toBe(1) // Should still be 1
})
})
describe('close event', () => {
it('emits update:open event when dialog closes', async () => {
const wrapper = createWrapper({ open: true })
;(wrapper.vm as unknown as { handleClose: (v: boolean) => void }).handleClose(false)
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
})
describe('built-in codes content', () => {
it('contains bold tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Font style bold')
})
it('contains italic tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Font style italic')
})
it('contains code tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[code]')
expect(wrapper.text()).toContain('Code')
})
it('contains email tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[email]')
expect(wrapper.text()).toContain('Email (clickable)')
})
it('contains url tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[url=http://example.com]')
expect(wrapper.text()).toContain('URL (clickable)')
})
it('contains image tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[img]')
expect(wrapper.text()).toContain('Image (not clickable)')
})
it('contains quote tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[quote]')
expect(wrapper.text()).toContain('Quote')
})
it('contains youtube tag example', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[youtube]')
expect(wrapper.text()).toContain('Embedded YouTube video')
})
it('contains list tags examples', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[list]')
expect(wrapper.text()).toContain('List')
})
it('contains alignment tag examples', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('[left]')
expect(wrapper.text()).toContain('[center]')
expect(wrapper.text()).toContain('[right]')
})
})
})

View File

@@ -0,0 +1,2 @@
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
export default BBCodeHelpDialog

View File

@@ -0,0 +1,447 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
// Mock icons
vi.mock('@icons/FormatBold.vue', () => createIconMock('FormatBoldIcon'))
vi.mock('@icons/FormatItalic.vue', () => createIconMock('FormatItalicIcon'))
vi.mock('@icons/FormatStrikethrough.vue', () => createIconMock('FormatStrikethroughIcon'))
vi.mock('@icons/FormatUnderline.vue', () => createIconMock('FormatUnderlineIcon'))
vi.mock('@icons/CodeTags.vue', () => createIconMock('CodeTagsIcon'))
vi.mock('@icons/Email.vue', () => createIconMock('EmailIcon'))
vi.mock('@icons/Link.vue', () => createIconMock('LinkIcon'))
vi.mock('@icons/Image.vue', () => createIconMock('ImageIcon'))
vi.mock('@icons/FormatQuoteClose.vue', () => createIconMock('FormatQuoteCloseIcon'))
vi.mock('@icons/Youtube.vue', () => createIconMock('YoutubeIcon'))
vi.mock('@icons/FormatFont.vue', () => createIconMock('FormatFontIcon'))
vi.mock('@icons/FormatSize.vue', () => createIconMock('FormatSizeIcon'))
vi.mock('@icons/FormatColorFill.vue', () => createIconMock('FormatColorFillIcon'))
vi.mock('@icons/FormatAlignLeft.vue', () => createIconMock('FormatAlignLeftIcon'))
vi.mock('@icons/FormatAlignCenter.vue', () => createIconMock('FormatAlignCenterIcon'))
vi.mock('@icons/FormatAlignRight.vue', () => createIconMock('FormatAlignRightIcon'))
vi.mock('@icons/EyeOff.vue', () => createIconMock('EyeOffIcon'))
vi.mock('@icons/FormatListBulleted.vue', () => createIconMock('FormatListBulletedIcon'))
vi.mock('@icons/Paperclip.vue', () => createIconMock('PaperclipIcon'))
vi.mock('@icons/Upload.vue', () => createIconMock('UploadIcon'))
vi.mock('@icons/Emoticon.vue', () => createIconMock('EmoticonIcon'))
vi.mock('@icons/HelpCircle.vue', () => createIconMock('HelpCircleIcon'))
// Mock child components
vi.mock('@/components/LazyEmojiPicker', () =>
createComponentMock('LazyEmojiPicker', {
template: '<div class="emoji-picker-mock"><slot /></div>',
props: [],
}),
)
vi.mock('@/components/BBCodeHelpDialog', () =>
createComponentMock('BBCodeHelpDialog', {
template: '<div class="bbcode-help-dialog-mock" v-if="open" />',
props: ['open'],
}),
)
// Mock Nextcloud dialogs
vi.mock('@nextcloud/dialogs', () => ({
getFilePickerBuilder: vi.fn(() => ({
setMultiSelect: vi.fn().mockReturnThis(),
setType: vi.fn().mockReturnThis(),
build: vi.fn(() => ({
pick: vi.fn(),
})),
})),
FilePickerType: { TYPE_FILE: 1 },
}))
// Mock Nextcloud auth
vi.mock('@nextcloud/auth', () => ({
getCurrentUser: vi.fn(() => ({ uid: 'testuser', displayName: 'Test User' })),
}))
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
webDav: {
put: vi.fn(),
request: vi.fn(),
},
}))
// Mock NcActions and NcActionButton since they're complex
vi.mock('@nextcloud/vue/components/NcActions', () => ({
default: {
name: 'NcActions',
template: '<div class="nc-actions-mock"><slot /><slot name="icon" /></div>',
props: ['ariaLabel'],
},
}))
vi.mock('@nextcloud/vue/components/NcActionButton', () => ({
default: {
name: 'NcActionButton',
template:
'<button class="nc-action-button-mock" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
props: [],
emits: ['click'],
},
}))
vi.mock('@nextcloud/vue/components/NcProgressBar', () => ({
default: {
name: 'NcProgressBar',
template: '<div class="nc-progress-bar-mock" :data-value="value" />',
props: ['value', 'size'],
},
}))
// Import after mocks
import BBCodeToolbar from './BBCodeToolbar.vue'
describe('BBCodeToolbar', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('prompt', vi.fn())
})
const createWrapper = (props = {}) => {
return mount(BBCodeToolbar, {
props: {
textareaRef: null,
modelValue: '',
...props,
},
})
}
describe('rendering', () => {
it('renders the toolbar', () => {
const wrapper = createWrapper()
expect(wrapper.find('.bbcode-toolbar').exists()).toBe(true)
})
it('renders BBCode formatting buttons', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.bbcode-button')
// Should have multiple BBCode buttons (bold, italic, etc.) + emoji + help
expect(buttons.length).toBeGreaterThan(10)
})
it('renders help button', () => {
const wrapper = createWrapper()
expect(wrapper.find('.bbcode-help-button').exists()).toBe(true)
})
it('renders emoji picker trigger', () => {
const wrapper = createWrapper()
expect(wrapper.find('.emoji-picker-mock').exists()).toBe(true)
})
it('renders attachment actions', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-actions-mock').exists()).toBe(true)
})
})
describe('bbcodeButtons computed', () => {
it('includes bold button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'b')).toBe(true)
})
it('includes italic button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'i')).toBe(true)
})
it('includes underline button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'u')).toBe(true)
})
it('includes strikethrough button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 's')).toBe(true)
})
it('includes code button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'code')).toBe(true)
})
it('includes quote button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'quote')).toBe(true)
})
it('includes url button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'url')).toBe(true)
})
it('includes img button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'img')).toBe(true)
})
it('includes youtube button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'youtube')).toBe(true)
})
it('includes list button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'list')).toBe(true)
})
it('includes color button with hasValue', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
bbcodeButtons: Array<{ tag: string; hasValue?: boolean }>
}
const colorButton = vm.bbcodeButtons.find((b) => b.tag === 'color')
expect(colorButton).toBeDefined()
expect(colorButton!.hasValue).toBe(true)
})
it('includes spoiler button', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
expect(vm.bbcodeButtons.some((b) => b.tag === 'spoiler')).toBe(true)
})
})
describe('help dialog', () => {
it('opens help dialog when help button is clicked', async () => {
const wrapper = createWrapper()
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(false)
await wrapper.find('.bbcode-help-button').trigger('click')
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(true)
})
it('closes help dialog when showHelp is set to false', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { showHelp: boolean }
vm.showHelp = true
await flushPromises()
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(true)
vm.showHelp = false
await flushPromises()
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(false)
})
})
describe('insertBBCode', () => {
it('does nothing when textareaRef is null', async () => {
const wrapper = createWrapper({ textareaRef: null })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: { tag: string; template: string }) => Promise<void>
}
await vm.insertBBCode({ tag: 'b', template: '[b]{text}[/b]' })
expect(wrapper.emitted('insert')).toBeFalsy()
})
it('emits insert event with new text for simple BBCode', async () => {
const textarea = document.createElement('textarea')
textarea.value = 'Hello world'
textarea.selectionStart = 0
textarea.selectionEnd = 5
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: { tag: string; template: string; label: string }) => Promise<void>
}
await vm.insertBBCode({ tag: 'b', template: '[b]{text}[/b]', label: 'Bold' })
expect(wrapper.emitted('insert')).toBeTruthy()
const emitted = wrapper.emitted('insert')![0]![0] as { text: string; cursorPos: number }
expect(emitted.text).toBe('[b]Hello[/b] world')
})
it('prompts for value when button has hasValue', async () => {
const mockPrompt = vi.fn().mockReturnValue('red')
vi.stubGlobal('prompt', mockPrompt)
const textarea = document.createElement('textarea')
textarea.value = 'Hello'
textarea.selectionStart = 0
textarea.selectionEnd = 5
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: {
tag: string
template: string
label: string
hasValue: boolean
placeholder: string
}) => Promise<void>
}
await vm.insertBBCode({
tag: 'color',
template: '[color={value}]{text}[/color]',
label: 'Color',
hasValue: true,
placeholder: 'red',
})
expect(mockPrompt).toHaveBeenCalled()
expect(wrapper.emitted('insert')).toBeTruthy()
})
it('does nothing when prompt is cancelled for hasValue button', async () => {
const mockPrompt = vi.fn().mockReturnValue(null)
vi.stubGlobal('prompt', mockPrompt)
const textarea = document.createElement('textarea')
textarea.value = 'Hello'
textarea.selectionStart = 0
textarea.selectionEnd = 5
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: {
tag: string
template: string
label: string
hasValue: boolean
placeholder: string
}) => Promise<void>
}
await vm.insertBBCode({
tag: 'color',
template: '[color={value}]{text}[/color]',
label: 'Color',
hasValue: true,
placeholder: 'red',
})
expect(wrapper.emitted('insert')).toBeFalsy()
})
it('prompts for content when no selection and promptForContent is true', async () => {
const mockPrompt = vi.fn().mockReturnValue('http://example.com/image.png')
vi.stubGlobal('prompt', mockPrompt)
const textarea = document.createElement('textarea')
textarea.value = ''
textarea.selectionStart = 0
textarea.selectionEnd = 0
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as {
insertBBCode: (button: {
tag: string
template: string
label: string
promptForContent: boolean
contentPlaceholder: string
}) => Promise<void>
}
await vm.insertBBCode({
tag: 'img',
template: '[img]{text}[/img]',
label: 'Image',
promptForContent: true,
contentPlaceholder: 'http://example.com/image.png',
})
expect(mockPrompt).toHaveBeenCalled()
expect(wrapper.emitted('insert')).toBeTruthy()
})
})
describe('handleEmojiSelect', () => {
it('emits insert event with emoji', async () => {
const textarea = document.createElement('textarea')
textarea.value = 'Hello '
textarea.selectionStart = 6
textarea.selectionEnd = 6
const wrapper = createWrapper({ textareaRef: textarea })
const vm = wrapper.vm as unknown as { handleEmojiSelect: (emoji: string) => void }
vm.handleEmojiSelect('😀')
expect(wrapper.emitted('insert')).toBeTruthy()
const emitted = wrapper.emitted('insert')![0]![0] as { text: string; cursorPos: number }
expect(emitted.text).toBe('Hello 😀')
expect(emitted.cursorPos).toBe(8) // After emoji
})
it('does nothing when textareaRef is null', () => {
const wrapper = createWrapper({ textareaRef: null })
const vm = wrapper.vm as unknown as { handleEmojiSelect: (emoji: string) => void }
vm.handleEmojiSelect('😀')
expect(wrapper.emitted('insert')).toBeFalsy()
})
})
describe('upload dialog', () => {
it('initializes with upload dialog closed', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { uploadDialog: boolean }
expect(vm.uploadDialog).toBe(false)
})
it('closeUploadDialog resets upload state', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as {
uploadDialog: boolean
uploadProgress: number
uploadFileName: string
uploadError: string | null
closeUploadDialog: () => void
}
vm.uploadDialog = true
vm.uploadProgress = 50
vm.uploadFileName = 'test.pdf'
vm.uploadError = 'Some error'
vm.closeUploadDialog()
expect(vm.uploadDialog).toBe(false)
expect(vm.uploadProgress).toBe(0)
expect(vm.uploadFileName).toBe('')
expect(vm.uploadError).toBeNull()
})
})
describe('strings', () => {
it('has correct translation keys', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { strings: Record<string, string> }
expect(vm.strings.helpLabel).toBe('BBCode help')
expect(vm.strings.emojiLabel).toBe('Insert emoji')
expect(vm.strings.attachmentLabel).toBe('Attachment')
expect(vm.strings.pickFileLabel).toBe('Pick file from Nextcloud')
expect(vm.strings.uploadFileLabel).toBe('Upload file to Nextcloud')
})
})
})

View File

@@ -98,6 +98,14 @@ import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
import LazyEmojiPicker from '@/components/LazyEmojiPicker'
import { getFilePickerBuilder, FilePickerType } from '@nextcloud/dialogs'
import {
applyBBCodeTemplate,
insertTextAtSelection,
getEditorState,
setCursorPosition,
editorStateToSelection,
extractRelativePathFromFilePicker,
} from '@/utils/bbcode'
import { generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import FormatBoldIcon from '@icons/FormatBold.vue'
@@ -122,7 +130,7 @@ import PaperclipIcon from '@icons/Paperclip.vue'
import UploadIcon from '@icons/Upload.vue'
import EmoticonIcon from '@icons/Emoticon.vue'
import HelpCircleIcon from '@icons/HelpCircle.vue'
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
import BBCodeHelpDialog from '@/components/BBCodeHelpDialog'
import { t } from '@nextcloud/l10n'
import { webDav, ocs } from '@/axios'
@@ -158,6 +166,10 @@ export default defineComponent({
type: Object as PropType<HTMLTextAreaElement | HTMLElement | null>,
default: null,
},
modelValue: {
type: String,
default: '',
},
},
emits: ['insert'],
data() {
@@ -314,110 +326,6 @@ export default defineComponent({
},
},
methods: {
/**
* Check if the element is a textarea
*/
isTextarea(el: HTMLElement | HTMLTextAreaElement): el is HTMLTextAreaElement {
return el.tagName === 'TEXTAREA'
},
/**
* Get text content and selection info from the editor element
*/
getEditorState(): { value: string; start: number; end: number; selectedText: string } | null {
if (!this.textareaRef) {
return null
}
if (this.isTextarea(this.textareaRef)) {
const textarea = this.textareaRef
const start = textarea.selectionStart
const end = textarea.selectionEnd
return {
value: textarea.value,
start,
end,
selectedText: textarea.value.substring(start, end),
}
} else {
// Contenteditable element
const el = this.textareaRef
const text = el.innerText || ''
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return { value: text, start: text.length, end: text.length, selectedText: '' }
}
const range = selection.getRangeAt(0)
// Check if selection is within this element
if (!el.contains(range.commonAncestorContainer)) {
return { value: text, start: text.length, end: text.length, selectedText: '' }
}
// Calculate start and end positions in the text
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(el)
preCaretRange.setEnd(range.startContainer, range.startOffset)
const start = preCaretRange.toString().length
preCaretRange.setEnd(range.endContainer, range.endOffset)
const end = preCaretRange.toString().length
return {
value: text,
start,
end,
selectedText: range.toString(),
}
}
},
/**
* Set cursor position in the editor element
*/
setCursorPosition(position: number): void {
if (!this.textareaRef) {
return
}
if (this.isTextarea(this.textareaRef)) {
this.textareaRef.setSelectionRange(position, position)
} else {
// For contenteditable, we need to find the text node and set cursor
const el = this.textareaRef
const selection = window.getSelection()
if (!selection) return
// Find the text node at the position
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null)
let currentPos = 0
let node: Node | null = walker.nextNode()
while (node) {
const nodeLength = (node.textContent || '').length
if (currentPos + nodeLength >= position) {
const range = document.createRange()
range.setStart(node, position - currentPos)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
return
}
currentPos += nodeLength
node = walker.nextNode()
}
// If we couldn't find the position, put cursor at end
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
},
async insertBBCode(button: BBCodeButton): Promise<void> {
// If button has a custom handler, use it instead
if (button.handler) {
@@ -425,16 +333,13 @@ export default defineComponent({
return
}
const state = this.getEditorState()
const state = getEditorState(this.textareaRef, this.modelValue)
if (!state || !this.textareaRef) {
return
}
const { value, start, end, selectedText } = state
const beforeText = value.substring(0, start)
const afterText = value.substring(end)
const { selectedText } = state
let insertText = ''
let promptValue = ''
let contentText = selectedText
@@ -457,28 +362,28 @@ export default defineComponent({
}
}
// Generate the BBCode text
insertText = button.template
.replace('{value}', promptValue)
.replace('{text}', contentText || button.placeholder || '')
// Calculate new cursor position
const newText = beforeText + insertText + afterText
const cursorPos = beforeText.length + insertText.length
// Use the bbcode utility to apply the template
const result = applyBBCodeTemplate(editorStateToSelection(state), {
template: button.template,
value: promptValue,
fallbackText: contentText || button.placeholder || '',
})
// Emit the insert event so the parent can update the model
this.$emit('insert', {
text: newText,
cursorPos,
text: result.text,
cursorPos: result.cursorPosition,
selectedText,
})
// Focus and set cursor position after insertion
// Use $nextTick + requestAnimationFrame to ensure DOM has fully updated
const editorRef = this.textareaRef
this.$nextTick(() => {
if (this.textareaRef) {
this.textareaRef.focus()
this.setCursorPosition(cursorPos)
}
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, result.cursorPosition)
})
})
},
@@ -499,46 +404,31 @@ export default defineComponent({
return
}
// Extract relative path from the full path
// File picker returns: /username/files/path/to/file.pdf
// We need: path/to/file.pdf (relative to user's files directory)
let relativePath = path
const fileId = extractRelativePathFromFilePicker(path)
// Remove the leading /username/files/ part
const pathParts = path.split('/')
if (pathParts.length >= 3 && pathParts[2] === 'files') {
// Remove first 3 parts: ['', 'username', 'files']
relativePath = pathParts.slice(3).join('/')
}
const fileId = relativePath
const state = this.getEditorState()
const state = getEditorState(this.textareaRef, this.modelValue)
if (!state) {
return
}
const { value, start, end } = state
const beforeText = value.substring(0, start)
const afterText = value.substring(end)
const insertText = `[attachment]${fileId}[/attachment]`
const newText = beforeText + insertText + afterText
const cursorPos = beforeText.length + insertText.length
// Use the bbcode utility to insert the attachment tag
const result = insertTextAtSelection(
editorStateToSelection(state),
`[attachment]${fileId}[/attachment]`,
)
// Emit the insert event so the parent can update the model
this.$emit('insert', {
text: newText,
cursorPos,
text: result.text,
cursorPos: result.cursorPosition,
selectedText: '',
})
// Focus the editor after insertion
const editorRef = this.textareaRef
this.$nextTick(() => {
if (this.textareaRef) {
this.textareaRef.focus()
this.setCursorPosition(cursorPos)
}
editorRef.focus()
setCursorPosition(editorRef, result.cursorPosition)
})
} catch (error) {
// Silently ignore if user canceled the dialog
@@ -555,31 +445,26 @@ export default defineComponent({
},
handleEmojiSelect(emoji: string): void {
const state = this.getEditorState()
const state = getEditorState(this.textareaRef, this.modelValue)
if (!state || !this.textareaRef) {
return
}
const { value, start, end } = state
const beforeText = value.substring(0, start)
const afterText = value.substring(end)
const newText = beforeText + emoji + afterText
const cursorPos = beforeText.length + emoji.length
// Use the bbcode utility to insert the emoji
const result = insertTextAtSelection(editorStateToSelection(state), emoji)
// Emit the insert event so the parent can update the model
this.$emit('insert', {
text: newText,
cursorPos,
text: result.text,
cursorPos: result.cursorPosition,
selectedText: '',
})
// Focus the editor after insertion
const editorRef = this.textareaRef
this.$nextTick(() => {
if (this.textareaRef) {
this.textareaRef.focus()
this.setCursorPosition(cursorPos)
}
editorRef.focus()
setCursorPosition(editorRef, result.cursorPosition)
})
},
@@ -651,34 +536,33 @@ export default defineComponent({
})
// Insert attachment BBCode
const state = this.getEditorState()
const state = getEditorState(this.textareaRef, this.modelValue)
if (!state) {
return
}
const { value, start, end } = state
const beforeText = value.substring(0, start)
const afterText = value.substring(end)
// Use the bbcode utility to insert the attachment tag
const filePath = `${uploadDirectory}/${file.name}`
const insertText = `[attachment]${filePath}[/attachment]`
const newText = beforeText + insertText + afterText
const cursorPos = beforeText.length + insertText.length
const result = insertTextAtSelection(
editorStateToSelection(state),
`[attachment]${filePath}[/attachment]`,
)
// Emit the insert event
this.$emit('insert', {
text: newText,
cursorPos,
text: result.text,
cursorPos: result.cursorPosition,
selectedText: '',
})
// Focus the editor after insertion
this.$nextTick(() => {
if (this.textareaRef) {
this.textareaRef.focus()
this.setCursorPosition(cursorPos)
}
})
const editorRef = this.textareaRef
if (editorRef) {
this.$nextTick(() => {
editorRef.focus()
setCursorPosition(editorRef, result.cursorPosition)
})
}
// Close dialog on success
this.uploadDialog = false

View File

@@ -0,0 +1,2 @@
import BBCodeToolbar from './BBCodeToolbar.vue'
export default BBCodeToolbar

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CategoryCard from './CategoryCard.vue'
import { createMockCategory } from '@/test-mocks'
// Uses global mock for @nextcloud/l10n from test-setup.ts
describe('CategoryCard', () => {
describe('rendering', () => {
it('should render category name', () => {
const category = createMockCategory({ name: 'General Discussion' })
const wrapper = mount(CategoryCard, {
props: { category },
})
expect(wrapper.find('.category-name').text()).toBe('General Discussion')
})
it('should render category description', () => {
const category = createMockCategory({ description: 'Talk about anything' })
const wrapper = mount(CategoryCard, {
props: { category },
})
expect(wrapper.find('.category-description').text()).toBe('Talk about anything')
})
it('should render placeholder when no description', () => {
const category = createMockCategory({ description: null })
const wrapper = mount(CategoryCard, {
props: { category },
})
expect(wrapper.find('.category-description').text()).toBe('No description available')
expect(wrapper.find('.category-description').classes()).toContain('muted')
})
})
describe('stats', () => {
it('should display thread count', () => {
const category = createMockCategory({ threadCount: 25 })
const wrapper = mount(CategoryCard, {
props: { category },
})
const stats = wrapper.findAll('.stat-value')
expect(stats[0]!.text()).toBe('25')
})
it('should display post count', () => {
const category = createMockCategory({ postCount: 150 })
const wrapper = mount(CategoryCard, {
props: { category },
})
const stats = wrapper.findAll('.stat-value')
expect(stats[1]!.text()).toBe('150')
})
it('should handle zero counts', () => {
const category = createMockCategory({ threadCount: 0, postCount: 0 })
const wrapper = mount(CategoryCard, {
props: { category },
})
const stats = wrapper.findAll('.stat-value')
expect(stats[0]!.text()).toBe('0')
expect(stats[1]!.text()).toBe('0')
})
it('should handle undefined counts as zero', () => {
const category = createMockCategory()
// @ts-expect-error Testing undefined case
category.threadCount = undefined
// @ts-expect-error Testing undefined case
category.postCount = undefined
const wrapper = mount(CategoryCard, {
props: { category },
})
const stats = wrapper.findAll('.stat-value')
expect(stats[0]!.text()).toBe('0')
expect(stats[1]!.text()).toBe('0')
})
})
describe('structure', () => {
it('should have correct class', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory() },
})
expect(wrapper.find('.category-card').exists()).toBe(true)
})
it('should have header with name and stats', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory() },
})
expect(wrapper.find('.category-header').exists()).toBe(true)
expect(wrapper.find('.category-name').exists()).toBe(true)
expect(wrapper.find('.category-stats').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,2 @@
import CategoryCard from './CategoryCard.vue'
export default CategoryCard

View File

@@ -0,0 +1,521 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import type { CatHeader } from '@/types'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
post: vi.fn(),
put: vi.fn(),
},
}))
// Import after mocks
import { ocs } from '@/axios'
import HeaderEditDialog from './HeaderEditDialog.vue'
const mockPost = vi.mocked(ocs.post)
const mockPut = vi.mocked(ocs.put)
describe('HeaderEditDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const createWrapper = (props = {}) => {
return mount(HeaderEditDialog, {
props: {
open: true,
...props,
},
})
}
describe('rendering', () => {
it('renders the dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('does not render the dialog when closed', () => {
const wrapper = createWrapper({ open: false })
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('shows create title when headerId is null', () => {
const wrapper = createWrapper({ headerId: null })
const vm = wrapper.vm as unknown as { isEditing: boolean }
expect(vm.isEditing).toBe(false)
})
it('shows edit title when headerId is provided', () => {
const wrapper = createWrapper({ headerId: 1 })
const vm = wrapper.vm as unknown as { isEditing: boolean }
expect(vm.isEditing).toBe(true)
})
it('renders name field', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-text-field').exists()).toBe(true)
})
it('renders description field', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-text-area').exists()).toBe(true)
})
it('renders sort order field', () => {
const wrapper = createWrapper()
const inputs = wrapper.findAll('.nc-text-field')
// Name and sort order
expect(inputs.length).toBe(2)
})
it('renders cancel button', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Cancel')).toBe(true)
})
it('renders create button when creating', () => {
const wrapper = createWrapper({ headerId: null })
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Create')).toBe(true)
})
it('renders update button when editing', () => {
const wrapper = createWrapper({ headerId: 1 })
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Update')).toBe(true)
})
})
describe('initial values', () => {
it('initializes with empty values when creating', () => {
const wrapper = createWrapper({ headerId: null })
const vm = wrapper.vm as unknown as {
localName: string
localDescription: string
localSortOrder: number
}
expect(vm.localName).toBe('')
expect(vm.localDescription).toBe('')
expect(vm.localSortOrder).toBe(0)
})
it('initializes with provided values when editing', () => {
const wrapper = createWrapper({
headerId: 1,
name: 'Test Header',
description: 'Test Description',
sortOrder: 5,
})
const vm = wrapper.vm as unknown as {
localName: string
localDescription: string
localSortOrder: number
}
expect(vm.localName).toBe('Test Header')
expect(vm.localDescription).toBe('Test Description')
expect(vm.localSortOrder).toBe(5)
})
it('resets values when dialog reopens', async () => {
const wrapper = createWrapper({
headerId: 1,
name: 'Original Name',
description: 'Original Description',
sortOrder: 3,
})
const vm = wrapper.vm as unknown as {
localName: string
localDescription: string
localSortOrder: number
}
// Modify local values
vm.localName = 'Modified Name'
vm.localDescription = 'Modified Description'
vm.localSortOrder = 10
// Close and reopen
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect(vm.localName).toBe('Original Name')
expect(vm.localDescription).toBe('Original Description')
expect(vm.localSortOrder).toBe(3)
})
})
describe('validation', () => {
it('disables save button when name is empty', () => {
const wrapper = createWrapper({ headerId: null, name: '' })
const vm = wrapper.vm as unknown as { canSave: boolean }
expect(vm.canSave).toBe(false)
})
it('disables save button when name is only whitespace', () => {
const wrapper = createWrapper({ headerId: null, name: ' ' })
const vm = wrapper.vm as unknown as { canSave: boolean }
expect(vm.canSave).toBe(false)
})
it('enables save button when name has content', () => {
const wrapper = createWrapper({ headerId: null, name: 'Valid Name' })
const vm = wrapper.vm as unknown as { canSave: boolean }
expect(vm.canSave).toBe(true)
})
})
describe('creating headers', () => {
it('calls ocs.post when creating a new header', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'New Header',
description: 'New Description',
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith(
'/headers',
expect.objectContaining({
name: 'New Header',
}),
)
})
it('emits saved event with new header data', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'New Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(wrapper.emitted('saved')).toBeTruthy()
expect(wrapper.emitted('saved')![0]).toEqual([mockHeader])
})
it('closes dialog after successful create', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'New Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
})
describe('updating headers', () => {
it('calls ocs.put when updating an existing header', async () => {
const mockHeader: CatHeader = {
id: 5,
name: 'Updated Header',
description: 'Updated Description',
sortOrder: 2,
createdAt: Date.now(),
}
mockPut.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({
headerId: 5,
name: 'Updated Header',
description: 'Updated Description',
sortOrder: 2,
})
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPut).toHaveBeenCalledWith(
'/headers/5',
expect.objectContaining({
name: 'Updated Header',
description: 'Updated Description',
sortOrder: 2,
}),
)
})
it('emits saved event with updated header data', async () => {
const mockHeader: CatHeader = {
id: 5,
name: 'Updated Header',
description: 'Updated Description',
sortOrder: 2,
createdAt: Date.now(),
}
mockPut.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({
headerId: 5,
name: 'Updated Header',
})
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(wrapper.emitted('saved')).toBeTruthy()
expect(wrapper.emitted('saved')![0]).toEqual([mockHeader])
})
})
describe('error handling', () => {
it('logs error when save fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockPost.mockRejectedValue(new Error('Network error'))
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
it('does not close dialog when save fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockPost.mockRejectedValue(new Error('Network error'))
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
// Should not emit update:open on failure
expect(wrapper.emitted('update:open')).toBeFalsy()
})
it('resets submitting state after error', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockPost.mockRejectedValue(new Error('Network error'))
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
const vm = wrapper.vm as unknown as {
handleSave: () => Promise<void>
submitting: boolean
}
await vm.handleSave()
await flushPromises()
expect(vm.submitting).toBe(false)
})
})
describe('close handling', () => {
it('emits update:open when cancel button is clicked', async () => {
const wrapper = createWrapper()
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
await cancelButton!.trigger('click')
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
it('does not close when submitting', async () => {
let resolvePromise: (value: unknown) => void
mockPost.mockImplementation(
() =>
new Promise((resolve) => {
resolvePromise = resolve
}) as never,
)
const wrapper = createWrapper({ headerId: null, name: 'New Header' })
// Start submitting
const vm = wrapper.vm as unknown as {
handleSave: () => Promise<void>
handleClose: () => void
}
vm.handleSave() // Don't await
await flushPromises()
// Try to close while submitting
vm.handleClose()
// Should not emit close event
expect(wrapper.emitted('update:open')).toBeFalsy()
// Clean up
resolvePromise!({ data: {} })
await flushPromises()
})
})
describe('data transformation', () => {
it('trims name before sending', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'Trimmed Name',
description: null,
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({ headerId: null, name: ' Trimmed Name ' })
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith(
'/headers',
expect.objectContaining({
name: 'Trimmed Name',
}),
)
})
it('trims description before sending', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'Header',
description: 'Trimmed Description',
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({
headerId: null,
name: 'Header',
description: ' Trimmed Description ',
})
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith(
'/headers',
expect.objectContaining({
description: 'Trimmed Description',
}),
)
})
it('sends null for empty description', async () => {
const mockHeader: CatHeader = {
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
}
mockPost.mockResolvedValue({ data: mockHeader } as never)
const wrapper = createWrapper({
headerId: null,
name: 'Header',
description: '',
})
const vm = wrapper.vm as unknown as { handleSave: () => Promise<void> }
await vm.handleSave()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith(
'/headers',
expect.objectContaining({
description: null,
}),
)
})
})
describe('reset method', () => {
it('resets all local values', () => {
const wrapper = createWrapper({
headerId: 1,
name: 'Header',
description: 'Description',
sortOrder: 5,
})
const vm = wrapper.vm as unknown as {
localName: string
localDescription: string
localSortOrder: number
submitting: boolean
reset: () => void
}
vm.reset()
expect(vm.localName).toBe('')
expect(vm.localDescription).toBe('')
expect(vm.localSortOrder).toBe(0)
expect(vm.submitting).toBe(false)
})
})
describe('prop watchers', () => {
it('updates localName when name prop changes', async () => {
const wrapper = createWrapper({ name: 'Initial' })
await wrapper.setProps({ name: 'Updated' })
const vm = wrapper.vm as unknown as { localName: string }
expect(vm.localName).toBe('Updated')
})
it('updates localDescription when description prop changes', async () => {
const wrapper = createWrapper({ description: 'Initial' })
await wrapper.setProps({ description: 'Updated' })
const vm = wrapper.vm as unknown as { localDescription: string }
expect(vm.localDescription).toBe('Updated')
})
it('updates localSortOrder when sortOrder prop changes', async () => {
const wrapper = createWrapper({ sortOrder: 1 })
await wrapper.setProps({ sortOrder: 10 })
const vm = wrapper.vm as unknown as { localSortOrder: number }
expect(vm.localSortOrder).toBe(10)
})
})
})

View File

@@ -0,0 +1,2 @@
import HeaderEditDialog from './HeaderEditDialog.vue'
export default HeaderEditDialog

View File

@@ -0,0 +1,2 @@
import LazyEmojiPicker from './LazyEmojiPicker'
export default LazyEmojiPicker

View File

@@ -0,0 +1,536 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import type { CategoryHeader, Category } from '@/types'
// Mock useCategories composable
const mockFetchCategories = vi.fn()
const mockCategoryHeaders = ref<CategoryHeader[]>([])
vi.mock('@/composables/useCategories', () => ({
useCategories: () => ({
categoryHeaders: mockCategoryHeaders,
fetchCategories: mockFetchCategories,
}),
}))
// Import after mocks
import MoveCategoryDialog from './MoveCategoryDialog.vue'
describe('MoveCategoryDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCategoryHeaders.value = []
mockFetchCategories.mockResolvedValue([])
})
const createMockCategory = (overrides: Partial<Category> = {}): Category => ({
id: 1,
headerId: 1,
name: 'Test Category',
description: null,
slug: 'test-category',
sortOrder: 0,
threadCount: 0,
postCount: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
})
const createMockHeader = (overrides: Partial<CategoryHeader> = {}): CategoryHeader => ({
id: 1,
name: 'Test Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [],
...overrides,
})
const createWrapper = (props = {}) => {
return mount(MoveCategoryDialog, {
props: {
open: true,
currentCategoryId: 1,
...props,
},
})
}
describe('rendering', () => {
it('renders the dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('does not render the dialog when closed', () => {
const wrapper = createWrapper({ open: false })
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('displays the correct title', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as unknown as { strings: { title: string } }
expect(vm.strings.title).toBe('Move thread to category')
})
it('displays description text', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Select the category to move this thread to')
})
it('renders cancel button', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Cancel')).toBe(true)
})
it('renders move button', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text() === 'Move')).toBe(true)
})
})
describe('loading state', () => {
it('shows loading state while fetching categories', async () => {
let resolvePromise: (value: unknown) => void
mockFetchCategories.mockImplementation(
() =>
new Promise((resolve) => {
resolvePromise = resolve
}),
)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading categories')
resolvePromise!(undefined)
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(false)
})
})
describe('error state', () => {
it('displays error state when fetch fails', async () => {
mockFetchCategories.mockRejectedValue(new Error('Network error'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.error-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Failed to load categories')
})
})
describe('category options', () => {
it('creates category options from headers', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header 1',
categories: [
createMockCategory({ id: 10, name: 'Category A' }),
createMockCategory({ id: 11, name: 'Category B' }),
],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
// Should have 1 header + 2 categories
expect(vm.categoryOptions.length).toBe(3)
})
it('marks headers with negative IDs', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 5,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
const headerOption = vm.categoryOptions.find((o) => o.isHeader)
expect(headerOption).toBeDefined()
expect(headerOption!.id).toBe(-5) // Negative of header ID
})
it('marks categories with isHeader false', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
const categoryOption = vm.categoryOptions.find((o) => !o.isHeader)
expect(categoryOption).toBeDefined()
expect(categoryOption!.isHeader).toBe(false)
})
it('indents category names with spaces', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
const categoryOption = vm.categoryOptions.find((o) => !o.isHeader)
expect(categoryOption!.name).toBe(' Category') // Two spaces prefix
})
it('excludes headers with no categories', async () => {
mockCategoryHeaders.value = [
createMockHeader({ id: 1, name: 'Empty Header', categories: [] }),
createMockHeader({
id: 2,
name: 'Header with Categories',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
categoryOptions: Array<{ id: number; name: string; isHeader?: boolean }>
}
// Should only have header 2 and its category
expect(vm.categoryOptions.length).toBe(2)
expect(vm.categoryOptions.some((o) => o.name === 'Empty Header')).toBe(false)
})
})
describe('validation warnings', () => {
it('shows error when header is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
// Select a header
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: -1, name: 'Header', isHeader: true }
await flushPromises()
expect(wrapper.text()).toContain('Cannot move to a category header')
})
it('shows warning when same category is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
// Select the same category
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: 10, name: 'Category', isHeader: false }
await flushPromises()
expect(wrapper.text()).toContain('This thread is already in this category')
})
})
describe('move button state', () => {
it('disables move button when no category is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const moveButton = wrapper.findAll('button').find((b) => b.text() === 'Move')
expect(moveButton!.attributes('disabled')).toBeDefined()
})
it('disables move button when header is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: -1, name: 'Header', isHeader: true }
await flushPromises()
const moveButton = wrapper.findAll('button').find((b) => b.text() === 'Move')
expect(moveButton!.attributes('disabled')).toBeDefined()
})
it('disables move button when same category is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 10, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: 10, name: 'Category', isHeader: false }
await flushPromises()
const moveButton = wrapper.findAll('button').find((b) => b.text() === 'Move')
expect(moveButton!.attributes('disabled')).toBeDefined()
})
})
describe('move action', () => {
it('emits move event with selected category ID', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Target Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
handleMove: () => void
}
vm.selectedCategory = { id: 20, name: 'Target Category', isHeader: false }
vm.handleMove()
expect(wrapper.emitted('move')).toBeTruthy()
expect(wrapper.emitted('move')![0]).toEqual([20])
})
it('sets moving state when move is triggered', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
handleMove: () => void
moving: boolean
}
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
vm.handleMove()
expect(vm.moving).toBe(true)
})
it('does not emit move when header is selected', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
handleMove: () => void
}
vm.selectedCategory = { id: -1, name: 'Header', isHeader: true }
vm.handleMove()
expect(wrapper.emitted('move')).toBeFalsy()
})
})
describe('close handling', () => {
it('emits update:open when cancel button is clicked', async () => {
const wrapper = createWrapper()
await flushPromises()
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
await cancelButton!.trigger('click')
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
it('does not close when moving', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
handleMove: () => void
handleClose: () => void
}
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
// Start moving
vm.handleMove()
// Try to close
vm.handleClose()
// Should not emit close event
expect(wrapper.emitted('update:open')).toBeFalsy()
})
})
describe('reset method', () => {
it('resets moving and selectedCategory', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
moving: boolean
reset: () => void
}
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
vm.moving = true
vm.reset()
expect(vm.selectedCategory).toBeNull()
expect(vm.moving).toBe(false)
})
})
describe('dialog reopening', () => {
it('resets selectedCategory when dialog reopens', async () => {
mockCategoryHeaders.value = [
createMockHeader({
id: 1,
name: 'Header',
categories: [createMockCategory({ id: 20, name: 'Category' })],
}),
]
const wrapper = createWrapper({ open: true })
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
}
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
// Close and reopen
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
expect(vm.selectedCategory).toBeNull()
})
it('refetches categories when dialog reopens', async () => {
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(mockFetchCategories).toHaveBeenCalledTimes(1)
// Close and reopen
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
expect(mockFetchCategories).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -0,0 +1,2 @@
import MoveCategoryDialog from './MoveCategoryDialog.vue'
export default MoveCategoryDialog

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock } from '@/test-utils'
import NotFoundPage from './NotFoundPage.vue'
// Uses global mocks for @nextcloud/l10n, @nextcloud/router, NcButton, NcEmptyContent from test-setup.ts
vi.mock('@icons/ArrowLeft.vue', () => createIconMock('ArrowLeftIcon'))
vi.mock('@icons/Home.vue', () => createIconMock('HomeIcon'))
vi.mock('@icons/AlertCircle.vue', () => createIconMock('AlertCircleIcon'))
const mockBack = vi.fn()
const mockPush = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ back: mockBack, push: mockPush }),
}))
describe('NotFoundPage', () => {
beforeEach(() => {
mockBack.mockClear()
mockPush.mockClear()
Object.defineProperty(window, 'history', {
value: { length: 5 },
writable: true,
})
})
describe('rendering', () => {
it('should render with default props', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.not-found-page').exists()).toBe(true)
expect(wrapper.find('.nc-empty-content').exists()).toBe(true)
})
it('should display default title', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.title').text()).toBe('Page not found')
})
it('should display default description', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.description').text()).toBe(
'The page you are looking for could not be found.',
)
})
it('should display custom title', () => {
const wrapper = mount(NotFoundPage, {
props: { title: 'Custom Title' },
})
expect(wrapper.find('.title').text()).toBe('Custom Title')
})
it('should display custom description', () => {
const wrapper = mount(NotFoundPage, {
props: { description: 'Custom description text' },
})
expect(wrapper.find('.description').text()).toBe('Custom description text')
})
})
describe('buttons', () => {
it('should show back button by default', () => {
const wrapper = mount(NotFoundPage)
const buttons = wrapper.findAll('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
expect(wrapper.find('.arrow-left-icon').exists()).toBe(true)
})
it('should show home button by default', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.home-icon').exists()).toBe(true)
})
it('should hide back button when showBackButton is false', () => {
const wrapper = mount(NotFoundPage, {
props: { showBackButton: false },
})
expect(wrapper.find('.arrow-left-icon').exists()).toBe(false)
})
it('should hide home button when showHomeButton is false', () => {
const wrapper = mount(NotFoundPage, {
props: { showHomeButton: false },
})
expect(wrapper.find('.home-icon').exists()).toBe(false)
})
it('should have correct home URL', () => {
const wrapper = mount(NotFoundPage)
const homeButton = wrapper.findAll('button').find((b) => b.find('.home-icon').exists())
expect(homeButton?.attributes('href')).toBe('/apps/forum')
})
})
describe('navigation', () => {
it('should go back when back button is clicked and history exists', async () => {
Object.defineProperty(window, 'history', {
value: { length: 5 },
writable: true,
})
const wrapper = mount(NotFoundPage)
const backButton = wrapper.findAll('button').find((b) => b.find('.arrow-left-icon').exists())
await backButton?.trigger('click')
expect(mockBack).toHaveBeenCalled()
})
it('should navigate to home when back button is clicked and no history', async () => {
Object.defineProperty(window, 'history', {
value: { length: 1 },
writable: true,
})
const wrapper = mount(NotFoundPage)
const backButton = wrapper.findAll('button').find((b) => b.find('.arrow-left-icon').exists())
await backButton?.trigger('click')
expect(mockPush).toHaveBeenCalledWith('/')
})
})
describe('icon', () => {
it('should render default AlertCircle icon', () => {
const wrapper = mount(NotFoundPage)
expect(wrapper.find('.alert-circle-icon').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,2 @@
import NotFoundPage from './NotFoundPage.vue'
export default NotFoundPage

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
import PageHeader from './PageHeader.vue'
vi.mock('@/components/Skeleton', () =>
createComponentMock('Skeleton', {
template: '<div class="skeleton-mock" :style="{ width, height }"></div>',
props: ['width', 'height', 'radius'],
}),
)
describe('PageHeader', () => {
describe('rendering', () => {
it('should render title', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Test Title' },
})
expect(wrapper.find('.page-title').text()).toBe('Test Title')
})
it('should render subtitle when provided', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title', subtitle: 'Subtitle text' },
})
expect(wrapper.find('.page-subtitle').exists()).toBe(true)
expect(wrapper.find('.page-subtitle').text()).toBe('Subtitle text')
})
it('should not render subtitle when not provided', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title' },
})
expect(wrapper.find('.page-subtitle').exists()).toBe(false)
})
it('should not render subtitle when empty string', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title', subtitle: '' },
})
expect(wrapper.find('.page-subtitle').exists()).toBe(false)
})
})
describe('loading state', () => {
it('should show skeleton loaders when loading', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title', loading: true },
})
expect(wrapper.findAll('.skeleton-mock').length).toBe(2)
expect(wrapper.find('.page-title').exists()).toBe(false)
})
it('should show content when not loading', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Title', loading: false },
})
expect(wrapper.find('.skeleton-mock').exists()).toBe(false)
expect(wrapper.find('.page-title').exists()).toBe(true)
})
})
describe('default props', () => {
it('should have empty title by default', () => {
const wrapper = mount(PageHeader)
expect(wrapper.find('.page-title').text()).toBe('')
})
it('should not be loading by default', () => {
const wrapper = mount(PageHeader, {
props: { title: 'Test' },
})
expect(wrapper.find('.page-title').exists()).toBe(true)
})
})
})

View File

@@ -13,7 +13,7 @@
<script lang="ts">
import { defineComponent } from 'vue'
import Skeleton from './Skeleton.vue'
import Skeleton from '@/components/Skeleton'
export default defineComponent({
name: 'PageHeader',

View File

@@ -0,0 +1,2 @@
import PageHeader from './PageHeader.vue'
export default PageHeader

View File

@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PageWrapper from './PageWrapper.vue'
describe('PageWrapper', () => {
describe('rendering', () => {
it('should render default slot content', () => {
const wrapper = mount(PageWrapper, {
slots: {
default: '<div class="test-content">Content</div>',
},
})
expect(wrapper.find('.test-content').exists()).toBe(true)
expect(wrapper.find('.test-content').text()).toBe('Content')
})
it('should render toolbar slot when provided', () => {
const wrapper = mount(PageWrapper, {
slots: {
toolbar: '<div class="test-toolbar">Toolbar</div>',
default: '<div>Content</div>',
},
})
expect(wrapper.find('.page-wrapper-toolbar').exists()).toBe(true)
expect(wrapper.find('.test-toolbar').exists()).toBe(true)
})
it('should not render toolbar wrapper when slot is empty', () => {
const wrapper = mount(PageWrapper, {
slots: {
default: '<div>Content</div>',
},
})
expect(wrapper.find('.page-wrapper-toolbar').exists()).toBe(false)
})
})
describe('fullWidth prop', () => {
it('should not have full-width class by default', () => {
const wrapper = mount(PageWrapper, {
slots: { default: '<div>Content</div>' },
})
expect(wrapper.find('.page-wrapper-content').classes()).not.toContain('full-width')
})
it('should have full-width class when fullWidth is true', () => {
const wrapper = mount(PageWrapper, {
props: { fullWidth: true },
slots: { default: '<div>Content</div>' },
})
expect(wrapper.find('.page-wrapper-content').classes()).toContain('full-width')
})
})
describe('structure', () => {
it('should have correct container structure', () => {
const wrapper = mount(PageWrapper, {
slots: {
toolbar: '<div>Toolbar</div>',
default: '<div>Content</div>',
},
})
expect(wrapper.find('.page-wrapper-container').exists()).toBe(true)
expect(wrapper.find('.page-wrapper-toolbar').exists()).toBe(true)
expect(wrapper.find('.page-wrapper-content').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,2 @@
import PageWrapper from './PageWrapper.vue'
export default PageWrapper

View File

@@ -0,0 +1,138 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock } from '@/test-utils'
import Pagination from './Pagination.vue'
vi.mock('@icons/PageFirst.vue', () => createIconMock('PageFirstIcon'))
vi.mock('@icons/PageLast.vue', () => createIconMock('PageLastIcon'))
vi.mock('@icons/ChevronLeft.vue', () => createIconMock('ChevronLeftIcon'))
vi.mock('@icons/ChevronRight.vue', () => createIconMock('ChevronRightIcon'))
describe('Pagination', () => {
describe('visibility', () => {
it('should not render when maxPages is 1', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 1 },
})
expect(wrapper.find('nav').exists()).toBe(false)
})
it('should render when maxPages is greater than 1', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 2 },
})
expect(wrapper.find('nav').exists()).toBe(true)
})
})
describe('pageItems calculation', () => {
it('should show all pages when maxPages <= 10', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 5 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toEqual([1, 2, 3, 4, 5])
})
it('should show all pages when maxPages is exactly 10', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 5, maxPages: 10 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
it('should add ellipsis for pages > 10 when on first page', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 18, 19, 20])
})
it('should add ellipsis for pages > 10 when on last page', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 20, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 18, 19, 20])
})
it('should show pages around current page in the middle', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 10, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toEqual([1, 2, 3, 'ellipsis', 8, 9, 10, 11, 12, 'ellipsis', 18, 19, 20])
})
it('should handle edge case where current page is near the start', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 4, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toContain(1)
expect(pageItems).toContain(4)
expect(pageItems).toContain(6)
})
it('should handle edge case where current page is near the end', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 17, maxPages: 20 },
})
const pageItems = (wrapper.vm as unknown as { pageItems: (number | 'ellipsis')[] }).pageItems
expect(pageItems).toContain(15)
expect(pageItems).toContain(17)
expect(pageItems).toContain(20)
})
})
describe('navigation', () => {
it('should emit update:currentPage when going to a page', async () => {
const wrapper = mount(Pagination, {
props: { currentPage: 5, maxPages: 10 },
})
const buttons = wrapper.findAll('button')
const page3Button = buttons.find((btn) => btn.text() === '3')
expect(page3Button).toBeDefined()
await page3Button!.trigger('click')
expect(wrapper.emitted('update:currentPage')).toBeTruthy()
expect(wrapper.emitted('update:currentPage')![0]).toEqual([3])
})
it('should not emit when clicking current page', async () => {
const wrapper = mount(Pagination, {
props: { currentPage: 5, maxPages: 10 },
})
const buttons = wrapper.findAll('button')
const page5Button = buttons.find((btn) => btn.text() === '5')
await page5Button!.trigger('click')
expect(wrapper.emitted('update:currentPage')).toBeFalsy()
})
it('should disable first/previous buttons on first page', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 1, maxPages: 10 },
})
const buttons = wrapper.findAll('button')
expect(buttons[0]!.attributes('disabled')).toBeDefined()
expect(buttons[1]!.attributes('disabled')).toBeDefined()
})
it('should disable next/last buttons on last page', () => {
const wrapper = mount(Pagination, {
props: { currentPage: 10, maxPages: 10 },
})
const buttons = wrapper.findAll('button')
const lastIdx = buttons.length - 1
expect(buttons[lastIdx]!.attributes('disabled')).toBeDefined()
expect(buttons[lastIdx - 1]!.attributes('disabled')).toBeDefined()
})
})
})

View File

@@ -0,0 +1,2 @@
import Pagination from './Pagination.vue'
export default Pagination

View File

@@ -0,0 +1,375 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockPost, createMockUser, createMockRole } from '@/test-mocks'
import PostCard from './PostCard.vue'
// Mock icons
vi.mock('@icons/Reply.vue', () => createIconMock('ReplyIcon'))
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon'))
// Mock components
vi.mock('@/components/UserInfo', () =>
createComponentMock('UserInfo', {
template: '<div class="user-info-mock" :data-user-id="userId"><slot name="meta" /></div>',
props: ['userId', 'displayName', 'isDeleted', 'avatarSize', 'roles'],
}),
)
vi.mock('@/components/PostReactions', () =>
createComponentMock('PostReactions', {
template: '<div class="post-reactions-mock" :data-post-id="postId" />',
props: ['postId', 'reactions'],
}),
)
vi.mock('@/components/PostEditForm', () =>
createComponentMock('PostEditForm', {
template: '<div class="post-edit-form-mock" />',
props: ['initialContent'],
}),
)
vi.mock('@/components/PostHistoryDialog', () =>
createComponentMock('PostHistoryDialog', {
template: '<div class="post-history-dialog-mock" v-if="open" />',
props: ['open', 'postId'],
}),
)
// Mock NcActions and NcActionButton
vi.mock('@nextcloud/vue/components/NcActions', () =>
createComponentMock('NcActions', {
template: '<div class="nc-actions-mock"><slot /></div>',
props: [],
}),
)
vi.mock('@nextcloud/vue/components/NcActionButton', () =>
createComponentMock('NcActionButton', {
template:
'<button class="nc-action-button" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
props: [],
emits: ['click'],
}),
)
// Mock getCurrentUser
const mockCurrentUser = vi.fn()
vi.mock('@nextcloud/auth', () => ({
getCurrentUser: () => mockCurrentUser(),
}))
// Mock useUserRole
const mockIsAdmin = vi.fn(() => false)
const mockIsModerator = vi.fn(() => false)
vi.mock('@/composables/useUserRole', () => ({
useUserRole: () => ({
isAdmin: mockIsAdmin(),
isModerator: mockIsModerator(),
}),
}))
describe('PostCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentUser.mockReturnValue({ uid: 'testuser', displayName: 'Test User' })
mockIsAdmin.mockReturnValue(false)
mockIsModerator.mockReturnValue(false)
})
describe('rendering', () => {
it('should render post content', () => {
const post = createMockPost({ content: '<p>Hello world</p>' })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.content-text').html()).toContain('Hello world')
})
it('should render user info with author data', () => {
const author = createMockUser({ userId: 'john', displayName: 'John Doe' })
const post = createMockPost({ author })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.user-info-mock').attributes('data-user-id')).toBe('john')
})
it('should render reactions component', () => {
const post = createMockPost({ id: 42 })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-reactions-mock').attributes('data-post-id')).toBe('42')
})
it('should render edited badge when post is edited', () => {
const post = createMockPost({ isEdited: true, editedAt: Date.now() / 1000 })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.edited-badge').exists()).toBe(true)
expect(wrapper.find('.edited-label').text()).toBe('Edited')
})
it('should not render edited badge when post is not edited', () => {
const post = createMockPost({ isEdited: false })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.edited-badge').exists()).toBe(false)
})
})
describe('CSS classes', () => {
it('should apply first-post class when isFirstPost is true', () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post, isFirstPost: true },
})
expect(wrapper.find('.post-card').classes()).toContain('first-post')
})
it('should apply unread class when isUnread is true', () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post, isUnread: true },
})
expect(wrapper.find('.post-card').classes()).toContain('unread')
})
it('should show unread indicator when isUnread is true', () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post, isUnread: true },
})
expect(wrapper.find('.unread-indicator').exists()).toBe(true)
})
})
describe('signature', () => {
it('should render signature when author has one', () => {
const author = createMockUser({ signature: '<p>My signature</p>' })
const post = createMockPost({ author })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-signature').exists()).toBe(true)
expect(wrapper.find('.signature-content').html()).toContain('My signature')
})
it('should not render signature when author has none', () => {
const author = createMockUser({ signature: null })
const post = createMockPost({ author })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-signature').exists()).toBe(false)
})
})
describe('action buttons', () => {
it('should always show reply button', () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Quote reply'))).toBe(true)
})
it('should show edit button when user is author', () => {
mockCurrentUser.mockReturnValue({ uid: 'author123' })
const post = createMockPost({ authorId: 'author123' })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
})
it('should show edit button when user is admin', () => {
mockCurrentUser.mockReturnValue({ uid: 'admin' })
mockIsAdmin.mockReturnValue(true)
const post = createMockPost({ authorId: 'someone_else' })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
})
it('should show edit button when user is moderator', () => {
mockCurrentUser.mockReturnValue({ uid: 'mod' })
mockIsModerator.mockReturnValue(true)
const post = createMockPost({ authorId: 'someone_else' })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
})
it('should show edit button when user can moderate category', () => {
mockCurrentUser.mockReturnValue({ uid: 'catmod' })
const post = createMockPost({ authorId: 'someone_else' })
const wrapper = mount(PostCard, {
props: { post, canModerateCategory: true },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(true)
})
it('should not show edit button when user has no permissions', () => {
mockCurrentUser.mockReturnValue({ uid: 'random_user' })
const post = createMockPost({ authorId: 'someone_else' })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(false)
})
it('should show view history button when post is edited', () => {
const post = createMockPost({ isEdited: true })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(true)
})
it('should not show view history button when post is not edited', () => {
const post = createMockPost({ isEdited: false })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('View edit history'))).toBe(false)
})
})
describe('events', () => {
it('should emit reply event when reply button is clicked', async () => {
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post },
})
const replyButton = wrapper
.findAll('.nc-action-button')
.find((b) => b.text().includes('Quote reply'))
await replyButton?.trigger('click')
expect(wrapper.emitted('reply')).toBeTruthy()
expect(wrapper.emitted('reply')![0]).toEqual([post])
})
it('should emit delete event when delete is confirmed', async () => {
const confirmMock = vi.fn(() => true)
vi.stubGlobal('confirm', confirmMock)
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author' })
const wrapper = mount(PostCard, {
props: { post },
})
const deleteButton = wrapper
.findAll('.nc-action-button')
.find((b) => b.text().includes('Delete'))
await deleteButton?.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')![0]).toEqual([post])
vi.unstubAllGlobals()
})
it('should not emit delete event when delete is cancelled', async () => {
const confirmMock = vi.fn(() => false)
vi.stubGlobal('confirm', confirmMock)
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author' })
const wrapper = mount(PostCard, {
props: { post },
})
const deleteButton = wrapper
.findAll('.nc-action-button')
.find((b) => b.text().includes('Delete'))
await deleteButton?.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('delete')).toBeFalsy()
vi.unstubAllGlobals()
})
})
describe('edit mode', () => {
it('should show edit form when edit button is clicked', async () => {
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author', contentRaw: 'Raw content' })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-edit-form-mock').exists()).toBe(false)
const editButton = wrapper.findAll('.nc-action-button').find((b) => b.text().includes('Edit'))
await editButton?.trigger('click')
expect(wrapper.find('.post-edit-form-mock').exists()).toBe(true)
expect(wrapper.find('.content-text').exists()).toBe(false)
})
it('should hide reactions when in edit mode', async () => {
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author' })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.post-reactions-mock').exists()).toBe(true)
const editButton = wrapper.findAll('.nc-action-button').find((b) => b.text().includes('Edit'))
await editButton?.trigger('click')
expect(wrapper.find('.post-reactions-mock').exists()).toBe(false)
})
it('should exit edit mode when cancel is triggered', async () => {
mockCurrentUser.mockReturnValue({ uid: 'author' })
const post = createMockPost({ authorId: 'author' })
const wrapper = mount(PostCard, {
props: { post },
})
const editButton = wrapper.findAll('.nc-action-button').find((b) => b.text().includes('Edit'))
await editButton?.trigger('click')
const vm = wrapper.vm as InstanceType<typeof PostCard>
vm.cancelEdit()
await wrapper.vm.$nextTick()
expect(wrapper.find('.post-edit-form-mock').exists()).toBe(false)
expect(wrapper.find('.content-text').exists()).toBe(true)
})
})
describe('unauthenticated user', () => {
it('should not show edit or delete buttons when not logged in', () => {
mockCurrentUser.mockReturnValue(null)
const post = createMockPost()
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Edit'))).toBe(false)
expect(buttons.some((b) => b.text().includes('Delete'))).toBe(false)
})
})
})

View File

@@ -96,10 +96,10 @@ import ReplyIcon from '@icons/Reply.vue'
import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
import HistoryIcon from '@icons/History.vue'
import UserInfo from './UserInfo.vue'
import PostReactions from './PostReactions.vue'
import PostEditForm from './PostEditForm.vue'
import PostHistoryDialog from './PostHistoryDialog.vue'
import UserInfo from '@/components/UserInfo'
import PostReactions from '@/components/PostReactions'
import PostEditForm from '@/components/PostEditForm'
import PostHistoryDialog from '@/components/PostHistoryDialog'
import { t } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
import { useUserRole } from '@/composables/useUserRole'
@@ -445,6 +445,12 @@ export default defineComponent({
line-height: 1.6;
}
}
// Images ([img]) - auto-scale to fit content width
:deep(img) {
max-width: 100%;
height: auto;
}
}
.icon {

View File

@@ -0,0 +1,2 @@
import PostCard from './PostCard.vue'
export default PostCard

View File

@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
import PostEditForm from './PostEditForm.vue'
// Mock BBCodeEditor
vi.mock('@/components/BBCodeEditor', () =>
createComponentMock('BBCodeEditor', {
template: `<div class="bbcode-editor-mock">
<textarea
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
@keydown="$emit('keydown', $event)"
/>
</div>`,
props: ['modelValue', 'placeholder', 'rows', 'disabled', 'minHeight'],
emits: ['update:modelValue', 'keydown'],
}),
)
// Mock NcLoadingIcon
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () =>
createComponentMock('NcLoadingIcon', {
template: '<span class="loading-icon-mock" />',
props: ['size'],
}),
)
describe('PostEditForm', () => {
const initialContent = 'Original post content'
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render with initial content', () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe(initialContent)
})
it('should render cancel and save buttons', () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0]!.text()).toBe('Cancel')
expect(buttons[1]!.text()).toBe('Save')
})
})
describe('submit button state', () => {
it('should disable save button when content is unchanged', () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const saveButton = wrapper.findAll('button')[1]!
expect(saveButton.attributes('disabled')).toBeDefined()
})
it('should disable save button when content is empty', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('')
const saveButton = wrapper.findAll('button')[1]!
expect(saveButton.attributes('disabled')).toBeDefined()
})
it('should disable save button when content is only whitespace', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue(' ')
const saveButton = wrapper.findAll('button')[1]!
expect(saveButton.attributes('disabled')).toBeDefined()
})
it('should enable save button when content is changed and not empty', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Updated content')
const saveButton = wrapper.findAll('button')[1]!
expect(saveButton.attributes('disabled')).toBeUndefined()
})
})
describe('submit', () => {
it('should emit submit with trimmed content when save is clicked', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue(' New content with spaces ')
const saveButton = wrapper.findAll('button')[1]!
await saveButton.trigger('click')
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')![0]).toEqual(['New content with spaces'])
})
it('should not emit submit when content is unchanged', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const saveButton = wrapper.findAll('button')[1]!
await saveButton.trigger('click')
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('cancel', () => {
it('should emit cancel when cancel button is clicked with no changes', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('should show confirmation when canceling with changes', async () => {
const confirmMock = vi.fn(() => false)
vi.stubGlobal('confirm', confirmMock)
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Changed content')
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('cancel')).toBeFalsy()
vi.unstubAllGlobals()
})
it('should emit cancel when confirmation is accepted', async () => {
const confirmMock = vi.fn(() => true)
vi.stubGlobal('confirm', confirmMock)
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Changed content')
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('cancel')).toBeTruthy()
vi.unstubAllGlobals()
})
})
describe('submitting state', () => {
it('should disable buttons when submitting', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('New content')
// Trigger submit
const saveButton = wrapper.findAll('button')[1]!
await saveButton.trigger('click')
// Both buttons should be disabled
const buttons = wrapper.findAll('button')
expect(buttons[0]!.attributes('disabled')).toBeDefined()
expect(buttons[1]!.attributes('disabled')).toBeDefined()
})
it('should disable editor when submitting', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const textarea = wrapper.find('textarea')
await textarea.setValue('New content')
const saveButton = wrapper.findAll('button')[1]!
await saveButton.trigger('click')
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
})
it('should expose setSubmitting method', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const vm = wrapper.vm as InstanceType<typeof PostEditForm>
vm.setSubmitting(true)
await wrapper.vm.$nextTick()
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
vm.setSubmitting(false)
await wrapper.vm.$nextTick()
expect(wrapper.find('textarea').attributes('disabled')).toBeUndefined()
})
})
describe('computed properties', () => {
it('should correctly compute hasChanges', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const vm = wrapper.vm as InstanceType<typeof PostEditForm>
expect(vm.hasChanges).toBe(false)
const textarea = wrapper.find('textarea')
await textarea.setValue('Different content')
expect(vm.hasChanges).toBe(true)
})
it('should correctly compute canSubmit', async () => {
const wrapper = mount(PostEditForm, {
props: { initialContent },
})
const vm = wrapper.vm as InstanceType<typeof PostEditForm>
// Same content - cannot submit
expect(vm.canSubmit).toBe(false)
const textarea = wrapper.find('textarea')
// Empty content - cannot submit
await textarea.setValue('')
expect(vm.canSubmit).toBe(false)
// Different non-empty content - can submit
await textarea.setValue('New content')
expect(vm.canSubmit).toBe(true)
})
})
})

View File

@@ -29,7 +29,7 @@
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import BBCodeEditor from './BBCodeEditor.vue'
import BBCodeEditor from '@/components/BBCodeEditor'
import { t } from '@nextcloud/l10n'
export default defineComponent({

View File

@@ -0,0 +1,2 @@
import PostEditForm from './PostEditForm.vue'
export default PostEditForm

View File

@@ -0,0 +1,409 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import type { PostHistoryResponse, Post, PostHistoryEntry, User } from '@/types'
// Mock axios - must use factory that doesn't reference external variables
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
// Mock icons
vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon'))
// Mock UserInfo component
vi.mock('@/components/UserInfo', () =>
createComponentMock('UserInfo', {
template: '<span class="user-info-mock">{{ displayName }}</span>',
props: ['userId', 'displayName', 'isDeleted', 'avatarSize', 'inline'],
}),
)
// Import after mocks
import { ocs } from '@/axios'
import PostHistoryDialog from './PostHistoryDialog.vue'
const mockGet = vi.mocked(ocs.get)
describe('PostHistoryDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: null } as never)
})
const createMockUser = (overrides: Partial<User> = {}): User => ({
userId: 'testuser',
displayName: 'Test User',
isDeleted: false,
roles: [],
signature: null,
signatureRaw: null,
...overrides,
})
const createMockPost = (overrides: Partial<Post> = {}): Post => ({
id: 1,
threadId: 1,
authorId: 'testuser',
content: '<p>Current content</p>',
contentRaw: 'Current content',
isEdited: true,
isFirstPost: false,
editedAt: 1700000000,
createdAt: 1699000000,
updatedAt: 1700000000,
author: createMockUser(),
...overrides,
})
const createMockHistoryEntry = (overrides: Partial<PostHistoryEntry> = {}): PostHistoryEntry => ({
id: 1,
postId: 1,
content: '<p>Old content</p>',
editedBy: 'editor1',
editedAt: 1699500000,
editor: createMockUser({ userId: 'editor1', displayName: 'Editor One' }),
...overrides,
})
const createMockHistoryResponse = (
overrides: Partial<PostHistoryResponse> = {},
): PostHistoryResponse => ({
current: createMockPost(),
history: [createMockHistoryEntry()],
...overrides,
})
const createWrapper = (props = {}) => {
return mount(PostHistoryDialog, {
props: {
open: true,
postId: 1,
...props,
},
})
}
describe('rendering', () => {
it('renders the dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('does not render the dialog when closed', () => {
const wrapper = createWrapper({ open: false })
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('passes the correct title to dialog', async () => {
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
const wrapper = createWrapper()
await flushPromises()
// The title is passed as a prop to NcDialog, not rendered as text
const vm = wrapper.vm as unknown as { strings: { title: string } }
expect(vm.strings.title).toBe('Edit history')
})
})
describe('loading state', () => {
it('shows loading state while fetching history', async () => {
let resolvePromise: (value: unknown) => void
mockGet.mockImplementation(
() =>
new Promise((resolve) => {
resolvePromise = resolve
}) as never,
)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading history')
resolvePromise!({ data: createMockHistoryResponse() })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(false)
})
})
describe('error state', () => {
it('displays error state when fetch fails', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.error-state').exists()).toBe(true)
expect(wrapper.text()).toContain('Failed to load edit history')
})
})
describe('empty state', () => {
it('displays empty state when no history exists', async () => {
mockGet.mockResolvedValue({
data: createMockHistoryResponse({ history: [] }),
} as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.empty-state').exists()).toBe(true)
expect(wrapper.text()).toContain('This post has no edit history')
})
it('displays history icon in empty state', async () => {
mockGet.mockResolvedValue({
data: createMockHistoryResponse({ history: [] }),
} as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
// The icon mock uses kebab-case class name: HistoryIcon -> .history-icon
expect(wrapper.find('.history-icon').exists()).toBe(true)
})
})
describe('history content', () => {
it('displays current version', async () => {
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.current-version').exists()).toBe(true)
expect(wrapper.text()).toContain('Current version')
})
it('displays current version content', async () => {
const response = createMockHistoryResponse({
current: createMockPost({ content: '<p>This is current content</p>' }),
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.entry-content').html()).toContain('This is current content')
})
it('displays historical versions', async () => {
const response = createMockHistoryResponse({
history: [
createMockHistoryEntry({ id: 1, content: '<p>Version 1 content</p>' }),
createMockHistoryEntry({ id: 2, content: '<p>Version 2 content</p>' }),
],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const entries = wrapper.findAll('.history-entry')
// 1 current + 2 historical
expect(entries.length).toBe(3)
})
it('displays version labels correctly', async () => {
const response = createMockHistoryResponse({
history: [createMockHistoryEntry({ id: 1 }), createMockHistoryEntry({ id: 2 })],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
// Translation mock replaces {index} with actual values
// Version labels should be "Version 2", "Version 1" (in reverse order)
expect(wrapper.text()).toContain('Version 2')
expect(wrapper.text()).toContain('Version 1')
})
it('displays editor info for historical versions', async () => {
const response = createMockHistoryResponse({
history: [
createMockHistoryEntry({
editor: createMockUser({ userId: 'editor1', displayName: 'Editor One' }),
}),
],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.text()).toContain('Edited by')
expect(wrapper.find('.user-info-mock').exists()).toBe(true)
})
})
describe('API calls', () => {
it('fetches history when dialog opens', async () => {
const wrapper = createWrapper({ open: true, postId: 42 })
await flushPromises()
expect(mockGet).toHaveBeenCalledWith('/posts/42/history')
})
it('does not fetch when dialog is closed', async () => {
createWrapper({ open: false, postId: 42 })
await flushPromises()
expect(mockGet).not.toHaveBeenCalled()
})
it('refetches when dialog reopens', async () => {
const wrapper = createWrapper({ open: true, postId: 42 })
await flushPromises()
expect(mockGet).toHaveBeenCalledTimes(1)
// Close
await wrapper.setProps({ open: false })
await flushPromises()
// Reopen
await wrapper.setProps({ open: true })
await flushPromises()
expect(mockGet).toHaveBeenCalledTimes(2)
})
it('clears data when dialog closes', async () => {
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.history-content').exists()).toBe(true)
await wrapper.setProps({ open: false })
await flushPromises()
// Reopen - should show loading again
mockGet.mockImplementation(
() =>
new Promise(() => {
/* never resolves */
}) as never,
)
await wrapper.setProps({ open: true })
await flushPromises()
expect(wrapper.find('.loading-state').exists()).toBe(true)
})
})
describe('close event', () => {
it('emits update:open event when close button is clicked', async () => {
mockGet.mockResolvedValue({ data: createMockHistoryResponse() } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const closeButton = wrapper.find('button')
await closeButton.trigger('click')
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
it('emits update:open event when handleClose is called', async () => {
const wrapper = createWrapper({ open: true })
;(wrapper.vm as unknown as { handleClose: () => void }).handleClose()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
})
describe('timestamps', () => {
it('displays editedAt timestamp for current version when edited', async () => {
const response = createMockHistoryResponse({
current: createMockPost({ editedAt: 1700000000, createdAt: 1699000000 }),
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const dateTime = wrapper.find('.current-version .nc-datetime')
expect(dateTime.exists()).toBe(true)
// editedAt * 1000 = 1700000000000
expect(dateTime.attributes('data-timestamp')).toBe('1700000000000')
})
it('displays createdAt timestamp for current version when not edited', async () => {
const response = createMockHistoryResponse({
current: createMockPost({ editedAt: null, createdAt: 1699000000 }),
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const dateTime = wrapper.find('.current-version .nc-datetime')
expect(dateTime.exists()).toBe(true)
// createdAt * 1000 = 1699000000000
expect(dateTime.attributes('data-timestamp')).toBe('1699000000000')
})
it('displays timestamps for historical versions', async () => {
const response = createMockHistoryResponse({
history: [createMockHistoryEntry({ editedAt: 1699500000 })],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
const entries = wrapper.findAll('.history-entry:not(.current-version)')
expect(entries.length).toBeGreaterThan(0)
const dateTime = entries[0]!.find('.nc-datetime')
expect(dateTime.exists()).toBe(true)
expect(dateTime.attributes('data-timestamp')).toBe('1699500000000')
})
})
describe('edge cases', () => {
it('handles null historyData gracefully', async () => {
mockGet.mockResolvedValue({ data: null } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
expect(wrapper.find('.empty-state').exists()).toBe(true)
})
it('uses editedBy as fallback when editor is not available', async () => {
const response = createMockHistoryResponse({
history: [
{
id: 1,
postId: 1,
content: '<p>Old content</p>',
editedBy: 'fallback_user',
editedAt: 1699500000,
editor: undefined,
},
],
})
mockGet.mockResolvedValue({ data: response } as never)
const wrapper = createWrapper({ open: true })
await flushPromises()
// Should use editedBy as userId and displayName
const userInfo = wrapper.find('.user-info-mock')
expect(userInfo.text()).toBe('fallback_user')
})
})
})

View File

@@ -75,7 +75,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import HistoryIcon from '@icons/History.vue'
import UserInfo from '@/components/UserInfo.vue'
import UserInfo from '@/components/UserInfo'
import { t, n } from '@nextcloud/l10n'
import { ocs } from '@/axios'
import type { PostHistoryResponse } from '@/types'
@@ -304,6 +304,12 @@ export default defineComponent({
line-height: 1.6;
}
}
// Images - auto-scale to fit content width
:deep(img) {
max-width: 100%;
height: auto;
}
}
}
</style>

View File

@@ -0,0 +1,2 @@
import PostHistoryDialog from './PostHistoryDialog.vue'
export default PostHistoryDialog

View File

@@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
import PostReactions from './PostReactions.vue'
import type { ReactionGroup } from '@/composables/useReactions'
// Mock LazyEmojiPicker
vi.mock('@/components/LazyEmojiPicker', () =>
createComponentMock('LazyEmojiPicker', {
template: '<div class="emoji-picker-mock"><slot /></div>',
props: [],
}),
)
// Mock useReactions composable
const mockToggleReaction = vi.fn()
vi.mock('@/composables/useReactions', () => ({
useReactions: () => ({
toggleReaction: mockToggleReaction,
}),
}))
// Mock getCurrentUser
vi.mock('@nextcloud/auth', () => ({
getCurrentUser: () => ({ uid: 'testuser', displayName: 'Test User' }),
}))
describe('PostReactions', () => {
beforeEach(() => {
mockToggleReaction.mockReset()
})
const defaultEmojis = ['👍', '❤️', '😄', '🎉', '👏']
describe('rendering', () => {
it('should render default emojis', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const buttons = wrapper.findAll('.reaction-button')
expect(buttons.length).toBe(defaultEmojis.length)
defaultEmojis.forEach((emoji) => {
expect(wrapper.text()).toContain(emoji)
})
})
it('should render add reaction button', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
expect(wrapper.find('.add-reaction-button').exists()).toBe(true)
})
it('should display reaction counts when present', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 5, hasReacted: false, userIds: ['user1', 'user2'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
expect(wrapper.find('.count').text()).toBe('5')
})
it('should not display count when zero', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.find('.count').exists()).toBe(false)
})
})
describe('CSS classes', () => {
it('should apply reacted class when user has reacted', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 1, hasReacted: true, userIds: ['testuser'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.classes()).toContain('reacted')
})
it('should not apply reacted class when user has not reacted', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 1, hasReacted: false, userIds: ['otheruser'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.classes()).not.toContain('reacted')
})
it('should apply has-count class when count is greater than zero', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 3, hasReacted: false, userIds: ['user1'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.classes()).toContain('has-count')
})
})
describe('sorting', () => {
it('should sort emojis by count (highest first)', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 2, hasReacted: false, userIds: [] },
{ emoji: '❤️', count: 10, hasReacted: false, userIds: [] },
{ emoji: '😄', count: 5, hasReacted: false, userIds: [] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const buttons = wrapper.findAll('.reaction-button')
const emojis = buttons.map((b) => b.find('.emoji').text())
// ❤️ (10) should be first, then 😄 (5), then 👍 (2)
expect(emojis[0]).toBe('❤️')
expect(emojis[1]).toBe('😄')
expect(emojis[2]).toBe('👍')
})
it('should preserve default order for equal counts', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const buttons = wrapper.findAll('.reaction-button')
const emojis = buttons.map((b) => b.find('.emoji').text())
expect(emojis).toEqual(defaultEmojis)
})
it('should show custom emojis with reactions', () => {
const reactions: ReactionGroup[] = [{ emoji: '🚀', count: 3, hasReacted: false, userIds: [] }]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
expect(wrapper.text()).toContain('🚀')
})
})
describe('tooltips', () => {
it('should show "React with" tooltip for zero reactions', () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.attributes('title')).toBe('React with 👍')
})
it('should show "You reacted" tooltip when user is sole reactor', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 1, hasReacted: true, userIds: ['testuser'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.attributes('title')).toBe('You reacted with 👍')
})
it('should show count tooltip when user has not reacted', () => {
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 3, hasReacted: false, userIds: ['a', 'b', 'c'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
expect(thumbsUpButton.attributes('title')).toBe('3 people reacted with 👍')
})
})
describe('toggle reaction', () => {
it('should call toggleReaction when clicking a reaction button', async () => {
mockToggleReaction.mockResolvedValue({ action: 'added' })
const wrapper = mount(PostReactions, {
props: { postId: 42, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
await thumbsUpButton.trigger('click')
expect(mockToggleReaction).toHaveBeenCalledWith(42, '👍')
})
it('should emit update event after toggling reaction', async () => {
mockToggleReaction.mockResolvedValue({ action: 'added' })
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
await thumbsUpButton.trigger('click')
expect(wrapper.emitted('update')).toBeTruthy()
})
it('should update local state when adding reaction', async () => {
mockToggleReaction.mockResolvedValue({ action: 'added' })
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
await thumbsUpButton.trigger('click')
// Wait for async update
await wrapper.vm.$nextTick()
// Check that the button now shows as reacted
expect(thumbsUpButton.classes()).toContain('reacted')
expect(thumbsUpButton.find('.count').text()).toBe('1')
})
it('should update local state when removing reaction', async () => {
mockToggleReaction.mockResolvedValue({ action: 'removed' })
const reactions: ReactionGroup[] = [
{ emoji: '👍', count: 1, hasReacted: true, userIds: ['testuser'] },
]
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions },
})
const thumbsUpButton = wrapper.findAll('.reaction-button')[0]!
await thumbsUpButton.trigger('click')
await wrapper.vm.$nextTick()
expect(thumbsUpButton.classes()).not.toContain('reacted')
})
})
describe('props reactivity', () => {
it('should update when reactions prop changes', async () => {
const wrapper = mount(PostReactions, {
props: { postId: 1, reactions: [] },
})
// Initially no count
expect(wrapper.find('.count').exists()).toBe(false)
// Update reactions
await wrapper.setProps({
reactions: [{ emoji: '👍', count: 5, hasReacted: false, userIds: [] }],
})
expect(wrapper.find('.count').text()).toBe('5')
})
})
})

View File

@@ -0,0 +1,2 @@
import PostReactions from './PostReactions.vue'
export default PostReactions

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import PostReplyForm from './PostReplyForm.vue'
// Mock BBCodeEditor
vi.mock('@/components/BBCodeEditor', () =>
createComponentMock('BBCodeEditor', {
template: `<div class="bbcode-editor-mock">
<textarea
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
@keydown="$emit('keydown', $event)"
/>
</div>`,
props: ['modelValue', 'placeholder', 'rows', 'disabled', 'minHeight'],
emits: ['update:modelValue', 'keydown'],
}),
)
// Mock UserInfo
vi.mock('@/components/UserInfo', () =>
createComponentMock('UserInfo', {
template: '<div class="user-info-mock" :data-user-id="userId">{{ displayName }}</div>',
props: ['userId', 'displayName', 'avatarSize', 'clickable'],
}),
)
// Mock icons
vi.mock('@icons/Send.vue', () => createIconMock('SendIcon'))
// Mock NcLoadingIcon
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () =>
createComponentMock('NcLoadingIcon', {
template: '<span class="loading-icon-mock" />',
props: ['size'],
}),
)
// Mock useCurrentUser composable
vi.mock('@/composables/useCurrentUser', () => ({
useCurrentUser: () => ({
userId: 'testuser',
displayName: 'Test User',
}),
}))
describe('PostReplyForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render user info header', () => {
const wrapper = mount(PostReplyForm)
const userInfo = wrapper.find('.user-info-mock')
expect(userInfo.exists()).toBe(true)
expect(userInfo.attributes('data-user-id')).toBe('testuser')
expect(userInfo.text()).toBe('Test User')
})
it('should render editor', () => {
const wrapper = mount(PostReplyForm)
expect(wrapper.find('.bbcode-editor-mock').exists()).toBe(true)
})
it('should render cancel and submit buttons', () => {
const wrapper = mount(PostReplyForm)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0]!.text()).toBe('Cancel')
expect(buttons[1]!.text()).toContain('Submit reply')
})
it('should render send icon in submit button', () => {
const wrapper = mount(PostReplyForm)
expect(wrapper.find('.send-icon').exists()).toBe(true)
})
})
describe('button states', () => {
it('should disable submit button when content is empty', () => {
const wrapper = mount(PostReplyForm)
const submitButton = wrapper.findAll('button')[1]!
expect(submitButton.attributes('disabled')).toBeDefined()
})
it('should disable cancel button when content is empty', () => {
const wrapper = mount(PostReplyForm)
const cancelButton = wrapper.findAll('button')[0]!
expect(cancelButton.attributes('disabled')).toBeDefined()
})
it('should enable submit button when content is not empty', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some reply content')
const submitButton = wrapper.findAll('button')[1]!
expect(submitButton.attributes('disabled')).toBeUndefined()
})
it('should enable cancel button when content is not empty', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some reply content')
const cancelButton = wrapper.findAll('button')[0]!
expect(cancelButton.attributes('disabled')).toBeUndefined()
})
})
describe('submit', () => {
it('should emit submit with trimmed content', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue(' Reply content with spaces ')
const submitButton = wrapper.findAll('button')[1]!
await submitButton.trigger('click')
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')![0]).toEqual(['Reply content with spaces'])
})
it('should not emit submit when content is empty', async () => {
const wrapper = mount(PostReplyForm)
const submitButton = wrapper.findAll('button')[1]!
await submitButton.trigger('click')
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('cancel', () => {
it('should show confirmation when canceling with content', async () => {
const confirmMock = vi.fn(() => false)
vi.stubGlobal('confirm', confirmMock)
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some content')
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(confirmMock).toHaveBeenCalled()
expect(wrapper.emitted('cancel')).toBeFalsy()
vi.unstubAllGlobals()
})
it('should emit cancel and clear content when confirmation is accepted', async () => {
const confirmMock = vi.fn(() => true)
vi.stubGlobal('confirm', confirmMock)
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some content')
const cancelButton = wrapper.findAll('button')[0]!
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
expect(textarea.element.value).toBe('')
vi.unstubAllGlobals()
})
})
describe('exposed methods', () => {
it('should clear content with clear()', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some content')
const vm = wrapper.vm as InstanceType<typeof PostReplyForm>
vm.clear()
await wrapper.vm.$nextTick()
expect(textarea.element.value).toBe('')
})
it('should set submitting state with setSubmitting()', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Some content')
const vm = wrapper.vm as InstanceType<typeof PostReplyForm>
vm.setSubmitting(true)
await wrapper.vm.$nextTick()
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
expect(wrapper.find('.loading-icon-mock').exists()).toBe(true)
vm.setSubmitting(false)
await wrapper.vm.$nextTick()
expect(wrapper.find('textarea').attributes('disabled')).toBeUndefined()
})
it('should set quoted content with setQuotedContent()', async () => {
const wrapper = mount(PostReplyForm)
const vm = wrapper.vm as InstanceType<typeof PostReplyForm>
vm.setQuotedContent('Original message')
await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe('[quote]Original message[/quote]\n')
})
})
describe('submitting state', () => {
it('should disable editor when submitting', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Reply content')
const submitButton = wrapper.findAll('button')[1]!
await submitButton.trigger('click')
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined()
})
it('should show loading icon when submitting', async () => {
const wrapper = mount(PostReplyForm)
const textarea = wrapper.find('textarea')
await textarea.setValue('Reply content')
const submitButton = wrapper.findAll('button')[1]!
await submitButton.trigger('click')
expect(wrapper.find('.loading-icon-mock').exists()).toBe(true)
})
})
})

View File

@@ -42,8 +42,8 @@ import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import SendIcon from '@icons/Send.vue'
import UserInfo from './UserInfo.vue'
import BBCodeEditor from './BBCodeEditor.vue'
import UserInfo from '@/components/UserInfo'
import BBCodeEditor from '@/components/BBCodeEditor'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'

View File

@@ -0,0 +1,2 @@
import PostReplyForm from './PostReplyForm.vue'
export default PostReplyForm

View File

@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import RoleBadge from './RoleBadge.vue'
import { createMockRole } from '@/test-mocks'
// Uses global mock for @nextcloud/vue/functions/isDarkTheme from test-setup.ts
describe('RoleBadge', () => {
describe('rendering', () => {
it('should display the role name', () => {
const role = createMockRole({ name: 'Super Admin' })
const wrapper = mount(RoleBadge, {
props: { role },
})
expect(wrapper.text()).toBe('Super Admin')
})
it('should apply normal density class by default', () => {
const role = createMockRole()
const wrapper = mount(RoleBadge, {
props: { role },
})
expect(wrapper.find('.role-badge').classes()).toContain('density-normal')
})
it('should apply compact density class when specified', () => {
const role = createMockRole()
const wrapper = mount(RoleBadge, {
props: { role, density: 'compact' },
})
expect(wrapper.find('.role-badge').classes()).toContain('density-compact')
})
})
describe('color calculation', () => {
it('should use colorLight when provided (light theme)', () => {
const role = createMockRole({ colorLight: '#ff5500' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('background-color: #ff5500')
})
it('should use fallback color for Admin role (id=1)', () => {
const role = createMockRole({ id: 1, name: 'Admin', roleType: 'admin' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('background-color: #dc2626')
})
it('should use fallback color for Moderator role (id=2)', () => {
const role = createMockRole({ id: 2, name: 'Moderator', roleType: 'moderator' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('background-color: #2563eb')
})
it('should use fallback color for User role (id=3)', () => {
const role = createMockRole({ id: 3, name: 'User', roleType: 'default' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('background-color: #059669')
})
it('should use default fallback for custom roles without colors', () => {
const role = createMockRole({ id: 999, name: 'Custom' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('background-color: #000000')
})
})
describe('text color calculation (contrast)', () => {
it('should use dark text on light backgrounds', () => {
const role = createMockRole({ colorLight: '#ffffff' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('color: #000000')
})
it('should use light text on dark backgrounds', () => {
const role = createMockRole({ colorLight: '#000000' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('color: #ffffff')
})
it('should use light text on moderately dark backgrounds', () => {
const role = createMockRole({ colorLight: '#1e3a5f' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('color: #ffffff')
})
it('should use dark text on moderately light backgrounds', () => {
const role = createMockRole({ colorLight: '#ffeb3b' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const style = wrapper.find('.role-badge').attributes('style')
expect(style).toContain('color: #000000')
})
})
describe('hexToRgb method', () => {
it('should correctly parse 6-digit hex colors', () => {
const role = createMockRole({ colorLight: '#ff5500' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const vm = wrapper.vm as unknown as {
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
}
const result = vm.hexToRgb('#ff5500')
expect(result).toEqual({ r: 255, g: 85, b: 0 })
})
it('should correctly parse 3-digit shorthand hex colors', () => {
const role = createMockRole({ colorLight: '#f00' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const vm = wrapper.vm as unknown as {
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
}
const result = vm.hexToRgb('#f00')
expect(result).toEqual({ r: 255, g: 0, b: 0 })
})
it('should handle hex without # prefix', () => {
const role = createMockRole({ colorLight: '#00ff00' })
const wrapper = mount(RoleBadge, {
props: { role },
})
const vm = wrapper.vm as unknown as {
hexToRgb: (hex: string) => { r: number; g: number; b: number } | null
}
const result = vm.hexToRgb('00ff00')
expect(result).toEqual({ r: 0, g: 255, b: 0 })
})
})
})

Some files were not shown because too many files have changed in this diff Show More