From 5bc4dbdb39c3b0eb51d8533c124fb724f02cc7c5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 24 Mar 2026 11:05:07 +0100 Subject: [PATCH 1/2] feat: Add Playwright E2E test scaffold and CI integration - playwright.config.ts with global-setup auth - tests/e2e/ with smoke tests, global-setup, .gitignore - tests/flows/ with README explaining convention - CI workflow: enable-playwright with seed command --- .github/workflows/code-quality.yml | 3 +++ playwright.config.ts | 26 ++++++++++++++++++++++++++ tests/e2e/.gitignore | 3 +++ tests/e2e/global-setup.ts | 26 ++++++++++++++++++++++++++ tests/e2e/smoke.spec.ts | 17 +++++++++++++++++ tests/flows/README.md | 22 ++++++++++++++++++++++ 6 files changed, 97 insertions(+) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/.gitignore create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/smoke.spec.ts create mode 100644 tests/flows/README.md diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 0313c4d..9f7c7ea 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -22,5 +22,8 @@ jobs: enable-eslint: true enable-phpunit: true enable-newman: false + enable-playwright: true + playwright-test-path: tests/e2e + playwright-seed-command: "php occ app:disable app-template && php occ app:enable app-template" additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"main"}]' enable-sbom: true diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..250a958 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test' + +const baseURL = process.env.NEXTCLOUD_URL || 'http://localhost:8080' + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30_000, + retries: 1, + use: { + baseURL, + storageState: './tests/e2e/.auth/user.json', + screenshot: 'only-on-failure', + trace: 'on-first-retry', + }, + globalSetup: './tests/e2e/global-setup.ts', + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], + reporter: [ + ['html', { open: 'never' }], + ['list'], + ], +}) diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000..976ce5b --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,3 @@ +.auth/ +test-results/ +playwright-report/ diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..b3199ed --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,26 @@ +import { chromium, type FullConfig } from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' + +async function globalSetup(config: FullConfig) { + const baseURL = config.projects[0].use.baseURL || 'http://localhost:8080' + const authDir = path.join(__dirname, '.auth') + + if (!fs.existsSync(authDir)) { + fs.mkdirSync(authDir, { recursive: true }) + } + + const browser = await chromium.launch() + const page = await browser.newPage() + + await page.goto(`${baseURL}/login`) + await page.getByRole('textbox', { name: 'Account name or email' }).fill('admin') + await page.getByRole('textbox', { name: 'Password' }).fill('admin') + await page.getByRole('button', { name: 'Log in', exact: true }).click() + await page.waitForURL('**/apps/**') + + await page.context().storageState({ path: path.join(authDir, 'user.json') }) + await browser.close() +} + +export default globalSetup diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..e94ddba --- /dev/null +++ b/tests/e2e/smoke.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from '@playwright/test' + +// Replace 'app-template' with your app ID from appinfo/info.xml +const APP_ID = 'app-template' + +test.describe('Smoke tests', () => { + test('app loads without server errors', async ({ page }) => { + const response = await page.goto(`/apps/${APP_ID}/`) + expect(response?.status()).toBeLessThan(500) + }) + + test('sidebar navigation is visible', async ({ page }) => { + await page.goto(`/apps/${APP_ID}/`) + const nav = page.locator('#app-navigation, [role="navigation"]').first() + await expect(nav).toBeVisible() + }) +}) diff --git a/tests/flows/README.md b/tests/flows/README.md new file mode 100644 index 0000000..4fc5f4c --- /dev/null +++ b/tests/flows/README.md @@ -0,0 +1,22 @@ +# Test Flows + +This directory contains markdown test flow files for LLM-based testing. + +Test flows describe user journeys in structured markdown that can be executed by: +- **LLM test agents** (via `/test-app` or persona testers) +- **Human testers** following the steps manually + +## Naming Convention + +Files are numbered and named by user journey: +- `01-dashboard.md` — Dashboard page verification +- `02-sidebar-navigation.md` — Navigation structure +- `03-{feature}.md` — Feature-specific flows + +## Structure + +Each flow file contains: +- **Persona** — Who is performing the test +- **Preconditions** — Required state before testing +- **Steps** — Numbered actions with expected results +- **Verification** — What to check after the flow completes From 99c4ca5a7328e0545499e1959eb439d0b34eb5cf Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 24 Mar 2026 15:41:54 +0100 Subject: [PATCH 2/2] feat: Add settings, services, listeners, l10n, and template setup - Add SettingsController and UserSettings view - Add Service layer, Listener scaffolding, and Repair steps - Add l10n translation structure - Add template-setup workflow and setup script - Add PHPUnit config with bootstrap - Update README, routes, App.vue, router, and store - Add project.md with coding standards --- .github/workflows/template-setup.yml | 135 ++++++++ README.md | 274 +++++++++++----- appinfo/info.xml | 32 +- appinfo/routes.php | 13 +- l10n/en.json | 34 ++ l10n/nl.json | 34 ++ lib/AppInfo/Application.php | 13 +- lib/Controller/SettingsController.php | 99 ++++++ lib/Listener/DeepLinkRegistrationListener.php | 58 ++++ lib/Repair/InitializeSettings.php | 101 ++++++ lib/Service/SettingsService.php | 307 ++++++++++++++++++ lib/Settings/app_template_register.json | 48 +++ phpunit.xml | 16 + project.md | 71 ++++ setup.sh | 208 ++++++++++++ src/App.vue | 73 ++++- src/navigation/MainMenu.vue | 42 +++ src/router/index.js | 9 +- src/store/store.js | 18 +- src/views/settings/UserSettings.vue | 131 ++++++++ tests/bootstrap.php | 48 +++ .../Controller/SettingsControllerTest.php | 120 +++++++ 22 files changed, 1750 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/template-setup.yml create mode 100644 l10n/en.json create mode 100644 l10n/nl.json create mode 100644 lib/Controller/SettingsController.php create mode 100644 lib/Listener/DeepLinkRegistrationListener.php create mode 100644 lib/Repair/InitializeSettings.php create mode 100644 lib/Service/SettingsService.php create mode 100644 lib/Settings/app_template_register.json create mode 100644 phpunit.xml create mode 100644 project.md create mode 100755 setup.sh create mode 100644 src/navigation/MainMenu.vue create mode 100644 src/views/settings/UserSettings.vue create mode 100644 tests/bootstrap.php create mode 100644 tests/unit/Controller/SettingsControllerTest.php diff --git a/.github/workflows/template-setup.yml b/.github/workflows/template-setup.yml new file mode 100644 index 0000000..0c26b14 --- /dev/null +++ b/.github/workflows/template-setup.yml @@ -0,0 +1,135 @@ +# One-time setup workflow — runs when a new repository is created from this template. +# Replaces all placeholders with the repo name and description, creates branches, then deletes itself. + +name: Template Setup + +on: + create: + +jobs: + setup: + # Only run on the default branch and only if this is a new repo (not a branch creation) + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.ref_type == 'repository' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive naming variants + id: names + run: | + # Get repo name (kebab-case) + REPO_NAME="${{ github.event.repository.name }}" + DESCRIPTION="${{ github.event.repository.description }}" + + # Default description if empty + if [ -z "$DESCRIPTION" ]; then + DESCRIPTION="A Nextcloud app" + fi + + # Derive variants + KEBAB="$REPO_NAME" + SNAKE=$(echo "$KEBAB" | tr '-' '_') + PASCAL=$(echo "$KEBAB" | sed -E 's/(^|-)([a-z])/\U\2/g') + DISPLAY=$(echo "$KEBAB" | sed -E 's/(^|-)([a-z])/\U\2/g; s/([A-Z])/ \1/g; s/^ //') + UPPER_SNAKE=$(echo "$SNAKE" | tr '[:lower:]' '[:upper:]') + + echo "kebab=$KEBAB" >> "$GITHUB_OUTPUT" + echo "snake=$SNAKE" >> "$GITHUB_OUTPUT" + echo "pascal=$PASCAL" >> "$GITHUB_OUTPUT" + echo "display=$DISPLAY" >> "$GITHUB_OUTPUT" + echo "upper_snake=$UPPER_SNAKE" >> "$GITHUB_OUTPUT" + echo "description=$DESCRIPTION" >> "$GITHUB_OUTPUT" + + echo "App name: $KEBAB ($PASCAL)" + echo "Description: $DESCRIPTION" + + - name: Replace placeholders in all files + run: | + KEBAB="${{ steps.names.outputs.kebab }}" + SNAKE="${{ steps.names.outputs.snake }}" + PASCAL="${{ steps.names.outputs.pascal }}" + DISPLAY="${{ steps.names.outputs.display }}" + UPPER_SNAKE="${{ steps.names.outputs.upper_snake }}" + DESCRIPTION="${{ steps.names.outputs.description }}" + ORG="${{ github.repository_owner }}" + + # Find all text files to process + FILES=$(find . \ + -not -path './.git/*' \ + -not -path './node_modules/*' \ + -not -path './vendor/*' \ + -not -path './js/*' \ + -not -path './setup.sh' \ + -not -name 'template-setup.yml' \ + -type f \ + \( -name '*.php' -o -name '*.js' -o -name '*.vue' -o -name '*.json' \ + -o -name '*.xml' -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' \ + -o -name '*.css' -o -name '*.neon' -o -name '*.config.js' \)) + + for file in $FILES; do + # Replace template placeholders + sed -i "s|app-template|${KEBAB}|g" "$file" + sed -i "s|app_template|${SNAKE}|g" "$file" + sed -i "s|AppTemplate|${PASCAL}|g" "$file" + sed -i "s|App Template|${DISPLAY}|g" "$file" + sed -i "s|APP_TEMPLATE|${UPPER_SNAKE}|g" "$file" + sed -i "s|nextcloud-app-template|${KEBAB}|g" "$file" + # Update GitHub org references + sed -i "s|ConductionNL/nextcloud-app-template|${ORG}/${KEBAB}|g" "$file" + sed -i "s|ConductionNL/app-template|${ORG}/${KEBAB}|g" "$file" + done + + # Update info.xml description + if [ -f "appinfo/info.xml" ]; then + sed -i "s|[^<]*|${DESCRIPTION}|" "appinfo/info.xml" + fi + + # Rename register JSON + if [ -f "lib/Settings/app_template_register.json" ]; then + mv "lib/Settings/app_template_register.json" "lib/Settings/${SNAKE}_register.json" + fi + + # Update settings mount point + if [ -f "templates/settings/admin.php" ]; then + sed -i "s|app-template-settings|${KEBAB}-settings|g" "templates/settings/admin.php" + fi + + echo "Placeholders replaced successfully" + + - name: Remove setup files + run: | + rm -f setup.sh + rm -f .github/workflows/template-setup.yml + echo "Setup files removed" + + - name: Commit changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore: Initialize ${{ steps.names.outputs.display }} from template + + Auto-configured from nextcloud-app-template: + - Replaced all placeholders with ${{ steps.names.outputs.kebab }} + - Renamed register JSON to ${{ steps.names.outputs.snake }}_register.json + - Removed setup script and this workflow" + + - name: Push to main and create branches + run: | + # Push the initialized main branch + git push origin main + + # Create beta and development branches + git checkout -b beta + git push origin beta + + git checkout -b development + git push origin development + + echo "Created branches: main, beta, development" diff --git a/README.md b/README.md index b360af8..6db62b8 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,232 @@ # Nextcloud App Template -A starting point for building Nextcloud apps following [ConductionNL](https://github.com/ConductionNL) conventions. +A starting point for building Nextcloud apps following [ConductionNL](https://github.com/ConductionNL) conventions. Includes a full-stack scaffold with PHP backend, Vue 2 frontend, OpenRegister integration, quality tooling, CI/CD, and automated setup. -## What's included +## Getting Started -- **PHP scaffold** — `Application`, `DashboardController`, `AdminSettings`, `SettingsSection` -- **Vue 2 + Pinia frontend** — `App.vue`, router, settings store, object store -- **Admin settings page** — version card + configurable settings form -- **OpenRegister integration** — pre-wired object store for the OpenRegister data layer -- **Full quality pipeline** — PHPCS, PHPMD, Psalm, PHPStan, ESLint, Stylelint -- **CI/CD workflows** — code quality, beta/stable releases, branch protection, OpenSpec sync, issue triage -- **Custom PHPCS sniff** — enforces named parameters on all internal code calls +There are two ways to set up a new app from this template: -## Quick start +### Option A: Automatic (GitHub Actions) -### 1. Use this template +1. Click **Use this template** on GitHub +2. Name your repository (kebab-case, e.g. `my-cool-app`) and add a description +3. The **Template Setup** workflow runs automatically and: + - Replaces all placeholders with your repo name + - Renames the register JSON file + - Creates `main`, `beta`, and `development` branches + - Deletes the setup script and workflow -Click **Use this template** on GitHub, or use the `/app-create` skill in the workspace. +### Option B: Manual (setup script) -### 2. Replace placeholders +1. Clone or create from template +2. Run the interactive setup script: -| Placeholder | Replace with | -|---|---| -| `app-template` | Your app ID (kebab-case, e.g. `my-new-app`) | -| `app_template` | Snake-case variant (e.g. `my_new_app`) | -| `AppTemplate` | PascalCase namespace (e.g. `MyNewApp`) | -| `APP_TEMPLATE` | SCREAMING_SNAKE constant (e.g. `MY_NEW_APP`) | -| `Nextcloud App Template` | Human-readable name | -| `A template for creating new Nextcloud apps` | Your app description | +```bash +bash setup.sh +``` + +The script asks for your app name, description, and author, then replaces all placeholders and renames files. Delete `setup.sh` when done. -### 3. Install dependencies +### After Setup ```bash composer install npm install +npm run build +docker exec nextcloud php occ app:enable your-app-name ``` -### 4. Build frontend +## What to Customize -```bash -npm run dev # development build with watch -npm run build # production build +After setup, these are the files you'll typically modify: + +| File | What to change | +|------|---------------| +| `lib/Settings/*_register.json` | Define your schemas (object types) in OpenAPI 3.0.0 format | +| `lib/Service/SettingsService.php` | Add config keys for each schema in `CONFIG_KEYS` and `SLUG_TO_CONFIG_KEY` | +| `src/store/store.js` | Register object types from settings config | +| `src/router/index.js` | Add routes for your views (list + detail per object type) | +| `src/navigation/MainMenu.vue` | Add navigation items for your object types | +| `src/views/` | Add list and detail views for your object types | +| `lib/Listener/DeepLinkRegistrationListener.php` | Register deep links for your object types | +| `l10n/en.json` + `l10n/nl.json` | Add translation keys | +| `appinfo/info.xml` | Update descriptions, categories, screenshots | +| `openspec/config.yaml` | Update project context for spec-driven development | +| `project.md` | Document your app's architecture and features | + +## Repository Structure + +``` +app-template/ +| +|-- appinfo/ # Nextcloud app metadata +| |-- info.xml # App manifest (name, version, deps, navigation, settings, repair steps) +| +-- routes.php # API routes + SPA catch-all +| +|-- lib/ # PHP backend +| |-- AppInfo/ +| | +-- Application.php # Main app class (IBootstrap), registers listeners +| |-- Controller/ +| | |-- DashboardController.php # Renders the main SPA template +| | +-- SettingsController.php # Settings API (GET/POST + load/reimport) +| |-- Service/ +| | +-- SettingsService.php # Config management, OpenRegister import, auto-configure +| |-- Listener/ +| | +-- DeepLinkRegistrationListener.php # Unified search deep links +| |-- Repair/ +| | +-- InitializeSettings.php # Post-migration: auto-imports register JSON +| |-- Settings/ +| | |-- AdminSettings.php # Admin settings page +| | +-- *_register.json # OpenRegister schema definitions (OpenAPI 3.0.0) +| +-- Sections/ +| +-- SettingsSection.php # Admin settings section +| +|-- src/ # Vue 2 frontend +| |-- main.js # App entry point (Pinia, router, translations) +| |-- settings.js # Admin settings entry point +| |-- pinia.js # Pinia store instance +| |-- App.vue # Root component (OpenRegister check, sidebar, loading) +| |-- router/ +| | +-- index.js # Hash-mode router with catch-all +| |-- store/ +| | |-- store.js # Store initializer (fetches settings, registers object types) +| | +-- modules/ +| | |-- settings.js # Settings Pinia store (fetch/save config) +| | +-- object.js # Generic OpenRegister object store (CRUD by type) +| |-- navigation/ +| | +-- MainMenu.vue # Left sidebar navigation +| |-- views/ +| | |-- Dashboard.vue # Main dashboard view +| | +-- settings/ +| | |-- AdminRoot.vue # Admin settings with version card +| | |-- Settings.vue # Admin config form +| | +-- UserSettings.vue # Personal preferences dialog +| +-- assets/ +| +-- app.css # Global styles (CSS variables only) +| +|-- templates/ # PHP templates for SPA mounting +| |-- index.php # Main app: loads JS, renders
+| +-- settings/ +| +-- admin.php # Admin: loads settings JS, renders mount point +| +|-- img/ # Icons and screenshots +| |-- app.svg # Light mode icon +| |-- app-dark.svg # Dark mode icon +| +-- app-store.svg # App store listing icon +| +|-- l10n/ # Translations +| |-- en.json # English +| +-- nl.json # Dutch +| +|-- tests/ # PHPUnit tests +| |-- bootstrap.php # Test bootstrap (OCP autoloader) +| +-- unit/ +| +-- Controller/ +| +-- SettingsControllerTest.php +| +|-- openspec/ # Spec-driven development +| +-- config.yaml # OpenSpec project configuration +| +|-- .github/workflows/ # CI/CD pipelines +| |-- code-quality.yml # PHPCS, PHPMD, Psalm, PHPStan, ESLint +| |-- release-stable.yml # Stable release (on main push) +| |-- release-beta.yml # Beta release (on beta push) +| |-- branch-protection.yml # PR check enforcement +| |-- sync-to-beta.yml # Auto-sync development -> beta +| |-- openspec-sync.yml # Sync specs to project management +| |-- issue-triage.yml # Auto-triage issues to project board +| |-- documentation.yml # Build docs site +| +-- template-setup.yml # One-time setup (deletes itself after run) +| +|-- phpcs-custom-sniffs/ # Custom PHPCS rules +| +-- CustomSniffs/Sniffs/Functions/NamedParametersSniff.php +| +|-- setup.sh # Interactive setup script (delete after use) +|-- project.md # Project architecture documentation +|-- composer.json # PHP dependencies + quality scripts +|-- package.json # npm dependencies + build scripts +|-- webpack.config.js # Webpack 5 config (main + settings entries) +|-- phpcs.xml # PHPCS standard +|-- phpmd.xml # PHPMD ruleset +|-- phpstan.neon # PHPStan config (level 5) +|-- psalm.xml # Psalm config (error level 4) +|-- phpunit.xml # PHPUnit config ++-- LICENSE # EUPL-1.2 ``` -### 5. Enable in Nextcloud +## Architecture + +### Data Flow -```bash -docker exec nextcloud php occ app:enable app-template ``` +User --> Vue Frontend (Pinia stores) + | + |--> OpenRegister API (object CRUD) + |--> Settings API (/api/settings) + +Admin --> Settings Page + | + |--> SettingsController --> SettingsService --> IAppConfig + |--> Load/reimport --> ConfigurationService --> *_register.json +``` + +### Key Patterns + +- **Thin client** — The app owns no database tables. All data is stored via OpenRegister. +- **Register JSON** — Schema definitions live in `lib/Settings/*_register.json` (OpenAPI 3.0.0 format). They are auto-imported on app install via the `InitializeSettings` repair step. +- **Deep links** — The `DeepLinkRegistrationListener` registers URL patterns so Nextcloud's unified search links directly to your detail views. +- **Sidebar state** — `App.vue` provides a reactive `sidebarState` object via Vue's `provide/inject` for the `CnIndexSidebar` component. +- **Hash-mode router** — All apps use hash-mode routing (`/#/path`) with a catch-all backend route. + +### Placeholder Naming Convention + +The template uses four naming variants that get replaced during setup: + +| Variant | Template Value | Example | +|---------|---------------|---------| +| kebab-case | `app-template` | `my-cool-app` | +| snake_case | `app_template` | `my_cool_app` | +| PascalCase | `AppTemplate` | `MyCoolApp` | +| Display | `App Template` | `My Cool App` | -## Code quality +## Code Quality ```bash -composer check:strict # lint + phpcs + phpmd + psalm + phpstan + tests -composer cs:fix # auto-fix PHPCS issues -npm run lint # ESLint -npm run stylelint # Stylelint -``` +# Full PHP quality suite (PHPCS + PHPMD + Psalm + PHPStan + tests) +composer check:strict -## Project structure +# Quick check (lint + PHPCS + Psalm + tests) +composer check -``` -app-template/ -├── appinfo/ -│ ├── info.xml # App metadata -│ └── routes.php # API + SPA routes -├── lib/ -│ ├── AppInfo/Application.php -│ ├── Controller/DashboardController.php -│ ├── Settings/AdminSettings.php -│ └── Sections/SettingsSection.php -├── templates/ -│ ├── index.php # Main SPA shell -│ └── settings/admin.php # Admin settings shell -├── src/ -│ ├── main.js # Main app entry -│ ├── settings.js # Admin settings entry -│ ├── App.vue # Root component -│ ├── pinia.js # Pinia instance -│ ├── router/index.js # Vue Router -│ ├── store/ -│ │ ├── store.js # Store initializer -│ │ └── modules/ -│ │ ├── settings.js # Settings Pinia store -│ │ └── object.js # Generic OpenRegister object store -│ ├── views/ -│ │ ├── Dashboard.vue -│ │ └── settings/ -│ │ ├── AdminRoot.vue -│ │ └── Settings.vue -│ └── assets/app.css -├── .github/workflows/ # CI/CD pipelines -├── phpcs-custom-sniffs/ # Named parameters enforcement -├── composer.json -├── package.json -├── webpack.config.js -├── psalm.xml -├── phpstan.neon -├── phpcs.xml -└── phpmd.xml +# Auto-fix PHP code style +composer cs:fix + +# Frontend linting +npm run lint # ESLint +npm run lint-fix # ESLint auto-fix +npm run stylelint # CSS/SCSS linting ``` ## Branches -| Branch | Purpose | -|---|---| -| `main` | Stable releases — triggers release workflow | -| `development` | Active development — auto-syncs to `beta` | -| `beta` | Beta releases | -| `documentation` | Docs site (GitHub Pages) | +| Branch | Purpose | Trigger | +|--------|---------|---------| +| `main` | Stable releases | Push triggers release-stable workflow | +| `beta` | Beta releases | Push triggers release-beta workflow | +| `development` | Active development | Push auto-syncs to beta | +| `documentation` | Docs site | Push triggers GitHub Pages build | ## Requirements - PHP 8.1+ -- Nextcloud 28–33 +- Nextcloud 28-33 - Node.js 20+, npm 10+ - [OpenRegister](https://github.com/ConductionNL/openregister) (runtime dependency) ## License -EUPL-1.2 — see [LICENSE](LICENSE) +EUPL-1.2 — see [LICENSE](LICENSE). + +Registered on the Nextcloud App Store as AGPL (the store does not recognize EUPL). **Support:** support@conduction.nl | [conduction.nl](https://conduction.nl) diff --git a/appinfo/info.xml b/appinfo/info.xml index 6fb1353..183778a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -2,8 +2,8 @@ app-template - Nextcloud App Template - Nextcloud App Template + App Template + App Template A template for creating new Nextcloud apps Een sjabloon voor het maken van nieuwe Nextcloud-apps Conduction AppTemplate - https://github.com/ConductionNL/nextcloud-app-template - https://github.com/ConductionNL/nextcloud-app-template - https://github.com/ConductionNL/nextcloud-app-template + https://github.com/ConductionNL/app-template + https://github.com/ConductionNL/app-template + https://github.com/ConductionNL/app-template tools - https://github.com/ConductionNL/nextcloud-app-template - https://github.com/ConductionNL/nextcloud-app-template/discussions - https://github.com/ConductionNL/nextcloud-app-template/issues - https://github.com/ConductionNL/nextcloud-app-template + https://github.com/ConductionNL/app-template + https://github.com/ConductionNL/app-template/discussions + https://github.com/ConductionNL/app-template/issues + https://github.com/ConductionNL/app-template - https://raw.githubusercontent.com/ConductionNL/nextcloud-app-template/main/img/app-store.svg + https://raw.githubusercontent.com/ConductionNL/app-template/main/img/app-store.svg @@ -69,4 +71,10 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\AppTemplate\Settings\AdminSettings OCA\AppTemplate\Sections\SettingsSection + + + + OCA\AppTemplate\Repair\InitializeSettings + + diff --git a/appinfo/routes.php b/appinfo/routes.php index 30da406..f0995b5 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -4,18 +4,15 @@ return [ 'routes' => [ - // Dashboard + Settings. + // Dashboard page. ['name' => 'dashboard#page', 'url' => '/', 'verb' => 'GET'], + + // Settings API. ['name' => 'settings#index', 'url' => '/api/settings', 'verb' => 'GET'], ['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'], - ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'], - - // Prometheus metrics endpoint. - ['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'], - // Health check endpoint. - ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'], + ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'], - // SPA catch-all — serves the Vue app for any frontend route (history mode) + // SPA catch-all — serves the Vue app for any frontend route (hash mode fallback). ['name' => 'dashboard#page', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']], ], ]; diff --git a/l10n/en.json b/l10n/en.json new file mode 100644 index 0000000..9563c4a --- /dev/null +++ b/l10n/en.json @@ -0,0 +1,34 @@ +{ + "translations": { + "App Template": "App Template", + "Dashboard": "Dashboard", + "Configuration": "Configuration", + "Settings": "Settings", + "Save": "Save", + "Saving...": "Saving...", + "Cancel": "Cancel", + "Delete": "Delete", + "Edit": "Edit", + "Search": "Search", + "Loading...": "Loading...", + "Register": "Register", + "OpenRegister register ID": "OpenRegister register ID", + "Settings saved successfully": "Settings saved successfully", + "Configuration saved": "Configuration saved", + "OpenRegister is required": "OpenRegister is required", + "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.", + "Install OpenRegister": "Install OpenRegister", + "Welcome to App Template": "Welcome to App Template", + "Start building your Nextcloud app here.": "Start building your Nextcloud app here.", + "Version Information": "Version Information", + "Information about the current App Template installation": "Information about the current App Template installation", + "Support": "Support", + "For support, contact us at": "For support, contact us at", + "App Template settings": "App Template settings", + "Preferences": "Preferences", + "Configure your personal preferences.": "Configure your personal preferences.", + "Notifications for changes": "Notifications for changes", + "Get notified when items are changed.": "Get notified when items are changed.", + "Configure the app settings": "Configure the app settings" + } +} diff --git a/l10n/nl.json b/l10n/nl.json new file mode 100644 index 0000000..7c68c5e --- /dev/null +++ b/l10n/nl.json @@ -0,0 +1,34 @@ +{ + "translations": { + "App Template": "App Template", + "Dashboard": "Dashboard", + "Configuration": "Configuratie", + "Settings": "Instellingen", + "Save": "Opslaan", + "Saving...": "Opslaan...", + "Cancel": "Annuleren", + "Delete": "Verwijderen", + "Edit": "Bewerken", + "Search": "Zoeken", + "Loading...": "Laden...", + "Register": "Register", + "OpenRegister register ID": "OpenRegister register-ID", + "Settings saved successfully": "Instellingen succesvol opgeslagen", + "Configuration saved": "Configuratie opgeslagen", + "OpenRegister is required": "OpenRegister is vereist", + "This app needs OpenRegister to store and manage data. Please install OpenRegister from the app store to get started.": "Deze app heeft OpenRegister nodig om gegevens op te slaan en te beheren. Installeer OpenRegister via de app store om te beginnen.", + "Install OpenRegister": "OpenRegister installeren", + "Welcome to App Template": "Welkom bij App Template", + "Start building your Nextcloud app here.": "Begin hier met het bouwen van je Nextcloud-app.", + "Version Information": "Versie-informatie", + "Information about the current App Template installation": "Informatie over de huidige App Template-installatie", + "Support": "Ondersteuning", + "For support, contact us at": "Voor ondersteuning, neem contact op via", + "App Template settings": "App Template-instellingen", + "Preferences": "Voorkeuren", + "Configure your personal preferences.": "Configureer je persoonlijke voorkeuren.", + "Notifications for changes": "Meldingen bij wijzigingen", + "Get notified when items are changed.": "Ontvang een melding wanneer items worden gewijzigd.", + "Configure the app settings": "Configureer de app-instellingen" + } +} diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3aa1da1..e9e0a7c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -21,6 +21,8 @@ namespace OCA\AppTemplate\AppInfo; +use OCA\OpenRegister\Event\DeepLinkRegistrationEvent; +use OCA\AppTemplate\Listener\DeepLinkRegistrationListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -52,12 +54,11 @@ public function __construct() */ public function register(IRegistrationContext $context): void { - // Register your event listeners and services here. - // Example: - // $context->registerEventListener( - // event: SomeEvent::class, - // listener: SomeListener::class - // ); + // Register deep link listener for Nextcloud unified search integration. + $context->registerEventListener( + event: DeepLinkRegistrationEvent::class, + listener: DeepLinkRegistrationListener::class + ); }//end register() /** diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php new file mode 100644 index 0000000..0c197a4 --- /dev/null +++ b/lib/Controller/SettingsController.php @@ -0,0 +1,99 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\AppTemplate\Controller; + +use OCA\AppTemplate\AppInfo\Application; +use OCA\AppTemplate\Service\SettingsService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Controller for managing AppTemplate application settings. + */ +class SettingsController extends Controller +{ + /** + * Constructor for the SettingsController. + * + * @param IRequest $request The request object + * @param SettingsService $settingsService The settings service + * + * @return void + */ + public function __construct( + IRequest $request, + private SettingsService $settingsService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Retrieve all current settings. + * + * @NoAdminRequired + * + * @return JSONResponse + */ + public function index(): JSONResponse + { + return new JSONResponse( + [ + 'success' => true, + 'config' => $this->settingsService->getSettings(), + ] + ); + }//end index() + + /** + * Update settings with provided data. + * + * @return JSONResponse + */ + public function create(): JSONResponse + { + $data = $this->request->getParams(); + $config = $this->settingsService->updateSettings($data); + + return new JSONResponse( + [ + 'success' => true, + 'config' => $config, + ] + ); + }//end create() + + /** + * Re-import the configuration from app_template_register.json. + * + * Forces a fresh import regardless of version, auto-configuring + * all schema and register IDs from the import result. + * + * @return JSONResponse + */ + public function load(): JSONResponse + { + $result = $this->settingsService->loadConfiguration(force: true); + + return new JSONResponse($result); + }//end load() +}//end class diff --git a/lib/Listener/DeepLinkRegistrationListener.php b/lib/Listener/DeepLinkRegistrationListener.php new file mode 100644 index 0000000..5e9ed94 --- /dev/null +++ b/lib/Listener/DeepLinkRegistrationListener.php @@ -0,0 +1,58 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\AppTemplate\Listener; + +use OCA\OpenRegister\Event\DeepLinkRegistrationEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** + * Registers AppTemplate's deep link URL patterns with OpenRegister's search provider. + * + * When a user searches in Nextcloud's unified search, results for AppTemplate schemas + * will link directly to AppTemplate's detail views. + */ +class DeepLinkRegistrationListener implements IEventListener +{ + /** + * Handle the deep link registration event. + * + * @param Event $event The event to handle + * + * @return void + */ + public function handle(Event $event): void + { + if ($event instanceof DeepLinkRegistrationEvent === false) { + return; + } + + // Register deep links for each object type. + // Update the register slug, schema slug, and URL template to match your app. + $event->register( + appId: 'app-template', + registerSlug: 'app-template', + schemaSlug: 'example', + urlTemplate: '/apps/app-template/#/examples/{uuid}' + ); + }//end handle() +}//end class diff --git a/lib/Repair/InitializeSettings.php b/lib/Repair/InitializeSettings.php new file mode 100644 index 0000000..2e91764 --- /dev/null +++ b/lib/Repair/InitializeSettings.php @@ -0,0 +1,101 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\AppTemplate\Repair; + +use OCA\AppTemplate\Service\SettingsService; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; + +/** + * Repair step that initializes AppTemplate configuration via ConfigurationService. + */ +class InitializeSettings implements IRepairStep +{ + /** + * Constructor for InitializeSettings. + * + * @param SettingsService $settingsService The settings service + * @param LoggerInterface $logger The logger interface + * + * @return void + */ + public function __construct( + private SettingsService $settingsService, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Get the name of this repair step. + * + * @return string + */ + public function getName(): string + { + return 'Initialize App Template register and schemas via ConfigurationService'; + }//end getName() + + /** + * Run the repair step to initialize AppTemplate configuration. + * + * @param IOutput $output The output interface for progress reporting + * + * @return void + */ + public function run(IOutput $output): void + { + $output->info('Initializing App Template configuration...'); + + if ($this->settingsService->isOpenRegisterAvailable() === false) { + $output->warning( + 'OpenRegister is not installed or enabled. Skipping auto-configuration.' + ); + $this->logger->warning( + 'AppTemplate: OpenRegister not available, skipping register initialization' + ); + return; + } + + try { + $result = $this->settingsService->loadConfiguration(force: true); + + if ($result['success'] === true) { + $version = ($result['version'] ?? 'unknown'); + $output->info( + 'App Template configuration imported successfully (version: '.$version.')' + ); + } else { + $message = ($result['message'] ?? 'unknown error'); + $output->warning( + 'App Template configuration import issue: '.$message + ); + } + } catch (\Throwable $e) { + $output->warning('Could not auto-configure App Template: '.$e->getMessage()); + $this->logger->error( + 'AppTemplate initialization failed', + ['exception' => $e->getMessage()] + ); + }//end try + }//end run() +}//end class diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php new file mode 100644 index 0000000..654a2d3 --- /dev/null +++ b/lib/Service/SettingsService.php @@ -0,0 +1,307 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\AppTemplate\Service; + +use OCA\AppTemplate\AppInfo\Application; +use OCP\IAppConfig; +use OCP\App\IAppManager; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Service for managing AppTemplate application configuration and settings. + */ +class SettingsService +{ + /** + * Configuration keys managed by this app. + * + * Add your own config keys here — one per schema/object type plus the register. + */ + private const CONFIG_KEYS = [ + 'register', + 'example_schema', + ]; + + /** + * Mapping of schema slugs (from app_template_register.json) to app config keys. + * + * When OpenRegister imports the register JSON, it creates schema objects with slugs. + * This map connects those slugs to the IAppConfig keys above. + */ + private const SLUG_TO_CONFIG_KEY = [ + 'example' => 'example_schema', + ]; + + private const OPENREGISTER_APP_ID = 'openregister'; + + /** + * Constructor for the SettingsService. + * + * @param IAppConfig $appConfig The app configuration service + * @param IAppManager $appManager The app manager service + * @param ContainerInterface $container The DI container + * @param LoggerInterface $logger The logger interface + * + * @return void + */ + public function __construct( + private IAppConfig $appConfig, + private IAppManager $appManager, + private ContainerInterface $container, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Check if OpenRegister is installed and enabled. + * + * @return bool + */ + public function isOpenRegisterAvailable(): bool + { + return $this->appManager->isEnabledForUser(self::OPENREGISTER_APP_ID); + }//end isOpenRegisterAvailable() + + /** + * Load the register configuration from app_template_register.json via ConfigurationService. + * + * @param bool $force Whether to force re-import regardless of version + * + * @return array Import result + */ + public function loadConfiguration(bool $force=false): array + { + if ($this->isOpenRegisterAvailable() === false) { + return [ + 'success' => false, + 'message' => 'OpenRegister is not installed or enabled', + ]; + } + + try { + $configurationService = $this->container->get( + 'OCA\OpenRegister\Service\ConfigurationService' + ); + } catch (\Exception $e) { + $this->logger->error( + 'AppTemplate: Could not access ConfigurationService', + ['exception' => $e->getMessage()] + ); + return [ + 'success' => false, + 'message' => 'Could not access ConfigurationService: '.$e->getMessage(), + ]; + } + + $configPath = __DIR__.'/../Settings/app_template_register.json'; + if (file_exists($configPath) === false) { + $this->logger->error( + 'AppTemplate: Configuration file not found at '.$configPath + ); + return [ + 'success' => false, + 'message' => 'Configuration file not found', + ]; + } + + $configContent = file_get_contents($configPath); + $configData = json_decode($configContent, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->logger->error('AppTemplate: Invalid JSON in configuration file'); + return [ + 'success' => false, + 'message' => 'Invalid JSON in configuration file', + ]; + } + + $configVersion = ($configData['info']['version'] ?? '0.0.0'); + + try { + $importResult = $configurationService->importFromApp( + appId: Application::APP_ID, + data: $configData, + version: $configVersion, + force: $force, + ); + + $this->logger->info( + 'AppTemplate: Configuration imported successfully', + ['version' => $configVersion] + ); + + // Auto-configure schema IDs from import result. + $configuredCount = $this->autoConfigureAfterImport(importResult: $importResult); + + return [ + 'success' => true, + 'message' => 'Configuration imported and auto-configured ('.$configuredCount.' schemas mapped)', + 'version' => $configVersion, + 'configured' => $configuredCount, + 'result' => $importResult, + ]; + } catch (\Exception $e) { + $this->logger->error( + 'AppTemplate: Configuration import failed', + ['exception' => $e->getMessage()] + ); + return [ + 'success' => false, + 'message' => 'Import failed: '.$e->getMessage(), + ]; + }//end try + }//end loadConfiguration() + + /** + * Get all current settings as an associative array. + * + * @return array + */ + public function getSettings(): array + { + $config = []; + foreach (self::CONFIG_KEYS as $key) { + $config[$key] = $this->appConfig->getValueString(Application::APP_ID, $key, ''); + } + + return $config; + }//end getSettings() + + /** + * Update settings with the provided data. + * + * @param array $data The settings data to update + * + * @return array + */ + public function updateSettings(array $data): array + { + foreach (self::CONFIG_KEYS as $key) { + if (isset($data[$key]) === true) { + $this->appConfig->setValueString(Application::APP_ID, $key, (string) $data[$key]); + } + } + + $this->logger->info('AppTemplate settings updated', ['keys' => array_keys($data)]); + + return $this->getSettings(); + }//end updateSettings() + + /** + * Get a single configuration value by key. + * + * @param string $key The configuration key + * @param string $default The default value if key not found + * + * @return string + */ + public function getConfigValue(string $key, string $default=''): string + { + return $this->appConfig->getValueString(Application::APP_ID, $key, $default); + }//end getConfigValue() + + /** + * Set a single configuration value. + * + * @param string $key The configuration key + * @param string $value The value to set + * + * @return void + */ + public function setConfigValue(string $key, string $value): void + { + $this->appConfig->setValueString(Application::APP_ID, $key, $value); + }//end setConfigValue() + + /** + * Auto-configure schema and register IDs from the import result. + * + * @param array $importResult The result from ConfigurationService::importFromApp() + * + * @return int The number of schemas successfully configured + */ + private function autoConfigureAfterImport(array $importResult): int + { + $configuredCount = 0; + + // Configure register ID from imported registers. + $registers = ($importResult['registers'] ?? []); + foreach ($registers as $register) { + if (is_object($register) === false) { + continue; + } + + $registerId = (string) $register->getId(); + $this->appConfig->setValueString( + Application::APP_ID, + 'register', + $registerId + ); + $this->logger->info( + 'AppTemplate: Auto-configured register ID', + ['registerId' => $registerId] + ); + break; + } + + // Configure schema IDs from imported schemas. + $schemas = ($importResult['schemas'] ?? []); + foreach ($schemas as $schema) { + if (is_object($schema) === false) { + continue; + } + + $slug = $schema->getSlug(); + if (isset(self::SLUG_TO_CONFIG_KEY[$slug]) === false) { + continue; + } + + $configKey = self::SLUG_TO_CONFIG_KEY[$slug]; + $schemaId = (string) $schema->getId(); + + $this->appConfig->setValueString( + Application::APP_ID, + $configKey, + $schemaId + ); + + $this->logger->debug( + 'AppTemplate: Auto-configured schema', + [ + 'slug' => $slug, + 'configKey' => $configKey, + 'schemaId' => $schemaId, + ] + ); + + $configuredCount++; + }//end foreach + + $this->logger->info( + 'AppTemplate: Auto-configuration complete', + ['configuredSchemas' => $configuredCount] + ); + + return $configuredCount; + }//end autoConfigureAfterImport() +}//end class diff --git a/lib/Settings/app_template_register.json b/lib/Settings/app_template_register.json new file mode 100644 index 0000000..9502d85 --- /dev/null +++ b/lib/Settings/app_template_register.json @@ -0,0 +1,48 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "App Template Register", + "description": "Register containing all schemas for the App Template application. Replace this with your own schemas.", + "version": "0.1.0" + }, + "x-openregister": { + "type": "application", + "app": "app-template", + "openregister": "^v0.2.10", + "description": "A template for creating new Nextcloud apps" + }, + "paths": {}, + "components": { + "schemas": { + "example": { + "slug": "example", + "icon": "FileDocumentOutline", + "version": "1.0.0", + "x-schema-org-type": "schema:Thing", + "title": "Example", + "description": "An example object type. Replace this with your own schema definition.", + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 255, + "description": "Name of this item" + }, + "description": { + "type": "string", + "description": "Detailed description" + }, + "status": { + "type": "string", + "enum": ["draft", "active", "archived"], + "default": "draft", + "description": "Current status" + } + } + } + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..0e5a2cf --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,16 @@ + + + + + ./tests/unit + + + + + lib/ + + + diff --git a/project.md b/project.md new file mode 100644 index 0000000..5f07aed --- /dev/null +++ b/project.md @@ -0,0 +1,71 @@ +# App Template + +## Overview + +A template for creating new Nextcloud apps following ConductionNL conventions. Replace this description with your app's purpose. + +## Tech Stack + +- **Backend:** PHP 8.1+, Nextcloud 28-33 +- **Frontend:** Vue 2.7, Pinia 2.1, Vue Router 3.6, Webpack 5 +- **UI Components:** @nextcloud/vue 8.x, @conduction/nextcloud-vue +- **Data Layer:** OpenRegister (JSON object storage with schema validation) +- **Quality:** PHPCS, PHPMD, Psalm, PHPStan, ESLint, Stylelint +- **CI/CD:** GitHub Actions (quality, release, branch protection) + +## Architecture + +**Pattern:** Thin client on OpenRegister — this app owns no database tables. + +- Frontend Vue stores query the OpenRegister API directly +- Backend is minimal: settings controller + configuration import +- Register/schema definitions in `lib/Settings/app_template_register.json` +- Auto-imported via repair step on app install/enable + +## Key Directories + +``` +appinfo/ — App metadata, routes +lib/ — PHP backend (controllers, services, listeners, repair) +src/ — Vue frontend (views, store, router, navigation) +templates/ — PHP template shells for SPA mounting +img/ — App icons and screenshots +l10n/ — Translations (en, nl) +tests/ — PHPUnit tests +openspec/ — OpenSpec configuration and specs +``` + +## Development + +```bash +# Install dependencies +composer install +npm install + +# Build frontend +npm run build # production +npm run dev # development +npm run watch # watch mode + +# Quality checks +composer check:strict # Full PHP quality suite +npm run lint # ESLint +npm run stylelint # CSS linting + +# Tests (inside Nextcloud container) +docker exec -w /var/www/html/custom_apps/app-template nextcloud php vendor/bin/phpunit -c phpunit.xml +``` + +## Features + +- [ ] Dashboard +- [ ] Admin settings with OpenRegister configuration +- [ ] User settings +- [ ] Deep link search integration +- [ ] Example object type CRUD + +## Standards + +- NL Design System (CSS variables, WCAG AA) +- Schema.org type annotations +- OpenRegister data layer (no custom DB tables) diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..b82dcc2 --- /dev/null +++ b/setup.sh @@ -0,0 +1,208 @@ +#!/bin/bash +# +# Interactive setup script for ConductionNL Nextcloud App Template +# +# Replaces all template placeholders with your app's details. +# Run once after creating a new repository from this template. +# +# Usage: bash setup.sh +# + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${BLUE}================================${NC}" +echo -e "${BLUE} ConductionNL App Setup${NC}" +echo -e "${BLUE}================================${NC}" +echo "" + +# --- Gather information --- + +# Try to detect from git remote +DETECTED_NAME="" +REMOTE_URL=$(git remote get-url origin 2>/dev/null || echo "") +if [[ -n "$REMOTE_URL" ]]; then + DETECTED_NAME=$(echo "$REMOTE_URL" | sed -E 's|.*/([^/]+)(\.git)?$|\1|') +fi + +# App name (kebab-case) +echo -e "${YELLOW}App name (kebab-case, e.g. 'my-cool-app'):${NC}" +if [[ -n "$DETECTED_NAME" && "$DETECTED_NAME" != "nextcloud-app-template" ]]; then + echo -e " Detected from git remote: ${GREEN}${DETECTED_NAME}${NC}" + read -rp " App name [$DETECTED_NAME]: " APP_NAME + APP_NAME="${APP_NAME:-$DETECTED_NAME}" +else + read -rp " App name: " APP_NAME +fi + +if [[ -z "$APP_NAME" ]]; then + echo -e "${RED}Error: App name is required.${NC}" + exit 1 +fi + +# Validate kebab-case +if [[ ! "$APP_NAME" =~ ^[a-z][a-z0-9-]*$ ]]; then + echo -e "${RED}Error: App name must be kebab-case (lowercase letters, numbers, hyphens).${NC}" + exit 1 +fi + +# Description +echo "" +echo -e "${YELLOW}Short description (one line, for info.xml summary):${NC}" +read -rp " Description: " APP_DESCRIPTION + +if [[ -z "$APP_DESCRIPTION" ]]; then + APP_DESCRIPTION="A Nextcloud app" +fi + +# Dutch description +echo "" +echo -e "${YELLOW}Dutch description (leave empty to skip):${NC}" +read -rp " Beschrijving: " APP_DESCRIPTION_NL + +if [[ -z "$APP_DESCRIPTION_NL" ]]; then + APP_DESCRIPTION_NL="Een Nextcloud-app" +fi + +# Author +echo "" +echo -e "${YELLOW}Author name [Conduction]:${NC}" +read -rp " Author: " APP_AUTHOR +APP_AUTHOR="${APP_AUTHOR:-Conduction}" + +# Author email +echo "" +echo -e "${YELLOW}Author email [info@conduction.nl]:${NC}" +read -rp " Email: " APP_EMAIL +APP_EMAIL="${APP_EMAIL:-info@conduction.nl}" + +# --- Derive naming variants --- + +# kebab-case: my-cool-app (already have this) +APP_KEBAB="$APP_NAME" + +# snake_case: my_cool_app +APP_SNAKE=$(echo "$APP_KEBAB" | tr '-' '_') + +# PascalCase: MyCoolApp +APP_PASCAL=$(echo "$APP_KEBAB" | sed -E 's/(^|-)([a-z])/\U\2/g') + +# Display Name (from kebab, capitalize words) +APP_DISPLAY=$(echo "$APP_KEBAB" | sed -E 's/(^|-)([a-z])/\U\2/g; s/([A-Z])/ \1/g; s/^ //') + +echo "" +echo -e "${BLUE}--- Naming variants ---${NC}" +echo -e " kebab-case: ${GREEN}${APP_KEBAB}${NC}" +echo -e " snake_case: ${GREEN}${APP_SNAKE}${NC}" +echo -e " PascalCase: ${GREEN}${APP_PASCAL}${NC}" +echo -e " Display: ${GREEN}${APP_DISPLAY}${NC}" +echo -e " Description: ${GREEN}${APP_DESCRIPTION}${NC}" +echo -e " Author: ${GREEN}${APP_AUTHOR} <${APP_EMAIL}>${NC}" +echo "" + +read -rp "Proceed with these values? [Y/n]: " CONFIRM +CONFIRM="${CONFIRM:-Y}" +if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 +fi + +echo "" +echo -e "${BLUE}Replacing placeholders...${NC}" + +# --- Replacement function --- +replace_in_file() { + local file="$1" + if [[ -f "$file" ]]; then + # Template placeholders + sed -i "s|app-template|${APP_KEBAB}|g" "$file" + sed -i "s|app_template|${APP_SNAKE}|g" "$file" + sed -i "s|AppTemplate|${APP_PASCAL}|g" "$file" + sed -i "s|App Template|${APP_DISPLAY}|g" "$file" + sed -i "s|APP_TEMPLATE|$(echo "$APP_SNAKE" | tr '[:lower:]' '[:upper:]')|g" "$file" + # Also handle the nextcloud-app-template repo name + sed -i "s|nextcloud-app-template|${APP_KEBAB}|g" "$file" + fi +} + +# --- Process all files --- + +# Find all text files (skip .git, node_modules, vendor, build artifacts) +FILES=$(find . \ + -not -path './.git/*' \ + -not -path './node_modules/*' \ + -not -path './vendor/*' \ + -not -path './js/*' \ + -not -path './build/*' \ + -not -path './setup.sh' \ + -type f \ + \( -name '*.php' -o -name '*.js' -o -name '*.vue' -o -name '*.json' \ + -o -name '*.xml' -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' \ + -o -name '*.css' -o -name '*.neon' -o -name '*.config.js' \)) + +for file in $FILES; do + replace_in_file "$file" + echo -e " ${GREEN}✓${NC} $file" +done + +# --- Rename the register JSON file --- +if [[ -f "lib/Settings/app_template_register.json" ]]; then + mv "lib/Settings/app_template_register.json" "lib/Settings/${APP_SNAKE}_register.json" + echo -e " ${GREEN}✓${NC} Renamed register JSON to ${APP_SNAKE}_register.json" + # Update the reference in SettingsService.php + sed -i "s|${APP_SNAKE}_register.json|${APP_SNAKE}_register.json|g" "lib/Service/SettingsService.php" +fi + +# --- Update descriptions in info.xml --- +if [[ -f "appinfo/info.xml" ]]; then + sed -i "s|.*|${APP_DESCRIPTION}|" "appinfo/info.xml" + sed -i "s|.*|${APP_DESCRIPTION_NL}|" "appinfo/info.xml" + sed -i "s|[^<]*|${APP_AUTHOR}|" "appinfo/info.xml" + echo -e " ${GREEN}✓${NC} Updated info.xml descriptions and author" +fi + +# --- Update composer.json --- +if [[ -f "composer.json" ]]; then + # The name replacement is already handled by the general replacements + echo -e " ${GREEN}✓${NC} Updated composer.json" +fi + +# --- Update GitHub URLs --- +ORG="ConductionNL" +GITHUB_URLS_FILES=$(find . -type f \( -name '*.xml' -o -name '*.md' -o -name '*.yaml' -o -name '*.yml' \) \ + -not -path './.git/*' -not -path './node_modules/*' -not -path './vendor/*') +for file in $GITHUB_URLS_FILES; do + if [[ -f "$file" ]]; then + sed -i "s|ConductionNL/nextcloud-app-template|${ORG}/${APP_KEBAB}|g" "$file" + sed -i "s|ConductionNL/app-template|${ORG}/${APP_KEBAB}|g" "$file" + fi +done +echo -e " ${GREEN}✓${NC} Updated GitHub URLs" + +# --- Update settings mount point in templates --- +if [[ -f "templates/settings/admin.php" ]]; then + sed -i "s|app-template-settings|${APP_KEBAB}-settings|g" "templates/settings/admin.php" +fi + +echo "" +echo -e "${GREEN}================================${NC}" +echo -e "${GREEN} Setup complete!${NC}" +echo -e "${GREEN}================================${NC}" +echo "" +echo -e "Next steps:" +echo -e " 1. Review the changes: ${BLUE}git diff${NC}" +echo -e " 2. Install dependencies: ${BLUE}composer install && npm install${NC}" +echo -e " 3. Build the frontend: ${BLUE}npm run build${NC}" +echo -e " 4. Update the register JSON with your schemas: ${BLUE}lib/Settings/${APP_SNAKE}_register.json${NC}" +echo -e " 5. Add your object types in: ${BLUE}src/store/store.js${NC}" +echo -e " 6. Add navigation items in: ${BLUE}src/navigation/MainMenu.vue${NC}" +echo -e " 7. Add routes in: ${BLUE}src/router/index.js${NC}" +echo -e " 8. Update translations in: ${BLUE}l10n/en.json${NC} and ${BLUE}l10n/nl.json${NC}" +echo -e " 9. Delete this setup script: ${BLUE}rm setup.sh${NC}" +echo "" diff --git a/src/App.vue b/src/App.vue index 44e762e..1d9ee35 100644 --- a/src/App.vue +++ b/src/App.vue @@ -23,20 +23,22 @@
@@ -47,9 +49,11 @@ diff --git a/src/navigation/MainMenu.vue b/src/navigation/MainMenu.vue new file mode 100644 index 0000000..71e8cdd --- /dev/null +++ b/src/navigation/MainMenu.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/router/index.js b/src/router/index.js index 4fdb249..1ae31f9 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,15 +1,18 @@ import Vue from 'vue' import Router from 'vue-router' -import { generateUrl } from '@nextcloud/router' import Dashboard from '../views/Dashboard.vue' +import AdminRoot from '../views/settings/AdminRoot.vue' Vue.use(Router) export default new Router({ - mode: 'history', - base: generateUrl('/apps/app-template'), + mode: 'hash', routes: [ { path: '/', name: 'Dashboard', component: Dashboard }, + { path: '/settings', name: 'Settings', component: AdminRoot }, + // Add your routes here, for example: + // { path: '/examples', name: 'Examples', component: ExampleList }, + // { path: '/examples/:id', name: 'ExampleDetail', component: ExampleDetail, props: route => ({ exampleId: route.params.id }) }, { path: '*', redirect: '/' }, ], }) diff --git a/src/store/store.js b/src/store/store.js index 6e29bed..598bd12 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -1,4 +1,3 @@ -import { generateUrl } from '@nextcloud/router' import { useObjectStore } from './modules/object.js' import { useSettingsStore } from './modules/settings.js' @@ -6,12 +5,19 @@ export async function initializeStores() { const settingsStore = useSettingsStore() const objectStore = useObjectStore() - objectStore.configure({ - baseUrl: generateUrl('/apps/openregister/api/objects'), - schemaBaseUrl: generateUrl('/apps/openregister/api/schemas'), - }) + const config = await settingsStore.fetchSettings() - await settingsStore.fetchSettings() + if (config) { + // Register object types from settings. + // Each object type maps a config key to an OpenRegister schema + register. + if (config.register && config.example_schema) { + objectStore.registerObjectType('example', config.example_schema, config.register) + } + // Add more object types here as you add schemas to your register JSON: + // if (config.register && config.another_schema) { + // objectStore.registerObjectType('another', config.another_schema, config.register) + // } + } return { settingsStore, objectStore } } diff --git a/src/views/settings/UserSettings.vue b/src/views/settings/UserSettings.vue new file mode 100644 index 0000000..41ec7f1 --- /dev/null +++ b/src/views/settings/UserSettings.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..0e114d8 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,48 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +// Define that we're running PHPUnit. +define('PHPUNIT_RUN', 1); + +// Include Composer's autoloader. +require_once __DIR__ . '/../vendor/autoload.php'; + +// Register OCP/NCU classes from nextcloud/ocp package. +// nextcloud/ocp has no autoload section in its composer.json, so we register it manually. +spl_autoload_register(function (string $class): void { + $prefixMap = [ + 'OCP\\' => __DIR__ . '/../vendor/nextcloud/ocp/OCP/', + 'NCU\\' => __DIR__ . '/../vendor/nextcloud/ocp/NCU/', + ]; + + foreach ($prefixMap as $prefix => $dir) { + if (strncmp($class, $prefix, strlen($prefix)) !== 0) { + continue; + } + + $relative = str_replace(search: '\\', replace: '/', subject: substr($class, strlen($prefix))); + $file = $dir . $relative . '.php'; + if (file_exists($file) === true) { + require_once $file; + } + + break; + }//end foreach + +}); diff --git a/tests/unit/Controller/SettingsControllerTest.php b/tests/unit/Controller/SettingsControllerTest.php new file mode 100644 index 0000000..a7ce55e --- /dev/null +++ b/tests/unit/Controller/SettingsControllerTest.php @@ -0,0 +1,120 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\AppTemplate\Tests\Unit\Controller; + +use OCA\AppTemplate\Controller\SettingsController; +use OCA\AppTemplate\Service\SettingsService; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Tests for SettingsController. + */ +class SettingsControllerTest extends TestCase +{ + + /** + * The controller under test. + * + * @var SettingsController + */ + private SettingsController $controller; + + /** + * Mock IRequest. + * + * @var IRequest&MockObject + */ + private IRequest&MockObject $request; + + /** + * Mock SettingsService. + * + * @var SettingsService&MockObject + */ + private SettingsService&MockObject $settingsService; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->settingsService = $this->createMock(SettingsService::class); + + $this->controller = new SettingsController( + request: $this->request, + settingsService: $this->settingsService, + ); + + }//end setUp() + + /** + * Test that index() returns a JSONResponse with success and config keys. + * + * @return void + */ + public function testIndexReturnsJsonResponseWithExpectedKeys(): void + { + $this->settingsService->expects($this->once()) + ->method('getSettings') + ->willReturn(['register' => 'test-register']); + + $result = $this->controller->index(); + + self::assertInstanceOf(JSONResponse::class, $result); + self::assertTrue($result->getData()['success']); + self::assertArrayHasKey('config', $result->getData()); + + }//end testIndexReturnsJsonResponseWithExpectedKeys() + + /** + * Test that create() calls updateSettings with request params and returns success. + * + * @return void + */ + public function testCreateCallsUpdateSettingsAndReturnsSuccess(): void + { + $params = ['register' => 'new-register']; + + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn($params); + + $this->settingsService->expects($this->once()) + ->method('updateSettings') + ->with($params) + ->willReturn($params); + + $result = $this->controller->create(); + + self::assertInstanceOf(JSONResponse::class, $result); + self::assertTrue($result->getData()['success']); + self::assertArrayHasKey('config', $result->getData()); + + }//end testCreateCallsUpdateSettingsAndReturnsSuccess() + +}//end class