mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 407df1d423 | |||
| e2dcebc6ee | |||
| a905ce3b4c | |||
| c017bb3d09 | |||
|
|
67c92c05a3 | ||
| e94ca2dec1 | |||
| 975744ec6f | |||
| cb7a03c1d5 | |||
| 00e5d6d3b2 | |||
| 8b489b9cc3 | |||
| 9f904a7e48 | |||
| 886c51fdca | |||
| 919a13fdd3 | |||
| 370eed1286 | |||
| 1ff6349337 | |||
| 7732f22f4e | |||
| a07c8e452f | |||
| 57642efc7b | |||
| 18a2918446 | |||
| 3e7cebc8c3 | |||
| eb1b2f86df | |||
| c72c8b3eed | |||
| ec49855173 | |||
| cdca135f7d | |||
| 145e6d8f81 | |||
| 01639c7545 | |||
| 8848ba0304 | |||
| 64a618f54a | |||
| e4281e2128 | |||
| b84d96488c | |||
| b6e40f9976 |
45
.github/workflows/lint-eslint.yml
vendored
45
.github/workflows/lint-eslint.yml
vendored
@@ -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
|
||||
|
||||
68
.github/workflows/vitest.yml
vendored
Normal file
68
.github/workflows/vitest.yml
vendored
Normal 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
|
||||
@@ -1,2 +1,2 @@
|
||||
templates/
|
||||
scaffolds/
|
||||
gen/
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.19.2"}
|
||||
{".":"0.20.2"}
|
||||
|
||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -1,5 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* roles seed ([8848ba0](https://github.com/chenasraf/nextcloud-forum/commit/8848ba03045f69cba40dd9094ade214f1c1b56cc))
|
||||
|
||||
## [0.19.4](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.3...v0.19.4) (2026-01-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* seed migration ([e4281e2](https://github.com/chenasraf/nextcloud-forum/commit/e4281e2128a86fa39b8f4a8deec21b82c901b935))
|
||||
|
||||
## [0.19.3](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.2...v0.19.3) (2025-12-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add seed migration ([b6e40f9](https://github.com/chenasraf/nextcloud-forum/commit/b6e40f9976d1b9a6d5a1a378d1ff43b72feace06))
|
||||
|
||||
## [0.19.2](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.1...v0.19.2) (2025-12-29)
|
||||
|
||||
|
||||
|
||||
@@ -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.2</version>
|
||||
<version>0.20.2</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
|
||||
<namespace>Forum</namespace>
|
||||
|
||||
26
composer.lock
generated
26
composer.lock
generated
@@ -1035,12 +1035,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Roave/SecurityAdvisories.git",
|
||||
"reference": "c04f8230182e06cd6b2ba948c85581a1b93887f2"
|
||||
"reference": "ebc5572f219ad85f60f20fcff71b98b5055c4f8e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/c04f8230182e06cd6b2ba948c85581a1b93887f2",
|
||||
"reference": "c04f8230182e06cd6b2ba948c85581a1b93887f2",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ebc5572f219ad85f60f20fcff71b98b5055c4f8e",
|
||||
"reference": "ebc5572f219ad85f60f20fcff71b98b5055c4f8e",
|
||||
"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",
|
||||
@@ -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",
|
||||
@@ -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-07T20:06:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
|
||||
85
gen/component/{{pascalCase name}}.test.ts
Normal file
85
gen/component/{{pascalCase name}}.test.ts
Normal 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')
|
||||
})
|
||||
@@ -40,7 +40,7 @@ class {{pascalCase name}}Mapper extends QBMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $projectId
|
||||
* @param string $id
|
||||
* @return array<{{pascalCase name}}>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,8 +17,10 @@ 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);
|
||||
$logger->info('Forum seeding: Starting data seed/repair');
|
||||
|
||||
@@ -26,25 +28,71 @@ class SeedHelper {
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
if ($output) {
|
||||
$output->info('Forum: Data seed/repair completed');
|
||||
foreach ($seedOperations as $name => $operation) {
|
||||
try {
|
||||
$operation();
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "$name: " . $e->getMessage();
|
||||
$logger->error("Forum seeding: $name failed", ['exception' => $e->getMessage()]);
|
||||
// Continue with other operations - don't let one failure block others
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public wrapper for ensureForumUsersTable for use in migrations
|
||||
*
|
||||
* @param \OCP\Migration\IOutput|null $output Optional output for console messages
|
||||
*/
|
||||
public static function ensureForumUsersTablePublic($output = null): void {
|
||||
self::ensureForumUsersTable($output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +164,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();
|
||||
@@ -129,6 +178,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,
|
||||
@@ -137,14 +187,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,
|
||||
@@ -152,16 +205,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,
|
||||
@@ -169,11 +224,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\")");
|
||||
}
|
||||
}
|
||||
@@ -224,23 +280,24 @@ class SeedHelper {
|
||||
$timestamp = time();
|
||||
|
||||
try {
|
||||
// Get existing role IDs to check what needs to be created
|
||||
// Get existing roles by role_type (not hardcoded IDs) to check what needs to be created
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
$qb->select('role_type')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter([
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
->where($qb->expr()->in('role_type', $qb->createNamedParameter([
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_ADMIN,
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_MODERATOR,
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT,
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_GUEST,
|
||||
], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR_ARRAY)));
|
||||
$result = $qb->executeQuery();
|
||||
$existingRoles = $result->fetchAll();
|
||||
$result->closeCursor();
|
||||
|
||||
$existingIds = array_map(fn ($role) => (int)$role['id'], $existingRoles);
|
||||
// Use array_unique to handle duplicates (shouldn't happen after cleanup migration, but be defensive)
|
||||
$existingTypes = array_unique(array_map(fn ($role) => $role['role_type'], $existingRoles));
|
||||
|
||||
if (count($existingIds) === 4) {
|
||||
if (count($existingTypes) === 4) {
|
||||
$logger->info('Forum seeding: Default roles already exist, skipping');
|
||||
if ($output) {
|
||||
$output->info(' ✓ Default roles already exist');
|
||||
@@ -255,9 +312,9 @@ class SeedHelper {
|
||||
$db->beginTransaction();
|
||||
$rolesCreated = 0;
|
||||
|
||||
// Define roles with their expected IDs and characteristics
|
||||
// Define roles by role_type (not hardcoded IDs)
|
||||
$rolesToCreate = [
|
||||
1 => [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_ADMIN => [
|
||||
'name' => $l->t('Admin'),
|
||||
'description' => $l->t('Administrator role with full permissions'),
|
||||
'can_access_admin_tools' => true,
|
||||
@@ -266,7 +323,7 @@ class SeedHelper {
|
||||
'is_system_role' => true,
|
||||
'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_ADMIN,
|
||||
],
|
||||
2 => [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_MODERATOR => [
|
||||
'name' => $l->t('Moderator'),
|
||||
'description' => $l->t('Moderator role with elevated permissions'),
|
||||
'can_access_admin_tools' => true,
|
||||
@@ -275,7 +332,7 @@ class SeedHelper {
|
||||
'is_system_role' => true,
|
||||
'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_MODERATOR,
|
||||
],
|
||||
3 => [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT => [
|
||||
'name' => $l->t('User'),
|
||||
'description' => $l->t('Default user role with basic permissions'),
|
||||
'can_access_admin_tools' => false,
|
||||
@@ -284,7 +341,7 @@ class SeedHelper {
|
||||
'is_system_role' => true,
|
||||
'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT,
|
||||
],
|
||||
4 => [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_GUEST => [
|
||||
'name' => $l->t('Guest'),
|
||||
'description' => $l->t('Guest role for unauthenticated users with read-only access'),
|
||||
'can_access_admin_tools' => false,
|
||||
@@ -295,11 +352,8 @@ class SeedHelper {
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($rolesToCreate as $roleId => $roleData) {
|
||||
if (!in_array($roleId, $existingIds)) {
|
||||
// Note: We cannot force auto-increment IDs in a portable way
|
||||
// This assumes roles are created in order during initial migration
|
||||
// If roles are missing, they will be created with next available IDs
|
||||
foreach ($rolesToCreate as $roleType => $roleData) {
|
||||
if (!in_array($roleType, $existingTypes)) {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_roles')
|
||||
->values([
|
||||
@@ -314,14 +368,15 @@ class SeedHelper {
|
||||
])
|
||||
->executeStatement();
|
||||
$rolesCreated++;
|
||||
$logger->warning("Forum seeding: Created role ID expected to be $roleId, but actual ID may differ due to database state");
|
||||
$logger->info("Forum seeding: Created role with type '$roleType'");
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
|
||||
// Validate that critical roles can be found by role_type after creation
|
||||
$roleMapper = \OC::$server->get(\OCA\Forum\Db\RoleMapper::class);
|
||||
// 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)
|
||||
$criticalRoles = [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_GUEST => 'Guest',
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT => 'Default User',
|
||||
@@ -329,10 +384,18 @@ class SeedHelper {
|
||||
];
|
||||
|
||||
foreach ($criticalRoles as $roleType => $roleName) {
|
||||
try {
|
||||
$role = $roleMapper->findByRoleType($roleType);
|
||||
$logger->info("Forum seeding: Validated $roleName role (ID {$role->getId()}, type: $roleType)");
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter($roleType, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)))
|
||||
->setMaxResults(1);
|
||||
$result = $qb->executeQuery();
|
||||
$role = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if ($role) {
|
||||
$logger->info("Forum seeding: Validated $roleName role (ID {$role['id']}, type: $roleType)");
|
||||
} else {
|
||||
$logger->error("Forum seeding: CRITICAL - $roleName role not found after creation. This will break functionality.");
|
||||
if ($output) {
|
||||
$output->warning(" ✗ CRITICAL: $roleName role not found - forum may not function correctly");
|
||||
@@ -694,19 +757,25 @@ class SeedHelper {
|
||||
throw new \RuntimeException('Cannot create category permissions: categories must be created first');
|
||||
}
|
||||
|
||||
// Check if roles exist
|
||||
// Find Moderator role by role_type (not hardcoded ID)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter([
|
||||
2,
|
||||
3,
|
||||
], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter(\OCA\Forum\Db\Role::ROLE_TYPE_MODERATOR, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)));
|
||||
$result = $qb->executeQuery();
|
||||
$roles = $result->fetchAll();
|
||||
$moderatorRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (count($roles) < 2) {
|
||||
// Find User (default) role by role_type (not hardcoded ID)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter(\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)));
|
||||
$result = $qb->executeQuery();
|
||||
$userRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$moderatorRole || !$userRole) {
|
||||
$logger->error('Forum seeding: Not all required roles exist, cannot create permissions');
|
||||
if ($output) {
|
||||
$output->warning(' ✗ Required roles do not exist, cannot create permissions');
|
||||
@@ -714,6 +783,9 @@ class SeedHelper {
|
||||
throw new \RuntimeException('Cannot create category permissions: roles must be created first');
|
||||
}
|
||||
|
||||
$moderatorRoleId = (int)$moderatorRole['id'];
|
||||
$userRoleId = (int)$userRole['id'];
|
||||
|
||||
if ($output) {
|
||||
$output->info(' → Creating category permissions...');
|
||||
}
|
||||
@@ -721,7 +793,7 @@ class SeedHelper {
|
||||
$db->beginTransaction();
|
||||
$permissionsCreated = 0;
|
||||
|
||||
// Role IDs: ROLE_MODERATOR, ROLE_USER (Admin has implicit permissions)
|
||||
// Create permissions for Moderator and User roles (Admin has implicit permissions)
|
||||
foreach ($categories as $category) {
|
||||
$categoryId = (int)$category['id'];
|
||||
|
||||
@@ -730,7 +802,7 @@ class SeedHelper {
|
||||
$qb->select('id')
|
||||
->from('forum_category_perms')
|
||||
->where($qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter(2, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter($moderatorRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$result = $qb->executeQuery();
|
||||
$exists = $result->fetch();
|
||||
$result->closeCursor();
|
||||
@@ -740,7 +812,7 @@ class SeedHelper {
|
||||
$qb->insert('forum_category_perms')
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter(2, \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),
|
||||
@@ -755,7 +827,7 @@ class SeedHelper {
|
||||
$qb->select('id')
|
||||
->from('forum_category_perms')
|
||||
->where($qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter(3, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$result = $qb->executeQuery();
|
||||
$exists = $result->fetch();
|
||||
$result->closeCursor();
|
||||
@@ -765,7 +837,7 @@ class SeedHelper {
|
||||
$qb->insert('forum_category_perms')
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter(3, \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),
|
||||
@@ -905,19 +977,25 @@ class SeedHelper {
|
||||
$timestamp = time();
|
||||
|
||||
try {
|
||||
// Check if roles exist before assigning
|
||||
// Find Admin role by role_type (not hardcoded ID)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter([
|
||||
1,
|
||||
3,
|
||||
], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter(\OCA\Forum\Db\Role::ROLE_TYPE_ADMIN, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)));
|
||||
$result = $qb->executeQuery();
|
||||
$roles = $result->fetchAll();
|
||||
$adminRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (count($roles) < 2) {
|
||||
// Find User (default) role by role_type (not hardcoded ID)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter(\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)));
|
||||
$result = $qb->executeQuery();
|
||||
$userRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$adminRole || !$userRole) {
|
||||
$logger->error('Forum seeding: Required roles do not exist, cannot assign user roles');
|
||||
if ($output) {
|
||||
$output->warning(' ✗ Required roles do not exist, cannot assign user roles');
|
||||
@@ -925,6 +1003,9 @@ class SeedHelper {
|
||||
throw new \RuntimeException('Cannot assign user roles: roles must be created first');
|
||||
}
|
||||
|
||||
$adminRoleId = (int)$adminRole['id'];
|
||||
$userRoleId = (int)$userRole['id'];
|
||||
|
||||
if ($output) {
|
||||
$output->info(' → Assigning roles to users...');
|
||||
}
|
||||
@@ -932,7 +1013,7 @@ class SeedHelper {
|
||||
// Assign roles to all users
|
||||
$usersProcessed = 0;
|
||||
$usersSkipped = 0;
|
||||
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $groupManager, $logger, $output, &$usersProcessed, &$usersSkipped) {
|
||||
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $groupManager, $logger, $output, &$usersProcessed, &$usersSkipped, $adminRoleId, $userRoleId) {
|
||||
try {
|
||||
$userId = $user->getUID();
|
||||
$isAdmin = $groupManager->isAdmin($userId);
|
||||
@@ -942,7 +1023,7 @@ class SeedHelper {
|
||||
$qb->select('id')
|
||||
->from('forum_user_roles')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter(3, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$result = $qb->executeQuery();
|
||||
$hasUserRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
@@ -953,7 +1034,7 @@ class SeedHelper {
|
||||
$qb->insert('forum_user_roles')
|
||||
->values([
|
||||
'user_id' => $qb->createNamedParameter($userId),
|
||||
'role_id' => $qb->createNamedParameter(3, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
@@ -965,7 +1046,7 @@ class SeedHelper {
|
||||
$qb->select('id')
|
||||
->from('forum_user_roles')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$result = $qb->executeQuery();
|
||||
$hasAdminRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
@@ -976,7 +1057,7 @@ class SeedHelper {
|
||||
$qb->insert('forum_user_roles')
|
||||
->values([
|
||||
'user_id' => $qb->createNamedParameter($userId),
|
||||
'role_id' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
|
||||
32
lib/Migration/Version13Date20251231000000.php
Normal file
32
lib/Migration/Version13Date20251231000000.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Version 13 Migration:
|
||||
* - Ensure forum_users table exists (fixes fresh installs where table was missing)
|
||||
*
|
||||
* Note: The seedAll() call was moved to Version14 migration to fix an issue where
|
||||
* the seeding used hardcoded role IDs that may not exist during upgrades.
|
||||
*/
|
||||
class Version13Date20251231000000 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// SeedHelper ensures forum_users table exists (table creation only, seeding moved to Version14)
|
||||
SeedHelper::ensureForumUsersTablePublic($output);
|
||||
}
|
||||
}
|
||||
31
lib/Migration/Version14Date20260101000000.php
Normal file
31
lib/Migration/Version14Date20260101000000.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Version 14 Migration:
|
||||
* - Originally ran seed to ensure all required data exists
|
||||
* - Seeding moved to Version15 which first cleans up duplicate roles
|
||||
*
|
||||
* This migration is now a no-op but kept for migration history.
|
||||
*/
|
||||
class Version14Date20260101000000 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// No-op: Seeding moved to Version15 which first cleans up duplicate roles
|
||||
}
|
||||
}
|
||||
177
lib/Migration/Version15Date20260103000000.php
Normal file
177
lib/Migration/Version15Date20260103000000.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Version 15 Migration:
|
||||
* - Clean up duplicate roles that may exist from partial installations
|
||||
* - Add unique constraint on role_type to prevent future duplicates
|
||||
* - Re-run seeding to ensure all required data exists
|
||||
*/
|
||||
class Version15Date20260103000000 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private IDBConnection $db,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up duplicate roles before schema changes
|
||||
*/
|
||||
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
$output->info('Forum: Checking for duplicate roles...');
|
||||
$this->cleanupDuplicateRoles($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return ISchemaWrapper|null
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
// Add unique index on role_type to prevent future duplicates
|
||||
if ($schema->hasTable('forum_roles')) {
|
||||
$table = $schema->getTable('forum_roles');
|
||||
|
||||
// Check if the unique index already exists
|
||||
$hasUniqueIndex = false;
|
||||
foreach ($table->getIndexes() as $index) {
|
||||
if ($index->getColumns() === ['role_type'] && $index->isUnique()) {
|
||||
$hasUniqueIndex = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasUniqueIndex) {
|
||||
$output->info('Forum: Adding unique constraint on role_type...');
|
||||
$table->addUniqueIndex(['role_type'], 'forum_roles_role_type_uniq');
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// Re-run seeding to ensure all required data exists
|
||||
// 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.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate roles, keeping only the first one of each type
|
||||
*/
|
||||
private function cleanupDuplicateRoles(IOutput $output): void {
|
||||
$roleTypes = ['admin', 'moderator', 'default', 'guest'];
|
||||
$duplicatesRemoved = 0;
|
||||
|
||||
foreach ($roleTypes as $roleType) {
|
||||
// Find all roles of this type, ordered by ID
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter($roleType, IQueryBuilder::PARAM_STR)))
|
||||
->orderBy('id', 'ASC');
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
$roles = $result->fetchAll();
|
||||
$result->closeCursor();
|
||||
|
||||
if (count($roles) <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep the first one, delete the rest
|
||||
$keepId = (int)$roles[0]['id'];
|
||||
$deleteIds = array_map(fn ($r) => (int)$r['id'], array_slice($roles, 1));
|
||||
|
||||
$this->logger->info('Forum migration: Found ' . count($deleteIds) . " duplicate '$roleType' roles, keeping ID $keepId");
|
||||
$output->info(' Found ' . count($deleteIds) . " duplicate '$roleType' roles, keeping ID $keepId");
|
||||
|
||||
// Update user_roles to point to the kept role before deleting duplicates
|
||||
foreach ($deleteIds as $deleteId) {
|
||||
// Update forum_user_roles: reassign users from duplicate role to kept role
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->update('forum_user_roles')
|
||||
->set('role_id', $qb->createNamedParameter($keepId, IQueryBuilder::PARAM_INT))
|
||||
->where($qb->expr()->eq('role_id', $qb->createNamedParameter($deleteId, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
try {
|
||||
$qb->executeStatement();
|
||||
} catch (\Exception $e) {
|
||||
// Might fail due to unique constraint if user already has the kept role - that's fine
|
||||
$this->logger->debug("Forum migration: Could not reassign user roles from $deleteId to $keepId: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Delete orphaned user_roles entries (users who already had the kept role)
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete('forum_user_roles')
|
||||
->where($qb->expr()->eq('role_id', $qb->createNamedParameter($deleteId, IQueryBuilder::PARAM_INT)));
|
||||
$qb->executeStatement();
|
||||
|
||||
// Update forum_category_perms: reassign permissions from duplicate role to kept role
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->update('forum_category_perms')
|
||||
->set('role_id', $qb->createNamedParameter($keepId, IQueryBuilder::PARAM_INT))
|
||||
->where($qb->expr()->eq('role_id', $qb->createNamedParameter($deleteId, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
try {
|
||||
$qb->executeStatement();
|
||||
} catch (\Exception $e) {
|
||||
// Might fail due to unique constraint - that's fine
|
||||
$this->logger->debug("Forum migration: Could not reassign category perms from $deleteId to $keepId: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Delete orphaned category_perms entries
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete('forum_category_perms')
|
||||
->where($qb->expr()->eq('role_id', $qb->createNamedParameter($deleteId, IQueryBuilder::PARAM_INT)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
|
||||
// Now delete the duplicate roles
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete('forum_roles')
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter($deleteIds, IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
$qb->executeStatement();
|
||||
|
||||
$duplicatesRemoved += count($deleteIds);
|
||||
}
|
||||
|
||||
if ($duplicatesRemoved > 0) {
|
||||
$output->info(" Removed $duplicatesRemoved duplicate roles");
|
||||
$this->logger->info("Forum migration: Removed $duplicatesRemoved duplicate roles");
|
||||
} else {
|
||||
$output->info(' No duplicate roles found');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -78,7 +78,6 @@ class Version7Date20251124120000 extends SimpleMigrationStep {
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
$this->migrateConfigValues($output);
|
||||
$this->updateExistingRoleFlags($output);
|
||||
// Note: SeedHelper::seedAll() is called in Version9 after table rename
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -7,24 +7,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Version 9 Migration:
|
||||
* - Rename forum_user_stats to forum_users (handled by SeedHelper)
|
||||
* - Run seed (creates initial data if not exists)
|
||||
* - Originally handled table rename and seeding
|
||||
* - Seeding moved to Version13 to fix fresh install issues
|
||||
* - This migration is now a no-op for backwards compatibility
|
||||
*/
|
||||
class Version9Date20251129000000 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// SeedHelper handles table rename (forum_user_stats -> forum_users) and seeding
|
||||
SeedHelper::seedAll($output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
17
package.json
17
package.json
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
1290
pnpm-lock.yaml
generated
1290
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ module.exports = () => {
|
||||
component: {
|
||||
templates: ['gen/component'],
|
||||
output: 'src/components',
|
||||
subDir: false,
|
||||
},
|
||||
view: {
|
||||
templates: ['gen/view'],
|
||||
|
||||
12
src/App.vue
12
src/App.vue
@@ -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>
|
||||
|
||||
145
src/components/AdminTable/AdminTable.test.ts
Normal file
145
src/components/AdminTable/AdminTable.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/AdminTable/index.ts
Normal file
2
src/components/AdminTable/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import AdminTable from './AdminTable.vue'
|
||||
export default AdminTable
|
||||
@@ -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'
|
||||
2
src/components/AppNavigation/index.ts
Normal file
2
src/components/AppNavigation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import AppNavigation from './AppNavigation.vue'
|
||||
export default AppNavigation
|
||||
61
src/components/AppToolbar/AppToolbar.test.ts
Normal file
61
src/components/AppToolbar/AppToolbar.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/AppToolbar/index.ts
Normal file
2
src/components/AppToolbar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import AppToolbar from './AppToolbar.vue'
|
||||
export default AppToolbar
|
||||
@@ -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'
|
||||
2
src/components/BBCodeEditor/index.ts
Normal file
2
src/components/BBCodeEditor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import BBCodeEditor from './BBCodeEditor.vue'
|
||||
export default BBCodeEditor
|
||||
419
src/components/BBCodeHelpDialog/BBCodeHelpDialog.test.ts
Normal file
419
src/components/BBCodeHelpDialog/BBCodeHelpDialog.test.ts
Normal 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]')
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/BBCodeHelpDialog/index.ts
Normal file
2
src/components/BBCodeHelpDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
|
||||
export default BBCodeHelpDialog
|
||||
447
src/components/BBCodeToolbar/BBCodeToolbar.test.ts
Normal file
447
src/components/BBCodeToolbar/BBCodeToolbar.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
2
src/components/BBCodeToolbar/index.ts
Normal file
2
src/components/BBCodeToolbar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import BBCodeToolbar from './BBCodeToolbar.vue'
|
||||
export default BBCodeToolbar
|
||||
97
src/components/CategoryCard/CategoryCard.test.ts
Normal file
97
src/components/CategoryCard/CategoryCard.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/CategoryCard/index.ts
Normal file
2
src/components/CategoryCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import CategoryCard from './CategoryCard.vue'
|
||||
export default CategoryCard
|
||||
521
src/components/HeaderEditDialog/HeaderEditDialog.test.ts
Normal file
521
src/components/HeaderEditDialog/HeaderEditDialog.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/HeaderEditDialog/index.ts
Normal file
2
src/components/HeaderEditDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import HeaderEditDialog from './HeaderEditDialog.vue'
|
||||
export default HeaderEditDialog
|
||||
2
src/components/LazyEmojiPicker/index.ts
Normal file
2
src/components/LazyEmojiPicker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import LazyEmojiPicker from './LazyEmojiPicker'
|
||||
export default LazyEmojiPicker
|
||||
536
src/components/MoveCategoryDialog/MoveCategoryDialog.test.ts
Normal file
536
src/components/MoveCategoryDialog/MoveCategoryDialog.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/MoveCategoryDialog/index.ts
Normal file
2
src/components/MoveCategoryDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import MoveCategoryDialog from './MoveCategoryDialog.vue'
|
||||
export default MoveCategoryDialog
|
||||
126
src/components/NotFoundPage/NotFoundPage.test.ts
Normal file
126
src/components/NotFoundPage/NotFoundPage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/NotFoundPage/index.ts
Normal file
2
src/components/NotFoundPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import NotFoundPage from './NotFoundPage.vue'
|
||||
export default NotFoundPage
|
||||
76
src/components/PageHeader/PageHeader.test.ts
Normal file
76
src/components/PageHeader/PageHeader.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
2
src/components/PageHeader/index.ts
Normal file
2
src/components/PageHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import PageHeader from './PageHeader.vue'
|
||||
export default PageHeader
|
||||
68
src/components/PageWrapper/PageWrapper.test.ts
Normal file
68
src/components/PageWrapper/PageWrapper.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/PageWrapper/index.ts
Normal file
2
src/components/PageWrapper/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import PageWrapper from './PageWrapper.vue'
|
||||
export default PageWrapper
|
||||
138
src/components/Pagination/Pagination.test.ts
Normal file
138
src/components/Pagination/Pagination.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/Pagination/index.ts
Normal file
2
src/components/Pagination/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import Pagination from './Pagination.vue'
|
||||
export default Pagination
|
||||
375
src/components/PostCard/PostCard.test.ts
Normal file
375
src/components/PostCard/PostCard.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
2
src/components/PostCard/index.ts
Normal file
2
src/components/PostCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import PostCard from './PostCard.vue'
|
||||
export default PostCard
|
||||
261
src/components/PostEditForm/PostEditForm.test.ts
Normal file
261
src/components/PostEditForm/PostEditForm.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
2
src/components/PostEditForm/index.ts
Normal file
2
src/components/PostEditForm/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import PostEditForm from './PostEditForm.vue'
|
||||
export default PostEditForm
|
||||
409
src/components/PostHistoryDialog/PostHistoryDialog.test.ts
Normal file
409
src/components/PostHistoryDialog/PostHistoryDialog.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
2
src/components/PostHistoryDialog/index.ts
Normal file
2
src/components/PostHistoryDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import PostHistoryDialog from './PostHistoryDialog.vue'
|
||||
export default PostHistoryDialog
|
||||
247
src/components/PostReactions/PostReactions.test.ts
Normal file
247
src/components/PostReactions/PostReactions.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/PostReactions/index.ts
Normal file
2
src/components/PostReactions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import PostReactions from './PostReactions.vue'
|
||||
export default PostReactions
|
||||
239
src/components/PostReplyForm/PostReplyForm.test.ts
Normal file
239
src/components/PostReplyForm/PostReplyForm.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
2
src/components/PostReplyForm/index.ts
Normal file
2
src/components/PostReplyForm/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import PostReplyForm from './PostReplyForm.vue'
|
||||
export default PostReplyForm
|
||||
157
src/components/RoleBadge/RoleBadge.test.ts
Normal file
157
src/components/RoleBadge/RoleBadge.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/RoleBadge/index.ts
Normal file
2
src/components/RoleBadge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import RoleBadge from './RoleBadge.vue'
|
||||
export default RoleBadge
|
||||
176
src/components/SearchPostResult/SearchPostResult.test.ts
Normal file
176
src/components/SearchPostResult/SearchPostResult.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock } from '@/test-utils'
|
||||
import { createMockPost, createMockUser } from '@/test-mocks'
|
||||
import SearchPostResult from './SearchPostResult.vue'
|
||||
|
||||
// Uses global mocks for @nextcloud/l10n, isDarkTheme, NcDateTime from test-setup.ts
|
||||
|
||||
vi.mock('@icons/Account.vue', () => createIconMock('AccountIcon'))
|
||||
vi.mock('@icons/Clock.vue', () => createIconMock('ClockIcon'))
|
||||
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
describe('SearchPostResult', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render thread title link', () => {
|
||||
const post = createMockPost({ threadTitle: 'Discussion Thread' })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: {
|
||||
'router-link': {
|
||||
template: '<a class="thread-link"><slot /></a>',
|
||||
props: ['to'],
|
||||
},
|
||||
},
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.thread-link').text()).toBe('Discussion Thread')
|
||||
})
|
||||
|
||||
it('should show thread unavailable when no slug', () => {
|
||||
const post = createMockPost({ threadSlug: undefined })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.thread-missing').text()).toBe('Thread unavailable')
|
||||
})
|
||||
|
||||
it('should render author name', () => {
|
||||
const post = createMockPost({
|
||||
author: createMockUser({ userId: 'john', displayName: 'John Doe' }),
|
||||
})
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.author').text()).toContain('John Doe')
|
||||
})
|
||||
|
||||
it('should show "Deleted user" when author is missing', () => {
|
||||
const post = createMockPost()
|
||||
post.author = undefined
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.author').text()).toContain('Deleted user')
|
||||
})
|
||||
|
||||
it('should show edited indicator when post is edited', () => {
|
||||
const post = createMockPost({ isEdited: true })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.edited').exists()).toBe(true)
|
||||
expect(wrapper.find('.pencil-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show edited indicator when post is not edited', () => {
|
||||
const post = createMockPost({ isEdited: false })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.edited').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('content processing', () => {
|
||||
it('should strip HTML from content', () => {
|
||||
const post = createMockPost({ content: '<p>Hello <strong>World</strong></p>' })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'xyz' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
const content = wrapper.find('.post-content').text()
|
||||
expect(content).not.toContain('<p>')
|
||||
expect(content).not.toContain('<strong>')
|
||||
})
|
||||
|
||||
it('should truncate long content', () => {
|
||||
const longContent = '<p>' + 'A'.repeat(500) + '</p>'
|
||||
const post = createMockPost({ content: longContent })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'xyz' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
const content = wrapper.find('.post-content').text()
|
||||
expect(content.length).toBeLessThan(300)
|
||||
expect(content).toContain('...')
|
||||
})
|
||||
|
||||
it('should highlight search terms', () => {
|
||||
const post = createMockPost({ content: '<p>This is a test post</p>' })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.post-content').html()).toContain('<mark>test</mark>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation', () => {
|
||||
it('should navigate to post when clicked', async () => {
|
||||
const post = createMockPost({ threadSlug: 'my-thread', id: 42 })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
await wrapper.find('.search-post-result').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/t/my-thread#post-42')
|
||||
})
|
||||
|
||||
it('should not navigate when thread slug is missing', async () => {
|
||||
const post = createMockPost({ threadSlug: undefined })
|
||||
const wrapper = mount(SearchPostResult, {
|
||||
props: { post, query: 'test' },
|
||||
global: {
|
||||
stubs: { 'router-link': true },
|
||||
mocks: { $router: { push: mockPush } },
|
||||
},
|
||||
})
|
||||
await wrapper.find('.search-post-result').trigger('click')
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/SearchPostResult/index.ts
Normal file
2
src/components/SearchPostResult/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import SearchPostResult from './SearchPostResult.vue'
|
||||
export default SearchPostResult
|
||||
112
src/components/SearchThreadResult/SearchThreadResult.test.ts
Normal file
112
src/components/SearchThreadResult/SearchThreadResult.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock } from '@/test-utils'
|
||||
import { createMockThread, createMockUser } from '@/test-mocks'
|
||||
import SearchThreadResult from './SearchThreadResult.vue'
|
||||
|
||||
// Uses global mocks for @nextcloud/l10n, isDarkTheme, NcDateTime from test-setup.ts
|
||||
|
||||
vi.mock('@icons/Folder.vue', () => createIconMock('FolderIcon'))
|
||||
vi.mock('@icons/Account.vue', () => createIconMock('AccountIcon'))
|
||||
vi.mock('@icons/Message.vue', () => createIconMock('MessageIcon'))
|
||||
vi.mock('@icons/Eye.vue', () => createIconMock('EyeIcon'))
|
||||
vi.mock('@icons/Clock.vue', () => createIconMock('ClockIcon'))
|
||||
vi.mock('@icons/Pin.vue', () => createIconMock('PinIcon'))
|
||||
vi.mock('@icons/Lock.vue', () => createIconMock('LockIcon'))
|
||||
|
||||
describe('SearchThreadResult', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render thread title', () => {
|
||||
const thread = createMockThread({ title: 'How to configure settings' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.thread-title').text()).toContain('How to configure settings')
|
||||
})
|
||||
|
||||
it('should render category name', () => {
|
||||
const thread = createMockThread({ categoryName: 'Technical Support' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.category').text()).toContain('Technical Support')
|
||||
})
|
||||
|
||||
it('should render author name', () => {
|
||||
const thread = createMockThread({
|
||||
author: createMockUser({ userId: 'john', displayName: 'John Doe' }),
|
||||
})
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.author').text()).toContain('John Doe')
|
||||
})
|
||||
|
||||
it('should show "Deleted user" when author is missing', () => {
|
||||
const thread = createMockThread()
|
||||
thread.author = undefined
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.author').text()).toContain('Deleted user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('badges', () => {
|
||||
it('should show pin icon when thread is pinned', () => {
|
||||
const thread = createMockThread({ isPinned: true })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.pin-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should show lock icon when thread is locked', () => {
|
||||
const thread = createMockThread({ isLocked: true })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.lock-icon').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('highlighting', () => {
|
||||
it('should highlight search terms in title', () => {
|
||||
const thread = createMockThread({ title: 'How to test your code' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
expect(wrapper.find('.thread-title').html()).toContain('<mark>test</mark>')
|
||||
})
|
||||
|
||||
it('should handle quoted phrases', () => {
|
||||
const thread = createMockThread({ title: 'Testing the exact phrase match' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: '"exact phrase"' },
|
||||
})
|
||||
expect(wrapper.find('.thread-title').html()).toContain('<mark>exact phrase</mark>')
|
||||
})
|
||||
|
||||
it('should exclude AND/OR operators from highlighting', () => {
|
||||
const thread = createMockThread({ title: 'Test AND production environments' })
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test AND production' },
|
||||
})
|
||||
const html = wrapper.find('.thread-title').html().toLowerCase()
|
||||
expect(html).toContain('<mark>test</mark>')
|
||||
expect(html).toContain('<mark>production</mark>')
|
||||
expect(html).not.toContain('<mark>and</mark>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('should emit click event when clicked', async () => {
|
||||
const thread = createMockThread()
|
||||
const wrapper = mount(SearchThreadResult, {
|
||||
props: { thread, query: 'test' },
|
||||
})
|
||||
await wrapper.find('.search-thread-result').trigger('click')
|
||||
expect(wrapper.emitted('click')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/SearchThreadResult/index.ts
Normal file
2
src/components/SearchThreadResult/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import SearchThreadResult from './SearchThreadResult.vue'
|
||||
export default SearchThreadResult
|
||||
72
src/components/Skeleton/Skeleton.test.ts
Normal file
72
src/components/Skeleton/Skeleton.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Skeleton from './Skeleton.vue'
|
||||
|
||||
describe('Skeleton', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render with default props', () => {
|
||||
const wrapper = mount(Skeleton)
|
||||
expect(wrapper.find('.skeleton').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should apply default width and height', () => {
|
||||
const wrapper = mount(Skeleton)
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('width: 100%')
|
||||
expect(style).toContain('height: 20px')
|
||||
})
|
||||
|
||||
it('should apply custom width and height', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { width: '200px', height: '40px' },
|
||||
})
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('width: 200px')
|
||||
expect(style).toContain('height: 40px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shapes', () => {
|
||||
it('should apply rounded-rect border radius by default', () => {
|
||||
const wrapper = mount(Skeleton)
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('border-radius: 4px')
|
||||
})
|
||||
|
||||
it('should apply circle border radius', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { shape: 'circle' },
|
||||
})
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('border-radius: 50%')
|
||||
})
|
||||
|
||||
it('should apply square border radius (0)', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { shape: 'square' },
|
||||
})
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('border-radius: 0')
|
||||
})
|
||||
|
||||
it('should apply custom radius for rounded-rect', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { shape: 'rounded-rect', radius: '8px' },
|
||||
})
|
||||
const style = wrapper.find('.skeleton').attributes('style')
|
||||
expect(style).toContain('border-radius: 8px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBorderRadius method', () => {
|
||||
it('should return correct radius for each shape', () => {
|
||||
const wrapper = mount(Skeleton, {
|
||||
props: { radius: '10px' },
|
||||
})
|
||||
const vm = wrapper.vm as unknown as { getBorderRadius: () => string; shape: string }
|
||||
|
||||
// Test via computed style since method is internal
|
||||
expect(wrapper.find('.skeleton').attributes('style')).toContain('border-radius: 10px')
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/Skeleton/index.ts
Normal file
2
src/components/Skeleton/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import Skeleton from './Skeleton.vue'
|
||||
export default Skeleton
|
||||
136
src/components/ThreadCard/ThreadCard.test.ts
Normal file
136
src/components/ThreadCard/ThreadCard.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
import { createMockThread } from '@/test-mocks'
|
||||
import ThreadCard from './ThreadCard.vue'
|
||||
|
||||
// Uses global mocks for @nextcloud/l10n, NcDateTime from test-setup.ts
|
||||
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template: '<div class="user-info-mock"><slot name="meta" /></div>',
|
||||
props: ['userId', 'displayName', 'isDeleted', 'avatarSize', 'roles', 'showRoles', 'layout'],
|
||||
}),
|
||||
)
|
||||
vi.mock('@icons/Pin.vue', () => createIconMock('PinIcon'))
|
||||
vi.mock('@icons/Lock.vue', () => createIconMock('LockIcon'))
|
||||
vi.mock('@icons/Comment.vue', () => createIconMock('CommentIcon'))
|
||||
vi.mock('@icons/Eye.vue', () => createIconMock('EyeIcon'))
|
||||
|
||||
describe('ThreadCard', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render thread title', () => {
|
||||
const thread = createMockThread({ title: 'My First Thread' })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-title').text()).toContain('My First Thread')
|
||||
})
|
||||
|
||||
it('should render post count', () => {
|
||||
const thread = createMockThread({ postCount: 25 })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-stats').text()).toContain('25')
|
||||
})
|
||||
|
||||
it('should render view count', () => {
|
||||
const thread = createMockThread({ viewCount: 500 })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-stats').text()).toContain('500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('badges', () => {
|
||||
it('should show pin icon when thread is pinned', () => {
|
||||
const thread = createMockThread({ isPinned: true })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.pin-icon').exists()).toBe(true)
|
||||
expect(wrapper.find('.badge-pinned').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show pin icon when thread is not pinned', () => {
|
||||
const thread = createMockThread({ isPinned: false })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.pin-icon').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show lock icon when thread is locked', () => {
|
||||
const thread = createMockThread({ isLocked: true })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.lock-icon').exists()).toBe(true)
|
||||
expect(wrapper.find('.badge-locked').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show lock icon when thread is not locked', () => {
|
||||
const thread = createMockThread({ isLocked: false })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.lock-icon').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS classes', () => {
|
||||
it('should have pinned class when thread is pinned', () => {
|
||||
const thread = createMockThread({ isPinned: true })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-card').classes()).toContain('pinned')
|
||||
})
|
||||
|
||||
it('should have locked class when thread is locked', () => {
|
||||
const thread = createMockThread({ isLocked: true })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.thread-card').classes()).toContain('locked')
|
||||
})
|
||||
|
||||
it('should have unread class when isUnread prop is true', () => {
|
||||
const thread = createMockThread()
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread, isUnread: true },
|
||||
})
|
||||
expect(wrapper.find('.thread-card').classes()).toContain('unread')
|
||||
})
|
||||
|
||||
it('should show unread indicator when isUnread is true', () => {
|
||||
const thread = createMockThread()
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread, isUnread: true },
|
||||
})
|
||||
expect(wrapper.find('.unread-indicator').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show unread indicator by default', () => {
|
||||
const thread = createMockThread()
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
expect(wrapper.find('.unread-indicator').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stats handling', () => {
|
||||
it('should handle zero counts', () => {
|
||||
const thread = createMockThread({ postCount: 0, viewCount: 0 })
|
||||
const wrapper = mount(ThreadCard, {
|
||||
props: { thread },
|
||||
})
|
||||
const statValues = wrapper.findAll('.stat-value')
|
||||
expect(statValues[0]!.text()).toBe('0')
|
||||
expect(statValues[1]!.text()).toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -58,7 +58,7 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import UserInfo from '@/components/UserInfo.vue'
|
||||
import UserInfo from '@/components/UserInfo'
|
||||
import PinIcon from '@icons/Pin.vue'
|
||||
import LockIcon from '@icons/Lock.vue'
|
||||
import CommentIcon from '@icons/Comment.vue'
|
||||
2
src/components/ThreadCard/index.ts
Normal file
2
src/components/ThreadCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import ThreadCard from './ThreadCard.vue'
|
||||
export default ThreadCard
|
||||
512
src/components/ThreadCreateForm/ThreadCreateForm.test.ts
Normal file
512
src/components/ThreadCreateForm/ThreadCreateForm.test.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref, computed } from 'vue'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
|
||||
// Mock useCurrentUser composable
|
||||
vi.mock('@/composables/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userId: computed(() => 'testuser'),
|
||||
displayName: computed(() => 'Test User'),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
vi.mock('@icons/ContentSave.vue', () => createIconMock('ContentSaveIcon'))
|
||||
vi.mock('@icons/ContentSaveCheck.vue', () => createIconMock('ContentSaveCheckIcon'))
|
||||
vi.mock('@icons/ContentSaveAlert.vue', () => createIconMock('ContentSaveAlertIcon'))
|
||||
|
||||
// Mock UserInfo component
|
||||
vi.mock('@/components/UserInfo', () =>
|
||||
createComponentMock('UserInfo', {
|
||||
template: '<div class="user-info-mock">{{ displayName }}</div>',
|
||||
props: ['userId', 'displayName', 'avatarSize', 'clickable'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock BBCodeEditor component
|
||||
vi.mock('@/components/BBCodeEditor', () => ({
|
||||
default: {
|
||||
name: 'BBCodeEditor',
|
||||
template:
|
||||
'<textarea class="bbcode-editor-mock" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" :disabled="disabled" />',
|
||||
props: ['modelValue', 'placeholder', 'rows', 'disabled', 'minHeight'],
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
focus() {},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import ThreadCreateForm from './ThreadCreateForm.vue'
|
||||
|
||||
describe('ThreadCreateForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
)
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(ThreadCreateForm, {
|
||||
props: {
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the form', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.thread-create-form').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders user info header', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.user-info-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders title input', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-text-field').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders BBCode editor', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.bbcode-editor-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders cancel button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((b) => b.text() === 'Cancel')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders submit button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((b) => b.text() === 'Create thread')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('draft status', () => {
|
||||
it('shows saving status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'saving' })
|
||||
expect(wrapper.text()).toContain('Saving draft')
|
||||
})
|
||||
|
||||
it('shows saved status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'saved' })
|
||||
expect(wrapper.text()).toContain('Draft saved')
|
||||
})
|
||||
|
||||
it('shows dirty status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'dirty' })
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
})
|
||||
|
||||
it('hides status when null', () => {
|
||||
const wrapper = createWrapper({ draftStatus: null })
|
||||
expect(wrapper.text()).not.toContain('Saving draft')
|
||||
expect(wrapper.text()).not.toContain('Draft saved')
|
||||
expect(wrapper.text()).not.toContain('Unsaved changes')
|
||||
})
|
||||
|
||||
it('displays saving icon for saving status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'saving' })
|
||||
expect(wrapper.find('.content-save-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays saved icon for saved status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'saved' })
|
||||
expect(wrapper.find('.content-save-check-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays dirty icon for dirty status', () => {
|
||||
const wrapper = createWrapper({ draftStatus: 'dirty' })
|
||||
expect(wrapper.find('.content-save-alert-icon').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation', () => {
|
||||
it('disables submit when title is empty', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { canSubmit: boolean }
|
||||
expect(vm.canSubmit).toBe(false)
|
||||
})
|
||||
|
||||
it('disables submit when content is empty', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; canSubmit: boolean }
|
||||
vm.title = 'Test Title'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.canSubmit).toBe(false)
|
||||
})
|
||||
|
||||
it('enables submit when both title and content have values', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; canSubmit: boolean }
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.canSubmit).toBe(true)
|
||||
})
|
||||
|
||||
it('disables submit when title is only whitespace', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; canSubmit: boolean }
|
||||
vm.title = ' '
|
||||
vm.content = 'Test Content'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.canSubmit).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitting', () => {
|
||||
it('emits submit event with trimmed data', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitThread: () => Promise<void>
|
||||
}
|
||||
vm.title = ' Test Title '
|
||||
vm.content = ' Test Content '
|
||||
|
||||
await vm.submitThread()
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')![0]).toEqual([
|
||||
{ title: 'Test Title', content: 'Test Content' },
|
||||
])
|
||||
})
|
||||
|
||||
it('sets submitting state to true', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
submitThread: () => Promise<void>
|
||||
}
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
await vm.submitThread()
|
||||
|
||||
expect(vm.submitting).toBe(true)
|
||||
})
|
||||
|
||||
it('does not submit when already submitting', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
submitThread: () => Promise<void>
|
||||
}
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
vm.submitting = true
|
||||
|
||||
await vm.submitThread()
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not submit when validation fails', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitThread: () => Promise<void>
|
||||
}
|
||||
vm.title = ''
|
||||
vm.content = ''
|
||||
|
||||
await vm.submitThread()
|
||||
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('canceling', () => {
|
||||
it('emits cancel event when cancel is clicked without content', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { cancel: () => void }
|
||||
vm.cancel()
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows confirmation when content exists', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; cancel: () => void }
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
vm.cancel()
|
||||
|
||||
expect(window.confirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits cancel event when confirmed', async () => {
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; cancel: () => void }
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
vm.cancel()
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not emit cancel event when not confirmed', async () => {
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => false),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; cancel: () => void }
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
vm.cancel()
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('clears title and content on cancel', async () => {
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
cancel: () => void
|
||||
}
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
|
||||
vm.cancel()
|
||||
|
||||
expect(vm.title).toBe('')
|
||||
expect(vm.content).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('update events', () => {
|
||||
it('emits update:title when title changes', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string }
|
||||
vm.title = 'New Title'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:title')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:title')![0]).toEqual(['New Title'])
|
||||
})
|
||||
|
||||
it('emits update:content when content changes', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { content: string }
|
||||
vm.content = 'New Content'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:content')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:content')![0]).toEqual(['New Content'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear method', () => {
|
||||
it('resets title, content, and submitting state', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
clear: () => void
|
||||
}
|
||||
vm.title = 'Test Title'
|
||||
vm.content = 'Test Content'
|
||||
vm.submitting = true
|
||||
|
||||
vm.clear()
|
||||
|
||||
expect(vm.title).toBe('')
|
||||
expect(vm.content).toBe('')
|
||||
expect(vm.submitting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSubmitting method', () => {
|
||||
it('sets submitting state', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
submitting: boolean
|
||||
setSubmitting: (value: boolean) => void
|
||||
}
|
||||
|
||||
vm.setSubmitting(true)
|
||||
expect(vm.submitting).toBe(true)
|
||||
|
||||
vm.setSubmitting(false)
|
||||
expect(vm.submitting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setTitle method', () => {
|
||||
it('sets title value', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
setTitle: (value: string) => void
|
||||
}
|
||||
|
||||
vm.setTitle('New Title')
|
||||
expect(vm.title).toBe('New Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setContent method', () => {
|
||||
it('sets content value', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
content: string
|
||||
setContent: (value: string) => void
|
||||
}
|
||||
|
||||
vm.setContent('New Content')
|
||||
expect(vm.content).toBe('New Content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasContent computed', () => {
|
||||
it('returns false when both title and content are empty', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { hasContent: boolean }
|
||||
expect(vm.hasContent).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when title has content', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; hasContent: boolean }
|
||||
vm.title = 'Test'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.hasContent).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when content has content', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { content: string; hasContent: boolean }
|
||||
vm.content = 'Test'
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.hasContent).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when title and content are only whitespace', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { title: string; content: string; hasContent: boolean }
|
||||
vm.title = ' '
|
||||
vm.content = ' '
|
||||
|
||||
expect(vm.hasContent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('disables inputs when submitting', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
}
|
||||
vm.title = 'Test'
|
||||
vm.content = 'Test'
|
||||
vm.submitting = true
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const titleInput = wrapper.find('.nc-text-field')
|
||||
expect(titleInput.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables cancel button when submitting', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as { submitting: boolean }
|
||||
vm.submitting = true
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
|
||||
expect(cancelButton!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('disables submit button when submitting', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
title: string
|
||||
content: string
|
||||
submitting: boolean
|
||||
}
|
||||
vm.title = 'Test'
|
||||
vm.content = 'Test'
|
||||
vm.submitting = true
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const submitButton = wrapper.findAll('button').find((b) => b.text() === 'Create thread')
|
||||
expect(submitButton!.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user