diff --git a/appinfo/info.xml b/appinfo/info.xml index 5683c6297..92ab59193 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -138,6 +138,10 @@ OCA\Portaliq\Command\TrafficReaggregate + + OCA\Portaliq\Command\ExampleSiteInstall + OCA\Portaliq\Command\ExampleSiteRemove diff --git a/css/site-theme.css b/css/site-theme.css index 68fd6319e..4ca4645b9 100644 --- a/css/site-theme.css +++ b/css/site-theme.css @@ -394,6 +394,105 @@ background-color: var(--thematiq-accent-color, var(--nldesign-color-primary)); } +/* + * A TABLE AND A LIST OF LINKS ARE READABLE WITHOUT A ROLE FOR THEM, TOO + * (example-site-zuiddrecht). + * + * Like the button above: the Utrecht table and link list read every measure + * from a token and declare no fallback. The theme app's bridge names the + * table's inline padding and nothing else of the two, so on a set that only + * declares the `--nldesign-*` layer a table had no room above or under a + * cell and no line between its rows, and the links of a list stood line on + * line. Measured on the Zuiddrecht content page: cell padding-top 0px, row + * border 0px, list row-gap `normal`. + * + * Each declaration reads the set's own token first, so a set that names the + * role renders as before. The line between rows is the set's border colour, + * else the text's own. + */ +.pq-site .utrecht-table__cell, +.pq-site .utrecht-table__header-cell { + padding-block: var(--utrecht-table-cell-padding-block-start, 12px) + var(--utrecht-table-cell-padding-block-end, 12px); + border-block-end: var(--utrecht-table-row-border-block-end-width, 1px) solid + var( + --utrecht-table-row-border-block-end-color, + var( + --nldesign-color-border-dark, + var(--nldesign-color-border, currentcolor) + ) + ); + text-align: start; +} + +.pq-site .utrecht-table__caption { + margin-block-end: var(--utrecht-table-caption-margin-block-end, 8px); + text-align: var(--utrecht-table-caption-text-align, start); +} + +.pq-site .utrecht-link-list { + row-gap: var(--utrecht-link-list-row-gap, 10px); +} + +.pq-site .utrecht-link-list__description { + display: block; +} + +/* + * THE DESIGNED SITE READS IN THE SET'S OWN FAMILY (example-site-zuiddrecht). + * + * The vendored sheet sets the page's family on `body` as a literal, so every + * text that inherits it (the menu, the hero heading, task labels, link lists, + * news titles) stayed in that face while paragraphs and buttons, which read a + * token, took the set's. Measured on the Zuiddrecht home page: 40 of 78 text + * runs in the vendored face under a set that names Fira Sans. + * + * Only for a portal with the designed header: every other portal keeps the + * face it has, which its tests measure on the menu. + */ +.pq-site:has(.pq-site__header--designed), +body:has(.pq-site__header--designed) .pq-site__skip { + font-family: var(--utrecht-document-font-family, inherit); +} + +/* + * And its last block does not stand against the footer. A band (a hero, a + * coloured section) brings its own room; a page that ends in text or links + * has none, and the footer's stripe then touched the last line. + */ +.pq-site:has(.pq-site__header--designed) .pq-site__main { + padding-block-end: 64px; +} + +/* + * THE CURRENT ITEM IN THE LINE (example-site-zuiddrecht). A set whose header + * carries a plain line under the menu may mark the page on screen as a piece + * of that line, in a colour of its own: Zuiddrecht's line is red and the + * piece under the current item is blue. The set says so with two tokens: + * + * --nldesign-website-nav-current-in-line 1 moves the bar down into the + * line and makes it as high as it + * --nldesign-website-nav-current-color the bar's colour + * + * Without them nothing changes: no shift, four pixels, the accent. The bar + * sits above the line (z-index 2 over the line's 1), so it covers its piece. + */ +.pq-site .ac-header .ac-c-navigation__link-container[aria-current='page']::after { + --pq-nav-current-in-line: var(--nldesign-website-nav-current-in-line, 0); + inset-block-end: calc( + var(--pq-nav-current-in-line) * -1 * var(--cn-brand-stripe-height, 0px) + ); + z-index: 2; + block-size: calc( + 4px + var(--pq-nav-current-in-line) * + (var(--cn-brand-stripe-height, 4px) - 4px) + ); + background-color: var( + --nldesign-website-nav-current-color, + var(--thematiq-accent-color, var(--nldesign-color-primary)) + ); +} + .pq-site .ac-header .ac-c-navigation__li { position: relative; } diff --git a/docs/Installation/example-site-zuiddrecht.md b/docs/Installation/example-site-zuiddrecht.md new file mode 100644 index 000000000..03efe82db --- /dev/null +++ b/docs/Installation/example-site-zuiddrecht.md @@ -0,0 +1,87 @@ +--- +title: Install the Zuiddrecht example site +sidebar_label: Zuiddrecht example site +description: Put the website of the demo municipality Zuiddrecht on your instance with one command, and take it off again +--- + +# Install the Zuiddrecht example site + +Zuiddrecht is a municipality that does not exist. Its website shows what a portal looks like when it is finished: a header with search and a button to the resident's own area, a home page with top tasks and news, content pages and a footer. You install it with one command and remove it with one command. + +## What you need + +- Portaliq and OpenRegister, both enabled. +- The theme app (thematiq) with the `zuiddrecht` set. Without it the site installs and shows without its house style. The command tells you when the set is missing. + +## Install + +```bash +occ portaliq:example-site:install zuiddrecht +``` + +You get one portal, three menus, 33 pages and four news items: + +``` +Portal: 1 declared, 1 created, 0 already there, 1 found afterwards +Menus: 3 declared, 3 created, 0 already there, 3 found afterwards +Pages: 33 declared, 33 created, 0 already there, 33 found afterwards +News items: 4 declared, 4 created, 0 already there, 4 found afterwards +The site is installed. Open it at /index.php/apps/portaliq/site?portal=zuiddrecht +``` + +Open that address in a browser. You do not have to sign in. + +The command only writes what is missing. A portal with the slug `zuiddrecht`, a menu with the same position and title, a page at the same route and a news item with the same title stay exactly as they are. So you can run it again after an editor changed a page: the change stays. + +## Read the result + +After writing, the command reads your instance back and compares it with what it meant to write. The last number on each line is what it found. + +| Exit code | Meaning | +| --- | --- | +| 0 | Everything is on the instance. | +| 1 | Nothing was written: the site name is unknown, or OpenRegister is not available. | +| 2 | Something is missing. Each missing object and each lost key is named. | + +A line such as `Written but not kept: portal zuiddrecht: headerSearch.label` means the portaliq register on your instance is older than the app. Run `occ maintenance:repair` and install again. + +## Open the site on your own address + +The site answers at `/index.php/apps/portaliq/site?portal=zuiddrecht`. To serve it on a hostname of its own, add the hostname to the portal and verify it. [Custom domains](../operations/custom-domains.md) explains how. The install never claims a hostname for you. + +## Signing in + +The header shows the button "Mijn Zuiddrecht". The portal names DigiD and eHerkenning as its ways in, as the design does. They work once your instance has a sign-in broker for them: see [Signing in through integriq](../operations/signing-in-through-integriq.md). On a test instance you can switch on the test sign-in instead: + +```bash +occ config:app:set portaliq dev_login_enabled --value=yes +``` + +Never switch that on for a site real residents use. + +## What is and is not in the site + +- The header, footer, home page and the page "Afval scheiden en ophalen" follow the Zuiddrecht design. The other pages are short, so that no link is dead. +- "Woo-publicaties" and the search box open `/zoeken`, with the search block every portal can place. It finds publications when OpenCatalogi is installed. +- Phone numbers and addresses read `[telefoonnummer]`, `[e-mailadres]` and `[adres]`. Zuiddrecht has none. + +## Remove + +```bash +occ portaliq:example-site:remove zuiddrecht +``` + +This deletes the objects the install created, from the install's own record, and nothing else: + +``` +Menus: 3 deleted, 0 already gone +Pages: 33 deleted, 0 already gone +News items: 4 deleted, 0 already gone +Portal: deleted +``` + +The portal stays when it was there before the install, or when it still holds a menu or a page you added yourself. The command then says so and ends with exit code 2. Delete or move your own content and run it again. + +## Next + +Change the site into your own: open **Portaliq → Portals**, pick `zuiddrecht` and edit its pages. To give it another house style, see [Choosing a portal's house style](../operations/choosing-a-portal-house-style.md). diff --git a/lib/Command/ExampleSiteInstall.php b/lib/Command/ExampleSiteInstall.php new file mode 100644 index 000000000..d24ad6af2 --- /dev/null +++ b/lib/Command/ExampleSiteInstall.php @@ -0,0 +1,145 @@ +`: put a shipped example site on + * this instance. Writes what is missing, never changes what is there, and + * reads the instance back to say what arrived. + * + * @category Command + * @package OCA\Portaliq\Command + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Command; + +use OCA\Portaliq\Service\ExampleSite\ExampleSiteCatalogue; +use OCA\Portaliq\Service\ExampleSite\ExampleSiteInstaller; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Install an example site. + * + * Exit codes: 0 everything declared is on the instance, 1 nothing could be + * done (unknown site, OpenRegister missing), 2 something did not arrive. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ +class ExampleSiteInstall extends Command { + /** + * The word the output uses for each schema. + */ + private const WORDS = ['portal' => 'Portal', 'menu' => 'Menus', 'page' => 'Pages', 'newsItem' => 'News items']; + + /** + * Constructor. + * + * @param ExampleSiteCatalogue $catalogue The shipped sites. + * @param ExampleSiteInstaller $installer Writes and proves. + * + * @return void + */ + public function __construct( + private readonly ExampleSiteCatalogue $catalogue, + private readonly ExampleSiteInstaller $installer, + ) { + parent::__construct(); + }//end __construct() + + /** + * Name and describe the command. + * + * @return void + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + protected function configure(): void { + $this->setName(name: 'portaliq:example-site:install'); + $this->setDescription(description: 'Install a shipped example site: its portal, menus, pages and news. Existing content is never changed'); + $this->addArgument(name: 'site', mode: InputArgument::REQUIRED, description: 'The example site, for example zuiddrecht'); + }//end configure() + + /** + * Install the site and report what arrived. + * + * @param InputInterface $input The command input. + * @param OutputInterface $output The command output. + * + * @return int 0, 1 or 2; see the class. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-the-install-must-prove-what-arrived + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $id = trim((string)$input->getArgument('site')); + $site = $this->catalogue->find(id: $id); + if ($site === null) { + $output->writeln('No example site "' . $id . '". Sites you can install: ' . implode(', ', $this->catalogue->ids()) . ''); + + return 1; + } + + $report = $this->installer->install(site: $site); + if ($report['available'] === false) { + $output->writeln('OpenRegister is not available, so nothing was written. Enable openregister and run this again.'); + + return 1; + } + + foreach ($report['types'] as $schema => $counts) { + $output->writeln( + sprintf( + '%s: %d declared, %d created, %d already there, %d found afterwards', + self::WORDS[$schema], + $counts['declared'], + $counts['created'], + $counts['kept'], + $counts['arrived'] + ) + ); + } + + if ($report['themeOffered'] === false) { + $output->writeln( + 'The theme app does not offer the set "' . (string)($site['portal']['theme'] ?? '') + . '". The site shows without its house style until the theme app (thematiq) with that set is installed.' + ); + } + + foreach ($report['missing'] as $missing) { + $output->writeln('Not on the instance: ' . $missing . ''); + } + + foreach ($report['lost'] as $lost) { + $output->writeln('Written but not kept: ' . $lost . ''); + } + + if ($report['ok'] === false) { + $output->writeln( + 'The site is not complete. A key that is not kept usually means the portaliq register is older than this app:' + . ' run "occ maintenance:repair" and install again.' + ); + + return 2; + } + + $output->writeln('The site is installed. Open it at /index.php/apps/portaliq/site?portal=' . $report['portal']); + + return 0; + }//end execute() +}//end class diff --git a/lib/Command/ExampleSiteRemove.php b/lib/Command/ExampleSiteRemove.php new file mode 100644 index 000000000..289b0023d --- /dev/null +++ b/lib/Command/ExampleSiteRemove.php @@ -0,0 +1,138 @@ +`: take an installed example site + * off this instance. Deletes exactly what the install created and leaves + * everything else, also inside the same portal. + * + * @category Command + * @package OCA\Portaliq\Command + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Command; + +use OCA\Portaliq\Service\ExampleSite\ExampleSiteCatalogue; +use OCA\Portaliq\Service\ExampleSite\ExampleSiteRemover; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Remove an example site. + * + * Exit codes: 0 everything the install created is gone, 1 nothing could be + * done (unknown site, no record of an install), 2 something could not be + * deleted or the portal was kept because it holds other content. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ +class ExampleSiteRemove extends Command { + /** + * The word the output uses for each schema. + */ + private const WORDS = ['menu' => 'Menus', 'page' => 'Pages', 'newsItem' => 'News items']; + + /** + * What the output says about the portal, per outcome. + */ + private const PORTAL_LINES = [ + 'deleted' => 'Portal: deleted', + 'kept-not-ours' => 'Portal: kept. It was there before the example site was installed', + 'kept-content' => 'Portal: kept. It still holds menus or pages that are not from the example site', + 'failed' => 'Portal: could not be deleted', + ]; + + /** + * Constructor. + * + * @param ExampleSiteCatalogue $catalogue The shipped sites. + * @param ExampleSiteRemover $remover Deletes from the install's own record. + * + * @return void + */ + public function __construct( + private readonly ExampleSiteCatalogue $catalogue, + private readonly ExampleSiteRemover $remover, + ) { + parent::__construct(); + }//end __construct() + + /** + * Name and describe the command. + * + * @return void + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + protected function configure(): void { + $this->setName(name: 'portaliq:example-site:remove'); + $this->setDescription(description: 'Remove an installed example site: only what the install created is deleted'); + $this->addArgument(name: 'site', mode: InputArgument::REQUIRED, description: 'The example site, for example zuiddrecht'); + }//end configure() + + /** + * Remove the site and report what was deleted. + * + * @param InputInterface $input The command input. + * @param OutputInterface $output The command output. + * + * @return int 0, 1 or 2; see the class. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $id = trim((string)$input->getArgument('site')); + $site = $this->catalogue->find(id: $id); + if ($site === null) { + $output->writeln('No example site "' . $id . '". Sites this app ships: ' . implode(', ', $this->catalogue->ids()) . ''); + + return 1; + } + + $report = $this->remover->remove(site: $id, slug: (string)$site['portal']['slug']); + if ($report['recorded'] === false) { + $output->writeln('Nothing is recorded for "' . $id . '": it was not installed with this command, or it is already removed.'); + + return 1; + } + + if ($report['available'] === false) { + $output->writeln('OpenRegister is not available, so nothing was deleted.'); + + return 1; + } + + foreach (self::WORDS as $schema => $word) { + $output->writeln( + sprintf('%s: %d deleted, %d already gone', $word, $report['deleted'][$schema], $report['gone'][$schema]) + ); + } + + $output->writeln(self::PORTAL_LINES[$report['portal']]); + foreach ($report['failed'] as $failed) { + $output->writeln('Could not be deleted: ' . $failed . ''); + } + + if ($report['failed'] !== [] || $report['portal'] === 'failed' || $report['portal'] === 'kept-content') { + return 2; + } + + return 0; + }//end execute() +}//end class diff --git a/lib/Controller/PortalPageController.php b/lib/Controller/PortalPageController.php index 225728d88..269a0cdc6 100644 --- a/lib/Controller/PortalPageController.php +++ b/lib/Controller/PortalPageController.php @@ -535,15 +535,15 @@ private function siteThemeStylesheet(): string { * an unthemed portal would quietly restyle a page that must render * unstyled, and an unthemed page names no bundled family. * - * @return array{bridge: string, fonts: string, logoInverse: string, emblem: string} The stylesheets - * (relative to the theme app's `css/`) and the two logo variants (absolute). + * @return array{bridge: string, fonts: string, logoInverse: string, emblem: string, emblemGrey: string} The stylesheets + * (relative to the theme app's `css/`) and the logo variants (absolute). * * @spec openspec/changes/site-links-the-theme-bridge/specs/portaliq-cms/spec.md#requirement-the-site-must-link-the-theme-apps-public-bridge-before-a-resolved-token-set-req-stb-001 * @spec openspec/changes/site-links-the-theme-bridge/specs/portaliq-cms/spec.md#requirement-the-site-must-link-the-faces-the-theme-app-bundles-req-stb-002 * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-footer-must-carry-the-motif-the-light-logo-and-the-brand-column-first */ private function siteThemeAppSheets(): array { - $none = ['bridge' => '', 'fonts' => '', 'logoInverse' => '', 'emblem' => '']; + $none = ['bridge' => '', 'fonts' => '', 'logoInverse' => '', 'emblem' => '', 'emblemGrey' => '']; if ($this->siteThemeStylesheet() === '') { return $none; } @@ -557,6 +557,9 @@ private function siteThemeAppSheets(): array { // a watermark, absolute, or '' (site-chrome-follows-the-design). 'logoInverse' => $this->siteThemeLogoUrl(variant: 'dark'), 'emblem' => $this->siteThemeLogoUrl(variant: 'emblem'), + // The emblem in grey, for a set whose watermark carries no + // tint, or '' (example-site-zuiddrecht). + 'emblemGrey' => $this->siteThemeLogoUrl(variant: 'emblem-grey'), ]; } catch (\Throwable) { return $none; @@ -587,7 +590,7 @@ private function siteThemeAppSheets(): array { * footer band, or `emblem`, the mark for a watermark; '' when the set * ships none. * - * @param string $variant '' for the logo, else `dark` or `emblem`. + * @param string $variant '' for the logo, else `dark`, `emblem` or `emblem-grey`. * * @return string An absolute URL, or '' when there is no logo to serve. * diff --git a/lib/Service/ExampleSite/ExampleSiteCatalogue.php b/lib/Service/ExampleSite/ExampleSiteCatalogue.php new file mode 100644 index 000000000..f1e82c728 --- /dev/null +++ b/lib/Service/ExampleSite/ExampleSiteCatalogue.php @@ -0,0 +1,155 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Service\ExampleSite; + +/** + * The shipped example sites. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ +class ExampleSiteCatalogue { + /** + * The object lists a declaration holds besides its portal, by key. + */ + public const LISTS = ['menus', 'pages', 'news']; + + /** + * Constructor. + * + * @param string|null $directory Where the declarations live; the shipped folder when null. + * + * @return void + */ + public function __construct( + private readonly ?string $directory = null, + ) { + }//end __construct() + + /** + * The ids of the sites that can be installed, in alphabetical order. + * + * @return array + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + public function ids(): array { + $ids = []; + foreach ((array)glob($this->folder() . '/*.json') as $file) { + $id = basename((string)$file, '.json'); + if ($this->find(id: $id) !== null) { + $ids[] = $id; + } + } + + sort($ids); + + return $ids; + }//end ids() + + /** + * One declaration, or null when no usable one carries that id. + * + * A declaration is usable when it parses, names itself by its file name, + * holds a portal with a slug and a title, and each of its lists is a list. + * An id is at most 40 characters: the install keeps its record under an + * app-config key that starts with `example_site_`, and such a key holds 64. + * + * @param string $id The site's id, as its file is named. + * + * @return array|null + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + public function find(string $id): ?array { + $site = $this->decoded(id: $id); + if ($site === null || $this->namesAPortal(site: $site) === false) { + return null; + } + + foreach (self::LISTS as $list) { + $site[$list] = ($site[$list] ?? []); + if (is_array($site[$list]) === false || array_is_list($site[$list]) === false) { + return null; + } + } + + return $site; + }//end find() + + /** + * The file with this id, parsed, when it names itself by that id. + * + * @param string $id The site's id. + * + * @return array|null + */ + private function decoded(string $id): ?array { + $file = $this->folder() . '/' . $id . '.json'; + if (preg_match('/^[a-z0-9][a-z0-9-]{0,39}$/', $id) !== 1 || is_file($file) === false) { + return null; + } + + $site = json_decode((string)file_get_contents($file), true); + if (is_array($site) === false || ($site['id'] ?? null) !== $id) { + return null; + } + + return $site; + }//end decoded() + + /** + * Whether the declaration holds a portal with a slug and a title. + * + * @param array $site The parsed declaration. + * + * @return bool + */ + private function namesAPortal(array $site): bool { + $portal = ($site['portal'] ?? null); + if (is_array($portal) === false) { + return false; + } + + foreach (['slug', 'title'] as $key) { + if (is_string($portal[$key] ?? null) === false || $portal[$key] === '') { + return false; + } + } + + return true; + }//end namesAPortal() + + /** + * The folder the declarations are read from. + * + * @return string + */ + private function folder(): string { + return ($this->directory ?? dirname(__DIR__, 2) . '/Settings/sites'); + }//end folder() +}//end class diff --git a/lib/Service/ExampleSite/ExampleSiteInstaller.php b/lib/Service/ExampleSite/ExampleSiteInstaller.php new file mode 100644 index 000000000..b3ed8100f --- /dev/null +++ b/lib/Service/ExampleSite/ExampleSiteInstaller.php @@ -0,0 +1,214 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Service\ExampleSite; + +use OCA\Portaliq\Service\PortalThemeResolver; + +/** + * Installs an example site. + * + * WHY THE PROOF IS A SECOND READ. OpenRegister answers a write with the + * object as it was sent, also for a key the schema does not know and + * therefore does not keep. A count of answers would call a site complete + * whose header search never reached the portal record. So the install reads + * every type back and compares it with the declaration, key by key. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-the-install-must-prove-what-arrived + */ +class ExampleSiteInstaller { + /** + * The schema behind each list of a declaration. + */ + private const SCHEMAS = ['menus' => 'menu', 'pages' => 'page', 'news' => 'newsItem']; + + /** + * Constructor. + * + * @param ExampleSiteStore $store Writes rows. + * @param ExampleSiteRows $rows Finds the portal's rows. + * @param ExampleSiteRecord $record Keeps what was created. + * @param ExampleSiteProof $proof Compares declared with stored. + * @param PortalThemeResolver $themes Says whether the theme app offers the site's set. + * + * @return void + */ + public function __construct( + private readonly ExampleSiteStore $store, + private readonly ExampleSiteRows $rows, + private readonly ExampleSiteRecord $record, + private readonly ExampleSiteProof $proof, + private readonly PortalThemeResolver $themes, + ) { + }//end __construct() + + /** + * Write what the declaration holds and the instance lacks, then prove it. + * + * @param array $site A declaration from ExampleSiteCatalogue. + * + * @return array `site`, `portal`, `available`, `themeOffered`, + * `types` (per schema: declared, created, kept, + * arrived), `missing`, `lost` and `ok`. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + public function install(array $site): array { + $slug = (string)$site['portal']['slug']; + $theme = (string)($site['portal']['theme'] ?? ''); + $report = [ + 'site' => (string)$site['id'], + 'portal' => $slug, + 'available' => $this->store->available(), + 'themeOffered' => ($theme === '' || $this->themes->stylesheetFor(theme: $theme) !== null), + 'types' => [], + 'missing' => [], + 'lost' => [], + 'ok' => false, + ]; + if ($report['available'] === false) { + return $report; + } + + $record = $this->record->read(site: $report['site']); + $created = ['portal' => [], 'menu' => [], 'page' => [], 'newsItem' => []]; + + $report['types']['portal'] = ['declared' => 1, 'created' => 0, 'kept' => 1, 'arrived' => 0]; + if ($this->rows->portal(slug: $slug) === null) { + $id = $this->store->create(schema: 'portal', data: $site['portal']); + $report['types']['portal']['kept'] = 0; + if ($id !== null) { + $report['types']['portal']['created'] = 1; + $record['portal'] = $id; + $created['portal'][] = $slug; + } + } + + foreach (self::SCHEMAS as $list => $schema) { + $counts = ['declared' => count($site[$list]), 'created' => 0, 'kept' => 0, 'arrived' => 0]; + $existing = $this->rows->keyed(schema: $schema, slug: $slug); + foreach ($site[$list] as $object) { + $key = $this->rows->keyOf(schema: $schema, object: $object); + if (isset($existing[$key]) === true) { + $counts['kept']++; + continue; + } + + $id = $this->store->create(schema: $schema, data: ($object + ['portal' => $slug])); + if ($id !== null) { + $counts['created']++; + $record[$schema][] = $id; + $created[$schema][] = $key; + } + } + + $report['types'][$schema] = $counts; + } + + $this->record->write(site: $report['site'], record: $record); + + return $this->proven(site: $site, report: $report, created: $created); + }//end install() + + /** + * Read the instance back and fill in what arrived, what is missing and + * which keys the objects of this run lost. + * + * An object that was there before this run is counted and not compared: + * an editor may have changed it, and that is not a loss. + * + * @param array $site The declaration. + * @param array $report The report so far. + * @param array> $created The keys this run created, per schema. + * + * @return array The finished report. + */ + private function proven(array $site, array $report, array $created): array { + $stored = $this->stored(slug: $report['portal']); + foreach ($this->declared(site: $site) as $schema => $objects) { + foreach ($objects as $key => $object) { + $name = $schema . ' ' . $key; + if (isset($stored[$schema][$key]) === false) { + $report['missing'][] = $name; + continue; + } + + $report['types'][$schema]['arrived']++; + if (in_array((string)$key, $created[$schema], true) === false) { + continue; + } + + foreach ($this->proof->lostPaths(declared: $object, stored: $stored[$schema][$key]) as $path) { + $report['lost'][] = $name . ': ' . $path; + } + } + } + + $report['ok'] = ($report['missing'] === [] && $report['lost'] === []); + + return $report; + }//end proven() + + /** + * What the declaration holds, per schema, by what tells objects apart. + * + * @param array $site The declaration. + * + * @return array>> + */ + private function declared(array $site): array { + $declared = ['portal' => [(string)$site['portal']['slug'] => $site['portal']]]; + foreach (self::SCHEMAS as $list => $schema) { + $declared[$schema] = []; + foreach ($site[$list] as $object) { + $declared[$schema][$this->rows->keyOf(schema: $schema, object: $object)] = $object; + } + } + + return $declared; + }//end declared() + + /** + * What the instance holds for the portal, in the same shape. + * + * @param string $slug The portal's slug. + * + * @return array>> + */ + private function stored(string $slug): array { + $stored = ['portal' => []]; + $portal = $this->rows->portal(slug: $slug); + if ($portal !== null) { + $stored['portal'][$slug] = $portal; + } + + foreach (self::SCHEMAS as $schema) { + $stored[$schema] = $this->rows->keyed(schema: $schema, slug: $slug); + } + + return $stored; + }//end stored() +}//end class diff --git a/lib/Service/ExampleSite/ExampleSiteProof.php b/lib/Service/ExampleSite/ExampleSiteProof.php new file mode 100644 index 000000000..e08fd7e99 --- /dev/null +++ b/lib/Service/ExampleSite/ExampleSiteProof.php @@ -0,0 +1,98 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-the-install-must-prove-what-arrived + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Service\ExampleSite; + +/** + * Names the declared keys a stored object lost. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-the-install-must-prove-what-arrived + */ +class ExampleSiteProof { + /** + * Every declared path the stored value does not hold as declared. + * + * An empty declared list or object names nothing that could be lost. A + * date is the same date in another time zone's notation. + * + * @param mixed $declared What the declaration holds. + * @param mixed $stored What the instance holds at the same place. + * @param string $path The path so far, dot-separated. + * + * @return array The lost paths. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-the-install-must-prove-what-arrived + */ + public function lostPaths(mixed $declared, mixed $stored, string $path = ''): array { + if (is_array($declared) === false) { + if ($this->sameValue(declared: $declared, stored: $stored) === true) { + return []; + } + + return [$path]; + } + + $lost = []; + foreach ($declared as $key => $value) { + $here = ltrim($path . '.' . $key, '.'); + if (is_array($stored) === true && array_key_exists($key, $stored) === true) { + $lost = array_merge($lost, $this->lostPaths(declared: $value, stored: $stored[$key], path: $here)); + continue; + } + + if ($value !== []) { + $lost[] = $here; + } + } + + return $lost; + }//end lostPaths() + + /** + * Whether a stored scalar is the declared one. + * + * @param mixed $declared The declared value. + * @param mixed $stored The stored value. + * + * @return bool + */ + private function sameValue(mixed $declared, mixed $stored): bool { + if ($declared === $stored) { + return true; + } + + if (is_numeric($declared) === true && is_numeric($stored) === true) { + return (float)$declared === (float)$stored; + } + + if (is_string($declared) === false || is_string($stored) === false || preg_match('/^\d{4}-\d{2}-\d{2}T/', $declared) !== 1) { + return false; + } + + $left = strtotime($declared); + + return $left !== false && $left === strtotime($stored); + }//end sameValue() +}//end class diff --git a/lib/Service/ExampleSite/ExampleSiteRecord.php b/lib/Service/ExampleSite/ExampleSiteRecord.php new file mode 100644 index 000000000..90261a25f --- /dev/null +++ b/lib/Service/ExampleSite/ExampleSiteRecord.php @@ -0,0 +1,119 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Service\ExampleSite; + +use OCA\Portaliq\AppInfo\Application; +use OCP\IAppConfig; + +/** + * The ids an install created, per example site. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ +class ExampleSiteRecord { + /** + * The schemas a record lists ids for, besides the portal. + */ + public const SCHEMAS = ['menu', 'page', 'newsItem']; + + /** + * The prefix of the app-config key that holds a site's record. + */ + private const PREFIX = 'example_site_'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig Holds the records. + * + * @return void + */ + public function __construct( + private readonly IAppConfig $appConfig, + ) { + }//end __construct() + + /** + * What an earlier install of this site created. + * + * @param string $site The site's id. + * + * @return array{portal: string, menu: array, page: array, newsItem: array} + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + public function read(string $site): array { + $record = ['portal' => '', 'menu' => [], 'page' => [], 'newsItem' => []]; + $decoded = json_decode($this->appConfig->getValueString(Application::APP_ID, self::PREFIX . $site, ''), true); + if (is_array($decoded) === false) { + return $record; + } + + if (is_string($decoded['portal'] ?? null) === true) { + $record['portal'] = $decoded['portal']; + } + + foreach (self::SCHEMAS as $schema) { + $record[$schema] = array_values( + array_filter((array)($decoded[$schema] ?? []), static fn ($id): bool => is_string($id) === true && $id !== '') + ); + } + + return $record; + }//end read() + + /** + * Whether a record names nothing. + * + * @param array $record The record. + * + * @return bool + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + public function isEmpty(array $record): bool { + return $record['portal'] === '' && $record['menu'] === [] && $record['page'] === [] && $record['newsItem'] === []; + }//end isEmpty() + + /** + * Keep the record, or drop the key when nothing is left in it. + * + * @param string $site The site's id. + * @param array $record What the install created and still exists. + * + * @return void + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + public function write(string $site, array $record): void { + if ($this->isEmpty(record: $record) === true) { + $this->appConfig->deleteKey(Application::APP_ID, self::PREFIX . $site); + return; + } + + $this->appConfig->setValueString(Application::APP_ID, self::PREFIX . $site, (string)json_encode($record)); + }//end write() +}//end class diff --git a/lib/Service/ExampleSite/ExampleSiteRemover.php b/lib/Service/ExampleSite/ExampleSiteRemover.php new file mode 100644 index 000000000..e09c2bd30 --- /dev/null +++ b/lib/Service/ExampleSite/ExampleSiteRemover.php @@ -0,0 +1,152 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Service\ExampleSite; + +/** + * Removes an example site from its install's own record. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ +class ExampleSiteRemover { + /** + * Constructor. + * + * @param ExampleSiteStore $store Deletes rows. + * @param ExampleSiteRows $rows Finds the portal's rows. + * @param ExampleSiteRecord $record Knows what the install created. + * + * @return void + */ + public function __construct( + private readonly ExampleSiteStore $store, + private readonly ExampleSiteRows $rows, + private readonly ExampleSiteRecord $record, + ) { + }//end __construct() + + /** + * Delete what the install recorded, and nothing else. + * + * @param string $site The site's id. + * @param string $slug The slug of its portal. + * + * @return array `recorded` (false when there is no record), + * `available`, `deleted` and `gone` per schema, + * `failed` ids, and `portal`: `deleted`, + * `kept-content`, `kept-not-ours` or `failed`. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + public function remove(string $site, string $slug): array { + $record = $this->record->read(site: $site); + $report = [ + 'site' => $site, + 'recorded' => ($this->record->isEmpty(record: $record) === false), + 'available' => $this->store->available(), + 'deleted' => [], + 'gone' => [], + 'failed' => [], + 'portal' => 'kept-not-ours', + ]; + if ($report['recorded'] === false || $report['available'] === false) { + return $report; + } + + $left = ['portal' => $record['portal'], 'menu' => [], 'page' => [], 'newsItem' => []]; + foreach (ExampleSiteRecord::SCHEMAS as $schema) { + $outcome = $this->removeRows(schema: $schema, slug: $slug, ids: $record[$schema]); + $report['deleted'][$schema] = $outcome['deleted']; + $report['gone'][$schema] = $outcome['gone']; + $left[$schema] = $outcome['failed']; + foreach ($outcome['failed'] as $id) { + $report['failed'][] = $schema . ' ' . $id; + } + } + + $report['portal'] = $this->removePortal(recordedId: $record['portal'], slug: $slug); + if ($report['portal'] === 'deleted' || $report['portal'] === 'kept-not-ours') { + $left['portal'] = ''; + } + + $this->record->write(site: $site, record: $left); + + return $report; + }//end remove() + + /** + * Delete the recorded rows of one schema that are still in the portal. + * + * @param string $schema The schema slug. + * @param string $slug The portal's slug. + * @param array $ids The recorded ids. + * + * @return array{deleted: int, gone: int, failed: array} + */ + private function removeRows(string $schema, string $slug, array $ids): array { + $present = $this->rows->ids(schema: $schema, slug: $slug); + $outcome = ['deleted' => 0, 'gone' => 0, 'failed' => []]; + foreach ($ids as $id) { + if (isset($present[$id]) === false) { + $outcome['gone']++; + continue; + } + + if ($this->store->delete(schema: $schema, id: $id) === true) { + $outcome['deleted']++; + continue; + } + + $outcome['failed'][] = $id; + } + + return $outcome; + }//end removeRows() + + /** + * Delete the portal when this install created it and nothing is left in it. + * + * @param string $recordedId The id the install recorded, or '' when the portal was there before. + * @param string $slug The portal's slug. + * + * @return string `deleted`, `kept-content`, `kept-not-ours` or `failed`. + */ + private function removePortal(string $recordedId, string $slug): string { + $portal = $this->rows->portal(slug: $slug); + if ($recordedId === '' || $portal === null || $this->store->idOf(row: $portal) !== $recordedId) { + return 'kept-not-ours'; + } + + if ($this->rows->rowsOf(schema: 'menu', slug: $slug) !== [] || $this->rows->rowsOf(schema: 'page', slug: $slug) !== []) { + return 'kept-content'; + } + + if ($this->store->delete(schema: 'portal', id: $recordedId) === true) { + return 'deleted'; + } + + return 'failed'; + }//end removePortal() +}//end class diff --git a/lib/Service/ExampleSite/ExampleSiteRows.php b/lib/Service/ExampleSite/ExampleSiteRows.php new file mode 100644 index 000000000..d27aad079 --- /dev/null +++ b/lib/Service/ExampleSite/ExampleSiteRows.php @@ -0,0 +1,149 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Service\ExampleSite; + +/** + * Finds a portal's rows and names them. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ +class ExampleSiteRows { + /** + * Constructor. + * + * @param ExampleSiteStore $store Reads the rows. + * + * @return void + */ + public function __construct( + private readonly ExampleSiteStore $store, + ) { + }//end __construct() + + /** + * The portal row with this slug, or null. + * + * @param string $slug The portal's slug. + * + * @return array|null + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + public function portal(string $slug): ?array { + foreach ($this->store->find(schema: 'portal', filters: []) as $row) { + if (($row['slug'] ?? null) === $slug) { + return $row; + } + } + + return null; + }//end portal() + + /** + * The rows of one schema that belong to the portal. + * + * The filter is checked again here: a store that ignored it would hand + * over another portal's rows, and an install would then see a page of + * another portal as its own. + * + * @param string $schema The schema slug. + * @param string $slug The portal's slug. + * + * @return array> + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-the-install-must-prove-what-arrived + */ + public function rowsOf(string $schema, string $slug): array { + $rows = []; + foreach ($this->store->find(schema: $schema, filters: ['portal' => $slug]) as $row) { + if (($row['portal'] ?? null) === $slug) { + $rows[] = $row; + } + } + + return $rows; + }//end rowsOf() + + /** + * The portal's rows of one schema, by what tells them apart. + * + * @param string $schema The schema slug. + * @param string $slug The portal's slug. + * + * @return array> + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + public function keyed(string $schema, string $slug): array { + $keyed = []; + foreach ($this->rowsOf(schema: $schema, slug: $slug) as $row) { + $keyed[$this->keyOf(schema: $schema, object: $row)] = $row; + } + + return $keyed; + }//end keyed() + + /** + * The ids of the portal's rows of one schema. + * + * @param string $schema The schema slug. + * @param string $slug The portal's slug. + * + * @return array Id to true. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + public function ids(string $schema, string $slug): array { + $ids = []; + foreach ($this->rowsOf(schema: $schema, slug: $slug) as $row) { + $ids[$this->store->idOf(row: $row)] = true; + } + + return $ids; + }//end ids() + + /** + * What tells one object from another, within its portal. + * + * @param string $schema The schema slug. + * @param array $object The object. + * + * @return string A menu's position and title, a page's route, a news item's title. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + public function keyOf(string $schema, array $object): string { + if ($schema === 'menu') { + return (int)($object['position'] ?? 0) . ' ' . (string)($object['title'] ?? ''); + } + + if ($schema === 'page') { + return (string)($object['route'] ?? ''); + } + + return (string)($object['title'] ?? ''); + }//end keyOf() +}//end class diff --git a/lib/Service/ExampleSite/ExampleSiteStore.php b/lib/Service/ExampleSite/ExampleSiteStore.php new file mode 100644 index 000000000..c3c94383b --- /dev/null +++ b/lib/Service/ExampleSite/ExampleSiteStore.php @@ -0,0 +1,233 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-the-install-must-prove-what-arrived + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Service\ExampleSite; + +use OCA\Portaliq\Service\PortalObjectWriter; +use OCA\Portaliq\Service\PortalRegisterContext; +use OCA\Portaliq\Service\PortalResolver; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Reads, writes and deletes rows of the portaliq register for an administrator. + * + * Every call runs without OpenRegister's access rules, like the repair step + * that seeds the first portal: the caller is `occ`, so there is no user whose + * rights could be asked. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-the-install-must-prove-what-arrived + */ +class ExampleSiteStore { + /** + * OpenRegister's ObjectService, fetched by string id: OpenRegister is an + * optional sibling app, so its classes are never imported. + */ + private const OBJECT_SERVICE = 'OCA\\OpenRegister\\Service\\ObjectService'; + + /** + * The most rows one read returns. + */ + private const PAGE_SIZE = 500; + + /** + * Constructor. + * + * @param ContainerInterface $container Hands out OpenRegister's ObjectService. + * @param PortalRegisterContext $context Points that service at a portaliq schema. + * @param PortalObjectWriter $writer Writes a row the way the repair step does. + * @param LoggerInterface $logger Logger. + * + * @return void + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly PortalRegisterContext $context, + private readonly PortalObjectWriter $writer, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether OpenRegister can be reached at all. + * + * @return bool + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + public function available(): bool { + return $this->objectService() !== null; + }//end available() + + /** + * The rows of one schema that match the filters, as plain arrays. + * + * @param string $schema The schema slug. + * @param array $filters Property filters; empty reads every row. + * + * @return array> + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-the-install-must-prove-what-arrived + */ + public function find(string $schema, array $filters): array { + $objectService = $this->objectService(); + if ($objectService === null) { + return []; + } + + try { + if ($this->context->apply(objectService: $objectService, schemaSlug: $schema) === false) { + return []; + } + + $rows = $objectService->findAll( + config: ['filters' => $filters, 'limit' => self::PAGE_SIZE, 'offset' => 0], + _rbac: false, + _multitenancy: false + ); + } catch (Throwable $e) { + $this->logger->warning('Portaliq: example site read failed', ['schema' => $schema, 'reason' => $e->getMessage()]); + return []; + } + + if (is_array($rows) === false) { + return []; + } + + $plain = []; + foreach ($rows as $row) { + if (is_object($row) === true && method_exists($row, 'jsonSerialize') === true) { + $row = $row->jsonSerialize(); + } + + if (is_array($row) === true) { + $plain[] = $row; + } + } + + return $plain; + }//end find() + + /** + * Write one row and return its id, or null when the write failed. + * + * @param string $schema The schema slug. + * @param array $data The row. + * + * @return string|null + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-install-an-example-site-with-one-command + */ + public function create(string $schema, array $data): ?string { + $saved = $this->writer->createAnonymousObject(register: PortalResolver::REGISTER, schema: $schema, data: $data); + if ($saved === null) { + return null; + } + + $id = $this->idOf(row: $saved); + if ($id === '') { + return null; + } + + return $id; + }//end create() + + /** + * Delete one row by its id. + * + * @param string $schema The schema slug. + * @param string $id The row's id. + * + * @return bool True when OpenRegister deleted it. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + public function delete(string $schema, string $id): bool { + $objectService = $this->objectService(); + if ($objectService === null || $id === '') { + return false; + } + + try { + $deleted = $objectService->deleteObject( + uuid: $id, + register: PortalResolver::REGISTER, + schema: $schema, + _rbac: false, + _multitenancy: false + ); + } catch (Throwable $e) { + $this->logger->warning('Portaliq: example site delete failed', ['schema' => $schema, 'reason' => $e->getMessage()]); + return false; + } + + return $deleted !== false; + }//end delete() + + /** + * The id OpenRegister gave a row, wherever it put it. + * + * @param array $row The row. + * + * @return string The id, or '' when the row names none. + * + * @spec openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md#requirement-an-administrator-must-be-able-to-remove-an-example-site + */ + public function idOf(array $row): string { + $self = []; + if (is_array($row['@self'] ?? null) === true) { + $self = $row['@self']; + } + + foreach ([($self['id'] ?? null), ($self['uuid'] ?? null), ($row['id'] ?? null), ($row['uuid'] ?? null)] as $id) { + if (is_string($id) === true && $id !== '') { + return $id; + } + } + + return ''; + }//end idOf() + + /** + * OpenRegister's ObjectService, or null when it is not installed. + * + * @return object|null + */ + private function objectService(): ?object { + try { + $service = $this->container->get(self::OBJECT_SERVICE); + } catch (Throwable) { + return null; + } + + if (is_object($service) === true) { + return $service; + } + + return null; + }//end objectService() +}//end class diff --git a/lib/Service/PortalThemeResolver.php b/lib/Service/PortalThemeResolver.php index cb7217aa0..bd8f438d0 100644 --- a/lib/Service/PortalThemeResolver.php +++ b/lib/Service/PortalThemeResolver.php @@ -92,7 +92,7 @@ class PortalThemeResolver { * * @var array */ - private const LOGO_VARIANTS = ['' => '', 'dark' => '-dark', 'emblem' => '-emblem']; + private const LOGO_VARIANTS = ['' => '', 'dark' => '-dark', 'emblem' => '-emblem', 'emblem-grey' => '-emblem-grey']; /** @@ -206,10 +206,12 @@ public function shippedStylesheets(): array { * A variant (site-chrome-follows-the-design) is `dark`, the light logo * for a dark band such as a school portal's footer (`-dark.svg`, * named after the dark scheme it was drawn for), or `emblem`, the mark - * without the name for a faint watermark (`-emblem.svg`). + * without the name for a faint watermark (`-emblem.svg`), or + * `emblem-grey`, that mark in grey for a set whose watermark carries no + * tint (`-emblem-grey.svg`, example-site-zuiddrecht). * * @param string $theme The portal's theme reference, e.g. 'opencatalogi'. - * @param string $variant '' for the logo, else `dark` or `emblem`. + * @param string $variant '' for the logo, else `dark`, `emblem` or `emblem-grey`. * * @return string|null The path relative to the theme app, or null. * diff --git a/lib/Settings/sites/zuiddrecht.json b/lib/Settings/sites/zuiddrecht.json new file mode 100644 index 000000000..9c780d976 --- /dev/null +++ b/lib/Settings/sites/zuiddrecht.json @@ -0,0 +1,2110 @@ +{ + "$comment": "The Zuiddrecht example site (openspec/changes/example-site-zuiddrecht). Read by ExampleSiteInstaller for `occ portaliq:example-site:install zuiddrecht`. Copy and layout follow the approved design (Kop, Voet, Home, Contentpagina). An object that already exists is never changed: only what is missing is written.", + "id": "zuiddrecht", + "name": "Gemeente Zuiddrecht", + "description": "The public website of a demo municipality that does not exist: header with search and a button to the resident's own area, home page with top tasks and news, content pages and a footer.", + "portal": { + "slug": "zuiddrecht", + "title": "Gemeente Zuiddrecht", + "status": "published", + "kind": "site", + "theme": "zuiddrecht", + "headerVariant": "double", + "domains": [], + "locales": [ + "nl" + ], + "authentication": { + "modes": [ + "public", + "digid", + "eherkenning" + ], + "minTrust": "low" + }, + "headerSearch": { + "label": "Zoeken op de website", + "placeholder": "Zoeken", + "route": "/zoeken" + }, + "accountLabel": "Mijn Zuiddrecht", + "footer": { + "description": "Heeft u een vraag? Bel of mail ons. We helpen u graag.", + "colophon": "Gemeente Zuiddrecht is een voorbeeldgemeente. Deze website laat zien hoe portaliq en Nextcloud samen werken.", + "cta": { + "label": "Contact en openingstijden", + "href": "/contact" + }, + "contact": { + "title": "Contact", + "lines": [ + { + "text": "Telefoon: [telefoonnummer]" + }, + { + "text": "E-mail: [e-mailadres]" + }, + { + "text": "Stadskantoor: [adres]" + } + ] + } + } + }, + "menus": [ + { + "title": "Hoofdmenu", + "position": 0, + "items": [ + { + "order": 1, + "name": "Home", + "link": "/" + }, + { + "order": 2, + "name": "Wonen en leven", + "link": "/wonen-en-leven" + }, + { + "order": 3, + "name": "Afval", + "link": "/afval" + }, + { + "order": 4, + "name": "Parkeren", + "link": "/parkeren" + }, + { + "order": 5, + "name": "Ondernemen", + "link": "/ondernemen" + }, + { + "order": 6, + "name": "Woo-publicaties", + "link": "/zoeken" + }, + { + "order": 7, + "name": "Bestuur en organisatie", + "link": "/bestuur" + } + ] + }, + { + "title": "Snel naar", + "position": 1, + "items": [ + { + "order": 1, + "name": "Afvalkalender", + "link": "/afval" + }, + { + "order": 2, + "name": "Melding openbare ruimte", + "link": "/melding-openbare-ruimte" + }, + { + "order": 3, + "name": "Woo-publicaties", + "link": "/zoeken" + }, + { + "order": 4, + "name": "Werken bij Zuiddrecht", + "link": "/werken-bij-zuiddrecht" + } + ] + }, + { + "title": "Over deze website", + "position": 1, + "items": [ + { + "order": 1, + "name": "Toegankelijkheid", + "link": "/toegankelijkheid" + }, + { + "order": 2, + "name": "Privacy", + "link": "/privacy" + }, + { + "order": 3, + "name": "Cookies", + "link": "/cookies" + }, + { + "order": 4, + "name": "Kwetsbaarheid melden", + "link": "/kwetsbaarheid-melden" + } + ] + } + ], + "pages": [ + { + "route": "/", + "title": "Home", + "summary": "Regel uw zaken met de gemeente Zuiddrecht.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-home-01-nlbanner", + "widgetKey": "nlBanner", + "gridX": 0, + "gridY": 0, + "gridWidth": 12, + "gridHeight": 1, + "props": { + "kind": "warning", + "closable": true, + "text": "Let op: zaterdag 10 oktober is het stadskantoor open van 9.00 tot 13.00 uur." + } + }, + { + "id": "zd-home-02-hero", + "widgetKey": "hero", + "gridX": 0, + "gridY": 1, + "gridWidth": 12, + "gridHeight": 4, + "props": { + "title": "Wat wilt u regelen?", + "search": true, + "searchLabel": "Zoek op onderwerp, bijvoorbeeld paspoort of afval", + "searchSubmitLabel": "Zoeken", + "headingVisible": true + } + }, + { + "id": "zd-home-03-nlquicktasks", + "widgetKey": "nlQuickTasks", + "gridX": 0, + "gridY": 5, + "gridWidth": 12, + "gridHeight": 3, + "props": { + "heading": "Direct regelen", + "columns": 4, + "overlap": true, + "items": [ + { + "label": "Paspoort en ID-kaart", + "href": "/paspoort-en-id-kaart", + "icon": "card" + }, + { + "label": "Afvalkalender", + "href": "/afval", + "icon": "calendar" + }, + { + "label": "Parkeervergunning", + "href": "/parkeren", + "icon": "document" + }, + { + "label": "Verhuizing doorgeven", + "href": "/verhuizing-doorgeven", + "icon": "home" + }, + { + "label": "Melding openbare ruimte", + "href": "/melding-openbare-ruimte", + "icon": "alert" + }, + { + "label": "Afspraak maken", + "href": "/afspraak-maken", + "icon": "clock" + }, + { + "label": "Woo-publicaties", + "href": "/zoeken", + "icon": "bookLines" + }, + { + "label": "Belastingen betalen", + "href": "/belastingen", + "icon": "documentGrade" + } + ], + "moreLabel": "Alle onderwerpen van A tot Z", + "moreHref": "/onderwerpen" + } + }, + { + "id": "zd-home-04-nlnewslist", + "widgetKey": "nlNewsList", + "gridX": 0, + "gridY": 9, + "gridWidth": 7, + "gridHeight": 6, + "props": { + "heading": "Nieuws", + "limit": 4, + "featured": true, + "showAudience": false, + "moreLabel": "Al het nieuws", + "moreHref": "/nieuwsoverzicht", + "articleRoute": "/nieuws" + } + }, + { + "id": "zd-home-05-nlsignin", + "widgetKey": "nlSignIn", + "gridX": 8, + "gridY": 9, + "gridWidth": 4, + "gridHeight": 4, + "props": { + "heading": "Mijn Zuiddrecht", + "display": "card", + "tone": "inverse", + "points": [ + "Zie waar uw aanvraag staat", + "Lees berichten van de gemeente", + "Bewaar documenten in een eigen dossier" + ], + "buttonLabel": "Inloggen bij Mijn Zuiddrecht" + } + }, + { + "id": "zd-home-06-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 8, + "gridY": 14, + "gridWidth": 4, + "gridHeight": 2, + "props": { + "heading": "Openbare informatie", + "links": [ + { + "label": "Zoeken in Woo-publicaties", + "href": "/zoeken", + "description": "Besluiten, rapporten en antwoorden op Woo-verzoeken." + } + ] + } + }, + { + "id": "zd-home-07-nlheading", + "widgetKey": "nlHeading", + "gridX": 0, + "gridY": 18, + "gridWidth": 12, + "gridHeight": 1, + "props": { + "text": "Bestuur en organisatie", + "level": 2 + } + }, + { + "id": "zd-home-08-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 19, + "gridWidth": 4, + "gridHeight": 3, + "props": { + "heading": "Gemeenteraad", + "links": [ + { + "label": "Vergaderingen en stukken", + "href": "/bestuur/vergaderingen" + }, + { + "label": "Raadsleden", + "href": "/bestuur/raadsleden" + }, + { + "label": "Inspreken bij de raad", + "href": "/bestuur/inspreken" + } + ] + } + }, + { + "id": "zd-home-09-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 4, + "gridY": 19, + "gridWidth": 4, + "gridHeight": 3, + "props": { + "heading": "College", + "links": [ + { + "label": "Burgemeester en wethouders", + "href": "/bestuur/college" + }, + { + "label": "Besluiten van het college", + "href": "/bestuur/collegebesluiten" + }, + { + "label": "Coalitieakkoord", + "href": "/bestuur/coalitieakkoord" + } + ] + } + }, + { + "id": "zd-home-10-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 8, + "gridY": 19, + "gridWidth": 4, + "gridHeight": 3, + "props": { + "heading": "Organisatie", + "links": [ + { + "label": "Werken bij Zuiddrecht", + "href": "/werken-bij-zuiddrecht" + }, + { + "label": "Inkoop en aanbesteding", + "href": "/bestuur/inkoop" + }, + { + "label": "Klacht over de gemeente", + "href": "/klacht" + } + ] + } + } + ] + } + }, + { + "route": "/afval", + "title": "Afval scheiden en ophalen", + "summary": "Wij halen uw afval om de week op. Op de afvalkalender ziet u welke container wanneer aan de straat moet.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-afval-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 7, + "gridHeight": 1, + "props": { + "text": "Wij halen uw afval om de week op. Op de afvalkalender ziet u welke container wanneer aan de straat moet." + } + }, + { + "id": "zd-afval-02-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 8, + "gridY": 0, + "gridWidth": 4, + "gridHeight": 5, + "props": { + "heading": "Meer over afval", + "links": [ + { + "label": "Afvalbrengstation", + "href": "/afval/afvalbrengstation" + }, + { + "label": "Container kapot of kwijt", + "href": "/afval/container" + }, + { + "label": "Afvalstoffenheffing", + "href": "/afval/afvalstoffenheffing" + }, + { + "label": "Zwerfafval melden", + "href": "/melding-openbare-ruimte" + } + ] + } + }, + { + "id": "zd-afval-03-nlheading", + "widgetKey": "nlHeading", + "gridX": 0, + "gridY": 2, + "gridWidth": 7, + "gridHeight": 1, + "props": { + "text": "Welke container is waarvoor?", + "level": 2 + } + }, + { + "id": "zd-afval-04-nltable", + "widgetKey": "nlTable", + "gridX": 0, + "gridY": 3, + "gridWidth": 7, + "gridHeight": 3, + "props": { + "caption": "Containers en ophaaldagen", + "columns": [ + "Container", + "Wat mag erin", + "Ophalen" + ], + "rows": [ + [ + "Groen", + "Groente-, fruit- en tuinafval", + "Om de week" + ], + [ + "Blauw", + "Papier en karton", + "Elke vier weken" + ], + [ + "Oranje", + "Plastic, blik en drinkpakken", + "Om de week" + ], + [ + "Grijs", + "Restafval", + "Om de week" + ] + ] + } + }, + { + "id": "zd-afval-05-nlheading", + "widgetKey": "nlHeading", + "gridX": 0, + "gridY": 7, + "gridWidth": 7, + "gridHeight": 1, + "props": { + "text": "Grof afval", + "level": 2 + } + }, + { + "id": "zd-afval-06-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 8, + "gridWidth": 7, + "gridHeight": 1, + "props": { + "text": "Grof afval brengt u zelf naar het afvalbrengstation. Of u maakt een afspraak en wij halen het op. De eerste vier keer per jaar zijn gratis." + } + }, + { + "id": "zd-afval-07-nlbuttonlink", + "widgetKey": "nlButtonLink", + "gridX": 0, + "gridY": 9, + "gridWidth": 4, + "gridHeight": 1, + "props": { + "label": "Grof afval laten ophalen", + "href": "/mijn", + "kind": "primary" + } + } + ] + } + }, + { + "route": "/afval/afvalbrengstation", + "title": "Afvalbrengstation", + "summary": "Breng grof afval, tuinafval en klein chemisch afval naar het afvalbrengstation.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-afvalbrengstation-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Naar het afvalbrengstation brengt u grof afval, tuinafval, puin en klein chemisch afval. Neem uw afvalpas mee." + } + }, + { + "id": "zd-afvalbrengstation-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Het station is open van dinsdag tot en met zaterdag, van 9.00 tot 16.30 uur. Inwoners van Zuiddrecht betalen niets voor de eerste vier bezoeken per jaar." + } + }, + { + "id": "zd-afvalbrengstation-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Afval scheiden en ophalen", + "href": "/afval" + }, + { + "label": "Afvalstoffenheffing", + "href": "/afval/afvalstoffenheffing" + } + ] + } + } + ] + } + }, + { + "route": "/afval/container", + "title": "Container kapot of kwijt", + "summary": "Meld een kapotte of verdwenen container. U krijgt binnen vijf werkdagen een nieuwe.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-container-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Is uw container kapot of weg? Meld het ons. U krijgt binnen vijf werkdagen een nieuwe container." + } + }, + { + "id": "zd-container-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Een container die door slijtage kapot gaat, vervangen wij gratis." + } + }, + { + "id": "zd-container-03-nlbuttonlink", + "widgetKey": "nlButtonLink", + "gridX": 0, + "gridY": 2, + "gridWidth": 4, + "gridHeight": 1, + "props": { + "label": "Melding openbare ruimte", + "href": "/melding-openbare-ruimte", + "kind": "primary" + } + } + ] + } + }, + { + "route": "/afval/afvalstoffenheffing", + "title": "Afvalstoffenheffing", + "summary": "Wat u betaalt voor het ophalen en verwerken van uw afval.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-afvalstoffenheffing-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Elk huishouden betaalt afvalstoffenheffing. Daarmee betaalt u het ophalen en verwerken van uw afval." + } + }, + { + "id": "zd-afvalstoffenheffing-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Het bedrag staat op de aanslag gemeentelijke belastingen. Die krijgt u elk jaar in februari." + } + }, + { + "id": "zd-afvalstoffenheffing-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Belastingen betalen", + "href": "/belastingen" + } + ] + } + } + ] + } + }, + { + "route": "/wonen-en-leven", + "title": "Wonen en leven", + "summary": "Alles over wonen, verhuizen, afval en uw buurt.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-wonen-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Hier vindt u wat u met de gemeente regelt rond uw huis en uw buurt." + } + }, + { + "id": "zd-wonen-02-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Onderwerpen", + "links": [ + { + "label": "Paspoort en ID-kaart", + "href": "/paspoort-en-id-kaart" + }, + { + "label": "Verhuizing doorgeven", + "href": "/verhuizing-doorgeven" + }, + { + "label": "Afval scheiden en ophalen", + "href": "/afval" + }, + { + "label": "Melding openbare ruimte", + "href": "/melding-openbare-ruimte" + }, + { + "label": "Belastingen betalen", + "href": "/belastingen" + } + ] + } + } + ] + } + }, + { + "route": "/paspoort-en-id-kaart", + "title": "Paspoort en ID-kaart", + "summary": "Vraag een paspoort of ID-kaart aan bij het stadskantoor. Maak eerst een afspraak.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-paspoort-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Een paspoort of ID-kaart vraagt u zelf aan bij het stadskantoor. Maak eerst een afspraak." + } + }, + { + "id": "zd-paspoort-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Neem een pasfoto mee die niet ouder is dan zes maanden, en uw oude paspoort of ID-kaart. Na vijf werkdagen haalt u het nieuwe document op." + } + }, + { + "id": "zd-paspoort-03-nlbuttonlink", + "widgetKey": "nlButtonLink", + "gridX": 0, + "gridY": 2, + "gridWidth": 4, + "gridHeight": 1, + "props": { + "label": "Afspraak maken", + "href": "/afspraak-maken", + "kind": "primary" + } + } + ] + } + }, + { + "route": "/verhuizing-doorgeven", + "title": "Verhuizing doorgeven", + "summary": "Geef uw verhuizing door, van vier weken voor tot vijf dagen na de verhuisdag.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-verhuizing-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Verhuist u naar of binnen Zuiddrecht? Geef dat aan ons door. Dat kan vanaf vier weken voor de verhuizing tot vijf dagen erna." + } + }, + { + "id": "zd-verhuizing-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "U geeft uw verhuizing door aan de balie van het stadskantoor. Maak eerst een afspraak." + } + }, + { + "id": "zd-verhuizing-03-nlbuttonlink", + "widgetKey": "nlButtonLink", + "gridX": 0, + "gridY": 2, + "gridWidth": 4, + "gridHeight": 1, + "props": { + "label": "Afspraak maken", + "href": "/afspraak-maken", + "kind": "primary" + } + } + ] + } + }, + { + "route": "/melding-openbare-ruimte", + "title": "Melding openbare ruimte", + "summary": "Meld een losse stoeptegel, kapotte lantaarnpaal of zwerfafval.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-melding-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Ziet u een losse stoeptegel, een kapotte lantaarnpaal of zwerfafval? Meld het ons per telefoon of e-mail. Wij laten u weten wat wij ermee doen." + } + }, + { + "id": "zd-melding-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Is er direct gevaar? Bel dan [telefoonnummer]." + } + }, + { + "id": "zd-melding-03-nlbuttonlink", + "widgetKey": "nlButtonLink", + "gridX": 0, + "gridY": 2, + "gridWidth": 4, + "gridHeight": 1, + "props": { + "label": "Contact en openingstijden", + "href": "/contact", + "kind": "primary" + } + } + ] + } + }, + { + "route": "/afspraak-maken", + "title": "Afspraak maken", + "summary": "Maak een afspraak bij het stadskantoor.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-afspraak-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Bij het stadskantoor werken wij op afspraak. Zo hoeft u niet te wachten." + } + }, + { + "id": "zd-afspraak-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "U maakt een afspraak per telefoon, op werkdagen van 8.30 tot 17.00 uur. Bel [telefoonnummer]." + } + }, + { + "id": "zd-afspraak-03-nlbuttonlink", + "widgetKey": "nlButtonLink", + "gridX": 0, + "gridY": 2, + "gridWidth": 4, + "gridHeight": 1, + "props": { + "label": "Contact en openingstijden", + "href": "/contact", + "kind": "primary" + } + } + ] + } + }, + { + "route": "/belastingen", + "title": "Belastingen betalen", + "summary": "Wanneer u de aanslag krijgt en hoe u betaalt.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-belastingen-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "In februari krijgt u de aanslag gemeentelijke belastingen. Daarop staan de afvalstoffenheffing, de rioolheffing en de onroerendezaakbelasting." + } + }, + { + "id": "zd-belastingen-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "U betaalt in één keer, of in tien maandelijkse termijnen met automatische incasso." + } + }, + { + "id": "zd-belastingen-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Afvalstoffenheffing", + "href": "/afval/afvalstoffenheffing" + } + ] + } + } + ] + } + }, + { + "route": "/parkeren", + "title": "Parkeren", + "summary": "Parkeervergunning aanvragen en betaald parkeren in de binnenstad.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-parkeren-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "In de binnenstad parkeert u met een vergunning of betaalt u per uur. Bewoners en bedrijven in het gebied kunnen een parkeervergunning aanvragen." + } + }, + { + "id": "zd-parkeren-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "U vraagt de vergunning aan bij het stadskantoor. In Mijn Zuiddrecht ziet u waar uw aanvraag staat." + } + }, + { + "id": "zd-parkeren-03-nlbuttonlink", + "widgetKey": "nlButtonLink", + "gridX": 0, + "gridY": 2, + "gridWidth": 4, + "gridHeight": 1, + "props": { + "label": "Naar Mijn Zuiddrecht", + "href": "/mijn", + "kind": "primary" + } + } + ] + } + }, + { + "route": "/ondernemen", + "title": "Ondernemen", + "summary": "Vergunningen, meldingen en zaken voor uw bedrijf.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-ondernemen-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Heeft u een bedrijf in Zuiddrecht, of wilt u er een beginnen? Hier regelt u vergunningen en meldingen voor uw organisatie." + } + }, + { + "id": "zd-ondernemen-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Namens een bedrijf logt u in met eHerkenning." + } + }, + { + "id": "zd-ondernemen-03-nlbuttonlink", + "widgetKey": "nlButtonLink", + "gridX": 0, + "gridY": 2, + "gridWidth": 4, + "gridHeight": 1, + "props": { + "label": "Naar Mijn Zuiddrecht", + "href": "/mijn", + "kind": "primary" + } + }, + { + "id": "zd-ondernemen-04-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 3, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Inkoop en aanbesteding", + "href": "/bestuur/inkoop" + }, + { + "label": "Parkeren", + "href": "/parkeren" + } + ] + } + } + ] + } + }, + { + "route": "/bestuur", + "title": "Bestuur en organisatie", + "summary": "De gemeenteraad, het college en de organisatie van de gemeente Zuiddrecht.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-bestuur-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 12, + "gridHeight": 1, + "props": { + "text": "De gemeenteraad beslist over de plannen van de gemeente. Het college van burgemeester en wethouders voert ze uit." + } + }, + { + "id": "zd-bestuur-02-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 1, + "gridWidth": 4, + "gridHeight": 3, + "props": { + "heading": "Gemeenteraad", + "links": [ + { + "label": "Vergaderingen en stukken", + "href": "/bestuur/vergaderingen" + }, + { + "label": "Raadsleden", + "href": "/bestuur/raadsleden" + }, + { + "label": "Inspreken bij de raad", + "href": "/bestuur/inspreken" + } + ] + } + }, + { + "id": "zd-bestuur-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 4, + "gridY": 1, + "gridWidth": 4, + "gridHeight": 3, + "props": { + "heading": "College", + "links": [ + { + "label": "Burgemeester en wethouders", + "href": "/bestuur/college" + }, + { + "label": "Besluiten van het college", + "href": "/bestuur/collegebesluiten" + }, + { + "label": "Coalitieakkoord", + "href": "/bestuur/coalitieakkoord" + } + ] + } + }, + { + "id": "zd-bestuur-04-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 8, + "gridY": 1, + "gridWidth": 4, + "gridHeight": 3, + "props": { + "heading": "Organisatie", + "links": [ + { + "label": "Werken bij Zuiddrecht", + "href": "/werken-bij-zuiddrecht" + }, + { + "label": "Inkoop en aanbesteding", + "href": "/bestuur/inkoop" + }, + { + "label": "Klacht over de gemeente", + "href": "/klacht" + } + ] + } + } + ] + } + }, + { + "route": "/bestuur/vergaderingen", + "title": "Vergaderingen en stukken", + "summary": "Wanneer de gemeenteraad vergadert en waar u de stukken vindt.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-vergaderingen-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "De gemeenteraad vergadert om de week op donderdagavond in het stadskantoor. De vergaderingen zijn openbaar." + } + }, + { + "id": "zd-vergaderingen-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "De agenda en de stukken staan een week van tevoren bij de openbare informatie." + } + }, + { + "id": "zd-vergaderingen-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Zoeken in Woo-publicaties", + "href": "/zoeken" + }, + { + "label": "Inspreken bij de raad", + "href": "/bestuur/inspreken" + } + ] + } + } + ] + } + }, + { + "route": "/bestuur/raadsleden", + "title": "Raadsleden", + "summary": "Wie er in de gemeenteraad van Zuiddrecht zitten.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-raadsleden-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "De gemeenteraad van Zuiddrecht heeft 27 leden. Inwoners kiezen de raad elke vier jaar." + } + }, + { + "id": "zd-raadsleden-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Wilt u een raadslid spreken? De griffie brengt u in contact." + } + }, + { + "id": "zd-raadsleden-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Contact en openingstijden", + "href": "/contact" + } + ] + } + } + ] + } + }, + { + "route": "/bestuur/inspreken", + "title": "Inspreken bij de raad", + "summary": "Zo vertelt u de gemeenteraad wat u van een plan vindt.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-inspreken-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Staat er een onderwerp op de agenda waar u iets over wilt zeggen? Dan kunt u inspreken. U krijgt vijf minuten." + } + }, + { + "id": "zd-inspreken-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Meld u uiterlijk een dag van tevoren aan bij de griffie." + } + }, + { + "id": "zd-inspreken-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Vergaderingen en stukken", + "href": "/bestuur/vergaderingen" + } + ] + } + } + ] + } + }, + { + "route": "/bestuur/college", + "title": "Burgemeester en wethouders", + "summary": "Het college van burgemeester en wethouders bestuurt de gemeente van dag tot dag.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-college-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Het college bestaat uit de burgemeester en vier wethouders. Samen besturen zij de gemeente van dag tot dag." + } + }, + { + "id": "zd-college-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Elke wethouder heeft eigen onderwerpen, zoals wonen, zorg of verkeer." + } + }, + { + "id": "zd-college-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Besluiten van het college", + "href": "/bestuur/collegebesluiten" + }, + { + "label": "Coalitieakkoord", + "href": "/bestuur/coalitieakkoord" + } + ] + } + } + ] + } + }, + { + "route": "/bestuur/collegebesluiten", + "title": "Besluiten van het college", + "summary": "Het college vergadert elke dinsdag. De besluiten zijn openbaar.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-collegebesluiten-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Het college vergadert elke dinsdag. De openbare besluiten staan een week later bij de openbare informatie." + } + }, + { + "id": "zd-collegebesluiten-02-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Zoeken in Woo-publicaties", + "href": "/zoeken" + } + ] + } + } + ] + } + }, + { + "route": "/bestuur/coalitieakkoord", + "title": "Coalitieakkoord", + "summary": "De afspraken van de coalitie voor de komende vier jaar.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-coalitieakkoord-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "In het coalitieakkoord staat wat de coalitie de komende vier jaar wil bereiken. Het akkoord is openbaar." + } + }, + { + "id": "zd-coalitieakkoord-02-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Zoeken in Woo-publicaties", + "href": "/zoeken" + } + ] + } + } + ] + } + }, + { + "route": "/bestuur/inkoop", + "title": "Inkoop en aanbesteding", + "summary": "Hoe de gemeente inkoopt en waar u opdrachten vindt.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-inkoop-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "De gemeente koopt in bij bedrijven uit de regio waar dat kan. Grote opdrachten besteden wij openbaar aan." + } + }, + { + "id": "zd-inkoop-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Lopende aanbestedingen staan op TenderNed." + } + }, + { + "id": "zd-inkoop-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Ondernemen", + "href": "/ondernemen" + } + ] + } + } + ] + } + }, + { + "route": "/werken-bij-zuiddrecht", + "title": "Werken bij Zuiddrecht", + "summary": "Vacatures en stages bij de gemeente Zuiddrecht.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-werken-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Bij de gemeente Zuiddrecht werkt u aan de stad waarin u woont. Wij zoeken geregeld nieuwe collega's en stagiairs." + } + }, + { + "id": "zd-werken-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Vragen over een vacature? Bel of mail ons." + } + }, + { + "id": "zd-werken-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Contact en openingstijden", + "href": "/contact" + } + ] + } + } + ] + } + }, + { + "route": "/klacht", + "title": "Klacht over de gemeente", + "summary": "Bent u niet tevreden over hoe wij u hebben geholpen? Vertel het ons.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-klacht-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Bent u niet tevreden over hoe wij u hebben geholpen? Vertel het ons. Wij bellen u binnen vijf werkdagen." + } + }, + { + "id": "zd-klacht-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Komen wij er samen niet uit? Dan kunt u naar de gemeentelijke ombudsman." + } + }, + { + "id": "zd-klacht-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Contact en openingstijden", + "href": "/contact" + } + ] + } + } + ] + } + }, + { + "route": "/contact", + "title": "Contact en openingstijden", + "summary": "Bel, mail of kom langs bij het stadskantoor.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-contact-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Heeft u een vraag? Bel of mail ons. We helpen u graag." + } + }, + { + "id": "zd-contact-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Telefoon: [telefoonnummer]. E-mail: [e-mailadres]. Stadskantoor: [adres]." + } + }, + { + "id": "zd-contact-03-nltable", + "widgetKey": "nlTable", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "caption": "Openingstijden van het stadskantoor", + "columns": [ + "Dag", + "Open" + ], + "rows": [ + [ + "Maandag tot en met vrijdag", + "8.30 tot 17.00 uur" + ], + [ + "Donderdag", + "8.30 tot 20.00 uur" + ], + [ + "Zaterdag", + "9.00 tot 13.00 uur" + ] + ] + } + }, + { + "id": "zd-contact-04-nlbuttonlink", + "widgetKey": "nlButtonLink", + "gridX": 0, + "gridY": 5, + "gridWidth": 4, + "gridHeight": 1, + "props": { + "label": "Afspraak maken", + "href": "/afspraak-maken", + "kind": "primary" + } + } + ] + } + }, + { + "route": "/onderwerpen", + "title": "Alle onderwerpen van A tot Z", + "summary": "Alle onderwerpen van de gemeente Zuiddrecht op een rij.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-onderwerpen-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Alle onderwerpen op deze website, op alfabet." + } + }, + { + "id": "zd-onderwerpen-02-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Onderwerpen", + "links": [ + { + "label": "Afspraak maken", + "href": "/afspraak-maken" + }, + { + "label": "Afval scheiden en ophalen", + "href": "/afval" + }, + { + "label": "Afvalbrengstation", + "href": "/afval/afvalbrengstation" + }, + { + "label": "Afvalstoffenheffing", + "href": "/afval/afvalstoffenheffing" + }, + { + "label": "Belastingen betalen", + "href": "/belastingen" + }, + { + "label": "Container kapot of kwijt", + "href": "/afval/container" + }, + { + "label": "Inkoop en aanbesteding", + "href": "/bestuur/inkoop" + }, + { + "label": "Inspreken bij de raad", + "href": "/bestuur/inspreken" + }, + { + "label": "Klacht over de gemeente", + "href": "/klacht" + }, + { + "label": "Melding openbare ruimte", + "href": "/melding-openbare-ruimte" + }, + { + "label": "Ondernemen", + "href": "/ondernemen" + }, + { + "label": "Parkeren", + "href": "/parkeren" + }, + { + "label": "Paspoort en ID-kaart", + "href": "/paspoort-en-id-kaart" + }, + { + "label": "Verhuizing doorgeven", + "href": "/verhuizing-doorgeven" + }, + { + "label": "Werken bij Zuiddrecht", + "href": "/werken-bij-zuiddrecht" + }, + { + "label": "Woo-publicaties", + "href": "/zoeken" + } + ] + } + } + ] + } + }, + { + "route": "/toegankelijkheid", + "title": "Toegankelijkheid", + "summary": "Deze website is gemaakt om voor iedereen bruikbaar te zijn.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-toegankelijkheid-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Iedereen moet deze website kunnen gebruiken. Wij bouwen de website volgens WCAG 2.2, niveau AA." + } + }, + { + "id": "zd-toegankelijkheid-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Loopt u ergens tegenaan? Laat het ons weten, dan lossen wij het op." + } + }, + { + "id": "zd-toegankelijkheid-03-nllinklist", + "widgetKey": "nlLinkList", + "gridX": 0, + "gridY": 2, + "gridWidth": 8, + "gridHeight": 3, + "props": { + "heading": "Lees ook", + "links": [ + { + "label": "Contact en openingstijden", + "href": "/contact" + } + ] + } + } + ] + } + }, + { + "route": "/privacy", + "title": "Privacy", + "summary": "Gemeente Zuiddrecht is een voorbeeldgemeente. Op deze website staan geen echte gegevens.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-privacy-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Gemeente Zuiddrecht is een voorbeeldgemeente. Op deze website staan geen echte gegevens van inwoners of bedrijven." + } + } + ] + } + }, + { + "route": "/cookies", + "title": "Cookies", + "summary": "Deze website gebruikt alleen cookies die nodig zijn om in te loggen.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-cookies-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Deze website gebruikt alleen cookies die nodig zijn om in te loggen. Wij volgen u niet." + } + } + ] + } + }, + { + "route": "/kwetsbaarheid-melden", + "title": "Kwetsbaarheid melden", + "summary": "Heeft u een zwakke plek in deze website gevonden? Meld het ons.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-kwetsbaarheid-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Heeft u een zwakke plek in deze website gevonden? Meld het ons, voordat u het aan anderen vertelt. Dan kunnen wij het eerst oplossen." + } + }, + { + "id": "zd-kwetsbaarheid-02-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 1, + "gridWidth": 8, + "gridHeight": 1, + "props": { + "text": "Mail uw melding naar [e-mailadres]. U krijgt binnen drie werkdagen antwoord." + } + } + ] + } + }, + { + "route": "/nieuws", + "title": "Nieuws", + "summary": "Nieuws van de gemeente Zuiddrecht.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-nieuws-01-nlnewsarticle", + "widgetKey": "nlNewsArticle", + "gridX": 0, + "gridY": 0, + "gridWidth": 8, + "gridHeight": 8, + "props": { + "backLabel": "Terug naar het nieuws" + } + }, + { + "id": "zd-nieuws-02-nlnewslist", + "widgetKey": "nlNewsList", + "gridX": 8, + "gridY": 0, + "gridWidth": 4, + "gridHeight": 8, + "props": { + "heading": "Meer nieuws", + "limit": 3, + "featured": false, + "display": "compact", + "showAudience": false, + "moreLabel": "Al het nieuws", + "moreHref": "/nieuwsoverzicht", + "articleRoute": "/nieuws" + } + } + ] + } + }, + { + "route": "/nieuwsoverzicht", + "title": "Al het nieuws", + "summary": "Al het nieuws van de gemeente Zuiddrecht.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-nieuwsoverzicht-01-nlnewslist", + "widgetKey": "nlNewsList", + "gridX": 0, + "gridY": 0, + "gridWidth": 12, + "gridHeight": 8, + "props": { + "limit": 12, + "featured": false, + "showAudience": false, + "articleRoute": "/nieuws" + } + } + ] + } + }, + { + "route": "/zoeken", + "title": "Zoeken in Woo-publicaties", + "summary": "Zoek in besluiten, rapporten en antwoorden op Woo-verzoeken.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-zoeken-01-nlparagraph", + "widgetKey": "nlParagraph", + "gridX": 0, + "gridY": 0, + "gridWidth": 12, + "gridHeight": 1, + "props": { + "text": "Zoek in besluiten, rapporten en antwoorden op verzoeken volgens de Wet open overheid (Woo)." + } + }, + { + "id": "zd-zoeken-02-federatedsearch", + "widgetKey": "federatedSearch", + "gridX": 0, + "gridY": 1, + "gridWidth": 12, + "gridHeight": 8, + "props": { + "placeholder": "Bijvoorbeeld: fietspad" + } + } + ] + } + }, + { + "route": "/publicatie", + "title": "Publicatie", + "summary": "Details van één publicatie.", + "status": "published", + "locale": "nl", + "body": { + "type": "grid", + "widgets": [ + { + "id": "zd-publicatie-01-publicationdetail", + "widgetKey": "publicationDetail", + "gridX": 0, + "gridY": 0, + "gridWidth": 12, + "gridHeight": 8, + "props": { + "endpoint": "/index.php/apps/opencatalogi/api/federation/publications" + } + } + ] + } + } + ], + "news": [ + { + "title": "Fietspad Lindelaan krijgt verlichting", + "status": "published", + "public": true, + "publishedAt": "2026-10-02T09:00:00+02:00", + "authorRef": "zuiddrecht-webredactie", + "target": { + "schoolRef": "zuiddrecht" + }, + "body": "Vanaf maart leggen we het fietspad aan. Langs het hele pad komt verlichting.\n\nHet fietspad langs de Lindelaan is nu in het donker slecht te zien. Bewoners en de fietsersbond vroegen daarom om verlichting. De gemeenteraad heeft het geld in september vrijgemaakt.\n\n## Wat gaat er gebeuren?\n\nIn maart beginnen wij met het werk. Er komen 42 lantaarnpalen met zuinige lampen. Het werk duurt ongeveer zes weken.\n\nTijdens het werk blijft het fietspad open. Soms fietst u een klein stuk om." + }, + { + "title": "Nieuwe afvalkalender voor 2027", + "status": "published", + "public": true, + "publishedAt": "2026-09-28T09:00:00+02:00", + "authorRef": "zuiddrecht-webredactie", + "target": { + "schoolRef": "zuiddrecht" + }, + "body": "De afvalkalender voor 2027 staat klaar. U ziet per adres welke container wanneer aan de straat moet.\n\nIn 2027 halen wij papier en karton voortaan elke vier weken op een vaste dag op. De andere containers houden hun ritme." + }, + { + "title": "Stadskantoor voortaan ook op zaterdag open", + "status": "published", + "public": true, + "publishedAt": "2026-09-21T09:00:00+02:00", + "authorRef": "zuiddrecht-webredactie", + "target": { + "schoolRef": "zuiddrecht" + }, + "body": "Vanaf 10 oktober is het stadskantoor ook op zaterdag open, van 9.00 tot 13.00 uur.\n\nU kunt op zaterdag terecht voor een paspoort, een ID-kaart en een rijbewijs. Maak eerst een afspraak." + }, + { + "title": "Denk mee over het nieuwe Havenpark", + "status": "published", + "public": true, + "publishedAt": "2026-09-14T09:00:00+02:00", + "authorRef": "zuiddrecht-webredactie", + "target": { + "schoolRef": "zuiddrecht" + }, + "body": "Aan de oude haven komt een park. Wij horen graag wat u daar wilt doen en zien.\n\nOp dinsdag 20 oktober is er een inloopavond in het stadskantoor, van 19.00 tot 21.00 uur. U kunt uw idee ook mailen." + } + ] +} diff --git a/openspec/changes/example-site-zuiddrecht/proposal.md b/openspec/changes/example-site-zuiddrecht/proposal.md new file mode 100644 index 000000000..2c34fa254 --- /dev/null +++ b/openspec/changes/example-site-zuiddrecht/proposal.md @@ -0,0 +1,42 @@ +## Why + +A fresh portaliq shows one portal, "Open Catalogi", with a hero and a search page. The portal we +show in demonstrations is hand-made on one machine and wears the VNG house style, which nobody can +install and which reads as somebody else's website. The Zuiddrecht design is approved: a +municipality that does not exist, with its own theme in thematiq (`zuiddrecht`). An administrator +has to be able to put that website on any instance with one command, and take it off again. + +## What Changes + +- **A site declaration**, `lib/Settings/sites/zuiddrecht.json`: the portal record (theme + `zuiddrecht`, header search, the "Mijn Zuiddrecht" button, the footer with its button and contact + column), the main menu and two footer menus, 33 pages (home, the content page "Afval scheiden en + ophalen", a page behind every link) and four public news items. Copy and layout of the header, + footer, home and content page follow the design files; the other pages are short new copy so no + link is dead. +- **`occ portaliq:example-site:install `** writes what is missing and never changes what is + there. After writing it reads the instance back, counts what arrived per type against what the + declaration holds, and names every declared key the stored object lost. It exits 2 when anything + is missing. +- **`occ portaliq:example-site:remove `** deletes exactly the objects the install created, + from its own record, and nothing else. A portal that was there before, or that has gained content + since, stays. +- **The current menu item can sit in the line under the menu**: two optional theme tokens, + `--nldesign-website-nav-current-color` and `--nldesign-website-nav-current-in-line`, read in + `css/site-theme.css`. Without them the item keeps the bar it has. +- **Links in `nlLinkList` and `nlButtonLink`** to a page of the site now open that page wherever + the site is served, also through Nextcloud (`?portal=`), like the newer blocks do. + +## Depends on + +- `site-chrome-follows-the-design` (header search, account button, footer button and contact + column) and `site-school-blocks` (`nlQuickTasks`, `nlNewsList`, `nlNewsArticle`, the `nlSignIn` + card): both are on development. This change adds no schema key, so the register version stays. +- thematiq `brand-motif-on-portals` for the role layer, and thematiq #1099 for the two + current-item tokens and the light hero on the `zuiddrecht` set. + +## Out of scope + +The Woo search and publication pages as designed, messages, dossiers, the editor view and phone +polish. `/zoeken` and `/publicatie` hold the search and detail blocks the shipped demo portal +already places. diff --git a/openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md b/openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md new file mode 100644 index 000000000..6e9ac2f04 --- /dev/null +++ b/openspec/changes/example-site-zuiddrecht/specs/example-site/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: An administrator must be able to install an example site with one command + +`occ portaliq:example-site:install ` MUST write the portal, menus, pages and news items of +the named declaration (`lib/Settings/sites/.json`) into the portaliq register. It MUST write +only what is missing: a portal found by its slug, a menu found by position and title, a page found +by its route and a news item found by its title are left exactly as they are. It MUST record the +id of every object it creates. An unknown site MUST end with exit code 1 and the list of sites +that exist. + +#### Scenario: A fresh instance +@e2e exclude Needs a Nextcloud with OpenRegister; covered by PHPUnit tests/Unit/Service/ExampleSite/ExampleSiteInstallerTest.php and the coordinator's live check +- GIVEN an instance without a portal `zuiddrecht` +- WHEN an administrator runs `occ portaliq:example-site:install zuiddrecht` +- THEN the portal, 3 menus, 33 pages and 4 news items exist, and the command exits 0 + +#### Scenario: A second run +@e2e exclude PHPUnit tests/Unit/Service/ExampleSite/ExampleSiteInstallerTest.php +- GIVEN the site was installed and an editor changed the home page +- WHEN the command runs again +- THEN nothing is written and the changed home page is as the editor left it + +#### Scenario: An unknown site +@e2e exclude PHPUnit tests/Unit/Command/ExampleSiteCommandsTest.php +- GIVEN no declaration named `nergens` +- WHEN the command runs with `nergens` +- THEN it exits 1 and names `zuiddrecht` + +### Requirement: The install must prove what arrived + +After writing, the install MUST read the instance back and compare it with the declaration: per +type the number of declared objects found, and per object every declared key, at any depth, that +the stored object does not hold with the declared value. A missing object or a lost key MUST be +named in the output and MUST end the command with exit code 2. The answer of the write itself MUST +NOT be used as proof. + +#### Scenario: The register drops a key +@e2e exclude PHPUnit tests/Unit/Service/ExampleSite/ExampleSiteInstallerTest.php +- GIVEN a register whose portal schema does not know `headerSearch` +- WHEN the site is installed +- THEN the report names `portal zuiddrecht: headerSearch.label` among the lost keys and the command exits 2 + +#### Scenario: A page does not arrive +@e2e exclude PHPUnit tests/Unit/Service/ExampleSite/ExampleSiteInstallerTest.php +- GIVEN a store that refuses one page +- WHEN the site is installed +- THEN the report counts 32 of 33 pages and names the route that is missing + +### Requirement: An administrator must be able to remove an example site + +`occ portaliq:example-site:remove ` MUST delete the objects the install recorded and no +others. The portal MUST stay when the install did not create it, or when it still holds a menu or +a page after the recorded ones are gone. The record MUST be cleared afterwards, so a new install +starts clean. + +#### Scenario: Remove after install +@e2e exclude PHPUnit tests/Unit/Service/ExampleSite/ExampleSiteInstallerTest.php +- GIVEN the site was installed on a fresh instance +- WHEN the administrator runs the remove command +- THEN the portal, its menus, pages and news items are gone + +#### Scenario: Content of the organisation's own +@e2e exclude PHPUnit tests/Unit/Service/ExampleSite/ExampleSiteInstallerTest.php +- GIVEN an editor added a page to the installed portal +- WHEN the administrator runs the remove command +- THEN that page and the portal stay, and the output says why the portal was kept + +### Requirement: A shipped declaration must fit the schemas and the blocks it names + +Every shipped declaration MUST hold only keys the `portal`, `menu`, `page` and `newsItem` schemas +of `lib/Settings/portaliq_register.json` declare, with every required key and only allowed enum +values. Every widget on a page MUST be one the public site renders, with only props that widget +takes. Every link to a page of the site, in a menu, a block or the portal record, MUST lead to a +declared page or to the resident's own area. No text MUST hold an em-dash. + +#### Scenario: The Zuiddrecht declaration +@e2e exclude Checked in node: tests/example-site.spec.mjs, in check:specs +- GIVEN `lib/Settings/sites/zuiddrecht.json` +- WHEN the check runs +- THEN no unknown key, unknown widget, unknown prop or dead link is found + +### Requirement: The current menu item may sit in the line under the menu + +When the theme sets `--nldesign-website-nav-current-in-line` to 1, the bar of the current menu +item MUST be drawn in the line under the menu bar, as high as that line, in +`--nldesign-website-nav-current-color`. Without these tokens the bar MUST stay where and as it +was. + +#### Scenario: Zuiddrecht +@e2e exclude CSS contract in tests/example-site.spec.mjs; live screenshot by the coordinator +- GIVEN a portal on a set with a 5px red line and the two tokens (blue, in the line) +- WHEN the page for "Afval" is open +- THEN a blue piece of 5px sits in the red line under "Afval", and the item has no bar above the line + +### Requirement: A link in a link list or a button link must open the page wherever the site is served + +`nlLinkList` and `nlButtonLink` MUST turn an authored path inside the site into the site's own +address for that route, and a plain click on it MUST stay in the site. Web, mail and phone +addresses MUST pass unchanged; anything else MUST NOT become a link. + +#### Scenario: A portal served through Nextcloud +@e2e exclude Rendered in node: tests/example-site.spec.mjs +- GIVEN the site at `/apps/portaliq/site?portal=zuiddrecht` +- WHEN a link list holds `/afval` +- THEN the link's address keeps `portal=zuiddrecht` and names the route `/afval` diff --git a/openspec/changes/example-site-zuiddrecht/tasks.md b/openspec/changes/example-site-zuiddrecht/tasks.md new file mode 100644 index 000000000..32ac012d4 --- /dev/null +++ b/openspec/changes/example-site-zuiddrecht/tasks.md @@ -0,0 +1,10 @@ +# Tasks: the Zuiddrecht example site + +- [x] 1. Declaration `lib/Settings/sites/zuiddrecht.json` from the design files. +- [x] 2. `ExampleSiteCatalogue`, `ExampleSiteStore`, `ExampleSiteInstaller`, with the read-back proof. +- [x] 3. `occ portaliq:example-site:install` and `:remove`, registered in `appinfo/info.xml`. +- [x] 4. The current menu item in the line (`css/site-theme.css`). +- [x] 5. `nlLinkList` and `nlButtonLink` open pages of the site wherever it is served. +- [x] 6. Tests: `tests/example-site.spec.mjs` in `check:specs`, PHPUnit for the catalogue, installer and commands. +- [x] 7. Documentation: `docs/Installation/example-site-zuiddrecht.md`. +- [ ] 8. Live check on a fresh instance (coordinator). diff --git a/package.json b/package.json index 567c135de..505c28379 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "check:register": "node tests/validate-register.js", "check:registry": "node tests/registry.spec.js", "check:json-strict": "node tests/validate-json-strict.js", - "check:specs": "npm run check:json-strict && npm run check:manifest-v2 && npm run check:register && npm run check:registry && npm run check:site-auth && npm run check:federated-search && npm run check:campaign-tracking && npm run check:traffic-client && npm run check:traffic-summary && npm run check:traffic-paths && npm run check:page-traffic && npm run check:open-portal-site && npm run check:shared-instance && npm run check:connection-registry && npm run check:leaf-integrations && npm run check:page-layout-entry && npm run check:collection-table && npm run check:case-timeline && npm run check:schema-form-file-field && npm run check:timed-task && npm run check:row-action && npm run check:translated-message-notice && npm run check:access-requests && npm run check:news-item-translation && npm run check:news-title-and-newsletter-translation && npm run check:newsletter-title-translation && npm run check:intake-entry && npm run check:access-request-asker && npm run check:registered-details && npm run check:branch-choice && npm run check:account-page && npm run check:signing-dialog && npm run check:form-binding-preview && npm run check:open-record && npm run check:message-box-channel && npm run check:portal-live-regions && npm run check:case-withdraw-screen && npm run check:case-documents-screen && npm run check:my-cases-page && npm run check:my-cases-acting-for && npm run check:proposal-queue-leaf && npm run check:portal-theme-choice && npm run check:site-head && npm run check:page-history && npm run check:media-library && npm run check:broker-login && npm run check:portal-signin-settings && npm run check:site-shell-blocks && npm run check:site-grid && npm run check:site-regions && npm run check:page-editor && npm run check:page-editor-regions && npm run check:notices && npm run check:site-edit-mode && npm run check:site-page-tree && npm run check:branch-in-effect && npm run check:idle-session && npm run check:publication-documents && npm run check:woo-entry-points && npm run check:attached-actions && npm run check:my-dossiers && npm run check:staff-account-screens && npm run check:site-signed-in-shell && npm run check:site-resident-menu && npm run check:create-names-its-action && npm run check:site-shared-dossier && npm run check:guest-action-page && npm run check:ways-in-screens && npm run check:visible-when-local && npm run check:schema-form && npm run check:propose-change && npm run check:site-collections && npm run check:collection-loader && npm run check:rich-text && npm run check:tasks-page && npm run check:site-inbox-pages && npm run check:embed-copy && npm run check:embed-height && npm run check:service-worker && npm run check:install-banner && npm run check:embed-frame && npm run check:array-cells && npm run check:collection-groups && npm run check:record-page && npm run check:case-type-portal-header && npm run check:inbox-unread && npm run check:inbox-attachments && npm run check:inbox-open-link && npm run check:inbox-delete && npm run check:value-labels && npm run check:admin-menu-access && npm run check:news-list-refresh && npm run check:site-navigation && npm run check:site-theme-bridge && npm run check:mijn-components && npm run check:mijn-cases && npm run check:mijn-home && npm run check:mijn-lists && npm run check:mijn-wave6 && npm run check:mijn-documents && npm run check:site-form-fields && npm run check:widget-registry && npm run check:action-summary-sentence && npm run check:site-school-blocks && npm run check:site-school-displays && npm run check:widget-palette && npm run check:editor-text-toolbar && npm run check:widget-tokens && npm run check:portal-home-page && npm run check:claim-invitation && npm run check:site-chrome && npm run check:resident-menu-badges", + "check:specs": "npm run check:json-strict && npm run check:manifest-v2 && npm run check:register && npm run check:registry && npm run check:site-auth && npm run check:federated-search && npm run check:campaign-tracking && npm run check:traffic-client && npm run check:traffic-summary && npm run check:traffic-paths && npm run check:page-traffic && npm run check:open-portal-site && npm run check:shared-instance && npm run check:connection-registry && npm run check:leaf-integrations && npm run check:page-layout-entry && npm run check:collection-table && npm run check:case-timeline && npm run check:schema-form-file-field && npm run check:timed-task && npm run check:row-action && npm run check:translated-message-notice && npm run check:access-requests && npm run check:news-item-translation && npm run check:news-title-and-newsletter-translation && npm run check:newsletter-title-translation && npm run check:intake-entry && npm run check:access-request-asker && npm run check:registered-details && npm run check:branch-choice && npm run check:account-page && npm run check:signing-dialog && npm run check:form-binding-preview && npm run check:open-record && npm run check:message-box-channel && npm run check:portal-live-regions && npm run check:case-withdraw-screen && npm run check:case-documents-screen && npm run check:my-cases-page && npm run check:my-cases-acting-for && npm run check:proposal-queue-leaf && npm run check:portal-theme-choice && npm run check:site-head && npm run check:page-history && npm run check:media-library && npm run check:broker-login && npm run check:portal-signin-settings && npm run check:site-shell-blocks && npm run check:site-grid && npm run check:site-regions && npm run check:page-editor && npm run check:page-editor-regions && npm run check:notices && npm run check:site-edit-mode && npm run check:site-page-tree && npm run check:branch-in-effect && npm run check:idle-session && npm run check:publication-documents && npm run check:woo-entry-points && npm run check:attached-actions && npm run check:my-dossiers && npm run check:staff-account-screens && npm run check:site-signed-in-shell && npm run check:site-resident-menu && npm run check:create-names-its-action && npm run check:site-shared-dossier && npm run check:guest-action-page && npm run check:ways-in-screens && npm run check:visible-when-local && npm run check:schema-form && npm run check:propose-change && npm run check:site-collections && npm run check:collection-loader && npm run check:rich-text && npm run check:tasks-page && npm run check:site-inbox-pages && npm run check:embed-copy && npm run check:embed-height && npm run check:service-worker && npm run check:install-banner && npm run check:embed-frame && npm run check:array-cells && npm run check:collection-groups && npm run check:record-page && npm run check:case-type-portal-header && npm run check:inbox-unread && npm run check:inbox-attachments && npm run check:inbox-open-link && npm run check:inbox-delete && npm run check:value-labels && npm run check:admin-menu-access && npm run check:news-list-refresh && npm run check:site-navigation && npm run check:site-theme-bridge && npm run check:mijn-components && npm run check:mijn-cases && npm run check:mijn-home && npm run check:mijn-lists && npm run check:mijn-wave6 && npm run check:mijn-documents && npm run check:site-form-fields && npm run check:widget-registry && npm run check:action-summary-sentence && npm run check:site-school-blocks && npm run check:site-school-displays && npm run check:widget-palette && npm run check:editor-text-toolbar && npm run check:widget-tokens && npm run check:portal-home-page && npm run check:claim-invitation && npm run check:site-chrome && npm run check:resident-menu-badges && npm run check:example-site", "prepare": "git config core.hooksPath .githooks || true", "build:site": "NODE_ENV=production webpack --config webpack.site.js --progress", "postbuild:site": "node scripts/check-single-dexie.js && node scripts/check-site-chunks.js", @@ -150,7 +150,8 @@ "check:portal-home-page": "node --test tests/portal-home-page.spec.mjs", "check:claim-invitation": "node --test tests/claim-invitation.spec.mjs", "check:site-chrome": "node --test tests/site-chrome.spec.mjs", - "check:resident-menu-badges": "node --test tests/resident-menu-badges.spec.mjs" + "check:resident-menu-badges": "node --test tests/resident-menu-badges.spec.mjs", + "check:example-site": "node --test tests/example-site.spec.mjs" }, "browserslist": [ "extends @nextcloud/browserslist-config" diff --git a/src/site/widgets/nlActionGroup/NlActionGroup.vue b/src/site/widgets/nlActionGroup/NlActionGroup.vue index a26f1be30..8e2326c1e 100644 --- a/src/site/widgets/nlActionGroup/NlActionGroup.vue +++ b/src/site/widgets/nlActionGroup/NlActionGroup.vue @@ -16,7 +16,8 @@ :key="`${index}-${button.href}`" :label="button.label" :href="button.href" - :kind="button.kind" /> + :kind="button.kind" + @navigate="$emit('navigate', $event)" /> @@ -37,6 +38,8 @@ export default { buttons: { type: Array, default: () => [] }, }, + emits: ['navigate'], + computed: { /** * @return {Array} The buttons that have a text and an address. diff --git a/src/site/widgets/nlButtonLink/NlButtonLink.vue b/src/site/widgets/nlButtonLink/NlButtonLink.vue index 7d5f759e7..df275ad42 100644 --- a/src/site/widgets/nlButtonLink/NlButtonLink.vue +++ b/src/site/widgets/nlButtonLink/NlButtonLink.vue @@ -13,11 +13,12 @@ -->