diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 1aa43ff..e60acc1 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -4,7 +4,7 @@ on: push: branches: [main, development, feature/**, bugfix/**, hotfix/**] pull_request: - branches: [main, master, development] + branches: [main, master, development, beta] workflow_dispatch: jobs: diff --git a/.github/workflows/pull-request-lint-check.yaml b/.github/workflows/pull-request-lint-check.yaml index 2ba165a..f240298 100644 --- a/.github/workflows/pull-request-lint-check.yaml +++ b/.github/workflows/pull-request-lint-check.yaml @@ -5,6 +5,7 @@ on: branches: - development - main + - beta jobs: lint-check: diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bcfa863 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +# Makefile for nextcloud-app-template development + +# Create a relative symlink in the parent directory so Nextcloud can find the +# app by its ID (app-template) even though the repo is cloned as nextcloud-app-template. +# Nextcloud requires the directory name to match the in appinfo/info.xml. +dev-link: + @if [ -L ../app-template ]; then \ + echo "Symlink ../app-template already exists."; \ + else \ + ln -s nextcloud-app-template ../app-template && \ + echo "Created symlink: apps-extra/app-template -> nextcloud-app-template"; \ + fi + +dev-unlink: + @if [ -L ../app-template ]; then \ + rm ../app-template && echo "Removed symlink ../app-template"; \ + else \ + echo "No symlink found at ../app-template."; \ + fi + +.PHONY: dev-link dev-unlink diff --git a/README.md b/README.md index 0f3a94d..3342792 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A starting point for building Nextcloud apps following ConductionNL conventions. -> **Requires:** [OpenRegister](https://github.com/ConductionNL/openregister) — all data is stored as OpenRegister objects. +> **Pre-wired for [OpenRegister](https://github.com/ConductionNL/openregister)** — all data is stored as OpenRegister objects. If your app needs OpenRegister, install it first. If not, remove the dependency from `appinfo/info.xml` and `openspec/app-config.json`. ## Screenshots @@ -26,7 +26,7 @@ _Add screenshots here once the app has a UI._ ## Features -Features are defined in [`appspec/features/`](appspec/features/). See the [roadmap](openspec/ROADMAP.md) for planned work. +Features are defined in [`openspec/specs/`](openspec/specs/). See the [roadmap](openspec/ROADMAP.md) for planned work. ### Core - **Dashboard** — Personal overview page with key information at a glance @@ -54,37 +54,40 @@ _Update this diagram during `/app-explore` sessions as the architecture evolves. |--------|-------------| | _(define your data objects here)_ | — | -_Data model is defined using OpenRegister schemas. See [`appspec/features/`](appspec/features/) for feature-level design decisions and [`appspec/adr/`](appspec/adr/) for architectural decisions._ +_Data model is defined using OpenRegister schemas. See [`openspec/specs/`](openspec/specs/) for feature-level design decisions and [`openspec/architecture/`](openspec/architecture/) for architectural decisions._ ### Directory Structure ``` app-template/ ├── appinfo/ # Nextcloud app manifest, routes, navigation -├── lib/ # PHP backend — controllers, settings +├── lib/ # PHP backend │ ├── AppInfo/Application.php -│ ├── Controller/DashboardController.php -│ ├── Settings/AdminSettings.php -│ └── Sections/SettingsSection.php +│ ├── Controller/ # DashboardController, SettingsController +│ ├── Service/SettingsService.php +│ ├── Listener/DeepLinkRegistrationListener.php +│ ├── Repair/InitializeSettings.php +│ └── Settings/ # AdminSettings, app_template_register.json ├── templates/ # PHP templates (SPA shells) ├── src/ # Vue 2 frontend │ ├── main.js # App entry point -│ ├── settings.js # Admin settings entry │ ├── App.vue # Root component +│ ├── navigation/MainMenu.vue # App navigation sidebar │ ├── router/ # Vue Router │ ├── store/ # Pinia stores -│ └── views/ # Route-level views -├── appspec/ # App configuration and specification +│ └── views/ # Route-level views + UserSettings.vue +├── openspec/ # Specifications, decisions, and roadmap │ ├── app-config.json # Canonical app config (id, goal, dependencies, CI) -│ ├── features/ # High-level feature definitions -│ └── adr/ # Architectural Decision Records -├── openspec/ # Implementation specifications and roadmap +│ ├── config.yaml # OpenSpec CLI configuration +│ ├── specs/ # Feature specs (input for OpenSpec changes) +│ ├── architecture/ # App-specific Architectural Decision Records │ ├── ROADMAP.md # Product roadmap -│ └── changes/ # OpenSpec change directories +│ └── changes/ # OpenSpec change directories (created on first change) +├── tests/ # Unit and integration tests +├── l10n/ # Translations (en, nl) ├── .github/workflows/ # CI/CD pipelines -├── phpcs-custom-sniffs/ # Named parameters enforcement -├── img/ # App icons and screenshots -└── l10n/ # Translations (en, nl) +├── Makefile # Dev helpers (make dev-link) +└── img/ # App icons and screenshots ``` ## Requirements @@ -121,7 +124,7 @@ php occ app:enable app-template ### Start the environment ```bash -docker compose -f openregister/docker-compose.yml up -d +docker compose -f ../openregister/docker-compose.yml up -d ``` ### Frontend development @@ -148,7 +151,14 @@ npm run stylelint # CSS linting ### Enable locally +Nextcloud requires the app directory name to match the `` in `appinfo/info.xml` (`app-template`). +When this repo is cloned as `nextcloud-app-template`, create a relative symlink first. + +> **Note:** The `js/` build output is not committed. You must build the frontend before enabling the app, or the UI will be blank. + ```bash +make dev-link +npm install && npm run build docker exec nextcloud php occ app:enable app-template ``` @@ -175,11 +185,11 @@ docker exec nextcloud php occ app:enable app-template | Resource | Description | |----------|-------------| -| [`appspec/`](appspec/) | App configuration, features, and architectural decisions | -| [`appspec/features/`](appspec/features/) | Feature definitions and lifecycle status | -| [`appspec/adr/`](appspec/adr/) | Architectural Decision Records | +| [`openspec/app-config.json`](openspec/app-config.json) | App identity, goals, dependencies, and CI configuration | +| [`openspec/specs/`](openspec/specs/) | Feature specs — what the app should do | +| [`openspec/architecture/`](openspec/architecture/) | App-specific Architectural Decision Records | | [`openspec/ROADMAP.md`](openspec/ROADMAP.md) | Product roadmap | -| [`openspec/`](openspec/) | Implementation specifications | +| [`openspec/`](openspec/) | Implementation specifications and changes | ## Standards & Compliance @@ -194,6 +204,30 @@ docker exec nextcloud php occ app:enable app-template _Add related apps here as integrations are built._ +## Troubleshooting + +### App UI is blank after enabling + +The `js/` build output is not committed to the repo. Run the frontend build before enabling the app: + +```bash +npm install && npm run build +``` + +### "Could not download app app-template" when running `occ app:enable` + +Nextcloud requires the app directory name to exactly match the `` in `appinfo/info.xml`. When this repo is cloned as `nextcloud-app-template`, create a symlink first: + +```bash +make dev-link # creates apps-extra/app-template -> nextcloud-app-template +``` + +Then enable the app again: + +```bash +docker exec nextcloud php occ app:enable app-template +``` + ## Support For support, contact us at [support@conduction.nl](mailto:support@conduction.nl). diff --git a/appinfo/info.xml b/appinfo/info.xml index 6fb1353..23c0833 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -60,7 +60,7 @@ Vrij en open source onder de EUPL-1.2-licentie. app-template App Template - app_template.dashboard.page + app-template.dashboard.page app.svg diff --git a/appinfo/routes.php b/appinfo/routes.php index 30da406..b7487b7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -15,7 +15,8 @@ // Health check endpoint. ['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'], - // SPA catch-all — serves the Vue app for any frontend route (history mode) - ['name' => 'dashboard#page', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']], + // SPA catch-all — same controller as the index route; must use a distinct route name + // (duplicate names replace the earlier route in Symfony, which breaks GET /). + ['name' => 'dashboard#catchAll', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']], ], ]; diff --git a/appspec/README.md b/appspec/README.md deleted file mode 100644 index 1776644..0000000 --- a/appspec/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Nextcloud App Template — App Specification - -This folder contains the configuration and specification for Nextcloud App Template. - -## Goal - -A starting point for building Nextcloud apps following ConductionNL conventions. - -## Structure - -| File / Folder | Purpose | -|---|---| -| `app-config.json` | Core app configuration — id, name, goal, dependencies, CI settings | -| `features/` | High-level feature definitions — each feature is a candidate for an OpenSpec change | -| `adr/` | Architectural Decision Records — documents key design decisions and their rationale | - -## Workflow - -1. **Define features** — Use `/app-explore` to identify and document features in `features/` -2. **Record decisions** — Use `/app-explore` to capture architectural decisions in `adr/` -3. **Apply config** — Use `/app-apply` to sync `app-config.json` changes to the actual app files -4. **Verify sync** — Use `/app-verify` to audit that app files match this configuration -5. **Implement features** — When a feature reaches `planned` status, use `/opsx:ff` to create an OpenSpec change - -## Commands - -- `/app-explore` — Think through and update app configuration, features, and ADRs -- `/app-apply` — Apply `app-config.json` changes to the actual app files -- `/app-verify` — Read-only audit of app files against this configuration -- `/opsx:ff {feature-name}` — Create a full OpenSpec change from a planned feature diff --git a/l10n/en.json b/l10n/en.json new file mode 100644 index 0000000..4624127 --- /dev/null +++ b/l10n/en.json @@ -0,0 +1,34 @@ +{ + "translations": { + "App Template settings": "App Template settings", + "Configure the app settings": "Configure the app settings", + "Configuration": "Configuration", + "Completed": "Completed", + "Dashboard": "Dashboard", + "Due this week": "Due this week", + "Open items": "Open items", + "Placeholder: comment added": "Placeholder: comment added", + "Placeholder: status changed to Review": "Placeholder: status changed to Review", + "Placeholder: user opened a record": "Placeholder: user opened a record", + "Quick actions": "Quick actions", + "Recent activity": "Recent activity", + "Starter overview with sample KPIs and activity placeholders. Replace this view with your own data.": "Starter overview with sample KPIs and activity placeholders. Replace this view with your own data.", + "Team members": "Team members", + "Wire buttons here to create records, open lists, or deep links. Use the sidebar for Settings and Documentation.": "Wire buttons here to create records, open lists, or deep links. Use the sidebar for Settings and Documentation.", + "sample": "sample", + "Documentation": "Documentation", + "General": "General", + "Install OpenRegister": "Install OpenRegister", + "No settings available yet": "No settings available yet", + "OpenRegister is required": "OpenRegister is required", + "OpenRegister register ID": "OpenRegister register ID", + "Register": "Register", + "Save": "Save", + "Settings": "Settings", + "Settings saved successfully": "Settings saved successfully", + "Saving...": "Saving...", + "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.", + "User settings will appear here in a future update.": "User settings will appear here in a future update." + }, + "plurals": "" +} diff --git a/l10n/nl.json b/l10n/nl.json new file mode 100644 index 0000000..3e30fe9 --- /dev/null +++ b/l10n/nl.json @@ -0,0 +1,34 @@ +{ + "translations": { + "App Template settings": "App Template instellingen", + "Configure the app settings": "Configureer de app-instellingen", + "Configuration": "Configuratie", + "Completed": "Afgerond", + "Dashboard": "Dashboard", + "Due this week": "Deze week vervallen", + "Open items": "Openstaande items", + "Placeholder: comment added": "Placeholder: reactie toegevoegd", + "Placeholder: status changed to Review": "Placeholder: status gewijzigd naar Review", + "Placeholder: user opened a record": "Placeholder: gebruiker opende een record", + "Quick actions": "Snelle acties", + "Recent activity": "Recente activiteit", + "Starter overview with sample KPIs and activity placeholders. Replace this view with your own data.": "Startoverzicht met voorbeeld-KPI's en activiteitsplaceholders. Vervang dit scherm door je eigen gegevens.", + "Team members": "Teamleden", + "Wire buttons here to create records, open lists, or deep links. Use the sidebar for Settings and Documentation.": "Koppel hier knoppen aan het aanmaken van records, lijsten of deep links. Gebruik de zijbalk voor Instellingen en Documentatie.", + "sample": "voorbeeld", + "Documentation": "Documentatie", + "General": "Algemeen", + "Install OpenRegister": "OpenRegister installeren", + "No settings available yet": "Nog geen instellingen beschikbaar", + "OpenRegister is required": "OpenRegister is vereist", + "OpenRegister register ID": "OpenRegister register-ID", + "Register": "Register", + "Save": "Opslaan", + "Settings": "Instellingen", + "Settings saved successfully": "Instellingen succesvol opgeslagen", + "Saving...": "Opslaan...", + "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.", + "User settings will appear here in a future update.": "Gebruikersinstellingen verschijnen hier in een toekomstige update." + }, + "plurals": "" +} diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index d68bf24..5e44fde 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -21,6 +21,9 @@ namespace OCA\AppTemplate\AppInfo; +use OCA\AppTemplate\Listener\DeepLinkRegistrationListener; +use OCA\AppTemplate\Repair\InitializeSettings; +use OCA\OpenRegister\Event\DeepLinkRegistrationEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -54,14 +57,15 @@ 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 patterns with OpenRegister's unified search provider. + // Only fires when OpenRegister is installed and dispatches the event. + $context->registerEventListener( + event: DeepLinkRegistrationEvent::class, + listener: DeepLinkRegistrationListener::class + ); + + // Initialize register and schemas on install/upgrade. + $context->registerRepairStep(InitializeSettings::class); }//end register() diff --git a/lib/Controller/DashboardController.php b/lib/Controller/DashboardController.php index 163f684..b273411 100644 --- a/lib/Controller/DashboardController.php +++ b/lib/Controller/DashboardController.php @@ -55,4 +55,17 @@ public function page(): TemplateResponse { return new TemplateResponse(Application::APP_ID, 'index'); }//end page() + + /** + * Serve the SPA for deep links (Vue history mode). Delegates to {@see page()}. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return TemplateResponse + */ + public function catchAll(): TemplateResponse + { + return $this->page(); + }//end catchAll() }//end class diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php new file mode 100644 index 0000000..e5eb6fd --- /dev/null +++ b/lib/Controller/SettingsController.php @@ -0,0 +1,96 @@ + + * @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( + $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..f5d96bb --- /dev/null +++ b/lib/Listener/DeepLinkRegistrationListener.php @@ -0,0 +1,62 @@ + + * @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 the relevant detail views in the app. + * + * @implements IEventListener + */ +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 example object deep links. + // Replace 'app-template' with your app ID and update the register slug, + // schema slug, and URL template to match your app's actual schemas. + $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..44b6626 --- /dev/null +++ b/lib/Repair/InitializeSettings.php @@ -0,0 +1,102 @@ + + * @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 SettingsService. + */ +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 AppTemplate 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 AppTemplate 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( + 'AppTemplate configuration imported successfully (version: '.$version.')' + ); + return; + } + + $message = ($result['message'] ?? 'unknown error'); + $output->warning( + 'AppTemplate configuration import issue: '.$message + ); + } catch (\Throwable $e) { + $output->warning('Could not auto-configure AppTemplate: '.$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..434a7c6 --- /dev/null +++ b/lib/Service/SettingsService.php @@ -0,0 +1,169 @@ + + * @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\App\IAppManager; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IUserSession; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Service for managing AppTemplate application configuration and settings. + */ +class SettingsService +{ + + /** + * Configuration keys managed by this service. + * + * @var array + */ + private const CONFIG_KEYS = [ + 'register', + ]; + + /** + * Constructor for the SettingsService. + * + * @param IAppConfig $appConfig The app config interface + * @param IAppManager $appManager The app manager + * @param ContainerInterface $container The container + * @param IGroupManager $groupManager The group manager + * @param IUserSession $userSession The user session + * @param LoggerInterface $logger The logger + * + * @return void + */ + public function __construct( + private IAppConfig $appConfig, + private IAppManager $appManager, + private ContainerInterface $container, + private IGroupManager $groupManager, + private IUserSession $userSession, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Check whether OpenRegister is installed and available. + * + * @return bool + */ + public function isOpenRegisterAvailable(): bool + { + return $this->appManager->isInstalled('openregister'); + }//end isOpenRegisterAvailable() + + /** + * Retrieve all current settings. + * + * Returns a flat array containing all app config values plus metadata + * fields (openregisters, isAdmin) consumed by the frontend. + * + * @return array + */ + public function getSettings(): array + { + $settings = []; + foreach (self::CONFIG_KEYS as $key) { + $settings[$key] = $this->appConfig->getValueString(Application::APP_ID, $key, ''); + } + + $user = $this->userSession->getUser(); + $isAdmin = ($user !== null && $this->groupManager->isAdmin($user->getUID())); + + return array_merge( + $settings, + [ + 'openregisters' => $this->isOpenRegisterAvailable(), + 'isAdmin' => $isAdmin, + ] + ); + }//end getSettings() + + /** + * Update settings with the provided data. + * + * @param array $data The data to update + * + * @return array The updated settings + */ + 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]); + } + } + + return $this->getSettings(); + }//end updateSettings() + + /** + * Load configuration from app_template_register.json via OpenRegister. + * + * @param bool $force Force re-import even if already configured. + * + * @return array Result with success flag, message, and version. + */ + public function loadConfiguration(bool $force=false): array + { + if ($this->isOpenRegisterAvailable() === false) { + $this->logger->warning('AppTemplate: OpenRegister not available, skipping register initialization'); + return [ + 'success' => false, + 'message' => 'OpenRegister is not installed or enabled.', + ]; + } + + try { + $configurationService = $this->container->get('OCA\OpenRegister\Service\ConfigurationService'); + $result = $configurationService->importFromApp(appId: Application::APP_ID, force: $force); + + if (empty($result) === false) { + $this->logger->info('AppTemplate: register configuration imported successfully'); + return [ + 'success' => true, + 'message' => 'Configuration imported successfully.', + 'version' => ($result['version'] ?? 'unknown'), + ]; + } + + return [ + 'success' => false, + 'message' => 'Import returned an empty result.', + ]; + } catch (\Throwable $e) { + $this->logger->error( + 'AppTemplate: configuration import failed', + ['exception' => $e->getMessage()] + ); + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + }//end try + }//end loadConfiguration() +}//end class diff --git a/lib/Settings/app_template_register.json b/lib/Settings/app_template_register.json new file mode 100644 index 0000000..497039b --- /dev/null +++ b/lib/Settings/app_template_register.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "App Template Register", + "description": "Register containing all schemas for the App Template application.", + "version": "0.1.0" + }, + "x-openregister": { + "type": "application", + "app": "app-template", + "openregister": "^v0.2.10", + "description": "App Template — replace with your app description" + }, + "paths": {}, + "components": { + "schemas": { + "example": { + "slug": "example", + "icon": "FileDocumentOutline", + "version": "0.1.0", + "title": "Example", + "description": "Example schema — replace with your app's actual schemas.", + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "description": "The title of the example object", + "example": "My example" + }, + "description": { + "type": "string", + "description": "An optional description", + "example": "This is an example" + } + } + } + } + } +} diff --git a/openspec/README.md b/openspec/README.md index edd5b0d..f29d7a5 100644 --- a/openspec/README.md +++ b/openspec/README.md @@ -1,69 +1,61 @@ -# OpenSpec — Implementation Specifications +# OpenSpec — Specifications & Architecture -This folder contains implementation specifications and the product roadmap for Nextcloud App Template. - -OpenSpec specifications define the **how** of building features. They are created from planned features in `appspec/features/` and follow the artifact progression below. +This folder contains feature specifications, architectural decisions, and implementation specs for this app. ## Structure | File / Folder | Purpose | |---|---| -| `ROADMAP.md` | High-level product roadmap, linked to features in `appspec/features/` | -| `changes/` | Individual change directories, each with a full set of specification artifacts | +| `app-config.json` | App identity, configuration, and tracked decisions — written by `/opsx:app-explore` | +| `config.yaml` | OpenSpec CLI project configuration — context and rules | +| `specs/` | Feature specs — what the app should do (input for OpenSpec changes) | +| `architecture/` | App-specific Architectural Decision Records (ADRs) | +| `changes/` | Individual change directories, each with a full set of specification artifacts (created on first change) | + +> If `app-config.json` has `"requiresOpenRegister": true`, install [OpenRegister](https://github.com/ConductionNL/openregister) before enabling this app. Set to `false` if your app does not use OpenRegister. ## Artifact Progression Each change in `changes/` moves through these artifacts: ``` -proposal.md ──► specs.md ──► design.md ──► tasks.md ──► plan.json - │ - ▼ - GitHub Issues - │ - ▼ - implementation - │ - ▼ - archive/ -``` - -## Starting a Change - -When a feature in `appspec/features/` reaches `planned` status, create a change spec: - -``` -/opsx:ff {feature-name} # Generate all artifacts at once -/opsx:new {change-name} # Or start step by step +proposal.md ──► specs/ ──► design.md ──► tasks.md ──► plan.json + │ + ▼ + GitHub Issues + │ + ▼ + implementation + │ + ▼ + review.md + │ + ▼ + archive/ ``` -The feature definition (goal, user stories, acceptance criteria) becomes the input for the proposal. - -## One Feature → Multiple Changes - -A single feature may result in multiple OpenSpec changes if the scope is large. For example, a "Document Upload" feature might become: -- `changes/document-upload-backend/` — schema and API endpoints -- `changes/document-upload-frontend/` — Vue upload component -- `changes/document-upload-notifications/` — email/push notifications +## Workflow -Keep changes independently deployable where possible. +1. **Explore** — Use `/opsx:app-explore` to think through goals, architecture, and features; captures decisions into `app-config.json` +2. **Plan** — When a feature spec reaches `planned` status, use `/opsx:ff` to create a change spec +3. **Implement** — Use `/opsx:apply` to implement the tasks +4. **Verify** — Use `/opsx:verify` to check implementation matches the spec +5. **Archive** — Use `/opsx:archive` to move completed changes to `changes/archive/` ## Commands | Command | Purpose | |---------|---------| +| `/opsx:app-design` | Full upfront design — architecture, features, wireframes (optional pre-step) | +| `/opsx:app-create` | Bootstrap a new app or onboard an existing repo | +| `/opsx:app-explore` | Think through goals, architecture, and features; updates `app-config.json` | +| `/opsx:app-apply` | Apply `app-config.json` decisions to actual app files | +| `/opsx:app-verify` | Audit app files against `app-config.json` (read-only) | +| `/opsx:explore` | Investigate a problem or idea before starting a change (no output) | | `/opsx:ff {name}` | Create all artifacts for a new change at once | | `/opsx:new {name}` | Start a new change (step-by-step) | | `/opsx:continue` | Generate the next artifact in the sequence | +| `/opsx:plan-to-issues` | Convert tasks.md into plan.json and GitHub Issues | | `/opsx:apply` | Implement tasks from a change | | `/opsx:verify` | Verify implementation matches the spec | | `/opsx:archive` | Archive a completed change | - -## Relationship to `appspec/` - -| `appspec/` | `openspec/` | -|---|---| -| **What** the app should do | **How** to build it | -| Feature concepts and ADRs | Detailed specs and design decisions | -| Config, identity, goals | Implementation tasks and GitHub Issues | -| Input for OpenSpec | Output for the development team | diff --git a/appspec/app-config.json b/openspec/app-config.json similarity index 96% rename from appspec/app-config.json rename to openspec/app-config.json index 2694de2..ff8ab0a 100644 --- a/appspec/app-config.json +++ b/openspec/app-config.json @@ -21,5 +21,5 @@ "enableNewman": false }, "createdAt": "2025-01-01", - "updatedAt": "2025-01-01" + "updatedAt": "2026-03-23" } diff --git a/appspec/adr/README.md b/openspec/architecture/README.md similarity index 70% rename from appspec/adr/README.md rename to openspec/architecture/README.md index c2d1fb0..5bfb680 100644 --- a/appspec/adr/README.md +++ b/openspec/architecture/README.md @@ -1,16 +1,19 @@ # Architectural Decision Records -This folder contains Architectural Decision Records (ADRs) for Nextcloud App Template. +This folder contains app-specific Architectural Decision Records (ADRs) for this app. ADRs document significant design decisions, their context, the reasoning behind them, and the alternatives that were considered. They provide a historical record of why the app is built the way it is. +> **Note:** Organisation-wide ADRs (ADR-001 through ADR-015) live in `apps-extra/.claude/openspec/architecture/` and apply to all Conduction apps. Only create an app-specific ADR here when the decision is **unique to this app** and not already covered by an org-wide ADR. + +ADRs are created and refined during `/opsx:app-explore` sessions. + ## Naming Convention Files are named `adr-{NNN}-{slug}.md` with sequential numbering: -- `adr-001-use-openregister-for-storage.md` -- `adr-002-vue2-pinia-frontend.md` -- `adr-003-eupl-license.md` +- `adr-001-example-decision.md` +- `adr-002-another-decision.md` ## File Format @@ -60,5 +63,4 @@ Write an ADR whenever you make a significant decision that: - Affects multiple parts of the codebase - Would surprise future developers if they didn't know the reasoning - Involves a meaningful trade-off - -ADRs are created and refined during `/app-explore` sessions. +- Is specific to this app (not already covered by an org-wide ADR) diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..a1c7f53 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,36 @@ +schema: conduction + +context: | + Project: App Template + Repo: ConductionNL/nextcloud-app-template + Type: Nextcloud App (PHP backend + Vue 2 frontend) + Description: Starter template for Conduction Nextcloud apps — replace this description with your app's purpose. + Key components: Dashboard, Settings, Example objects + Database: PostgreSQL (via OpenRegister's ObjectService) + Mount path: /var/www/html/custom_apps/app-template + + Architecture: + Pattern: Thin client — App Template owns no database tables + Data layer: OpenRegister (JSON object storage with schema validation) + Frontend: Vue 2.7 + Pinia stores querying OpenRegister API directly + Backend: Minimal — SettingsController + SettingsService for register setup + + Shared specs: See ../openspec/specs/ for cross-project conventions + Project guidelines: See ../project.md for workspace-wide standards + +rules: + proposal: + - Reference shared nextcloud-app spec for app structure requirements + - Consider impact on other Conduction apps + specs: + - Use Schema.org type annotations for all entities + - Specify which Nextcloud OCP interfaces are used for integration features + design: + - Uses OpenRegister API directly from frontend (no own backend CRUD) + - Register config in lib/Settings/app_template_register.json (OpenAPI 3.0.0 format) + - Imported via ConfigurationService::importFromApp() in repair step + tasks: + - Test with OpenRegister to verify schema validation works + review: + - Cross-reference shared specs (nextcloud-app, api-patterns, nl-design, docker) + - Flag spec deviations with WARNING and justification diff --git a/appspec/features/README.md b/openspec/specs/README.md similarity index 58% rename from appspec/features/README.md rename to openspec/specs/README.md index 27b7cb7..56f121b 100644 --- a/appspec/features/README.md +++ b/openspec/specs/README.md @@ -1,8 +1,8 @@ -# Features +# Feature Specs -This folder contains high-level feature definitions for Nextcloud App Template. +Feature specs define what the app should do — they are the input for OpenSpec changes when you are ready to build. -Features are concepts and goals — not implementation specs. They serve as the input for OpenSpec changes when you are ready to build. +Specs are created and refined during `/opsx:app-explore` sessions. ## Feature Lifecycle @@ -24,21 +24,31 @@ needs more thinking | `in-progress` | One or more OpenSpec changes have been created from this feature | | `done` | All associated OpenSpec changes have been archived | -## File Format +## Spec Format -Each feature is a Markdown file named `{feature-name}.md`: +Each feature spec lives at `openspec/specs/{feature-name}/spec.md`: ```markdown -# {Feature Name} +# {Feature Name} Specification **Status**: idea | planned | in-progress | done -**OpenSpec changes:** _(links to openspec/ change directories when applicable)_ +**OpenSpec changes:** _(links to openspec/changes/ directories when in-progress or done)_ -## Goal +## Purpose What this feature does and why it matters to users. +## Requirements + +### Requirement: {Requirement Name} +The system MUST/SHOULD/MAY {requirement statement}. + +#### Scenario: {Scenario Name} +- GIVEN {precondition} +- WHEN {action} +- THEN the system {MUST/SHOULD} {expected outcome} + ## User Stories - As a [role], I want to [action] so that [outcome] @@ -53,9 +63,10 @@ What this feature does and why it matters to users. Open questions, constraints, dependencies, related ADRs. ``` +> For `idea` status, a lightweight spec (Purpose + User Stories + Acceptance Criteria) is fine. Fill in Requirements/Scenarios when moving to `planned`. + ## Important Notes - A single feature can result in **multiple OpenSpec changes** — break large features into independently deployable slices -- Features are maintained at the concept level here; implementation details live in `openspec/` +- Features are maintained at the concept level here; implementation details live in `openspec/changes/` - Once a feature moves to `in-progress`, link to the OpenSpec change directories in the `OpenSpec changes` field -- Features are explored and created during `/app-explore` sessions diff --git a/phpstan.neon b/phpstan.neon index b9e02de..dfca451 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -27,3 +27,5 @@ parameters: - '#Caught class GuzzleHttp\\#' # Dynamic HTTP status codes from business rule validation results - '#Parameter \$statusCode of class OCP\\AppFramework\\Http\\JSONResponse constructor expects#' + # registerRepairStep exists on server; not yet in nextcloud/ocp stub used for analysis + - '#Call to an undefined method OCP\\AppFramework\\Bootstrap\\IRegistrationContext::registerRepairStep#' diff --git a/phpunit-unit.xml b/phpunit-unit.xml index 1c94ef9..7bcb89d 100644 --- a/phpunit-unit.xml +++ b/phpunit-unit.xml @@ -14,6 +14,7 @@ tests/Unit + tests/unit diff --git a/phpunit.xml b/phpunit.xml index 60981f4..a69e2fc 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -15,6 +15,7 @@ tests/Unit + tests/unit diff --git a/project.md b/project.md new file mode 100644 index 0000000..26322cc --- /dev/null +++ b/project.md @@ -0,0 +1,50 @@ +# App Template — Nextcloud App Template + +## Overview + +App Template is the official starter template for Conduction Nextcloud apps. It provides the standard structure, configuration, and tooling that all Conduction apps share. + +When creating a new app, clone this template and use `/app-create` to rename all identifiers. + +## Architecture + +- **Type**: Nextcloud App (PHP backend + Vue 2 frontend) +- **Data layer**: OpenRegister (all data stored as register objects) +- **Pattern**: Thin client — App Template provides UI/UX, OpenRegister handles persistence +- **License**: EUPL-1.2 + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Backend | PHP 8.1+, Nextcloud AppFramework | +| Frontend | Vue 2.7, Pinia, @nextcloud/vue | +| Data | OpenRegister (JSON object store) | +| Testing | PHPUnit (unit + integration), Newman (API) | +| Quality | PHPCS, PHPMD, Psalm, PHPStan, ESLint, Stylelint | + +## Key Files + +| File | Purpose | +|------|---------| +| `lib/AppInfo/Application.php` | App bootstrap, listener + repair registration | +| `lib/Controller/SettingsController.php` | Settings API endpoints | +| `lib/Service/SettingsService.php` | Settings business logic, OpenRegister integration | +| `lib/Listener/DeepLinkRegistrationListener.php` | Registers deep link patterns with OpenRegister search | +| `lib/Repair/InitializeSettings.php` | Import register on install/upgrade | +| `lib/Settings/app_template_register.json` | OpenAPI 3.0 register schema definition | +| `src/App.vue` | App shell (navigation + routing) | +| `src/navigation/MainMenu.vue` | App navigation sidebar | +| `src/views/settings/UserSettings.vue` | User settings dialog | +| `openspec/config.yaml` | OpenSpec project configuration | + +## Development Setup + +See the workspace-level `.claude/docs/` for: +- `commands.md` — available Claude commands +- `testing.md` — testing workflows +- `app-lifecycle.md` — full development lifecycle + +## Standards + +This app follows all [Conduction app standards](../.claude/openspec/architecture/). diff --git a/src/App.vue b/src/App.vue index 44e762e..0e7148c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -23,17 +23,7 @@ diff --git a/src/router/index.js b/src/router/index.js index 4fdb249..00e155c 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -2,6 +2,7 @@ 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) @@ -10,6 +11,7 @@ export default new Router({ base: generateUrl('/apps/app-template'), routes: [ { path: '/', name: 'Dashboard', component: Dashboard }, + { path: '/settings', name: 'Settings', component: AdminRoot }, { path: '*', redirect: '/' }, ], }) diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index 09f9643..1426b4a 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -1,18 +1,129 @@ diff --git a/src/views/settings/UserSettings.vue b/src/views/settings/UserSettings.vue new file mode 100644 index 0000000..d5e6db3 --- /dev/null +++ b/src/views/settings/UserSettings.vue @@ -0,0 +1,43 @@ + + + diff --git a/tests/unit/Controller/SettingsControllerTest.php b/tests/unit/Controller/SettingsControllerTest.php new file mode 100644 index 0000000..96e426d --- /dev/null +++ b/tests/unit/Controller/SettingsControllerTest.php @@ -0,0 +1,150 @@ + + * @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 containing the settings from the service. + * + * @return void + */ + public function testIndexReturnsJsonResponseWithSettings(): void + { + $settings = [ + 'register' => 'some-uuid', + 'openregisters' => true, + 'isAdmin' => false, + ]; + + $this->settingsService->expects($this->once()) + ->method('getSettings') + ->willReturn($settings); + + $result = $this->controller->index(); + + self::assertInstanceOf(JSONResponse::class, $result); + self::assertSame($settings, $result->getData()); + + }//end testIndexReturnsJsonResponseWithSettings() + + /** + * Test that create() calls updateSettings with request params and returns success. + * + * @return void + */ + public function testCreateCallsUpdateSettingsAndReturnsSuccess(): void + { + $params = ['register' => 'new-uuid']; + $updated = ['register' => 'new-uuid', 'openregisters' => true, 'isAdmin' => false]; + + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn($params); + + $this->settingsService->expects($this->once()) + ->method('updateSettings') + ->with($params) + ->willReturn($updated); + + $result = $this->controller->create(); + + self::assertInstanceOf(JSONResponse::class, $result); + self::assertTrue($result->getData()['success']); + self::assertArrayHasKey('config', $result->getData()); + + }//end testCreateCallsUpdateSettingsAndReturnsSuccess() + + /** + * Test that load() returns the result of loadConfiguration. + * + * @return void + */ + public function testLoadReturnsConfigurationResult(): void + { + $loadResult = [ + 'success' => true, + 'message' => 'Configuration imported successfully.', + 'version' => '0.1.0', + ]; + + $this->settingsService->expects($this->once()) + ->method('loadConfiguration') + ->with(force: true) + ->willReturn($loadResult); + + $result = $this->controller->load(); + + self::assertInstanceOf(JSONResponse::class, $result); + self::assertTrue($result->getData()['success']); + + }//end testLoadReturnsConfigurationResult() +}//end class