mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f904a7e48 | |||
| 886c51fdca | |||
| 919a13fdd3 | |||
| 370eed1286 | |||
| 1ff6349337 | |||
| 7732f22f4e | |||
| a07c8e452f | |||
| 57642efc7b | |||
| 18a2918446 | |||
| 3e7cebc8c3 | |||
| eb1b2f86df | |||
| c72c8b3eed | |||
| ec49855173 | |||
| cdca135f7d | |||
| 145e6d8f81 | |||
| 01639c7545 | |||
| 8848ba0304 | |||
| 64a618f54a | |||
| e4281e2128 | |||
| b84d96488c | |||
| b6e40f9976 | |||
| 9e0bdecc80 | |||
| 8277ccb87f | |||
| 55f1dbd258 | |||
|
|
81e35c114b | ||
|
|
d28b8c0f88 | ||
|
|
4d7efe9d32 | ||
|
|
7ae4d8f369 | ||
|
|
1dad565072 |
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.1"}
|
||||
{".":"0.19.7"}
|
||||
|
||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* db seeds ([55f1dbd](https://github.com/chenasraf/nextcloud-forum/commit/55f1dbd25867488f7a3cf93726fb444976341e5d))
|
||||
* **l10n:** Update translations from Transifex ([81e35c1](https://github.com/chenasraf/nextcloud-forum/commit/81e35c114b84d77bee5f471c6d1f27154a7730d8))
|
||||
* **l10n:** Update translations from Transifex ([d28b8c0](https://github.com/chenasraf/nextcloud-forum/commit/d28b8c0f88255fad38bff24dd6747ce420f08919))
|
||||
* **l10n:** Update translations from Transifex ([4d7efe9](https://github.com/chenasraf/nextcloud-forum/commit/4d7efe9d32084e8accd857e68bd4434d415ff784))
|
||||
* **l10n:** Update translations from Transifex ([7ae4d8f](https://github.com/chenasraf/nextcloud-forum/commit/7ae4d8f369c87fb5a65a7dbc60980e4760fb4f7b))
|
||||
* **l10n:** Update translations from Transifex ([1dad565](https://github.com/chenasraf/nextcloud-forum/commit/1dad565072ced353a47e2f7ece865a81757ff81a))
|
||||
|
||||
## [0.19.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.0...v0.19.1) (2025-12-22)
|
||||
|
||||
|
||||
|
||||
@@ -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.1</version>
|
||||
<version>0.19.7</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": "a08c38341cd11dc1b1cb4fa87f65913c95908d73"
|
||||
"reference": "5ba14c800ff89c74333c22d56ca1c1f35c424805"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a08c38341cd11dc1b1cb4fa87f65913c95908d73",
|
||||
"reference": "a08c38341cd11dc1b1cb4fa87f65913c95908d73",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/5ba14c800ff89c74333c22d56ca1c1f35c424805",
|
||||
"reference": "5ba14c800ff89c74333c22d56ca1c1f35c424805",
|
||||
"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",
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"bvbmedia/multishop": "<2.0.39",
|
||||
"bytefury/crater": "<6.0.2",
|
||||
"cachethq/cachet": "<2.5.1",
|
||||
"cadmium-org/cadmium-cms": "<=0.4.9",
|
||||
"cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10",
|
||||
"cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10",
|
||||
"cardgate/magento2": "<2.0.33",
|
||||
@@ -1162,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",
|
||||
@@ -1176,7 +1177,7 @@
|
||||
"cosenary/instagram": "<=2.3",
|
||||
"couleurcitron/tarteaucitron-wp": "<0.3",
|
||||
"craftcms/cms": "<=4.16.5|>=5,<=5.8.6",
|
||||
"croogo/croogo": "<4",
|
||||
"croogo/croogo": "<=4.0.7",
|
||||
"cuyz/valinor": "<0.12",
|
||||
"czim/file-handling": "<1.5|>=2,<2.3",
|
||||
"czproject/git-php": "<4.0.3",
|
||||
@@ -1287,7 +1288,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",
|
||||
@@ -1459,7 +1460,7 @@
|
||||
"leantime/leantime": "<3.3",
|
||||
"lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3",
|
||||
"libreform/libreform": ">=2,<=2.0.8",
|
||||
"librenms/librenms": "<25.11",
|
||||
"librenms/librenms": "<25.12",
|
||||
"liftkit/database": "<2.13.2",
|
||||
"lightsaml/lightsaml": "<1.3.5",
|
||||
"limesurvey/limesurvey": "<6.5.12",
|
||||
@@ -1594,6 +1595,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",
|
||||
@@ -1661,7 +1663,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",
|
||||
@@ -1838,7 +1840,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",
|
||||
@@ -1954,7 +1956,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",
|
||||
@@ -2032,7 +2034,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-19T16:40:43+00:00"
|
||||
"time": "2026-01-02T22:05:49+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 {
|
||||
|
||||
43
l10n/cs.js
43
l10n/cs.js
@@ -38,6 +38,7 @@ OC.L10N.register(
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Neváhejte zahájit novou diskuzi nebo odpovězte na existující vlákna. Vesele pište příspěvky!.",
|
||||
"Forum" : "Diskuzní fórum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nová odpověď v {thread} ","{count} nové odpovědi v {thread} ","{count} nových odpovědí v {thread} ","{count} nové odpovědi v {thread} "],
|
||||
"{user} mentioned you in {thread}" : "{user} vás zmínil(a) v {thread}",
|
||||
"Welcome to the forum!" : "Vítejte ve fóru!",
|
||||
"Deleted user" : "Smazaný uživatel",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "Komunitou řízené fórum, vestavěné přímo do vámi využívané instance Nextcloud",
|
||||
@@ -121,25 +122,36 @@ OC.L10N.register(
|
||||
"The page you are looking for could not be found." : "Stránka kterou hledáte nebylo možné nalézt.",
|
||||
"Back" : "Zpět",
|
||||
"Go to home" : "Přejít na úvodní stránku",
|
||||
"Pagination" : "Stránkování",
|
||||
"First page" : "První stránka",
|
||||
"Previous page" : "Předchozí stránka",
|
||||
"Next page" : "Následující stránka",
|
||||
"Last page" : "Poslední stránka",
|
||||
"Go to page {page}" : "Přejít na stránku {page}",
|
||||
"Edited" : "Upraveno",
|
||||
"Quote reply" : "Odpovědět s citací",
|
||||
"Edit" : "Upravit",
|
||||
"Delete" : "Smazat",
|
||||
"View edit history" : "Zobrazit historii úprav",
|
||||
"Are you sure you want to delete this post? This action cannot be undone." : "Opravdu chcete tento příspěvek smazat? Tuto akci nepůjde vzít zpět.",
|
||||
"Unread" : "Nastavit jako nepřečtené",
|
||||
"Edit your reply …" : "Upravit vaši odpověď …",
|
||||
"Save" : "Uložit",
|
||||
"Are you sure you want to discard your changes?" : "Opravdu chcete vámi provedené změny zahodit?",
|
||||
"Edit history" : "Upravit historii",
|
||||
"Loading history …" : "Načítání historie …",
|
||||
"This post has no edit history." : "Tento příspěvek nemá žádnou historii úprav.",
|
||||
"Current version" : "Stávající verze",
|
||||
"Edited by" : "Upravil/a",
|
||||
"Failed to load edit history" : "Nepodařilo se načíst historii úprav",
|
||||
"Version {index}" : "Verze {index}",
|
||||
"Add reaction" : "Přidat reakci",
|
||||
"React with {emoji}" : "Zareagovat {emoji}",
|
||||
"You reacted with {emoji}" : "Zareagovali jste s použitím {emoji}",
|
||||
"_You and %n other reacted with {emoji}_::_You and %n others reacted with {emoji}_" : ["Vy a %n další jste zareagovali s použitím {emoji}","Vy a %n další jste zareagovali s použitím {emoji}","Vy a %n dalších jste zareagovali s použitím {emoji}","Vy a %n další jste zareagovali s použitím {emoji}"],
|
||||
"_%n person reacted with {emoji}_::_%n people reacted with {emoji}_" : ["%n osoba zareagovala s použitím {emoji}","%n lidé zareagovali s použitím {emoji}","%n lidí zareagovalo s použitím {emoji}","%n lidé zareagovali s použitím {emoji}"],
|
||||
"Write your reply …" : "Napište svou odpověď …",
|
||||
"Submit reply" : "Odeslat odpověď",
|
||||
"Are you sure you want to discard your reply?" : "Opravdu chcete svou odpověď zahodit?",
|
||||
"In thread" : "Ve vláknu",
|
||||
"Thread unavailable" : "Vlákno není k dispozici",
|
||||
@@ -151,6 +163,7 @@ OC.L10N.register(
|
||||
"Views" : "Zobrazení",
|
||||
"Title" : "Titul",
|
||||
"Enter thread title …" : "Zadejte titulek vlákna …",
|
||||
"Write your thread content …" : "Napište obsah vašeho vlákna …",
|
||||
"Create thread" : "Vytvořit vlákno",
|
||||
"Are you sure you want to discard this thread?" : "Opravdu chcete toto vlákno zahodit?",
|
||||
"Saving draft …" : "Ukládání konceptu…",
|
||||
@@ -158,8 +171,13 @@ OC.L10N.register(
|
||||
"Unsaved changes" : "Neuložené změny",
|
||||
"Back to home" : "Zpět na začátek",
|
||||
"Refresh" : "Znovu načíst",
|
||||
"Your bookmarked threads" : "Vaše záložky na vlákna",
|
||||
"Error loading bookmarks" : "Chyba při načítání záložek",
|
||||
"No bookmarks yet" : "Zatím ještě žádné záložky",
|
||||
"Bookmark threads to quickly find them later." : "Ukládejte si vlákna do záložek, abyste je později rychle našli.",
|
||||
"Retry" : "Zkusit znovu",
|
||||
"An unexpected error occurred" : "Došlo k neočekávané chybě",
|
||||
"Failed to load bookmarks" : "Nepodařilo se načíst záložky",
|
||||
"No categories yet" : "Zatím ještě žádné kategorie",
|
||||
"Categories will appear here once they are created." : "Kategorie se objeví, jakmile budou vytvořeny.",
|
||||
"No categories in this section" : "Žádné kategorie v této sekci",
|
||||
@@ -179,14 +197,17 @@ OC.L10N.register(
|
||||
"Failed to create thread" : "Vlákno se nepodařilo vytvořit",
|
||||
"No category specified" : "Neurčena žádná kategorie",
|
||||
"Error" : "Error",
|
||||
"First activity" : "První aktivita",
|
||||
"Threads ({count})" : "Vlákna ({count})",
|
||||
"Replies ({count})" : "Odpovědi ({count})",
|
||||
"No threads" : "Žádná vlákna",
|
||||
"This user has not created any threads yet" : "Tento uživatel zatím nevytvořil žádná vlákna",
|
||||
"No replies" : "Žádné odpovědi",
|
||||
"This user has not written any replies yet" : "Tento uživatel zatím nenapsal žádné odpovědi",
|
||||
"Failed to load user profile" : "Nepodařilo se načíst uživatelský profil",
|
||||
"Enter search query …" : "Zadejte vyhledávací dotaz …",
|
||||
"Search in threads" : "Hledat ve vláknech",
|
||||
"Search in replies" : "Hledat v odpovědích",
|
||||
"Syntax help" : "Nápověda k syntaxi",
|
||||
"Search syntax" : "Syntaxe vyhledávání",
|
||||
"Match exact phrase" : "Hledat shodu v přesné frázi",
|
||||
@@ -197,9 +218,11 @@ OC.L10N.register(
|
||||
"Searching …" : "Hledání …",
|
||||
"Search Error" : "Chyba hledání",
|
||||
"Enter a search query" : "Zadejte vyhledávací dotaz",
|
||||
"Use the search box above to find threads and replies" : "Ve vláknech a odpovědích je možné vyhledávat pomocí kolonky výše",
|
||||
"No results found" : "Nic nenalezeno",
|
||||
"Try different keywords or check your syntax" : "Zkuste jiná klíčová slova nebo zkontrolujte syntaxi",
|
||||
"_%n thread found_::_%n threads found_" : ["Nalezeno %n vlákno","Nalezena %n vlákna","Nalezeno %n vláken","Nalezena %n vlákna"],
|
||||
"_%n reply found_::_%n replies found_" : ["Nalezena %n odpověď","Nalezeny %n odpovědi","Nalezeno %n odpovědí","Nalezeny %n odpovědi"],
|
||||
"Please enter a search query" : "Zadejte vyhledávací dotaz",
|
||||
"Please select at least one search scope" : "Vyberte alespoň jednu oblast vyhledávání",
|
||||
"Failed to search" : "Nepodařilo se hledat",
|
||||
@@ -208,7 +231,10 @@ OC.L10N.register(
|
||||
"Back to {category}" : "Zpět na {category}",
|
||||
"Reply" : "Odpověď",
|
||||
"Error loading thread" : "Chyba při načítání vlákna",
|
||||
"No replies yet" : "Zatím žádné odpovědi",
|
||||
"Be the first to reply in this thread." : "Buďte první kdo odpoví v tomto vlákně.",
|
||||
"by" : "od",
|
||||
"This thread is locked. Only moderators can add replies." : "Toto vlákno je uzamčené. Odpovědi mohou přidávat pouze moderátoři.",
|
||||
"You must be signed in to reply to this thread." : "Pokud chcete v tomto vlákně odpovědět, je třeba, abyste byli přihlášení.",
|
||||
"Sign in to reply" : "Pokud chcete odpovědět, přihlaste se ke svému účtu",
|
||||
"Lock thread" : "Uzamknout vlákno",
|
||||
@@ -224,16 +250,27 @@ OC.L10N.register(
|
||||
"Subscribed to thread" : "Přihlášeno se k odběru vlákna",
|
||||
"Unsubscribed from thread" : "Zrušeno odebírání vlákna",
|
||||
"Bookmark" : "Záložka",
|
||||
"Remove bookmark" : "Odebrat záložku",
|
||||
"Thread bookmarked" : "Vlákno uloženo do záložek",
|
||||
"Bookmark removed" : "Záložka odebrána",
|
||||
"Edit title" : "Upravit nadpis",
|
||||
"Save title" : "Uložit nadpis",
|
||||
"Thread title updated" : "Nadpis vlákna zaktualizován",
|
||||
"Move thread" : "Přesunout vlákno",
|
||||
"Thread moved successfully" : "Vlákno úspěšně přesunuto",
|
||||
"No thread ID or slug provided" : "Nezadán žádný identifikátor vlákna nebo slug",
|
||||
"Failed to load replies" : "Nepodařilo se načíst odpovědi",
|
||||
"Thread updated" : "Vlákno zaktualizováno",
|
||||
"Reply updated" : "Odpověď zaktualizována",
|
||||
"Failed to update thread" : "Nepodařilo se zaktualizovat vlákno",
|
||||
"Failed to update reply" : "Nepodařilo se zaktualizovat odpověď",
|
||||
"Thread deleted" : "Vlákno smazáno",
|
||||
"Reply deleted" : "Odpověď smazána",
|
||||
"Failed to delete reply" : "Nepodařilo se smazat odpověď",
|
||||
"Failed to update thread lock status" : "Nepodařilo se zaktualizovat stav zámku vlákna",
|
||||
"Failed to update thread pin status" : "Nepodařilo se zaktualizovat stav připnutí vlákna",
|
||||
"Failed to update subscription" : "Nepodařilo se zaktualizovat přihlášení se k odběru",
|
||||
"Failed to update bookmark" : "Nepodařilo se zaktualizovat záložku",
|
||||
"Failed to update thread title" : "Nepodařilo se zaktualizovat nadpis vlákna",
|
||||
"Failed to move thread" : "Nepodařilo se přesunout vlákno",
|
||||
"Preferences" : "Předvolby",
|
||||
@@ -247,12 +284,17 @@ OC.L10N.register(
|
||||
"Files" : "Soubory",
|
||||
"Configure file upload settings" : "Nastavit nahrávání souborů",
|
||||
"Upload directory" : "Složka pro nahrání",
|
||||
"Files attached to threads or replies will be uploaded to this directory in your Nextcloud files" : "Soubory připojené k vláknům nebo odpovědím budou nahrány do této složky v Nextcloud Soubory",
|
||||
"Browse" : "Procházet",
|
||||
"Preferences saved" : "Předvolby uloženy",
|
||||
"Signature" : "Podpis",
|
||||
"Your signature appears at the bottom of your threads or replies" : "Váš podpis se objevuje ve spodní části vašich vláken nebo odpovědí",
|
||||
"You can use BBCode formatting in your signature" : "Svůj podpis můžete formátovat pomocí BBCode",
|
||||
"Enter your signature …" : "Zadejte svůj podpis …",
|
||||
"Failed to save preferences" : "Nepodařilo se uložit vaše předvolby",
|
||||
"Select upload directory" : "Vyberte složku pro nahrávání",
|
||||
"BBCode management" : "Správa BBCode",
|
||||
"Manage custom BBCode tags for formatting" : "Spravovat uživatelsky určené BBCode značky pro formátování",
|
||||
"Error loading BBCodes" : "Chyba při načítání BBCode kódů",
|
||||
"Create BBCode" : "Vytvořit BBCode",
|
||||
"Enable" : "Povolit",
|
||||
@@ -334,6 +376,7 @@ OC.L10N.register(
|
||||
"Recent activity (last 7 days)" : "Nedávná aktivita (uplynulých 7 dnů)",
|
||||
"New users" : "Nový uživatelé",
|
||||
"New threads" : "Nová vlákna",
|
||||
"New replies" : "Nové odpovědi",
|
||||
"Top contributors" : "Nejaktivnější přispěvatelé",
|
||||
"No contributors yet" : "Zatím žádní přispěvatelé",
|
||||
"Last 7 days" : "Uplynulých 7 dnů",
|
||||
|
||||
43
l10n/cs.json
43
l10n/cs.json
@@ -36,6 +36,7 @@
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Neváhejte zahájit novou diskuzi nebo odpovězte na existující vlákna. Vesele pište příspěvky!.",
|
||||
"Forum" : "Diskuzní fórum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nová odpověď v {thread} ","{count} nové odpovědi v {thread} ","{count} nových odpovědí v {thread} ","{count} nové odpovědi v {thread} "],
|
||||
"{user} mentioned you in {thread}" : "{user} vás zmínil(a) v {thread}",
|
||||
"Welcome to the forum!" : "Vítejte ve fóru!",
|
||||
"Deleted user" : "Smazaný uživatel",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "Komunitou řízené fórum, vestavěné přímo do vámi využívané instance Nextcloud",
|
||||
@@ -119,25 +120,36 @@
|
||||
"The page you are looking for could not be found." : "Stránka kterou hledáte nebylo možné nalézt.",
|
||||
"Back" : "Zpět",
|
||||
"Go to home" : "Přejít na úvodní stránku",
|
||||
"Pagination" : "Stránkování",
|
||||
"First page" : "První stránka",
|
||||
"Previous page" : "Předchozí stránka",
|
||||
"Next page" : "Následující stránka",
|
||||
"Last page" : "Poslední stránka",
|
||||
"Go to page {page}" : "Přejít na stránku {page}",
|
||||
"Edited" : "Upraveno",
|
||||
"Quote reply" : "Odpovědět s citací",
|
||||
"Edit" : "Upravit",
|
||||
"Delete" : "Smazat",
|
||||
"View edit history" : "Zobrazit historii úprav",
|
||||
"Are you sure you want to delete this post? This action cannot be undone." : "Opravdu chcete tento příspěvek smazat? Tuto akci nepůjde vzít zpět.",
|
||||
"Unread" : "Nastavit jako nepřečtené",
|
||||
"Edit your reply …" : "Upravit vaši odpověď …",
|
||||
"Save" : "Uložit",
|
||||
"Are you sure you want to discard your changes?" : "Opravdu chcete vámi provedené změny zahodit?",
|
||||
"Edit history" : "Upravit historii",
|
||||
"Loading history …" : "Načítání historie …",
|
||||
"This post has no edit history." : "Tento příspěvek nemá žádnou historii úprav.",
|
||||
"Current version" : "Stávající verze",
|
||||
"Edited by" : "Upravil/a",
|
||||
"Failed to load edit history" : "Nepodařilo se načíst historii úprav",
|
||||
"Version {index}" : "Verze {index}",
|
||||
"Add reaction" : "Přidat reakci",
|
||||
"React with {emoji}" : "Zareagovat {emoji}",
|
||||
"You reacted with {emoji}" : "Zareagovali jste s použitím {emoji}",
|
||||
"_You and %n other reacted with {emoji}_::_You and %n others reacted with {emoji}_" : ["Vy a %n další jste zareagovali s použitím {emoji}","Vy a %n další jste zareagovali s použitím {emoji}","Vy a %n dalších jste zareagovali s použitím {emoji}","Vy a %n další jste zareagovali s použitím {emoji}"],
|
||||
"_%n person reacted with {emoji}_::_%n people reacted with {emoji}_" : ["%n osoba zareagovala s použitím {emoji}","%n lidé zareagovali s použitím {emoji}","%n lidí zareagovalo s použitím {emoji}","%n lidé zareagovali s použitím {emoji}"],
|
||||
"Write your reply …" : "Napište svou odpověď …",
|
||||
"Submit reply" : "Odeslat odpověď",
|
||||
"Are you sure you want to discard your reply?" : "Opravdu chcete svou odpověď zahodit?",
|
||||
"In thread" : "Ve vláknu",
|
||||
"Thread unavailable" : "Vlákno není k dispozici",
|
||||
@@ -149,6 +161,7 @@
|
||||
"Views" : "Zobrazení",
|
||||
"Title" : "Titul",
|
||||
"Enter thread title …" : "Zadejte titulek vlákna …",
|
||||
"Write your thread content …" : "Napište obsah vašeho vlákna …",
|
||||
"Create thread" : "Vytvořit vlákno",
|
||||
"Are you sure you want to discard this thread?" : "Opravdu chcete toto vlákno zahodit?",
|
||||
"Saving draft …" : "Ukládání konceptu…",
|
||||
@@ -156,8 +169,13 @@
|
||||
"Unsaved changes" : "Neuložené změny",
|
||||
"Back to home" : "Zpět na začátek",
|
||||
"Refresh" : "Znovu načíst",
|
||||
"Your bookmarked threads" : "Vaše záložky na vlákna",
|
||||
"Error loading bookmarks" : "Chyba při načítání záložek",
|
||||
"No bookmarks yet" : "Zatím ještě žádné záložky",
|
||||
"Bookmark threads to quickly find them later." : "Ukládejte si vlákna do záložek, abyste je později rychle našli.",
|
||||
"Retry" : "Zkusit znovu",
|
||||
"An unexpected error occurred" : "Došlo k neočekávané chybě",
|
||||
"Failed to load bookmarks" : "Nepodařilo se načíst záložky",
|
||||
"No categories yet" : "Zatím ještě žádné kategorie",
|
||||
"Categories will appear here once they are created." : "Kategorie se objeví, jakmile budou vytvořeny.",
|
||||
"No categories in this section" : "Žádné kategorie v této sekci",
|
||||
@@ -177,14 +195,17 @@
|
||||
"Failed to create thread" : "Vlákno se nepodařilo vytvořit",
|
||||
"No category specified" : "Neurčena žádná kategorie",
|
||||
"Error" : "Error",
|
||||
"First activity" : "První aktivita",
|
||||
"Threads ({count})" : "Vlákna ({count})",
|
||||
"Replies ({count})" : "Odpovědi ({count})",
|
||||
"No threads" : "Žádná vlákna",
|
||||
"This user has not created any threads yet" : "Tento uživatel zatím nevytvořil žádná vlákna",
|
||||
"No replies" : "Žádné odpovědi",
|
||||
"This user has not written any replies yet" : "Tento uživatel zatím nenapsal žádné odpovědi",
|
||||
"Failed to load user profile" : "Nepodařilo se načíst uživatelský profil",
|
||||
"Enter search query …" : "Zadejte vyhledávací dotaz …",
|
||||
"Search in threads" : "Hledat ve vláknech",
|
||||
"Search in replies" : "Hledat v odpovědích",
|
||||
"Syntax help" : "Nápověda k syntaxi",
|
||||
"Search syntax" : "Syntaxe vyhledávání",
|
||||
"Match exact phrase" : "Hledat shodu v přesné frázi",
|
||||
@@ -195,9 +216,11 @@
|
||||
"Searching …" : "Hledání …",
|
||||
"Search Error" : "Chyba hledání",
|
||||
"Enter a search query" : "Zadejte vyhledávací dotaz",
|
||||
"Use the search box above to find threads and replies" : "Ve vláknech a odpovědích je možné vyhledávat pomocí kolonky výše",
|
||||
"No results found" : "Nic nenalezeno",
|
||||
"Try different keywords or check your syntax" : "Zkuste jiná klíčová slova nebo zkontrolujte syntaxi",
|
||||
"_%n thread found_::_%n threads found_" : ["Nalezeno %n vlákno","Nalezena %n vlákna","Nalezeno %n vláken","Nalezena %n vlákna"],
|
||||
"_%n reply found_::_%n replies found_" : ["Nalezena %n odpověď","Nalezeny %n odpovědi","Nalezeno %n odpovědí","Nalezeny %n odpovědi"],
|
||||
"Please enter a search query" : "Zadejte vyhledávací dotaz",
|
||||
"Please select at least one search scope" : "Vyberte alespoň jednu oblast vyhledávání",
|
||||
"Failed to search" : "Nepodařilo se hledat",
|
||||
@@ -206,7 +229,10 @@
|
||||
"Back to {category}" : "Zpět na {category}",
|
||||
"Reply" : "Odpověď",
|
||||
"Error loading thread" : "Chyba při načítání vlákna",
|
||||
"No replies yet" : "Zatím žádné odpovědi",
|
||||
"Be the first to reply in this thread." : "Buďte první kdo odpoví v tomto vlákně.",
|
||||
"by" : "od",
|
||||
"This thread is locked. Only moderators can add replies." : "Toto vlákno je uzamčené. Odpovědi mohou přidávat pouze moderátoři.",
|
||||
"You must be signed in to reply to this thread." : "Pokud chcete v tomto vlákně odpovědět, je třeba, abyste byli přihlášení.",
|
||||
"Sign in to reply" : "Pokud chcete odpovědět, přihlaste se ke svému účtu",
|
||||
"Lock thread" : "Uzamknout vlákno",
|
||||
@@ -222,16 +248,27 @@
|
||||
"Subscribed to thread" : "Přihlášeno se k odběru vlákna",
|
||||
"Unsubscribed from thread" : "Zrušeno odebírání vlákna",
|
||||
"Bookmark" : "Záložka",
|
||||
"Remove bookmark" : "Odebrat záložku",
|
||||
"Thread bookmarked" : "Vlákno uloženo do záložek",
|
||||
"Bookmark removed" : "Záložka odebrána",
|
||||
"Edit title" : "Upravit nadpis",
|
||||
"Save title" : "Uložit nadpis",
|
||||
"Thread title updated" : "Nadpis vlákna zaktualizován",
|
||||
"Move thread" : "Přesunout vlákno",
|
||||
"Thread moved successfully" : "Vlákno úspěšně přesunuto",
|
||||
"No thread ID or slug provided" : "Nezadán žádný identifikátor vlákna nebo slug",
|
||||
"Failed to load replies" : "Nepodařilo se načíst odpovědi",
|
||||
"Thread updated" : "Vlákno zaktualizováno",
|
||||
"Reply updated" : "Odpověď zaktualizována",
|
||||
"Failed to update thread" : "Nepodařilo se zaktualizovat vlákno",
|
||||
"Failed to update reply" : "Nepodařilo se zaktualizovat odpověď",
|
||||
"Thread deleted" : "Vlákno smazáno",
|
||||
"Reply deleted" : "Odpověď smazána",
|
||||
"Failed to delete reply" : "Nepodařilo se smazat odpověď",
|
||||
"Failed to update thread lock status" : "Nepodařilo se zaktualizovat stav zámku vlákna",
|
||||
"Failed to update thread pin status" : "Nepodařilo se zaktualizovat stav připnutí vlákna",
|
||||
"Failed to update subscription" : "Nepodařilo se zaktualizovat přihlášení se k odběru",
|
||||
"Failed to update bookmark" : "Nepodařilo se zaktualizovat záložku",
|
||||
"Failed to update thread title" : "Nepodařilo se zaktualizovat nadpis vlákna",
|
||||
"Failed to move thread" : "Nepodařilo se přesunout vlákno",
|
||||
"Preferences" : "Předvolby",
|
||||
@@ -245,12 +282,17 @@
|
||||
"Files" : "Soubory",
|
||||
"Configure file upload settings" : "Nastavit nahrávání souborů",
|
||||
"Upload directory" : "Složka pro nahrání",
|
||||
"Files attached to threads or replies will be uploaded to this directory in your Nextcloud files" : "Soubory připojené k vláknům nebo odpovědím budou nahrány do této složky v Nextcloud Soubory",
|
||||
"Browse" : "Procházet",
|
||||
"Preferences saved" : "Předvolby uloženy",
|
||||
"Signature" : "Podpis",
|
||||
"Your signature appears at the bottom of your threads or replies" : "Váš podpis se objevuje ve spodní části vašich vláken nebo odpovědí",
|
||||
"You can use BBCode formatting in your signature" : "Svůj podpis můžete formátovat pomocí BBCode",
|
||||
"Enter your signature …" : "Zadejte svůj podpis …",
|
||||
"Failed to save preferences" : "Nepodařilo se uložit vaše předvolby",
|
||||
"Select upload directory" : "Vyberte složku pro nahrávání",
|
||||
"BBCode management" : "Správa BBCode",
|
||||
"Manage custom BBCode tags for formatting" : "Spravovat uživatelsky určené BBCode značky pro formátování",
|
||||
"Error loading BBCodes" : "Chyba při načítání BBCode kódů",
|
||||
"Create BBCode" : "Vytvořit BBCode",
|
||||
"Enable" : "Povolit",
|
||||
@@ -332,6 +374,7 @@
|
||||
"Recent activity (last 7 days)" : "Nedávná aktivita (uplynulých 7 dnů)",
|
||||
"New users" : "Nový uživatelé",
|
||||
"New threads" : "Nová vlákna",
|
||||
"New replies" : "Nové odpovědi",
|
||||
"Top contributors" : "Nejaktivnější přispěvatelé",
|
||||
"No contributors yet" : "Zatím žádní přispěvatelé",
|
||||
"Last 7 days" : "Uplynulých 7 dnů",
|
||||
|
||||
@@ -145,6 +145,7 @@ OC.L10N.register(
|
||||
"Current version" : "Leagan reatha",
|
||||
"Edited by" : "Eagarthóireacht déanta ag",
|
||||
"Failed to load edit history" : "Theip ar stair eagarthóireachta a lódáil",
|
||||
"Version {index}" : "Leagan {index}",
|
||||
"Add reaction" : "Cuir imoibriú leis",
|
||||
"React with {emoji}" : "Freagair le {emoji}",
|
||||
"You reacted with {emoji}" : "D'imoibrigh tú le {emoji}",
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"Current version" : "Leagan reatha",
|
||||
"Edited by" : "Eagarthóireacht déanta ag",
|
||||
"Failed to load edit history" : "Theip ar stair eagarthóireachta a lódáil",
|
||||
"Version {index}" : "Leagan {index}",
|
||||
"Add reaction" : "Cuir imoibriú leis",
|
||||
"React with {emoji}" : "Freagair le {emoji}",
|
||||
"You reacted with {emoji}" : "D'imoibrigh tú le {emoji}",
|
||||
|
||||
@@ -3,6 +3,7 @@ OC.L10N.register(
|
||||
{
|
||||
"Admin" : "Admi",
|
||||
"User" : "Používateľ",
|
||||
"Guest" : "Hosť",
|
||||
"General" : "Všeobecné",
|
||||
"Support" : "Podpora",
|
||||
"Bold text" : "Tučné písmo",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{ "translations": {
|
||||
"Admin" : "Admi",
|
||||
"User" : "Používateľ",
|
||||
"Guest" : "Hosť",
|
||||
"General" : "Všeobecné",
|
||||
"Support" : "Podpora",
|
||||
"Bold text" : "Tučné písmo",
|
||||
|
||||
206
l10n/sw.js
206
l10n/sw.js
@@ -10,38 +10,39 @@ OC.L10N.register(
|
||||
"Guest" : "Mgeni",
|
||||
"Guest role for unauthenticated users with read-only access" : "Jukumu la mgeni kwa watumiaji ambao hawajaidhinishwa na ufikiaji wa kusoma tu",
|
||||
"General" : "Kuu",
|
||||
"General discussion categories" : "General discussion categories",
|
||||
"General discussions" : "General discussions",
|
||||
"A place for general conversations and discussions" : "A place for general conversations and discussions",
|
||||
"Support" : "Support",
|
||||
"Ask questions about the forum, provide feedback or report issues." : "Ask questions about the forum, provide feedback or report issues.",
|
||||
"Inline code" : "Inline code",
|
||||
"Spoiler title" : "Spoiler title",
|
||||
"Hidden content" : "Hidden content",
|
||||
"Spoilers" : "Spoilers",
|
||||
"Attachment" : "Attachment",
|
||||
"Welcome to Nextcloud Forums" : "Welcome to Nextcloud Forums",
|
||||
"Welcome to the Nextcloud Forums!" : "Welcome to the Nextcloud Forums!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users.",
|
||||
"Features:" : "Features:",
|
||||
"Create and reply to threads" : "Create and reply to threads",
|
||||
"Organize discussions by categories" : "Organize discussions by categories",
|
||||
"Use BBCode for rich text formatting" : "Use BBCode for rich text formatting",
|
||||
"Attach files from your Nextcloud storage" : "Attach files from your Nextcloud storage",
|
||||
"React to posts" : "React to posts",
|
||||
"Track read/unread threads" : "Track read/unread threads",
|
||||
"BBCode examples:" : "BBCode examples:",
|
||||
"Bold text" : "Bold text",
|
||||
"Use %1$stext%2$s" : "Use %1$stext%2$s",
|
||||
"Italic text" : "Italic text",
|
||||
"Underlined text" : "Underlined text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Feel free to start a new discussion or reply to existing threads. Happy posting!",
|
||||
"General discussion categories" : "Makundi ya majadiliano ya jumla",
|
||||
"General discussions" : "Mijadala ya jumla",
|
||||
"A place for general conversations and discussions" : "Mahali pa mazungumzo ya jumla na mijadala",
|
||||
"Support" : "Msaada",
|
||||
"Ask questions about the forum, provide feedback or report issues." : "Uliza maswali kuhusu jukwaa, toa maoni au ripoti masuala.",
|
||||
"Inline code" : "Msimbo wa ndani",
|
||||
"Spoiler title" : "Kichwa kiharibifu",
|
||||
"Hidden content" : "Maudhui yaliyofichika",
|
||||
"Spoilers" : "Waharibifu",
|
||||
"Attachment" : "Kiambatisho",
|
||||
"Welcome to Nextcloud Forums" : "Karibu kwenye jukwaa la Nextcloud ",
|
||||
"Welcome to the Nextcloud Forums!" : "Karibu kwenye majukwaa ya Nextcloud! ",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Hili ni jukwaa linaloendeshwa na jamii lililojengwa ndani ya mfano wako wa Nextcloud. Hapa unaweza kujadili mada, kushiriki mawazo na kushirikiana na watumiaji wengine.",
|
||||
"Features:" : "Sifa:",
|
||||
"Create and reply to threads" : "Unda na ujibu kwenye mazungumzo",
|
||||
"Organize discussions by categories" : "Panga mijadala kwa kategoria",
|
||||
"Use BBCode for rich text formatting" : "Tumia BBCode kwa umbizo la maandishi wasilianifu ",
|
||||
"Attach files from your Nextcloud storage" : "Ambatisha faili kutoka kwa hifadhi yako ya Nextcloud",
|
||||
"React to posts" : "Jibu kwa machapisho",
|
||||
"Track read/unread threads" : "Fuatilia nyuzi zilizosomwa/hazijasomwa",
|
||||
"BBCode examples:" : "Mifano ya BBCode:",
|
||||
"Bold text" : "Maandishi mazito",
|
||||
"Use %1$stext%2$s" : "Tumia %1$smaandishi%2$s",
|
||||
"Italic text" : "Maandishi ya italiki",
|
||||
"Underlined text" : "Maandishi yaliyopigiwa mstari",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Jisikie huru kuanzisha mjadala mpya au kujibu mazungumzo yaliyopo. Furaha ya kuchapisha!",
|
||||
"Forum" : "Jukwaa",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} new reply in {thread}","{count} majibu mapya ndani {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} amekutaja katika {thread}",
|
||||
"Welcome to the forum!" : "Karibu kwenye jukwaa!",
|
||||
"Deleted user" : "Mtumiaji aliyefutwa",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "Jukwaa linaloendeshwa na jamii lililojengwa ndani ya mfano wako wa Nextcloud",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "Anzisha mijadala, shiriki mawazo na ushirikiane na jumuiya yako moja kwa moja kwenye Nextcloud.**⚠️ Notisi ya Maendeleo ya Mapema:**Programu hii iko katika hatua za awali za maendeleo. Wakati inafanya kazi, unaweza kukutana na hitilafu au vipengele visivyo kamili. Tafadhali ripoti matatizo yoyote kwenye GitHub na uzingatie kuhifadhi nakala za data yako mara kwa mara.**Sifa muhimu:**- **Majadiliano yanayotegemea nyuzi** - Unda na ujibu mijadala iliyopangwa- ** Shirika la Kitengo ** - Tengeneza jukwaa lako kwa kategoria na vichwa vinavyoweza kubinafsishwa- **Uumbizaji wa Maandishi Tajiri** - Tumia BBCode kuumbiza machapisho kwa herufi nzito, italiki, viungo, picha, vizuizi vya msimbo na zaidi.- **Viambatisho vya Faili** - Ambatisha faili kutoka kwa hifadhi yako ya Nextcloud kwenye machapisho- **Maoni ya Chapisho** - Jibu machapisho kwa miitikio ya emoji- **Ufuatiliaji Uliosoma/Haujasomwa** - Fuatilia ni nyuzi gani umesoma- **Tafuta** - Tafuta majadiliano haraka ukitumia utaftaji uliojumuishwa- **Wasifu wa Mtumiaji** - Tazama historia ya chapisho la mtumiaji na takwimu- **Ruhusa Zinazotegemea Wajibu** - Dhibiti ufikiaji na udhibiti kwa majukumu rahisi - **Ufikiaji wa Wageni**: Ufikiaji wa hiari wa umma kwa watumiaji ambao hawajaidhinishwa na ruhusa zinazoweza kusanidiwa- **Zana za Msimamizi** - Dhibiti kategoria, majukumu, BBCode na mipangilio ya mijadala- **Zana za Kudhibiti** - Bandika, funga na udhibiti nyuzi na machapisho**Nzuri kwa:**- Majadiliano ya timu na ushirikiano- Jamii forums- Njia za usaidizi- Msingi wa maarifa- Majadiliano ya mradi- Mawasiliano ya ndaniMijadala inaunganishwa kwa urahisi na mfano wako wa Nextcloud, kwa kutumia watumiaji na vikundi vyako vilivyopo kwa uthibitishaji na udhibiti wa ufikiaji.",
|
||||
"Loading …" : "Inapakia",
|
||||
"Search" : "Tafuta",
|
||||
"Home" : "Nyumbani",
|
||||
@@ -132,12 +133,19 @@ OC.L10N.register(
|
||||
"Quote reply" : "Jibu la nukuu",
|
||||
"Edit" : "Hariri",
|
||||
"Delete" : "Futa",
|
||||
"View edit history" : "Tazama historia ya uhariri",
|
||||
"Are you sure you want to delete this post? This action cannot be undone." : "Je, una uhakika unataka kufuta chapisho hili? Kitendo hiki hakiwezi kutenduliwa.",
|
||||
"Unread" : "Haijasomwa",
|
||||
"Edit your reply …" : "Hariri jibu lako...",
|
||||
"Save" : "Hifadhi",
|
||||
"Are you sure you want to discard your changes?" : "Je, una uhakika unataka kutupa mabadiliko yako?",
|
||||
"Edit history" : "Hariri historia",
|
||||
"Loading history …" : "Inapakia historia ...",
|
||||
"This post has no edit history." : "Chapisho hili halina historia ya uhariri.",
|
||||
"Current version" : "Toleo la sasa",
|
||||
"Edited by" : "Imehaririwa na",
|
||||
"Failed to load edit history" : "Imeshindwa kupakia historia ya uhariri",
|
||||
"Version {index}" : "Toleo {index}",
|
||||
"Add reaction" : "Ongeza majibu",
|
||||
"React with {emoji}" : "Jibu kwa {emoji}",
|
||||
"You reacted with {emoji}" : "You reacted with {emoji}",
|
||||
@@ -159,6 +167,8 @@ OC.L10N.register(
|
||||
"Write your thread content …" : "Write your thread content …",
|
||||
"Create thread" : "Create thread",
|
||||
"Are you sure you want to discard this thread?" : "Are you sure you want to discard this thread?",
|
||||
"Saving draft …" : "Saving draft …",
|
||||
"Draft saved" : "Draft saved",
|
||||
"Unsaved changes" : "Mabadiliko yasiyohifadhiwa",
|
||||
"Back to home" : "Back to home",
|
||||
"Refresh" : "Onyesha upya",
|
||||
@@ -251,7 +261,9 @@ OC.L10N.register(
|
||||
"Thread moved successfully" : "Thread moved successfully",
|
||||
"No thread ID or slug provided" : "No thread ID or slug provided",
|
||||
"Failed to load replies" : "Failed to load replies",
|
||||
"Thread updated" : "Thread updated",
|
||||
"Reply updated" : "Reply updated",
|
||||
"Failed to update thread" : "Failed to update thread",
|
||||
"Failed to update reply" : "Failed to update reply",
|
||||
"Thread deleted" : "Thread deleted",
|
||||
"Reply deleted" : "Reply deleted",
|
||||
@@ -267,28 +279,168 @@ OC.L10N.register(
|
||||
"Loading preferences …" : "Loading preferences …",
|
||||
"Error loading preferences" : "Error loading preferences",
|
||||
"Notifications" : "Arifa",
|
||||
"Configure how you receive notifications" : "Configure how you receive notifications",
|
||||
"Auto-subscribe to threads I create" : "Auto-subscribe to threads I create",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "When enabled, you will automatically receive notifications for replies to threads you create",
|
||||
"Files" : "Faili",
|
||||
"Configure file upload settings" : "Configure file upload settings",
|
||||
"Upload directory" : "Upload directory",
|
||||
"Files attached to threads or replies will be uploaded to this directory in your Nextcloud files" : "Files attached to threads or replies will be uploaded to this directory in your Nextcloud files",
|
||||
"Browse" : "Vinjari",
|
||||
"Preferences saved" : "Preferences saved",
|
||||
"Signature" : "Saini",
|
||||
"Your signature appears at the bottom of your threads or replies" : "Your signature appears at the bottom of your threads or replies",
|
||||
"You can use BBCode formatting in your signature" : "You can use BBCode formatting in your signature",
|
||||
"Enter your signature …" : "Enter your signature …",
|
||||
"Failed to save preferences" : "Failed to save preferences",
|
||||
"Select upload directory" : "Select upload directory",
|
||||
"BBCode management" : "BBCode management",
|
||||
"Manage custom BBCode tags for formatting" : "Manage custom BBCode tags for formatting",
|
||||
"Error loading BBCodes" : "Error loading BBCodes",
|
||||
"Create BBCode" : "Create BBCode",
|
||||
"Enable" : "Wezesha",
|
||||
"Disable" : "Zima",
|
||||
"Enabled BBCodes" : "Enabled BBCodes",
|
||||
"These BBCode tags are currently active" : "These BBCode tags are currently active",
|
||||
"Disabled BBCodes" : "Disabled BBCodes",
|
||||
"These BBCode tags are currently inactive" : "These BBCode tags are currently inactive",
|
||||
"No enabled BBCodes" : "No enabled BBCodes",
|
||||
"Parses Inner" : "Parses Inner",
|
||||
"Delete BBCode" : "Delete BBCode",
|
||||
"Are you sure you want to delete the BBCode tag [{tag}]?" : "Are you sure you want to delete the BBCode tag [{tag}]?",
|
||||
"This action cannot be undone." : "Kitendo hiki hakiwezi kutenduliwa.",
|
||||
"Edit BBCode" : "Edit BBCode",
|
||||
"Tag" : "Tag",
|
||||
"e.g., b, i, url, color" : "e.g., b, i, url, color",
|
||||
"The BBCode tag name (without brackets)" : "The BBCode tag name (without brackets)",
|
||||
"HTML replacement" : "HTML replacement",
|
||||
"e.g., {strongStart}{content}{strongEnd}" : "e.g., {strongStart}{content}{strongEnd}",
|
||||
"Use {content} for the tag content and {paramName} for parameters" : "Use {content} for the tag content and {paramName} for parameters",
|
||||
"e.g., {tagStart}Hello world{tagEnd}" : "e.g., {tagStart}Hello world{tagEnd}",
|
||||
"Example usage of this BBCode tag" : "Example usage of this BBCode tag",
|
||||
"Description" : "Maelezo",
|
||||
"Brief description of what this BBCode does" : "Brief description of what this BBCode does",
|
||||
"Enabled" : "Washwa",
|
||||
"Parse inner content" : "Parse inner content",
|
||||
"If enabled, BBCode tags inside this tag will also be parsed" : "If enabled, BBCode tags inside this tag will also be parsed",
|
||||
"Create category" : "Create category",
|
||||
"Edit category" : "Edit category",
|
||||
"Configure category details" : "Configure category details",
|
||||
"Basic information" : "Basic information",
|
||||
"Category header" : "Category header",
|
||||
"-- Select a header --" : "-- Select a header --",
|
||||
"Name" : "Jina",
|
||||
"Enter category name" : "Enter category name",
|
||||
"Slug" : "Slug",
|
||||
"URL-friendly identifier (e.g., \"{slug}\")" : "URL-friendly identifier (e.g., \"{slug}\")",
|
||||
"Slug cannot be changed after category creation" : "Slug cannot be changed after category creation",
|
||||
"Enter category description (optional)" : "Enter category description (optional)",
|
||||
"New" : "Mpya",
|
||||
"Permissions" : "Ruhusa",
|
||||
"Control which roles can access and moderate this category" : "Control which roles can access and moderate this category",
|
||||
"Roles that can view" : "Roles that can view",
|
||||
"Select roles that can view this category and its threads" : "Select roles that can view this category and its threads",
|
||||
"Roles that can moderate" : "Roles that can moderate",
|
||||
"Select roles that can moderate (edit/delete) content in this category" : "Select roles that can moderate (edit/delete) content in this category",
|
||||
"Select roles …" : "Select roles …",
|
||||
"Manage forum categories and organization" : "Manage forum categories and organization",
|
||||
"Error loading categories" : "Error loading categories",
|
||||
"No categories in this header" : "No categories in this header",
|
||||
"Delete category" : "Delete category",
|
||||
"Are you sure you want to delete the category \"{name}\"?" : "Are you sure you want to delete the category \"{name}\"?",
|
||||
"_This category contains %n thread._::_This category contains %n threads._" : ["This category contains %n thread.","This category contains %n threads."],
|
||||
"What should happen to the threads?" : "What should happen to the threads?",
|
||||
"Move threads to another category" : "Move threads to another category",
|
||||
"Delete all threads (soft delete)" : "Delete all threads (soft delete)",
|
||||
"Threads will be hidden but not permanently deleted" : "Threads will be hidden but not permanently deleted",
|
||||
"Select target category" : "Select target category",
|
||||
"-- Select a category --" : "-- Select a category --",
|
||||
"Create header" : "Create header",
|
||||
"_%n category_::_%n categories_" : ["%n category","%n categories"],
|
||||
"_%n thread_::_%n threads_" : ["%n thread","%n threads"],
|
||||
"Delete header" : "Delete header",
|
||||
"Are you sure you want to delete the header \"{name}\"?" : "Are you sure you want to delete the header \"{name}\"?",
|
||||
"_This header contains %n category._::_This header contains %n categories._" : ["This header contains %n category.","This header contains %n categories."],
|
||||
"This action cannot be undone" : "This action cannot be undone",
|
||||
"What should happen to the categories?" : "What should happen to the categories?",
|
||||
"Move categories to another header" : "Move categories to another header",
|
||||
"Delete all categories" : "Delete all categories",
|
||||
"All categories and their threads will be permanently deleted" : "All categories and their threads will be permanently deleted",
|
||||
"Select target header" : "Select target header",
|
||||
"Move up" : "Hamia juu",
|
||||
"Move down" : "Hamia chini",
|
||||
"Admin dashboard" : "Dashibodi ya msimamizi",
|
||||
"Overview of forum activity and statistics" : "Muhtasari wa shughuli za jukwaa na takwimu",
|
||||
"Loading statistics …" : "Inapakia takwimu…",
|
||||
"Error loading dashboard" : "Hitilafu katika kupakia dashibodi",
|
||||
"Total statistics" : "Takwimu za jumla",
|
||||
"Recent activity (last 7 days)" : "Shughuli ya hivi karibuni (siku 7 zilizopita)",
|
||||
"New users" : "Watumiaji wapya",
|
||||
"New threads" : "Magumzo mapya",
|
||||
"New replies" : "Majibu mapya",
|
||||
"Top contributors" : "Wachangiaji wa juu",
|
||||
"No contributors yet" : "Bado hakuna wachangiaji",
|
||||
"Last 7 days" : "Siku 7 zilizopita",
|
||||
"All time" : "Muda wote",
|
||||
"General settings" : "Mipangilio ya jumla",
|
||||
"Configure general forum settings" : "Configure general forum settings",
|
||||
"Loading settings …" : "Loading settings …",
|
||||
"Error loading settings" : "Error loading settings",
|
||||
"Appearance" : "Mwonekano",
|
||||
"Customize how your forum looks to users" : "Customize how your forum looks to users",
|
||||
"Forum title" : "Forum title",
|
||||
"Displayed at the top of the forum home page" : "Displayed at the top of the forum home page",
|
||||
"Forum subtitle" : "Forum subtitle",
|
||||
"Welcome to the forum" : "Welcome to the forum",
|
||||
"A brief description shown below the title" : "A brief description shown below the title",
|
||||
"Access control" : "Access control",
|
||||
"Manage who can access the forum" : "Manage who can access the forum",
|
||||
"Allow guest access" : "Allow guest access",
|
||||
"When enabled, unauthenticated users can view forum content in read-only mode" : "When enabled, unauthenticated users can view forum content in read-only mode",
|
||||
"Settings saved" : "Mipangilio imehifadhiwa",
|
||||
"Failed to save settings" : "Imeshindwa kuhifadhi mipangilio",
|
||||
"Create role" : "Create role",
|
||||
"Edit role" : "Edit role",
|
||||
"Configure role permissions and category access" : "Configure role permissions and category access",
|
||||
"Error loading role" : "Error loading role",
|
||||
"Enter role name" : "Enter role name",
|
||||
"Enter role description (optional)" : "Enter role description (optional)",
|
||||
"System role names cannot be changed" : "System role names cannot be changed",
|
||||
"Colors" : "Colors",
|
||||
"Set colors for this role badge" : "Set colors for this role badge",
|
||||
"Light mode color" : "Light mode color",
|
||||
"Dark mode color" : "Dark mode color",
|
||||
"Reset" : "Pangilia upya",
|
||||
"Role permissions" : "Role permissions",
|
||||
"Set global permissions for this role" : "Set global permissions for this role",
|
||||
"Can access admin tools" : "Can access admin tools",
|
||||
"Allow access to the admin dashboard and tools" : "Allow access to the admin dashboard and tools",
|
||||
"Can edit roles" : "Can edit roles",
|
||||
"Allow creating, editing and deleting roles" : "Allow creating, editing and deleting roles",
|
||||
"Can edit categories" : "Can edit categories",
|
||||
"Allow creating, editing and deleting categories" : "Allow creating, editing and deleting categories",
|
||||
"Category permissions" : "Category permissions",
|
||||
"Set which categories this role can access" : "Set which categories this role can access",
|
||||
"Category" : "Kipengele",
|
||||
"Can view" : "Can view",
|
||||
"Can moderate" : "Can moderate",
|
||||
"Allow" : "Ruhusu",
|
||||
"No categories available" : "No categories available",
|
||||
"Admin role must have all permissions enabled" : "Admin role must have all permissions enabled",
|
||||
"Admin role has full access to all categories" : "Admin role has full access to all categories",
|
||||
"Guest role cannot have admin permissions" : "Guest role cannot have admin permissions",
|
||||
"Guest role cannot moderate categories" : "Guest role cannot moderate categories",
|
||||
"You can control which categories guests can view using the checkboxes below." : "You can control which categories guests can view using the checkboxes below.",
|
||||
"Guest access is currently disabled" : "Guest access is currently disabled",
|
||||
"Guest users will not be able to access the forum until guest access is enabled in the forum settings." : "Guest users will not be able to access the forum until guest access is enabled in the forum settings.",
|
||||
"Go to forum settings" : "Go to forum settings",
|
||||
"Default role cannot moderate categories" : "Default role cannot moderate categories",
|
||||
"Role management" : "Role management",
|
||||
"Create and manage forum roles and permissions" : "Create and manage forum roles and permissions",
|
||||
"Loading roles …" : "Loading roles …",
|
||||
"Error loading roles" : "Error loading roles",
|
||||
"No roles found" : "No roles found",
|
||||
"Create your first role to get started" : "Create your first role to get started",
|
||||
"ID" : "Kitambulisho",
|
||||
"Created" : "Imetengenezwa",
|
||||
"Actions" : "Matendo",
|
||||
|
||||
206
l10n/sw.json
206
l10n/sw.json
@@ -8,38 +8,39 @@
|
||||
"Guest" : "Mgeni",
|
||||
"Guest role for unauthenticated users with read-only access" : "Jukumu la mgeni kwa watumiaji ambao hawajaidhinishwa na ufikiaji wa kusoma tu",
|
||||
"General" : "Kuu",
|
||||
"General discussion categories" : "General discussion categories",
|
||||
"General discussions" : "General discussions",
|
||||
"A place for general conversations and discussions" : "A place for general conversations and discussions",
|
||||
"Support" : "Support",
|
||||
"Ask questions about the forum, provide feedback or report issues." : "Ask questions about the forum, provide feedback or report issues.",
|
||||
"Inline code" : "Inline code",
|
||||
"Spoiler title" : "Spoiler title",
|
||||
"Hidden content" : "Hidden content",
|
||||
"Spoilers" : "Spoilers",
|
||||
"Attachment" : "Attachment",
|
||||
"Welcome to Nextcloud Forums" : "Welcome to Nextcloud Forums",
|
||||
"Welcome to the Nextcloud Forums!" : "Welcome to the Nextcloud Forums!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users.",
|
||||
"Features:" : "Features:",
|
||||
"Create and reply to threads" : "Create and reply to threads",
|
||||
"Organize discussions by categories" : "Organize discussions by categories",
|
||||
"Use BBCode for rich text formatting" : "Use BBCode for rich text formatting",
|
||||
"Attach files from your Nextcloud storage" : "Attach files from your Nextcloud storage",
|
||||
"React to posts" : "React to posts",
|
||||
"Track read/unread threads" : "Track read/unread threads",
|
||||
"BBCode examples:" : "BBCode examples:",
|
||||
"Bold text" : "Bold text",
|
||||
"Use %1$stext%2$s" : "Use %1$stext%2$s",
|
||||
"Italic text" : "Italic text",
|
||||
"Underlined text" : "Underlined text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Feel free to start a new discussion or reply to existing threads. Happy posting!",
|
||||
"General discussion categories" : "Makundi ya majadiliano ya jumla",
|
||||
"General discussions" : "Mijadala ya jumla",
|
||||
"A place for general conversations and discussions" : "Mahali pa mazungumzo ya jumla na mijadala",
|
||||
"Support" : "Msaada",
|
||||
"Ask questions about the forum, provide feedback or report issues." : "Uliza maswali kuhusu jukwaa, toa maoni au ripoti masuala.",
|
||||
"Inline code" : "Msimbo wa ndani",
|
||||
"Spoiler title" : "Kichwa kiharibifu",
|
||||
"Hidden content" : "Maudhui yaliyofichika",
|
||||
"Spoilers" : "Waharibifu",
|
||||
"Attachment" : "Kiambatisho",
|
||||
"Welcome to Nextcloud Forums" : "Karibu kwenye jukwaa la Nextcloud ",
|
||||
"Welcome to the Nextcloud Forums!" : "Karibu kwenye majukwaa ya Nextcloud! ",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Hili ni jukwaa linaloendeshwa na jamii lililojengwa ndani ya mfano wako wa Nextcloud. Hapa unaweza kujadili mada, kushiriki mawazo na kushirikiana na watumiaji wengine.",
|
||||
"Features:" : "Sifa:",
|
||||
"Create and reply to threads" : "Unda na ujibu kwenye mazungumzo",
|
||||
"Organize discussions by categories" : "Panga mijadala kwa kategoria",
|
||||
"Use BBCode for rich text formatting" : "Tumia BBCode kwa umbizo la maandishi wasilianifu ",
|
||||
"Attach files from your Nextcloud storage" : "Ambatisha faili kutoka kwa hifadhi yako ya Nextcloud",
|
||||
"React to posts" : "Jibu kwa machapisho",
|
||||
"Track read/unread threads" : "Fuatilia nyuzi zilizosomwa/hazijasomwa",
|
||||
"BBCode examples:" : "Mifano ya BBCode:",
|
||||
"Bold text" : "Maandishi mazito",
|
||||
"Use %1$stext%2$s" : "Tumia %1$smaandishi%2$s",
|
||||
"Italic text" : "Maandishi ya italiki",
|
||||
"Underlined text" : "Maandishi yaliyopigiwa mstari",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Jisikie huru kuanzisha mjadala mpya au kujibu mazungumzo yaliyopo. Furaha ya kuchapisha!",
|
||||
"Forum" : "Jukwaa",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} new reply in {thread}","{count} majibu mapya ndani {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} amekutaja katika {thread}",
|
||||
"Welcome to the forum!" : "Karibu kwenye jukwaa!",
|
||||
"Deleted user" : "Mtumiaji aliyefutwa",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "Jukwaa linaloendeshwa na jamii lililojengwa ndani ya mfano wako wa Nextcloud",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "Anzisha mijadala, shiriki mawazo na ushirikiane na jumuiya yako moja kwa moja kwenye Nextcloud.**⚠️ Notisi ya Maendeleo ya Mapema:**Programu hii iko katika hatua za awali za maendeleo. Wakati inafanya kazi, unaweza kukutana na hitilafu au vipengele visivyo kamili. Tafadhali ripoti matatizo yoyote kwenye GitHub na uzingatie kuhifadhi nakala za data yako mara kwa mara.**Sifa muhimu:**- **Majadiliano yanayotegemea nyuzi** - Unda na ujibu mijadala iliyopangwa- ** Shirika la Kitengo ** - Tengeneza jukwaa lako kwa kategoria na vichwa vinavyoweza kubinafsishwa- **Uumbizaji wa Maandishi Tajiri** - Tumia BBCode kuumbiza machapisho kwa herufi nzito, italiki, viungo, picha, vizuizi vya msimbo na zaidi.- **Viambatisho vya Faili** - Ambatisha faili kutoka kwa hifadhi yako ya Nextcloud kwenye machapisho- **Maoni ya Chapisho** - Jibu machapisho kwa miitikio ya emoji- **Ufuatiliaji Uliosoma/Haujasomwa** - Fuatilia ni nyuzi gani umesoma- **Tafuta** - Tafuta majadiliano haraka ukitumia utaftaji uliojumuishwa- **Wasifu wa Mtumiaji** - Tazama historia ya chapisho la mtumiaji na takwimu- **Ruhusa Zinazotegemea Wajibu** - Dhibiti ufikiaji na udhibiti kwa majukumu rahisi - **Ufikiaji wa Wageni**: Ufikiaji wa hiari wa umma kwa watumiaji ambao hawajaidhinishwa na ruhusa zinazoweza kusanidiwa- **Zana za Msimamizi** - Dhibiti kategoria, majukumu, BBCode na mipangilio ya mijadala- **Zana za Kudhibiti** - Bandika, funga na udhibiti nyuzi na machapisho**Nzuri kwa:**- Majadiliano ya timu na ushirikiano- Jamii forums- Njia za usaidizi- Msingi wa maarifa- Majadiliano ya mradi- Mawasiliano ya ndaniMijadala inaunganishwa kwa urahisi na mfano wako wa Nextcloud, kwa kutumia watumiaji na vikundi vyako vilivyopo kwa uthibitishaji na udhibiti wa ufikiaji.",
|
||||
"Loading …" : "Inapakia",
|
||||
"Search" : "Tafuta",
|
||||
"Home" : "Nyumbani",
|
||||
@@ -130,12 +131,19 @@
|
||||
"Quote reply" : "Jibu la nukuu",
|
||||
"Edit" : "Hariri",
|
||||
"Delete" : "Futa",
|
||||
"View edit history" : "Tazama historia ya uhariri",
|
||||
"Are you sure you want to delete this post? This action cannot be undone." : "Je, una uhakika unataka kufuta chapisho hili? Kitendo hiki hakiwezi kutenduliwa.",
|
||||
"Unread" : "Haijasomwa",
|
||||
"Edit your reply …" : "Hariri jibu lako...",
|
||||
"Save" : "Hifadhi",
|
||||
"Are you sure you want to discard your changes?" : "Je, una uhakika unataka kutupa mabadiliko yako?",
|
||||
"Edit history" : "Hariri historia",
|
||||
"Loading history …" : "Inapakia historia ...",
|
||||
"This post has no edit history." : "Chapisho hili halina historia ya uhariri.",
|
||||
"Current version" : "Toleo la sasa",
|
||||
"Edited by" : "Imehaririwa na",
|
||||
"Failed to load edit history" : "Imeshindwa kupakia historia ya uhariri",
|
||||
"Version {index}" : "Toleo {index}",
|
||||
"Add reaction" : "Ongeza majibu",
|
||||
"React with {emoji}" : "Jibu kwa {emoji}",
|
||||
"You reacted with {emoji}" : "You reacted with {emoji}",
|
||||
@@ -157,6 +165,8 @@
|
||||
"Write your thread content …" : "Write your thread content …",
|
||||
"Create thread" : "Create thread",
|
||||
"Are you sure you want to discard this thread?" : "Are you sure you want to discard this thread?",
|
||||
"Saving draft …" : "Saving draft …",
|
||||
"Draft saved" : "Draft saved",
|
||||
"Unsaved changes" : "Mabadiliko yasiyohifadhiwa",
|
||||
"Back to home" : "Back to home",
|
||||
"Refresh" : "Onyesha upya",
|
||||
@@ -249,7 +259,9 @@
|
||||
"Thread moved successfully" : "Thread moved successfully",
|
||||
"No thread ID or slug provided" : "No thread ID or slug provided",
|
||||
"Failed to load replies" : "Failed to load replies",
|
||||
"Thread updated" : "Thread updated",
|
||||
"Reply updated" : "Reply updated",
|
||||
"Failed to update thread" : "Failed to update thread",
|
||||
"Failed to update reply" : "Failed to update reply",
|
||||
"Thread deleted" : "Thread deleted",
|
||||
"Reply deleted" : "Reply deleted",
|
||||
@@ -265,28 +277,168 @@
|
||||
"Loading preferences …" : "Loading preferences …",
|
||||
"Error loading preferences" : "Error loading preferences",
|
||||
"Notifications" : "Arifa",
|
||||
"Configure how you receive notifications" : "Configure how you receive notifications",
|
||||
"Auto-subscribe to threads I create" : "Auto-subscribe to threads I create",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "When enabled, you will automatically receive notifications for replies to threads you create",
|
||||
"Files" : "Faili",
|
||||
"Configure file upload settings" : "Configure file upload settings",
|
||||
"Upload directory" : "Upload directory",
|
||||
"Files attached to threads or replies will be uploaded to this directory in your Nextcloud files" : "Files attached to threads or replies will be uploaded to this directory in your Nextcloud files",
|
||||
"Browse" : "Vinjari",
|
||||
"Preferences saved" : "Preferences saved",
|
||||
"Signature" : "Saini",
|
||||
"Your signature appears at the bottom of your threads or replies" : "Your signature appears at the bottom of your threads or replies",
|
||||
"You can use BBCode formatting in your signature" : "You can use BBCode formatting in your signature",
|
||||
"Enter your signature …" : "Enter your signature …",
|
||||
"Failed to save preferences" : "Failed to save preferences",
|
||||
"Select upload directory" : "Select upload directory",
|
||||
"BBCode management" : "BBCode management",
|
||||
"Manage custom BBCode tags for formatting" : "Manage custom BBCode tags for formatting",
|
||||
"Error loading BBCodes" : "Error loading BBCodes",
|
||||
"Create BBCode" : "Create BBCode",
|
||||
"Enable" : "Wezesha",
|
||||
"Disable" : "Zima",
|
||||
"Enabled BBCodes" : "Enabled BBCodes",
|
||||
"These BBCode tags are currently active" : "These BBCode tags are currently active",
|
||||
"Disabled BBCodes" : "Disabled BBCodes",
|
||||
"These BBCode tags are currently inactive" : "These BBCode tags are currently inactive",
|
||||
"No enabled BBCodes" : "No enabled BBCodes",
|
||||
"Parses Inner" : "Parses Inner",
|
||||
"Delete BBCode" : "Delete BBCode",
|
||||
"Are you sure you want to delete the BBCode tag [{tag}]?" : "Are you sure you want to delete the BBCode tag [{tag}]?",
|
||||
"This action cannot be undone." : "Kitendo hiki hakiwezi kutenduliwa.",
|
||||
"Edit BBCode" : "Edit BBCode",
|
||||
"Tag" : "Tag",
|
||||
"e.g., b, i, url, color" : "e.g., b, i, url, color",
|
||||
"The BBCode tag name (without brackets)" : "The BBCode tag name (without brackets)",
|
||||
"HTML replacement" : "HTML replacement",
|
||||
"e.g., {strongStart}{content}{strongEnd}" : "e.g., {strongStart}{content}{strongEnd}",
|
||||
"Use {content} for the tag content and {paramName} for parameters" : "Use {content} for the tag content and {paramName} for parameters",
|
||||
"e.g., {tagStart}Hello world{tagEnd}" : "e.g., {tagStart}Hello world{tagEnd}",
|
||||
"Example usage of this BBCode tag" : "Example usage of this BBCode tag",
|
||||
"Description" : "Maelezo",
|
||||
"Brief description of what this BBCode does" : "Brief description of what this BBCode does",
|
||||
"Enabled" : "Washwa",
|
||||
"Parse inner content" : "Parse inner content",
|
||||
"If enabled, BBCode tags inside this tag will also be parsed" : "If enabled, BBCode tags inside this tag will also be parsed",
|
||||
"Create category" : "Create category",
|
||||
"Edit category" : "Edit category",
|
||||
"Configure category details" : "Configure category details",
|
||||
"Basic information" : "Basic information",
|
||||
"Category header" : "Category header",
|
||||
"-- Select a header --" : "-- Select a header --",
|
||||
"Name" : "Jina",
|
||||
"Enter category name" : "Enter category name",
|
||||
"Slug" : "Slug",
|
||||
"URL-friendly identifier (e.g., \"{slug}\")" : "URL-friendly identifier (e.g., \"{slug}\")",
|
||||
"Slug cannot be changed after category creation" : "Slug cannot be changed after category creation",
|
||||
"Enter category description (optional)" : "Enter category description (optional)",
|
||||
"New" : "Mpya",
|
||||
"Permissions" : "Ruhusa",
|
||||
"Control which roles can access and moderate this category" : "Control which roles can access and moderate this category",
|
||||
"Roles that can view" : "Roles that can view",
|
||||
"Select roles that can view this category and its threads" : "Select roles that can view this category and its threads",
|
||||
"Roles that can moderate" : "Roles that can moderate",
|
||||
"Select roles that can moderate (edit/delete) content in this category" : "Select roles that can moderate (edit/delete) content in this category",
|
||||
"Select roles …" : "Select roles …",
|
||||
"Manage forum categories and organization" : "Manage forum categories and organization",
|
||||
"Error loading categories" : "Error loading categories",
|
||||
"No categories in this header" : "No categories in this header",
|
||||
"Delete category" : "Delete category",
|
||||
"Are you sure you want to delete the category \"{name}\"?" : "Are you sure you want to delete the category \"{name}\"?",
|
||||
"_This category contains %n thread._::_This category contains %n threads._" : ["This category contains %n thread.","This category contains %n threads."],
|
||||
"What should happen to the threads?" : "What should happen to the threads?",
|
||||
"Move threads to another category" : "Move threads to another category",
|
||||
"Delete all threads (soft delete)" : "Delete all threads (soft delete)",
|
||||
"Threads will be hidden but not permanently deleted" : "Threads will be hidden but not permanently deleted",
|
||||
"Select target category" : "Select target category",
|
||||
"-- Select a category --" : "-- Select a category --",
|
||||
"Create header" : "Create header",
|
||||
"_%n category_::_%n categories_" : ["%n category","%n categories"],
|
||||
"_%n thread_::_%n threads_" : ["%n thread","%n threads"],
|
||||
"Delete header" : "Delete header",
|
||||
"Are you sure you want to delete the header \"{name}\"?" : "Are you sure you want to delete the header \"{name}\"?",
|
||||
"_This header contains %n category._::_This header contains %n categories._" : ["This header contains %n category.","This header contains %n categories."],
|
||||
"This action cannot be undone" : "This action cannot be undone",
|
||||
"What should happen to the categories?" : "What should happen to the categories?",
|
||||
"Move categories to another header" : "Move categories to another header",
|
||||
"Delete all categories" : "Delete all categories",
|
||||
"All categories and their threads will be permanently deleted" : "All categories and their threads will be permanently deleted",
|
||||
"Select target header" : "Select target header",
|
||||
"Move up" : "Hamia juu",
|
||||
"Move down" : "Hamia chini",
|
||||
"Admin dashboard" : "Dashibodi ya msimamizi",
|
||||
"Overview of forum activity and statistics" : "Muhtasari wa shughuli za jukwaa na takwimu",
|
||||
"Loading statistics …" : "Inapakia takwimu…",
|
||||
"Error loading dashboard" : "Hitilafu katika kupakia dashibodi",
|
||||
"Total statistics" : "Takwimu za jumla",
|
||||
"Recent activity (last 7 days)" : "Shughuli ya hivi karibuni (siku 7 zilizopita)",
|
||||
"New users" : "Watumiaji wapya",
|
||||
"New threads" : "Magumzo mapya",
|
||||
"New replies" : "Majibu mapya",
|
||||
"Top contributors" : "Wachangiaji wa juu",
|
||||
"No contributors yet" : "Bado hakuna wachangiaji",
|
||||
"Last 7 days" : "Siku 7 zilizopita",
|
||||
"All time" : "Muda wote",
|
||||
"General settings" : "Mipangilio ya jumla",
|
||||
"Configure general forum settings" : "Configure general forum settings",
|
||||
"Loading settings …" : "Loading settings …",
|
||||
"Error loading settings" : "Error loading settings",
|
||||
"Appearance" : "Mwonekano",
|
||||
"Customize how your forum looks to users" : "Customize how your forum looks to users",
|
||||
"Forum title" : "Forum title",
|
||||
"Displayed at the top of the forum home page" : "Displayed at the top of the forum home page",
|
||||
"Forum subtitle" : "Forum subtitle",
|
||||
"Welcome to the forum" : "Welcome to the forum",
|
||||
"A brief description shown below the title" : "A brief description shown below the title",
|
||||
"Access control" : "Access control",
|
||||
"Manage who can access the forum" : "Manage who can access the forum",
|
||||
"Allow guest access" : "Allow guest access",
|
||||
"When enabled, unauthenticated users can view forum content in read-only mode" : "When enabled, unauthenticated users can view forum content in read-only mode",
|
||||
"Settings saved" : "Mipangilio imehifadhiwa",
|
||||
"Failed to save settings" : "Imeshindwa kuhifadhi mipangilio",
|
||||
"Create role" : "Create role",
|
||||
"Edit role" : "Edit role",
|
||||
"Configure role permissions and category access" : "Configure role permissions and category access",
|
||||
"Error loading role" : "Error loading role",
|
||||
"Enter role name" : "Enter role name",
|
||||
"Enter role description (optional)" : "Enter role description (optional)",
|
||||
"System role names cannot be changed" : "System role names cannot be changed",
|
||||
"Colors" : "Colors",
|
||||
"Set colors for this role badge" : "Set colors for this role badge",
|
||||
"Light mode color" : "Light mode color",
|
||||
"Dark mode color" : "Dark mode color",
|
||||
"Reset" : "Pangilia upya",
|
||||
"Role permissions" : "Role permissions",
|
||||
"Set global permissions for this role" : "Set global permissions for this role",
|
||||
"Can access admin tools" : "Can access admin tools",
|
||||
"Allow access to the admin dashboard and tools" : "Allow access to the admin dashboard and tools",
|
||||
"Can edit roles" : "Can edit roles",
|
||||
"Allow creating, editing and deleting roles" : "Allow creating, editing and deleting roles",
|
||||
"Can edit categories" : "Can edit categories",
|
||||
"Allow creating, editing and deleting categories" : "Allow creating, editing and deleting categories",
|
||||
"Category permissions" : "Category permissions",
|
||||
"Set which categories this role can access" : "Set which categories this role can access",
|
||||
"Category" : "Kipengele",
|
||||
"Can view" : "Can view",
|
||||
"Can moderate" : "Can moderate",
|
||||
"Allow" : "Ruhusu",
|
||||
"No categories available" : "No categories available",
|
||||
"Admin role must have all permissions enabled" : "Admin role must have all permissions enabled",
|
||||
"Admin role has full access to all categories" : "Admin role has full access to all categories",
|
||||
"Guest role cannot have admin permissions" : "Guest role cannot have admin permissions",
|
||||
"Guest role cannot moderate categories" : "Guest role cannot moderate categories",
|
||||
"You can control which categories guests can view using the checkboxes below." : "You can control which categories guests can view using the checkboxes below.",
|
||||
"Guest access is currently disabled" : "Guest access is currently disabled",
|
||||
"Guest users will not be able to access the forum until guest access is enabled in the forum settings." : "Guest users will not be able to access the forum until guest access is enabled in the forum settings.",
|
||||
"Go to forum settings" : "Go to forum settings",
|
||||
"Default role cannot moderate categories" : "Default role cannot moderate categories",
|
||||
"Role management" : "Role management",
|
||||
"Create and manage forum roles and permissions" : "Create and manage forum roles and permissions",
|
||||
"Loading roles …" : "Loading roles …",
|
||||
"Error loading roles" : "Error loading roles",
|
||||
"No roles found" : "No roles found",
|
||||
"Create your first role to get started" : "Create your first role to get started",
|
||||
"ID" : "Kitambulisho",
|
||||
"Created" : "Imetengenezwa",
|
||||
"Actions" : "Matendo",
|
||||
|
||||
@@ -145,6 +145,7 @@ OC.L10N.register(
|
||||
"Current version" : "目前版本",
|
||||
"Edited by" : "編輯者",
|
||||
"Failed to load edit history" : "載入編輯歷史紀錄失敗",
|
||||
"Version {index}" : "版本 {index}",
|
||||
"Add reaction" : "新增反應",
|
||||
"React with {emoji}" : "使用 {emoji} 做出反應",
|
||||
"You reacted with {emoji}" : "您已以 {emoji} 做出反應",
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"Current version" : "目前版本",
|
||||
"Edited by" : "編輯者",
|
||||
"Failed to load edit history" : "載入編輯歷史紀錄失敗",
|
||||
"Version {index}" : "版本 {index}",
|
||||
"Add reaction" : "新增反應",
|
||||
"React with {emoji}" : "使用 {emoji} 做出反應",
|
||||
"You reacted with {emoji}" : "您已以 {emoji} 做出反應",
|
||||
|
||||
@@ -48,8 +48,18 @@ class SeedHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure forum_users table exists, renaming from forum_user_stats if needed
|
||||
* This handles cases where migrations partially failed
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure forum_users table exists, renaming from forum_user_stats if needed,
|
||||
* or creating it from scratch for fresh installations.
|
||||
* This handles cases where migrations partially failed or fresh installs.
|
||||
*
|
||||
* @param \OCP\Migration\IOutput|null $output Optional output for console messages
|
||||
*/
|
||||
@@ -66,6 +76,7 @@ class SeedHelper {
|
||||
$newTableExists = self::tableExists($db, $newTable);
|
||||
|
||||
if ($oldTableExists && !$newTableExists) {
|
||||
// Case 1: Old table exists, rename it
|
||||
$logger->info('Forum seeding: Renaming forum_user_stats to forum_users...');
|
||||
if ($output) {
|
||||
$output->info(' → Renaming forum_user_stats to forum_users...');
|
||||
@@ -90,6 +101,89 @@ class SeedHelper {
|
||||
$logger->error('Forum seeding: Failed to rename table', ['exception' => $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
} elseif (!$oldTableExists && !$newTableExists) {
|
||||
// Case 2: Neither table exists (fresh install), create forum_users
|
||||
$logger->info('Forum seeding: Creating forum_users table...');
|
||||
if ($output) {
|
||||
$output->info(' → Creating forum_users table...');
|
||||
}
|
||||
|
||||
try {
|
||||
self::createForumUsersTable($db);
|
||||
|
||||
$logger->info('Forum seeding: Table created successfully');
|
||||
if ($output) {
|
||||
$output->info(' ✓ Table created successfully');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$logger->error('Forum seeding: Failed to create forum_users table', ['exception' => $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
// Case 3: $newTableExists is true - nothing to do
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the forum_users table from scratch
|
||||
* This mirrors the schema from Version1 + Version8 migrations
|
||||
*/
|
||||
private static function createForumUsersTable(\OCP\IDBConnection $db): void {
|
||||
$platform = $db->getDatabasePlatform();
|
||||
$config = \OC::$server->get(\OCP\IConfig::class);
|
||||
$prefix = $config->getSystemValueString('dbtableprefix', 'oc_');
|
||||
$tableName = $prefix . 'forum_users';
|
||||
|
||||
// Use instanceof checks for reliable platform detection (getName() is deprecated)
|
||||
if ($platform instanceof \Doctrine\DBAL\Platforms\MySQLPlatform) {
|
||||
// MySQL and MariaDB both extend MySQLPlatform
|
||||
$db->executeStatement("
|
||||
CREATE TABLE `{$tableName}` (
|
||||
`user_id` VARCHAR(64) NOT NULL,
|
||||
`post_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`thread_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`last_post_at` INT UNSIGNED DEFAULT NULL,
|
||||
`deleted_at` INT UNSIGNED DEFAULT NULL,
|
||||
`signature` TEXT DEFAULT NULL,
|
||||
`created_at` INT UNSIGNED NOT NULL,
|
||||
`updated_at` INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (`user_id`),
|
||||
INDEX `forum_users_post_count_idx` (`post_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}\" (
|
||||
\"user_id\" VARCHAR(64) NOT NULL,
|
||||
\"post_count\" INTEGER NOT NULL DEFAULT 0,
|
||||
\"thread_count\" INTEGER NOT NULL DEFAULT 0,
|
||||
\"last_post_at\" INTEGER DEFAULT NULL,
|
||||
\"deleted_at\" INTEGER DEFAULT NULL,
|
||||
\"signature\" TEXT DEFAULT NULL,
|
||||
\"created_at\" INTEGER NOT NULL,
|
||||
\"updated_at\" INTEGER NOT NULL,
|
||||
PRIMARY KEY (\"user_id\")
|
||||
)
|
||||
");
|
||||
$db->executeStatement("CREATE INDEX \"forum_users_post_count_idx\" ON \"{$tableName}\" (\"post_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}\" (
|
||||
\"user_id\" VARCHAR(64) NOT NULL,
|
||||
\"post_count\" INTEGER NOT NULL DEFAULT 0,
|
||||
\"thread_count\" INTEGER NOT NULL DEFAULT 0,
|
||||
\"last_post_at\" INTEGER DEFAULT NULL,
|
||||
\"deleted_at\" INTEGER DEFAULT NULL,
|
||||
\"signature\" TEXT DEFAULT NULL,
|
||||
\"created_at\" INTEGER NOT NULL,
|
||||
\"updated_at\" INTEGER NOT NULL,
|
||||
PRIMARY KEY (\"user_id\")
|
||||
)
|
||||
");
|
||||
$db->executeStatement("CREATE INDEX \"forum_users_post_count_idx\" ON \"{$tableName}\" (\"post_count\")");
|
||||
$db->executeStatement("CREATE INDEX \"forum_users_deleted_at_idx\" ON \"{$tableName}\" (\"deleted_at\")");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,23 +233,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');
|
||||
@@ -170,9 +265,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,
|
||||
@@ -181,7 +276,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,
|
||||
@@ -190,7 +285,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,
|
||||
@@ -199,7 +294,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,
|
||||
@@ -210,11 +305,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([
|
||||
@@ -229,14 +321,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',
|
||||
@@ -244,10 +337,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");
|
||||
@@ -609,19 +710,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');
|
||||
@@ -629,6 +736,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...');
|
||||
}
|
||||
@@ -636,7 +746,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'];
|
||||
|
||||
@@ -645,7 +755,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();
|
||||
@@ -655,7 +765,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),
|
||||
@@ -670,7 +780,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();
|
||||
@@ -680,7 +790,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),
|
||||
@@ -820,19 +930,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');
|
||||
@@ -840,6 +956,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...');
|
||||
}
|
||||
@@ -847,7 +966,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);
|
||||
@@ -857,7 +976,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();
|
||||
@@ -868,7 +987,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();
|
||||
@@ -880,7 +999,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();
|
||||
@@ -891,7 +1010,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
|
||||
}
|
||||
}
|
||||
169
lib/Migration/Version15Date20260103000000.php
Normal file
169
lib/Migration/Version15Date20260103000000.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?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
|
||||
SeedHelper::seedAll($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
13
package.json
13
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,8 +39,11 @@
|
||||
"@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.0.11",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^2.8.8",
|
||||
@@ -46,9 +52,10 @@
|
||||
"sass": "^1.97.1",
|
||||
"sass-embedded": "^1.97.1",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "^8.50.0",
|
||||
"typescript-eslint": "^8.51.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"
|
||||
}
|
||||
|
||||
735
pnpm-lock.yaml
generated
735
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'
|
||||
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'
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -61,8 +61,8 @@ import CheckIcon from '@icons/Check.vue'
|
||||
import ContentSaveIcon from '@icons/ContentSave.vue'
|
||||
import ContentSaveCheckIcon from '@icons/ContentSaveCheck.vue'
|
||||
import ContentSaveAlertIcon from '@icons/ContentSaveAlert.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'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user