diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..713c41db --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose{*,/**/*}.{yml,yaml}] +indent_size = 4 diff --git a/.github/workflows/.gitignore b/.github/workflows/.gitignore deleted file mode 100644 index e69de29b..00000000 diff --git a/.github/workflows/daily-e2e-robotrun.yml b/.github/workflows/daily-e2e-robotrun.yml new file mode 100644 index 00000000..c6d388af --- /dev/null +++ b/.github/workflows/daily-e2e-robotrun.yml @@ -0,0 +1,33 @@ +name: E2E regression tests +on: + schedule: + - cron: '0 0 * * *' # This cron schedule runs the workflow every day at midnight UTC + workflow_dispatch: + +jobs: + e2e-robot-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + pip install -r tests/robot_framework/requirements.txt --use-deprecated=legacy-resolver + rfbrowser init + - name: Execute E2E tests (Robot Framework) + env: + OTP_SECRET_WOO: ${{ secrets.OTP_SECRET_WOO }} + USERNAME_WOO: ${{ secrets.USERNAME_WOO }} + PASSWORD_WOO: ${{ secrets.PASSWORD_WOO }} + run: | + python -m robot -d tests/robot_framework/results -x outputxunit.xml -i E2E -e LOGS -v headless:true tests/robot_framework + - name: Upload test results + uses: actions/upload-artifact@v3 + if: always() + with: + name: reports + path: tests/robot_framework/results + +# - name: echo environment +# run: echo "Testing on env: ${{ github.event.inputs.env }}" \ No newline at end of file diff --git a/.github/workflows/migration-check.yml b/.github/workflows/migration-check.yml deleted file mode 100644 index 7dd2b9cb..00000000 --- a/.github/workflows/migration-check.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Check for missing migrations - -on: - pull_request: - branches: [ main ] - -jobs: - sync: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v3 - - name: Checkout db - uses: actions/checkout@v3 - with: - repository: minvws/nl-rdo-databases - ref: 'main' - token: ${{ secrets.repo_read_only_token }} - path: './database' - - - name: check for missing migrations - id: migration_check - run: | - # Run the script and store the output - set +e - OUT="$(sh .github/scripts/check-missing-migrations.sh)" - RETVAL=$? - set -e - - EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64 | tr -dc 'a-zA-Z0-9') - { - echo "output<<$EOF" - echo "$OUT" - echo "$EOF" - if [[ $RETVAL -eq 1 ]] ; then - echo "missing_migrations=true" - else - echo "missing_migrations=false" - fi - } >> "$GITHUB_OUTPUT" - - - name: debug it - run: | - echo ${{ steps }} - echo $GITHUB_OUTPUT - echo $GITHUB_STATE - - # Find the comment - - name: Find Comment - uses: peter-evans/find-comment@v2 - id: fc - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-includes: Missing Database Migrations - - # Create a comment when migrations are missing in the db repo - - name: Create comment - if: contains(steps.migration_check.outputs.missing_migrations, 'true') - uses: peter-evans/create-or-update-comment@v3 - with: - comment-id: ${{ steps.fc.outputs.comment-id }} - issue-number: ${{ github.event.pull_request.number }} - edit-mode: replace - body: | - ## 🦙🦙 Missing Database Migrations detected - ``` - ${{ steps.migration_check.outputs.output }} - ``` - 👨‍💻 Please run `php bin/console woopie:sql:dump` to create the SQL migrations files, and add them to the database repository to get rid of this message. - - # Remove comment if no missing migrations - - if: ${{ contains(steps.git.outputs.missing_migrations, 'false') && steps.fc.outputs.comment-id != '' }} - uses: actions/github-script@v6 - with: - script: | - github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: ${{ steps.fc.outputs.comment-id }} - }) diff --git a/.github/workflows/robotframeworkci.yml b/.github/workflows/robotframeworkci.yml.disabled similarity index 100% rename from .github/workflows/robotframeworkci.yml rename to .github/workflows/robotframeworkci.yml.disabled diff --git a/assets/carousel.js b/assets/carousel.js deleted file mode 100644 index b6b747c1..00000000 --- a/assets/carousel.js +++ /dev/null @@ -1,25 +0,0 @@ -import "../node_modules/latte-carousel/dist/latte-carousel.min.css"; -import { Carousel } from "../node_modules/latte-carousel/dist/latte-carousel.min.js"; - -var options = { - count: 5, - move: 1, - touch: true, - mode: "align", - buttons: true, - dots: true, - rewind: true, - autoplay: 0, - animation: 500, - responsive: { - "0": { count: 1.5, mode: "free", buttons: false }, - "480": { count: 2.5, mode: "free", buttons: false }, - "768": { count: 3, move: 3, touch: false, dots: true }, - "1440": { count: 6, move: 2, touch: false, dots: true }, - }, -}; - -if (document.getElementById('carousel') !== null) { - new Carousel("#carousel", options); -} - diff --git a/assets/facet.js b/assets/facet.js deleted file mode 100644 index ed6aa4d7..00000000 --- a/assets/facet.js +++ /dev/null @@ -1,103 +0,0 @@ -const remove = (params, key, value) => { - const values = params.getAll(key) - - if (!values.length) { - return; - } - - params.delete(key); - for (const v of values) { - if (v !== encodeURIComponent(value)) { - params.append(key, v); - } - } -}; - -const append = (params, key, value) => { - params.append(key, encodeURIComponent(value)); -}; - -const updateQueryString = (checked, key, value) => { - const params = new URLSearchParams(location.search); - if (checked) { - append(params, key, value); - } else { - remove(params, key, value); - } - - return params; -}; - -const removeQueryString = (key) => { - const params = new URLSearchParams(location.search); - params.delete(key); - - return params; -} - -const replaceQueryString = (key, value) => { - const params = new URLSearchParams(location.search); - params.set(key, encodeURIComponent(value)); - - return params; -} - -window.toggleDateFacet = (el) => { - var params; - - if (Date.parse(el.value)) { - params = replaceQueryString(el.name, el.value) - } else { - params = removeQueryString(el.name) - } - - window.history.replaceState([], '', `${location.pathname}?${params}`); - - updateResults(params); -} - -window.toggleFacet = (el) => { - const params = updateQueryString(el.checked, el.name, el.value) - window.history.replaceState({}, '', `${location.pathname}?${params}`); - - updateResults(params); -} - -window.setFacet = (el) => { - const params = replaceQueryString(el.name, el.value) - window.history.replaceState({}, '', `${location.pathname}?${params}`); - - updateResults(params); -} - -window.removeFacetPill = (el) => { - // Remove pill - el.remove(); - - // update the query string and reload - var params; - if (el.dataset.value === '') { - params = removeQueryString(el.dataset.key); - } else { - params = updateQueryString(false, el.dataset.key, el.dataset.value); - } - window.history.replaceState({}, '', `${location.pathname}?${params}`); - - updateResults(params); -} - -window.updateResults = (params) => { - el = document.getElementById('js-results'); - if (!el) { - return; - } - - fetch(`/_result?${params}`) - .then(response => response.text()) - .then(json => { - const data = JSON.parse(json); - document.getElementById('js-facets').innerHTML = JSON.parse(data.facets); - document.getElementById('js-results').innerHTML = JSON.parse(data.results); - }) - ; -} diff --git a/assets/img/admin/admin-logo.svg b/assets/img/admin/admin-logo.svg new file mode 100644 index 00000000..98534e7f --- /dev/null +++ b/assets/img/admin/admin-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/img/chevron-right.svg b/assets/img/admin/chevron-right.svg similarity index 100% rename from assets/img/chevron-right.svg rename to assets/img/admin/chevron-right.svg diff --git a/assets/img/admin/mail.svg b/assets/img/admin/mail.svg new file mode 100644 index 00000000..11eab250 --- /dev/null +++ b/assets/img/admin/mail.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/inquiry.js b/assets/inquiry.js new file mode 100644 index 00000000..dfcbca77 --- /dev/null +++ b/assets/inquiry.js @@ -0,0 +1,7 @@ +window.setInquiryDossierLink = () => { + var caseNumberSelect = document.getElementById('dossier-case-nr'); + var caseNumber = caseNumberSelect.options[caseNumberSelect.selectedIndex].text; + + var link = document.getElementById('dossier-case-link') + link.setAttribute('href', link.dataset.base_href + caseNumber); +} diff --git a/assets/navigation.js b/assets/navigation.js new file mode 100644 index 00000000..b704afae --- /dev/null +++ b/assets/navigation.js @@ -0,0 +1,96 @@ +// @ts-check + +import { + ensureElementHasId, + onMediaQueryMatch, + onDomReady, +} from "@minvws/manon/utils.js"; + +onDomReady(initNaviation); + +/** + * Add responsive behaviour to header navigation. Safe to call again to make a + * newly added header navigation responsive. + */ +export function initNaviation() { + var headers = document.querySelectorAll("header:not(.breadcrumbs)"); + for (var i = 0; i < headers.length; i++) { + var nav = headers[i].querySelector("nav"); + if (!(nav instanceof HTMLElement) || nav.querySelector(".menu-toggle")) { + continue; + } + var isCondensed = headers[i].className.indexOf("condensed") !== -1; + makeResponsive(nav, isCondensed); + } +} + +/** + * @param {HTMLElement} nav + * @param {boolean} isCondensed + */ +function makeResponsive(nav, isCondensed) { + var menu = nav.querySelector(".collapsible"); + if (!(menu instanceof HTMLElement)) { + return; + } + ensureElementHasId(menu); + + var button = createMenuButton( + menu, + nav.dataset.openLabel || "Menu", + nav.dataset.closeLabel || "Sluit menu" + ); + + menu.parentNode.insertBefore(button.element, menu); + + if (!isCondensed) { + onMediaQueryMatch( + nav.dataset.media || "(min-width: 42rem)", + function (event) { + button.setExpanded(false); + if (event.matches) { + nav.classList.remove("collapsible-menu"); + } else { + nav.classList.add("collapsible-menu"); + } + } + ); + } +} + +/** + * @param {HTMLElement} ul + * @param {string} openLabel + * @param {string} closeLabel + * @return {{ element: HTMLButtonElement, setExpanded: (expanded: boolean) => void }} + */ +function createMenuButton(ul, openLabel, closeLabel) { + var button = document.createElement("button"); + button.className = "menu-toggle"; + button.setAttribute("aria-controls", ul.id); + button.setAttribute("aria-expanded", "false"); + + var label = document.createElement("span"); + label.innerText = openLabel; + label.className = "sr-only"; + ensureElementHasId(label); + + button.appendChild(label); + button.setAttribute("aria-labelledby", label.id); + + function setExpanded(expanded) { + if (expanded !== (button.getAttribute("aria-expanded") === "true")) { + button.setAttribute("aria-expanded", String(expanded)); + label.innerText = expanded ? closeLabel : openLabel; + } + } + + button.addEventListener("click", function () { + setExpanded(button.getAttribute("aria-expanded") === "false"); + }); + + return { + element: button, + setExpanded: setExpanded, + }; +} diff --git a/assets/search-previews.js b/assets/search-previews.js new file mode 100644 index 00000000..cf0b113b --- /dev/null +++ b/assets/search-previews.js @@ -0,0 +1,73 @@ +import { onDomReady } from '@minvws/manon/utils'; +import { debounce } from 'lodash'; + +onDomReady(function () { + const searchFieldElement = document.getElementById('search-field'); + const searchSuggestionsElement = document.getElementById('js-search-suggestions'); + + if (!searchFieldElement || !searchSuggestionsElement) { + return; + } + + const HIDDEN_ATTRIBUTE = 'hidden'; + const searchFormElement = searchFieldElement.closest('form'); + + const hideSuggestions = () => { + searchSuggestionsElement.setAttribute(HIDDEN_ATTRIBUTE, 'hidden'); + }; + + const revealSuggestions = () => { + const abortController = new AbortController(); + searchSuggestionsElement.removeAttribute(HIDDEN_ATTRIBUTE); + + document.addEventListener('click', (event) => { + if (!searchFormElement.contains(event.target)) { + hideSuggestions(); + abortController.abort(); + } + }, { signal: abortController.signal }); + + document.addEventListener('focusin', (event) => { + if (!searchFormElement.contains(event.target)) { + hideSuggestions(); + abortController.abort(); + } + }, { signal: abortController.signal }); + }; + + const hideOrRevealSuggestions = () => { + const hasSuggestions = searchSuggestionsElement.childNodes.length > 0; + if (!hasSuggestions) { + hideSuggestions(); + return; + } + + if (hasSuggestions && searchSuggestionsElement.hasAttribute(HIDDEN_ATTRIBUTE)) { + revealSuggestions(); + return; + } + }; + + const fetchAndUpdateResults = (searchQuery) => { + if (searchQuery.length < 2) { + return; + } + + fetch(`/_result_minimalistic?q=${encodeURIComponent(searchQuery)}&size=4`) + .then((response) => response.text()) + .then((html) => { + searchSuggestionsElement.innerHTML = html; + hideOrRevealSuggestions(); + }) + ; + }; + + searchFieldElement.addEventListener('input', debounce((event) => { + fetchAndUpdateResults(searchFieldElement.value); + }, 400)); + + searchFieldElement.addEventListener('focus', (event) => { + hideOrRevealSuggestions(); + }); + +}); diff --git a/assets/search/active-filter-pills.js b/assets/search/active-filter-pills.js new file mode 100644 index 00000000..1fd958f3 --- /dev/null +++ b/assets/search/active-filter-pills.js @@ -0,0 +1,47 @@ +import { getSearchParamsAndAppendOrDelete, getSearchParamsAndDelete, updateUrl } from './helpers'; + +export const activeFilterPills = () => { + let abortController = null; + + const initialize = (fetchAndUpdateResultsFunction) => { + if (abortController) { + abortController.abort(); + } + + abortController = new AbortController(); + const activeFilterPillElements = document.querySelectorAll('.js-active-filter-pill'); + + activeFilterPillElements.forEach((activeFilterPillElement) => { + activeFilterPillElement.addEventListener('click', (event) => { + event.preventDefault(); + + const { target: element } = event; + const { key, value } = element.dataset; + + const params = value === '' ? getSearchParamsAndDelete(key) : getSearchParamsAndAppendOrDelete(false, key, value); + + removeActiveFilterPillElement(element); + + updateUrl(params, fetchAndUpdateResultsFunction); + }, { signal: abortController.signal }); + }); + } + + const removeActiveFilterPillElement = (activeFilterPillElement) => { + const listItemElement = activeFilterPillElement.closest('li'); + const listElement = activeFilterPillElement.closest('ul'); + + activeFilterPillElement.remove(); + if (listItemElement) { + listItemElement.remove(); + } + + if (listElement && listElement.children.length === 0) { + listElement.remove(); + } + } + + return { + initialize, + }; +} diff --git a/assets/search/checkbox-filters.js b/assets/search/checkbox-filters.js new file mode 100644 index 00000000..6bd61ee9 --- /dev/null +++ b/assets/search/checkbox-filters.js @@ -0,0 +1,27 @@ +import { getSearchParamsAndAppendOrDelete, updateUrl } from './helpers'; + +export const checkboxFilters = () => { + let abortController = null; + + const initialize = (fetchAndUpdateResultsFunction) => { + if (abortController) { + abortController.abort(); + } + + abortController = new AbortController(); + const checkboxElements = document.querySelectorAll('.js-search-filter-checkbox'); + + checkboxElements.forEach((checkboxElement) => { + checkboxElement.addEventListener('change', (event) => { + const { checked, name, value } = event.target; + const params = getSearchParamsAndAppendOrDelete(checked, name, value); + + updateUrl(params, fetchAndUpdateResultsFunction); + }, { signal: abortController.signal }); + }); + } + + return { + initialize, + }; +} diff --git a/assets/search/collapsible-filters.js b/assets/search/collapsible-filters.js new file mode 100644 index 00000000..37e33a28 --- /dev/null +++ b/assets/search/collapsible-filters.js @@ -0,0 +1,257 @@ +export const collapsibleFilters = () => { + let abortController = null; + + const TOGGLE_BUTTON_COLLAPSED_CLASS = 'toggle-button--collapsed'; + const TOGGLE_BUTTON_WITH_ANIMATION_CLASS = 'toggle-button--with-animation'; + const GROUP_STORAGE_KEY = 'collapsed-search-filter-groups'; + const IS_COLLAPSING_ATTRIBUTE = 'data-is-collapsing'; + const IS_EXPANDING_ATTRIBUTE = 'data-is-expanding'; + const ITEMS_STORAGE_KEY = 'expanded-search-filter-items'; + + const getFilterItemsCollapsibeElement = (filtersGroupElement) => filtersGroupElement.querySelector('.js-filters-item-collapsible'); + const getFiltersGroupCollapsibeElement = (filtersGroupElement) => filtersGroupElement.querySelector('.js-filters-group-collapsible'); + const getFiltersGroupKey = (filtersGroupElement) => filtersGroupElement.getAttribute('data-key'); + const getToggleGroupButtonElement = (filtersGroupElement) => filtersGroupElement.querySelector('.js-toggle-filters-group-button'); + const getToggleItemsButtonElement = (filtersGroupElement) => filtersGroupElement.querySelector('.js-toggle-filter-items-button'); + + const initialize = () => { + if (abortController) { + abortController.abort(); // This will remove event listeners and prevent memory leaks + } + + abortController = new AbortController(); + document.querySelectorAll('.js-filters-group').forEach(initializeFiltersGroup); + }; + + const initializeFiltersGroup = (filtersGroupElement) => { + initializeToggleItems(filtersGroupElement); + initializeToggleGroup(filtersGroupElement); + }; + + const initializeToggleGroup = (filtersGroupElement) => { + const toggleGroupButtonElement = getToggleGroupButtonElement(filtersGroupElement); + if (!toggleGroupButtonElement) { + return; + } + + isFiltersGroupSavedAsCollapsed(filtersGroupElement) ? collapseFiltersGroup(filtersGroupElement, false) : expandFiltersGroup(filtersGroupElement, false); + setTimeout(() => { + toggleGroupButtonElement.classList.add(TOGGLE_BUTTON_WITH_ANIMATION_CLASS); + }, 0); + + toggleGroupButtonElement.addEventListener('click', () => { + if (isToggleButtonMarkedAsCollapsed(toggleGroupButtonElement)) { + expandFiltersGroup(filtersGroupElement); + return; + } + collapseFiltersGroup(filtersGroupElement); + }, { signal: abortController.signal }); + }; + + const initializeToggleItems = (filtersGroupElement) => { + const toggleItemsButtonElement = getToggleItemsButtonElement(filtersGroupElement); + if (!toggleItemsButtonElement) { + return; + } + + areFilterItemsSavedAsExpanded(filtersGroupElement) ? expandFilterItems(filtersGroupElement, false) : collapseFilterItems(filtersGroupElement, false); + + toggleItemsButtonElement.addEventListener('click', () => { + if (isToggleButtonMarkedAsCollapsed(toggleItemsButtonElement)) { + expandFilterItems(filtersGroupElement); + return; + } + collapseFilterItems(filtersGroupElement); + }, { signal: abortController.signal }); + }; + + const collapseFiltersGroup = (filtersGroupElement, withAnimation) => { + const toggleButtonElement = getToggleGroupButtonElement(filtersGroupElement); + + markToggleButtonAs(toggleButtonElement, 'collapsed'); + + const collapsibleElement = getFiltersGroupCollapsibeElement(filtersGroupElement); + collapseElement(collapsibleElement, withAnimation); + + saveFiltersGroupAsCollapsed(filtersGroupElement); + }; + + const expandFiltersGroup = (filtersGroupElement, withAnimation) => { + const toggleButtonElement = getToggleGroupButtonElement(filtersGroupElement); + + markToggleButtonAs(toggleButtonElement, 'expanded'); + + const collapsibleElement = getFiltersGroupCollapsibeElement(filtersGroupElement); + expandElement(collapsibleElement, withAnimation); + + saveFiltersGroupAsExpanded(filtersGroupElement); + }; + + const collapseFilterItems = (filtersGroupElement, withAnimation) => { + const toggleButtonElement = getToggleItemsButtonElement(filtersGroupElement); + + const toState = 'collapsed'; + markToggleButtonAs(toggleButtonElement, toState); + toggleElementText(toggleButtonElement, toState); + + const collapsibleElement = getFilterItemsCollapsibeElement(filtersGroupElement); + collapseElement(collapsibleElement, withAnimation); + + saveFilterItemsAsCollapsed(filtersGroupElement); + }; + + const expandFilterItems = (filtersGroupElement, withAnimation) => { + const toggleButtonElement = getToggleItemsButtonElement(filtersGroupElement); + + const toState = 'expanded'; + markToggleButtonAs(toggleButtonElement, toState); + toggleElementText(toggleButtonElement, toState); + + const collapsibleElement = getFilterItemsCollapsibeElement(filtersGroupElement); + expandElement(collapsibleElement, withAnimation); + + saveFilterItemsAsExpanded(filtersGroupElement); + }; + + const markToggleButtonAs = (toggleButtonElement, markAs) => { + if (markAs === 'collapsed') { + toggleButtonElement.setAttribute('aria-expanded', false); + toggleButtonElement.classList.add(TOGGLE_BUTTON_COLLAPSED_CLASS); + return; + } + + toggleButtonElement.setAttribute('aria-expanded', true); + toggleButtonElement.classList.remove(TOGGLE_BUTTON_COLLAPSED_CLASS); + }; + + const isToggleButtonMarkedAsCollapsed = (toggleButtonElement) => toggleButtonElement.classList.contains(TOGGLE_BUTTON_COLLAPSED_CLASS); + + const collapseElement = (element, withAnimation) => { + if (withAnimation === false) { + element.setAttribute('hidden', ''); + element.style.height = '0px'; + element.style.overflow = 'hidden'; + return; + } + + markElementAsCollapsing(element); + + element.style.overflow = 'hidden'; + element.style.height = `${element.scrollHeight}px`; + + setTimeout(() => { + element.style.height = '0px'; + }, 0); + + element.addEventListener('transitionend', () => { + markElementAsNotCollapsing(element); + + if (isElementMarkedAsExpanding(element)) { + // The user clicked fast enough to expand the group again before the animation ended. + return; + } + + element.setAttribute('hidden', ''); + }, { once: true }); + }; + + const expandElement = (element, withAnimation) => { + if (withAnimation === false) { + element.removeAttribute('hidden', ''); + element.style.height = 'auto'; + element.style.overflow = null; + return; + } + + markElementAsExpanding(element); + + element.removeAttribute('hidden'); + element.style.height = `${element.scrollHeight}px`; + element.style.overflow = 'hidden'; + + element.addEventListener('transitionend', () => { + markElementAsNotExpanding(element); + + if (isElementMarkedAsCollapsing(element)) { + // The user clicked fast enough to collapse the group again before the animation ended. + return; + } + element.style.height = null; + element.style.overflow = null; + }, { once: true }); + }; + + const markElementAsCollapsing = (element) => markElementAs(element, IS_COLLAPSING_ATTRIBUTE); + const markElementAsNotCollapsing = (element) => markElementAsNot(element, IS_COLLAPSING_ATTRIBUTE); + const isElementMarkedAsCollapsing = (element) => element.getAttribute(IS_COLLAPSING_ATTRIBUTE) !== null; + const markElementAsExpanding = (element) => markElementAs(element, IS_EXPANDING_ATTRIBUTE); + const markElementAsNotExpanding = (element) => markElementAsNot(element, IS_EXPANDING_ATTRIBUTE); + const isElementMarkedAsExpanding = (element) => isElementMarkedAs(element, IS_EXPANDING_ATTRIBUTE); + + const markElementAs = (element, attribute) => element.setAttribute(attribute, ''); + const markElementAsNot = (element, attribute) => element.removeAttribute(attribute); + const isElementMarkedAs = (element, attribute) => element.hasAttribute(attribute); + + const toggleElementText = (element, state) => { + const readFromAttribute = state === 'collapsed' ? 'data-text-collapsed' : 'data-text-expanded'; + element.textContent = element.getAttribute(readFromAttribute); + }; + + const saveFiltersGroupAsCollapsed = (filtersGroupElement) => { + const set = getCollapsedFiltersGroups(); + set.add(getFiltersGroupKey(filtersGroupElement)); + saveCollapsedFiltersGroups(set); + }; + + const saveFiltersGroupAsExpanded = (filtersGroupElement) => { + const set = getCollapsedFiltersGroups(); + set.delete(getFiltersGroupKey(filtersGroupElement)); + saveCollapsedFiltersGroups(set); + }; + + const saveFilterItemsAsCollapsed = (filtersGroupElement) => { + const set = getExpandedFilterItems(); + set.delete(getFiltersGroupKey(filtersGroupElement)); + saveExpandedFilterItems(set); + }; + + const saveFilterItemsAsExpanded = (filtersGroupElement) => { + const set = getExpandedFilterItems(); + set.add(getFiltersGroupKey(filtersGroupElement)); + saveExpandedFilterItems(set); + }; + + const areFilterItemsSavedAsExpanded = (filtersGroupElement) => { + const set = getExpandedFilterItems(); + return set.has(getFiltersGroupKey(filtersGroupElement)); + }; + + const isFiltersGroupSavedAsCollapsed = (filtersGroupElement) => { + const set = getCollapsedFiltersGroups(); + return set.has(getFiltersGroupKey(filtersGroupElement)); + }; + + const getCollapsedFiltersGroups = () => getSetFromLocalStorage(GROUP_STORAGE_KEY); + const saveCollapsedFiltersGroups = (set) => saveSetInLocalStorage(GROUP_STORAGE_KEY, set); + + const getExpandedFilterItems = () => getSetFromLocalStorage(ITEMS_STORAGE_KEY); + const saveExpandedFilterItems = (set) => saveSetInLocalStorage(ITEMS_STORAGE_KEY, set); + + const getSetFromLocalStorage = (storageKey) => { + const savedFilterGroups = localStorage.getItem(storageKey); + + if (!savedFilterGroups) { + return new Set(); + } + + return new Set(JSON.parse(savedFilterGroups)); + }; + + const saveSetInLocalStorage = (storageKey, set) => { + localStorage.setItem(storageKey, JSON.stringify([...set.values()])); + }; + + return { + initialize, + } +}; diff --git a/assets/search/date-filters.js b/assets/search/date-filters.js new file mode 100644 index 00000000..3649d5c1 --- /dev/null +++ b/assets/search/date-filters.js @@ -0,0 +1,34 @@ +import { getSearchParams, getSearchParamsAndDelete, getSearchParamsAndSet, updateUrl } from './helpers'; + +export const dateFilters = () => { + let abortController = null; + + const initialize = (fetchAndUpdateResultsFunction) => { + if (abortController) { + abortController.abort(); + } + + abortController = new AbortController(); + const dateFilterElements = document.querySelectorAll('.js-date-filter'); + + dateFilterElements.forEach((dateFilterElement) => { + dateFilterElement.addEventListener('blur', (event) => { + const { name, value: date } = event.target; + + if (date === (getSearchParams().get(name) || '')) { + // Date did not change + return; + } + + const isDateInvalid = new Date(date).toString() === 'Invalid Date'; + const params = isDateInvalid ? getSearchParamsAndDelete(name) : getSearchParamsAndSet(name, date); + + updateUrl(params, fetchAndUpdateResultsFunction); + }, { signal: abortController.signal }); + }); + } + + return { + initialize, + }; +} diff --git a/assets/search/fetch-and-update-results.js b/assets/search/fetch-and-update-results.js new file mode 100644 index 00000000..65c4df59 --- /dev/null +++ b/assets/search/fetch-and-update-results.js @@ -0,0 +1,22 @@ +export const fetchAndUpdateResults = (params, callbackFunction) => { + const filtersElement = document.getElementById('js-search-filters'); + const resultsElement = document.getElementById('js-search-results'); + + if (!filtersElement || !resultsElement) { + return; + } + + fetch(`/_result?${params}`) + .then((response) => response.text()) + .then((json) => { + const data = JSON.parse(json); + const { activeElement: previousActiveElement } = document; + + filtersElement.innerHTML = JSON.parse(data.facets); + resultsElement.innerHTML = JSON.parse(data.results); + + if (callbackFunction) { + callbackFunction(previousActiveElement); + } + }); +}; diff --git a/assets/search/helpers/index.js b/assets/search/helpers/index.js new file mode 100644 index 00000000..c4929d27 --- /dev/null +++ b/assets/search/helpers/index.js @@ -0,0 +1,2 @@ +export * from './params.js'; +export * from './url.js'; diff --git a/assets/search/helpers/params.js b/assets/search/helpers/params.js new file mode 100644 index 00000000..8f15994a --- /dev/null +++ b/assets/search/helpers/params.js @@ -0,0 +1,95 @@ +export const getSearchParamsAndAppendOrDelete = (hasValue, key, value) => { + if (hasValue) { + return appendToParams(getSearchParams(), key, value); + } + + return deleteFromParams(getSearchParams(), key, value); +}; + +export const getSearchParamsAndDelete = (key) => { + const params = getSearchParams(); + params.delete(key); + + return params; +} + +export const getSearchParamsAndSet = (key, value) => { + const params = getSearchParams(); + params.set(key, encodeURIComponent(value)); + + return params; +} + +export const getSearchParams = () => { + const params = new URLSearchParams(location.search); + return rewriteParamKeys(params); +}; + +export const resetPageNumber = (params) => { + params.delete('page'); + return params; +} + +/* + * Urls constructed in PHP (using `url_encode` in Twig) sometimes result in a query string like: + * ?dep[0]=Ministerie&dep[1]=AnderMinisterie + * The keys `dep[0]` and `dep[1]` are rewritten to `dep[]` in this function to make the url functions work as expected. + */ +const rewriteParamKeys = (params) => { + const removeKeys = []; + + for (const key of params.keys()) { + const rewrittenKey = rewiteKey(key); + if (key === rewrittenKey) { + continue; + } + + removeKeys.push(key); + if (params.has(rewrittenKey)) { + params.append(rewrittenKey, params.get(key)); + } else { + params.set(rewrittenKey, params.get(key)); + } + } + + removeKeys.forEach((key) => { + params.delete(key); + }); + + return params; +} + +const rewiteKey = (key) => { + const regex = /(.+)\[\d+\]$/; + const [, matchBeforeBrackets] = key.match(regex) || []; + + if (matchBeforeBrackets) { + // This is a key like `dep[0]` or `dep[1]`, rewrite to `dep[]` + return `${matchBeforeBrackets}[]`; + } + + return key; +} + +const deleteFromParams = (params, key, value) => { + const paramValues = params.getAll(key); + + if (!paramValues.length) { + return params; + } + + + params.delete(key); + for (const paramValue of paramValues) { + if (paramValue !== encodeURIComponent(value)) { + params.append(key, paramValue); + } + } + + return params; +}; + +const appendToParams = (params, key, value) => { + params.append(key, encodeURIComponent(value)); + return params; +}; diff --git a/assets/search/helpers/url.js b/assets/search/helpers/url.js new file mode 100644 index 00000000..5bf582bd --- /dev/null +++ b/assets/search/helpers/url.js @@ -0,0 +1,16 @@ +import { getSearchParams, resetPageNumber } from './params'; + +export const updateUrl = (params, fetchAndUpdateResultsFunction, shouldResetPageNumber = true) => { + const updatedParams = shouldResetPageNumber ? resetPageNumber(params) : params; + + const currentParams = getSearchParams(); + if (updatedParams.toString() === currentParams.toString()) { + return; + } + + window.history.pushState({}, '', `${location.pathname}?${updatedParams}`); + + if (fetchAndUpdateResultsFunction) { + fetchAndUpdateResultsFunction(updatedParams); + } +} diff --git a/assets/search/init.js b/assets/search/init.js new file mode 100644 index 00000000..601b4fc6 --- /dev/null +++ b/assets/search/init.js @@ -0,0 +1,46 @@ +import { onDomReady } from '@minvws/manon/utils'; +import { activeFilterPills } from './active-filter-pills'; +import { checkboxFilters } from './checkbox-filters'; +import { collapsibleFilters } from './collapsible-filters'; +import { dateFilters } from './date-filters'; +import { fetchAndUpdateResults } from './fetch-and-update-results'; +import { getSearchParams } from './helpers'; +import { resetFocus } from './reset-focus'; + +onDomReady(() => { + const { initialize: initializeActiveFilterPills } = activeFilterPills(); + const { initialize: initializeCheckboxFilters } = checkboxFilters(); + const { initialize: initializeCollapsibleFilters } = collapsibleFilters(); + const { initialize: initializeDateFilters } = dateFilters(); + const { initialize: initializeResetFocus } = resetFocus(); + + const executeFetchAndUpdateResults = (params) => { + fetchAndUpdateResults(params, (previousActiveElement) => { + initialize(previousActiveElement); + }); + }; + + const initialize = (previousActiveElement) => { + initializeResetFocus(previousActiveElement); + + initializeCollapsibleFilters(); + initializeActiveFilterPills(executeFetchAndUpdateResults); + initializeCheckboxFilters(executeFetchAndUpdateResults); + initializeDateFilters(executeFetchAndUpdateResults); + }; + + const listenAndUnlistenToUrlChanges = () => { + const controller = new AbortController(); + + window.addEventListener('popstate', () => { + executeFetchAndUpdateResults(getSearchParams()); + }, { signal: controller.signal }); + + window.addEventListener('beforeunload', () => { + controller.abort(); + }); + } + + initialize(); + listenAndUnlistenToUrlChanges(); +}); diff --git a/assets/search/reset-focus.js b/assets/search/reset-focus.js new file mode 100644 index 00000000..edcb98a2 --- /dev/null +++ b/assets/search/reset-focus.js @@ -0,0 +1,66 @@ +export const resetFocus = () => { + let abortController = null; + + const ATTRIBUTE_ARIA_DESCRIBED_BY = 'aria-describedby'; + + const initialize = (previousActiveElement) => { + if (abortController) { + abortController.abort(); + } + + const newActiveElement = findElement(previousActiveElement); + if (!newActiveElement) { + return; + } + + newActiveElement.focus(); + abortController = new AbortController(); + + /** + * To make the search results (working with ajax and updating html) accessible, we need to add an aria-describedby attribute to the + * active element. + * It results in a screen reader announcing the number of search results. However, it should announce it only once. That's why we + * need to remove the aria-describedby attribute when the active element loses focus. + */ + + const originalAriaDescribedBy = newActiveElement.getAttribute(ATTRIBUTE_ARIA_DESCRIBED_BY); + newActiveElement.setAttribute(ATTRIBUTE_ARIA_DESCRIBED_BY, 'js-number-of-search-results'); + + newActiveElement.addEventListener('focusout', () => { + if (originalAriaDescribedBy) { + // Reset to original value. + newActiveElement.setAttribute(ATTRIBUTE_ARIA_DESCRIBED_BY, originalAriaDescribedBy); + return; + } + + newActiveElement.removeAttribute(ATTRIBUTE_ARIA_DESCRIBED_BY); + }, { once: true, signal: abortController.signal }); + } + + const findElement = (previousActiveElement) => { + if (!previousActiveElement) { + return; + } + + const { id } = previousActiveElement; + if (id) { + return document.getElementById(id); + } + + const ariaControls = previousActiveElement.getAttribute('aria-controls'); + if (ariaControls) { + return document.querySelector(`[aria-controls="${ariaControls}"]`); + } + + const { name, value } = previousActiveElement; + if (name && value) { + return Array.from(document.getElementsByName(name)).find((element) => element.value === value); + } + + return; + } + + return { + initialize, + }; +} diff --git a/assets/styles/admin/admin-components/all.scss b/assets/styles/admin/admin-components/all.scss new file mode 100644 index 00000000..2fbdd048 --- /dev/null +++ b/assets/styles/admin/admin-components/all.scss @@ -0,0 +1,8 @@ +@import "./button-primary"; +@import "./button-secondary"; +@import "./notification"; +@import "./tabs"; +@import "./pagination"; +@import "./collapsible"; +@import "./steps"; +@import "./top-menu"; diff --git a/assets/styles/admin/admin-components/button-primary.scss b/assets/styles/admin/admin-components/button-primary.scss new file mode 100644 index 00000000..19ce59cb --- /dev/null +++ b/assets/styles/admin/admin-components/button-primary.scss @@ -0,0 +1,2 @@ +@use "@minvws/manon/button-base"; + diff --git a/assets/styles/admin/admin-components/button-secondary.scss b/assets/styles/admin/admin-components/button-secondary.scss new file mode 100644 index 00000000..9ad7bf39 --- /dev/null +++ b/assets/styles/admin/admin-components/button-secondary.scss @@ -0,0 +1,36 @@ +@use "@minvws/manon/button-base"; + +.secondary { + button, + a.button, + input[type="button"], + input[type="submit"], + input[type="reset"] { + --button-base-border-color: #d0d5dd; + --button-base-focus-border-color: #f3f3f3; + --button-base-border-width: 1px; + --button-base-focus-border-width: 5px; + --button-base-border-radius: 8px; + --button-base-background-color: #fff; + --button-base-hover-background-color: #f3f3f3; + --button-base-focus-background-color: #fff; + --button-base-text-color: #696969; + --button-base-hover-text-color: #404040; + --button-base-line-height: 20px; /* 125% */ + --button-base-padding-top: 10px; + --button-base-padding-right: 16px; + --button-base-padding-bottom: 10px; + --button-base-padding-left: 16px; + font-family: $font-sans-bold; + box-shadow: 0 1px 2px 0 rgba(16, 24, 40, 0.05); + + &:hover { + border-color: #ccc; + } + + &:focus { + border: 1px solid #f3f3f3; + background: #fff; + } + } +} diff --git a/assets/styles/admin/admin-components/collapsible.scss b/assets/styles/admin/admin-components/collapsible.scss new file mode 100644 index 00000000..70466aed --- /dev/null +++ b/assets/styles/admin/admin-components/collapsible.scss @@ -0,0 +1,38 @@ +/*---------------------------------------------------------------*/ +/*----------------------- collapsible.scss ----------------------*/ +/*---------------------------------------------------------------*/ + +@import "../admin-variables.scss"; + +.collapsible.collapsed { + .collapsing-element.filters { + top: 8px; + padding: 10px; + border-radius: 5px; + border: 1px solid $gray-main-bg; + box-shadow: 2px 2px 2px 0 rgba(16, 24, 40, 0.05); + max-width: 520px; + + legend { + font-family: $font-sans-bold; + padding-top: 10px; + } + + fieldset { + border: none; + #search_form_department, + #search_form_status { + div { + padding: 5px 0; + input, + label { + cursor: pointer; + } + label { + margin: 0; + } + } + } + } + } +} diff --git a/assets/styles/admin/admin-components/notification.scss b/assets/styles/admin/admin-components/notification.scss new file mode 100644 index 00000000..54abe8b2 --- /dev/null +++ b/assets/styles/admin/admin-components/notification.scss @@ -0,0 +1,7 @@ +@use "@minvws/manon/notification"; +@use "@minvws/manon/notification-error"; + +div.error { + padding: .5rem; + margin: 0.5rem 0; +} diff --git a/assets/styles/admin/admin-components/pagination.scss b/assets/styles/admin/admin-components/pagination.scss new file mode 100644 index 00000000..0d49a144 --- /dev/null +++ b/assets/styles/admin/admin-components/pagination.scss @@ -0,0 +1,62 @@ +@import "../admin-variables"; + +$padding: 5px; +$spacing: 2px; + +.navigation { + padding: 20px; +} +.pagination { + ul { + list-style: none; + display: flex; + justify-content: center; + width: 100%; + padding: 0; + a { + padding: $padding; + color: $gray-neutral; + display: inline-flex; + justify-content: center; + align-items: center; + text-decoration: none; + width: 40px; + height: 40px; + border-radius: $padding; + margin: 0 $spacing; + + &:hover, + &:focus, + &.active { + background: $gray-main-bg; + } + } + + li.pagination__text a { + border: 1px solid $gray-blue-x-light; + padding: $padding 15px; + width: auto; + } + .svg-icon { + display: inline-block; + width: 24px; + height: 24px; + + &:before { + display: inline-block; + content: ""; + width: 100%; + height: 100%; + } + &.svg-chevron-light-right:before { + background: url("../../../svg/chevron.svg") no-repeat center + center; + } + &.svg-chevron-light-left:before { + background: url("../../../svg/chevron.svg") no-repeat center + center; + transform: rotate(180deg); + } + } + } +} diff --git a/assets/styles/admin/admin-components/steps.scss b/assets/styles/admin/admin-components/steps.scss new file mode 100644 index 00000000..4c91b054 --- /dev/null +++ b/assets/styles/admin/admin-components/steps.scss @@ -0,0 +1,54 @@ +.steps { + position: relative; + display: flex; + list-style: none; + justify-content: space-between; + @include default-small-container; + @include center; + @include clear-padding; + background: linear-gradient(transparent 15px, var(--color-step-default) 15px, var(--color-step-default) 17px, transparent 17px); + + li { + a { + position: relative; + z-index: 1; + + span { + position: absolute; + font-family: $font-sans-bold; + text-decoration: none; + color: black; + white-space: nowrap; + transform: translateX(-25%) translateY(10px); + } + } + } + + svg { + display: flex; + width: 32px; + height: 32px; + justify-content: center; + align-items: center; + fill: var(--color-step-default); + } + + .active { + &:before { + content: ""; + background: var(--notification-explanation-intense-default); + width: 33%; + height: 2px; + display: block; + transform: translateY(15px); + position: absolute; + z-index: 0; + + } + svg { + border-radius: 16px; + background: var(--notification-explanation-intense-default, #007BC7); + fill: var(--notification-explanation-intense-default); + } + } +} diff --git a/assets/styles/admin/admin-components/tabs.scss b/assets/styles/admin/admin-components/tabs.scss new file mode 100644 index 00000000..3e878e2c --- /dev/null +++ b/assets/styles/admin/admin-components/tabs.scss @@ -0,0 +1,5 @@ +@use "@minvws/manon/tabs"; + +.tabs ul li > a:hover { + border: none; +} diff --git a/assets/styles/admin/admin-components/top-menu.scss b/assets/styles/admin/admin-components/top-menu.scss new file mode 100644 index 00000000..9ae01f45 --- /dev/null +++ b/assets/styles/admin/admin-components/top-menu.scss @@ -0,0 +1,40 @@ +.tabs { + &__top-menu { + @include default-big-container; + width: auto; + display: flex; + justify-content: center; + height: 100%; + font-family: $font-sans-bold; + font-size: 18px; + + ul, li, a, span { + height: 100%; + + &:hover { + text-decoration: none; + border: none; + } + } + + ul { + --tabs-border-style: none; + margin: 0; + + li { + --tabs-item-text-color: white; + + &.active, &:hover { + background: $color-tab-active; + } + + > a, span { + --tabs-item-active-border-width: 0; + display: flex; + padding: 0 16px; + align-items: center; + } + } + } + } +} diff --git a/assets/styles/admin/admin-decisions-new.scss b/assets/styles/admin/admin-decisions-new.scss new file mode 100644 index 00000000..1d76c426 --- /dev/null +++ b/assets/styles/admin/admin-decisions-new.scss @@ -0,0 +1,16 @@ +.admin-decisions-new { + &__help-text { + ul { + padding: 0; + li { + list-style: none; + display: flex; + align-items: center; + + img { + margin-right: 10px; + } + } + } + } +} diff --git a/assets/styles/admin/admin-search-form.scss b/assets/styles/admin/admin-search-form.scss new file mode 100644 index 00000000..82316b37 --- /dev/null +++ b/assets/styles/admin/admin-search-form.scss @@ -0,0 +1,36 @@ +form[name="search_form"] { + padding: 12px 16px; + display: flex; + justify-content: flex-end; +} + +.search-decisions { + display: flex; + justify-content: space-between; + padding: $default-container-padding-nth-1; + align-items: center; + + &__keyword { + display: flex; + align-items: center; + } + + &__input { + display: flex; + width: 424px; + padding: 10px 14px; + align-items: center; + gap: 8px; + + &:placeholder-shown { + text-overflow: ellipsis; + } + } + + &__icon { + color: #999; + transform: translateX(-30px); + font-size: 18px; + display: flex; + } +} \ No newline at end of file diff --git a/assets/styles/admin/admin-tailwind.css b/assets/styles/admin/admin-tailwind.css new file mode 100644 index 00000000..dc83d5cf --- /dev/null +++ b/assets/styles/admin/admin-tailwind.css @@ -0,0 +1,3 @@ +/* @tailwind base; */ +@tailwind components; +@tailwind utilities; diff --git a/assets/styles/components/breadcrumb-bar-variables.scss b/assets/styles/components/breadcrumb-bar-variables.scss new file mode 100644 index 00000000..af7d204f --- /dev/null +++ b/assets/styles/components/breadcrumb-bar-variables.scss @@ -0,0 +1,51 @@ +/*---------------------------------------------------------------*/ +/*---------------- breadcrumb-bar-variables.scss ----------------*/ +/*---------------------------------------------------------------*/ +:root { + --breadcrumb-bar-flex-direction: column; + --breadcrumb-bar-justify-content: flex-start; + --breadcrumb-bar-align-items: center; + --breadcrumb-bar-gap: 0; + --breadcrumb-bar-padding-top: 0; + --breadcrumb-bar-padding-right: var(--page-whitespace-right); + --breadcrumb-bar-padding-bottom: 0; + --breadcrumb-bar-padding-left: var(--page-whitespace-left); + --breadcrumb-bar-max-width: 100%; + --breadcrumb-bar-background-color: var(--branding-color-accent-5); + --breadcrumb-bar-text-color: var(--application-base-text-color); + --breadcrumb-bar-border-width: 0; + --breadcrumb-bar-border-style: solid; + --breadcrumb-bar-border-color: transparent; + --breadcrumb-bar-border-radius: 0; + --breadcrumb-bar-min-height: 0; + + /* Hover */ + --breadcrumb-bar-hover-text-color: var(--application-base-text-color); + + /* List */ + --breadcrumb-bar-list-padding: 0; + --breadcrumb-bar-list-gap: 1rem; + --breadcrumb-bar-list-vertical-align: center; + + /* List item */ + --breadcrumb-bar-list-item-gap: 1rem; + + /* Icon */ + --breadcrumb-bar-icon: ">"; + --breadcrumb-bar-icon-font-family: var(--icon-font-family); + --breadcrumb-bar-icon-font-size: var(--icon-font-size); + --breadcrumb-bar-icon-padding-right: 0; + --breadcrumb-bar-icon-padding-left: 0; + --breadcrumb-bar-icon-margin-left: 0; + --breadcrumb-bar-icon-margin-right: 0; + + /* Last item / current page */ + --breadcrumb-bar-list-item-last-child-font-weight: bold; + + /* Link */ + --breadcrumb-bar-link-text-decoration: none; + --breadcrumb-bar-link-white-space: nowrap; + + /* Link hover */ + --breadcrumb-bar-link-hover-text-decoration: none; +} diff --git a/assets/styles/components/breadcrumb-bar.scss b/assets/styles/components/breadcrumb-bar.scss new file mode 100644 index 00000000..fea58e98 --- /dev/null +++ b/assets/styles/components/breadcrumb-bar.scss @@ -0,0 +1,98 @@ +/*---------------------------------------------------------------*/ +/*-------------------- breadcrumb-bar.scss ----------------------*/ +/*---------------------------------------------------------------*/ +@use "breadcrumb-bar-variables"; +@use "mixins/icon"; + +.breadcrumb-bar { + display: flex; + flex-direction: var(--breadcrumb-bar-flex-direction); + justify-content: var(--breadcrumb-bar-justify-content); + align-items: var(--breadcrumb-bar-align-items); + gap: var(--breadcrumb-bar-gap); + width: 100%; + margin: 0 auto; + min-height: var(--breadcrumb-bar-min-height); + box-sizing: border-box; + padding-top: var(--breadcrumb-bar-padding-top); + padding-right: var(--breadcrumb-bar-padding-right); + padding-bottom: var(--breadcrumb-bar-padding-bottom); + padding-left: var(--breadcrumb-bar-padding-left); + max-width: var(--breadcrumb-bar-max-width); + flex-wrap: wrap; + background-color: var(--breadcrumb-bar-background-color); + color: var(--breadcrumb-bar-text-color); + border-width: var(--breadcrumb-bar-border-width); + border-style: var(--breadcrumb-bar-border-style); + border-color: var(--breadcrumb-bar-border-color); + border-radius: var(--breadcrumb-bar-border-radius); + + @media (min-width: 59rem) { + margin: 17px auto; + } + + ul, + ol { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: var(--breadcrumb-bar-justify-content); + align-items: var(--breadcrumb-bar-list-vertical-align); + padding: var(--breadcrumb-bar-list-padding); + gap: var(--breadcrumb-bar-list-gap); + color: inherit; + background-color: transparent; + + li { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + list-style: none; + gap: var(--breadcrumb-bar-list-item-gap); + + &:before { + display: none; + /* Preventing default nav before to show up */ + } + + &:after { + background: url("../../svg/chevron.svg") no-repeat center center; + display: inline-block; + content: ""; + width: 30px; + height: 30px; + + margin-left: var(--breadcrumb-bar-icon-margin-left); + margin-right: var(--breadcrumb-bar-icon-margin-right); + } + + &:hover { + color: var(--breadcrumb-bar-hover-text-color); + + &:after { + color: var(--breadcrumb-bar-text-color); + } + } + + &:last-child { + font-weight: var(--breadcrumb-bar-list-item-last-child-font-weight); + + &:after { + content: none; + } + } + + a { + word-break: break-word; + /* Break words that won't fit the available space */ + white-space: var(--breadcrumb-bar-link-white-space); + + &:hover { + text-decoration: var(--breadcrumb-bar-link-hover-text-decoration); + border: 0; + } + } + } + } +} diff --git a/assets/styles/components/button-base-variables.scss b/assets/styles/components/button-base-variables.scss new file mode 100644 index 00000000..b3bfc1bd --- /dev/null +++ b/assets/styles/components/button-base-variables.scss @@ -0,0 +1,87 @@ +/*---------------------------------------------------------------*/ +/*----------- components/button-base-variables.scss -------------*/ +/*---------------------------------------------------------------*/ + +:root { + // --application-base-accent-color-active: #8d0041; + // --application-base-accent-color-active-text-color: white; + // --application-base-accent-color-focus: #ca005d; + // --application-base-accent-color-focus-text-color: white; + // --application-base-accent-color-selected: #8d0041; + + /* Button */ + --button-base-flex: inline-flex; + --button-base-gap: 0.5rem; + --button-base-padding-top: 0.25rem; + --button-base-padding-right: 1rem; + --button-base-padding-bottom: 0.25rem; + --button-base-padding-left: 1rem; + --button-base-justify-content: center; + --button-base-min-width: 8rem; + --button-base-min-height: 2.75rem; + --button-base-height: unset; + // --button-base-background-color: var(--application-base-accent-color); + --button-base-text-color: var(--application-base-accent-color-text-color); + --button-base-border-width: 2px; + --button-base-border-style: solid; + --button-base-border-color: var(--button-base-background-color); + --button-base-font-family: var(--application-base-font-family); + --button-base-font-size: var(--application-base-font-size); + --button-base-line-height: var(--application-base-line-height); + --button-base-font-weight: var(--application-base-font-weight); + --button-base-text-align: center; + --button-base-text-decoration: none; + --button-base-border-radius: var(--application-base-border-radius); + + /* Reset ios button styling */ + /* --button-base-webkit-appearance: none; */ + --button-base-align-self: flex-start; + + /* Hover */ + // --button-base-hover-background-color: var(--application-base-accent-color-hover, + // var(--application-base-accent-color)); + --button-base-hover-text-color: var(--application-base-accent-color-hover-text-color, + var(--application-base-accent-color-text-color)); + + /* Active */ + /* --button-base-active-outline-style: ; */ + /* --button-base-active-outline-color: ; */ + /* --button-base-active-outline-width: ; */ + /* --button-base-active-outline-offset: ; */ + /* --button-base-active-border-style: ; */ + /* --button-base-active-border-color: ; */ + /* --button-base-active-border-width: ; */ + --button-base-active-background-color: var(--application-base-accent-color-active, + var(--application-base-accent-color)); + --button-base-active-text-color: var(--application-base-accent-color-active-text-color, + var(--application-base-accent-color-text-color)); + + /* Focus */ + /* --button-base-focus-outline-style: ; */ + /* --button-base-focus-outline-color: ; */ + /* --button-base-focus-outline-width: ; */ + /* --button-base-focus-outline-offset: ; */ + /* --button-base-focus-border-style: ; */ + /* --button-base-focus-border-color: ; */ + /* --button-base-focus-border-width: ; */ + // --button-base-focus-background-color: var(--application-base-accent-color-focus, + // var(--application-base-accent-color)); + --button-base-focus-text-color: var(--application-base-accent-color-focus-text-color, + var(--application-base-accent-color-text-color)); + + /* Selected */ + /* --button-base-selected-outline-style: ; */ + /* --button-base-selected-outline-color: ; */ + /* --button-base-selected-outline-width: ; */ + /* --button-base-selected-outline-offset: ; */ + /* --button-base-selected-border-style: ; */ + /* --button-base-selected-border-color: ; */ + /* --button-base-selected-border-width: ; */ + --button-base-selected-background-color: var(--application-base-accent-color-selected, + var(--application-base-accent-color)); + --button-base-selected-text-color: var(--application-base-accent-color-selected-text-color, + var(--application-base-accent-color-text-color)); + + /* Image */ + --button-base-image-max-width: 1.5rem; +} \ No newline at end of file diff --git a/assets/styles/components/button-base.scss b/assets/styles/components/button-base.scss new file mode 100644 index 00000000..d9e88fda --- /dev/null +++ b/assets/styles/components/button-base.scss @@ -0,0 +1,144 @@ +/*---------------------------------------------------------------*/ +/*-------------- components/buttons/base.scss -------------------*/ +/*---------------------------------------------------------------*/ +@use "button-base-variables"; + +button, +a.button, +input[type="button"], +input[type="submit"], +input[type="reset"] { + $breakpoint: 24rem !default; + + justify-content: var(--button-base-justify-content); + align-items: center; + align-self: flex-start; + gap: var(--button-base-gap); + + box-sizing: border-box; + + padding-top: var(--button-base-padding-top); + padding-right: var(--button-base-padding-right); + padding-bottom: var(--button-base-padding-bottom); + padding-left: var(--button-base-padding-left); + margin: 0; + + width: auto; + min-height: var(--button-base-min-height); + + background-color: var(--button-base-background-color); + color: var(--button-base-text-color); + + border-width: var(--button-base-border-width); + border-style: var(--button-base-border-style); + border-color: var(--button-base-border-color); + border-radius: var(--button-base-border-radius); + /* Reset ios button styling */ + webkit-border-radius: var(--button-base-border-radius); + /* Reset ios button styling */ + -webkit-appearance: var(--button-base-webkit-appearance); + + cursor: pointer; + overflow-wrap: break-word; + + font-family: var(--button-base-font-family); + font-size: var(--button-base-font-size); + font-weight: var(--button-base-font-weight); + line-height: var(--button-base-line-height); + text-decoration: var(--button-base-text-decoration); + text-align: var(--button-base-text-align); + + &:visited { + color: var(--button-base-text-color); + } + + &:hover, + &.hover { + background-color: var(--button-base-hover-background-color); + color: var(--button-base-hover-text-color); + border-color: var(--button-base-hover-background-color); + } + + &:active, + &.active { + outline-style: var(--button-base-active-outline-style); + outline-color: var(--button-base-active-outline-color); + outline-width: var(--button-base-active-outline-width); + outline-offset: var(--button-base-active-outline-offset); + background-color: var(--button-base-active-background-color); + color: var(--button-base-active-text-color); + border-style: var(--button-base-active-border-style); + border-color: var( + --button-base-active-border-color, + var(--application-base-accent-color-accent) + ); + border-width: var(--button-base-active-border-width); + } + + &:focus, + &.focus { + outline-style: var(--button-base-focus-outline-style); + outline-color: var(--button-base-focus-outline-color); + outline-width: var(--button-base-focus-outline-width); + outline-offset: var(--button-base-focus-outline-offset); + border-style: var(--button-base-focus-border-style); + border-color: var( + --button-base-focus-border-color, + var(--application-base-accent-color-accent) + ); + border-width: var(--button-base-focus-border-width); + background-color: var( + --button-base-focus-background-color, + var(--application-base-accent-color-accent) + ); + color: var( + --button-base-focus-text-color, + var(--application-text-color-accent) + ); + } + + &.selected { + outline-style: var(--button-base-focus-outline-style); + outline-color: var(--button-base-focus-outline-color); + outline-width: var(--button-base-focus-outline-width); + outline-offset: var(--button-base-focus-outline-offset); + border-style: var(--button-base-focus-border-style); + border-color: var( + --button-base-focus-border-color, + var(--application-base-accent-color-accent) + ); + border-width: var(--button-base-focus-border-width); + background-color: var( + --button-base-selected-background-color, + var(--application-base-accent-color-accent) + ); + color: var( + --button-base-selected-text-color, + var(--application-text-color-accent) + ); + } + + > img { + max-width: var( + --button-base-image-max-width + ); /* Limiting the use of images within buttons for readability. */ + } + + /* Prevent elements within the button to block onclick events */ + > * { + pointer-events: none; + } + + @media (min-width: $breakpoint) { + min-width: var(--button-base-min-width); + } +} + +/* Buttons can not be flex containers */ +/* But these elements can. So using flex box where possible. */ +a.button, +input[type="button"], +input[type="submit"], +input[type="reset"] { + display: flex; +} diff --git a/assets/styles/components/colors.scss b/assets/styles/components/colors.scss new file mode 100644 index 00000000..903cc145 --- /dev/null +++ b/assets/styles/components/colors.scss @@ -0,0 +1,15 @@ +:root { + --application-base-accent-color: var(--ro-blue); + --branding-color-1-background-color: var(--ro-blue); + --branding-color-1-text-color: white; + + --application-base-accent-color-active: var(--link-text-color); + --application-base-accent-color-active-text-color: white; + --application-base-accent-color-focus: var(--ro-blue); + --application-base-accent-color-focus-text-color: white; + --application-base-accent-color-selected: var(--link-text-color-hover); + + --header-navigation-link-hover-background-color: #cbd2e3; + --header-navigation-link-active-background-color: #cbd2e3; + --header-navigation-link-active-text-color: black; +} diff --git a/assets/styles/components/content-container.scss b/assets/styles/components/content-container.scss new file mode 100644 index 00000000..17bd41ae --- /dev/null +++ b/assets/styles/components/content-container.scss @@ -0,0 +1,7 @@ +.content-container { + max-width: 56rem; + + a { + display: inline-block; + } +} diff --git a/assets/styles/components/form-base.scss b/assets/styles/components/form-base.scss new file mode 100644 index 00000000..27e442df --- /dev/null +++ b/assets/styles/components/form-base.scss @@ -0,0 +1,10 @@ +/*---------------------------------------------------------------*/ +/*----------------------- form-base.scss ------------------------*/ +/*---------------------------------------------------------------*/ + +main section form, +main article form, +main div form, +form { + padding: 0; +} diff --git a/assets/styles/components/form-select.scss b/assets/styles/components/form-select.scss new file mode 100644 index 00000000..adef030d --- /dev/null +++ b/assets/styles/components/form-select.scss @@ -0,0 +1,9 @@ +/*---------------------------------------------------------------*/ +/*----------------- components/select.scss ----------------------*/ +/*---------------------------------------------------------------*/ + +form { + select { + padding: 10px; + } +} diff --git a/assets/styles/components/breadcrumbs.scss b/assets/styles/components/grid.scss similarity index 100% rename from assets/styles/components/breadcrumbs.scss rename to assets/styles/components/grid.scss diff --git a/assets/styles/components/header-navigation-link-variables.scss b/assets/styles/components/header-navigation-link-variables.scss new file mode 100644 index 00000000..4d3c221e --- /dev/null +++ b/assets/styles/components/header-navigation-link-variables.scss @@ -0,0 +1,47 @@ +/*---------------------------------------------------------------*/ +/*------------- header-navigation-link-variables.scss -----------*/ +/*---------------------------------------------------------------*/ +@use "mixins/link"; + +:root { + /* Link */ + @include link.styling-variables( + "header-navigation-link-", + "navigation-link-" + ); + + --header-navigation-link-justify-content: center; + --header-navigation-link-align-items: center; + --header-navigation-link-padding-top: 0; + --header-navigation-link-padding-right: 0; + --header-navigation-link-padding-bottom: 0; + --header-navigation-link-padding-left: 0; + --header-navigation-link-min-height: 0; + + /* Icon */ + --header-navigation-link-icon-font-family: var( + --navigation-link-icon-font-family + ); + --header-navigation-link-icon-font-size: var( + --navigation-link-icon-font-size + ); + --header-navigation-link-icon-line-height: var( + --navigation-link-icon-line-height + ); + --header-navigation-link-icon-padding-right: var( + --navigation-link-icon-padding-right + ); + --header-navigation-link-icon-padding-left: var( + --navigation-link-icon-padding-left + ); + --header-navigation-link-icon-text-decoration: var( + --navigation-link-icon-text-decoration + ); + + --header-navigation-link-icon-background-color: var( + --header-navigation-link-background-color + ); + --header-navigation-link-icon-text-color: var( + --header-navigation-link-text-color + ); +} diff --git a/assets/styles/components/header-navigation-link.scss b/assets/styles/components/header-navigation-link.scss new file mode 100644 index 00000000..d00eb0df --- /dev/null +++ b/assets/styles/components/header-navigation-link.scss @@ -0,0 +1,25 @@ +/*---------------------------------------------------------------*/ +/*---------------- header-navigation-link.scss ------------------*/ +/*---------------------------------------------------------------*/ +@use "mixins/icon"; +@use "mixins/link"; + +body > header, +.page-header, +%header-navigation-style { + nav { + a { + @include link.link("header-navigation-link-"); + + height: 100%; + display: inline-flex; + align-items: var(--header-navigation-link-align-items); + justify-content: var(--header-navigation-link-justify-content); + padding-top: var(--header-navigation-link-padding-top); + padding-right: var(--header-navigation-link-padding-right); + padding-bottom: var(--header-navigation-link-padding-bottom); + padding-left: var(--header-navigation-link-padding-left); + min-height: var(--header-navigation-link-min-height); + } + } +} diff --git a/assets/styles/components/header-navigation-variables.scss b/assets/styles/components/header-navigation-variables.scss new file mode 100644 index 00000000..071b7f16 --- /dev/null +++ b/assets/styles/components/header-navigation-variables.scss @@ -0,0 +1,60 @@ +/*---------------------------------------------------------------*/ +/*-------------- header-navigation-variables.scss ---------------*/ +/*---------------------------------------------------------------*/ + +:root { + /* header-Navigation layout */ + --header-navigation-gap: var(--content-gap, 2rem); + --header-navigation-justify-content: var( + --content-justify-content, + flex-start + ); + --header-navigation-align-items: center; + + --header-navigation-min-height: 3rem; + --header-navigation-width: 100%; + --header-navigation-max-width: var(--content-max-width, 100%); + + --header-navigation-margin: 0; + --header-navigation-padding-top: 0; + --header-navigation-padding-right: var(--page-whitespace-right); + --header-navigation-padding-bottom: 0; + --header-navigation-padding-left: var(--page-whitespace-left); + + --header-navigation-border-width: 0; + --header-navigation-border-style: solid; + --header-navigation-border-color: transparent; + --header-navigation-border-radius: 0; + + --header-navigation-position: relative; + --header-navigation-white-space: nowrap; + + --header-navigation-background-color: var( + --application-base-accent-color, + #fff + ); + --header-navigation-text-color: var( + --application-base-accent-color-text-color + ); + + /* List */ + --header-navigation-list-gap: 1rem; + --header-navigation-list-justify-content: var( + --header-navigation-justify-content + ); + --header-navigation-list-align-items: var(--header-navigation-align-items); + --header-navigation-list-text-color: var( + --header-navigation-text-color, + var(--application-base-accent-color-text-color) + ); + --header-navigation-list-width: 100%; + + /* List item */ + --header-navigation-list-item-background-color: transparent; + --header-navigation-list-item-text-color: var( + --header-navigation-list-text-color, + var(--application-base-accent-color-text-color) + ); + --header-navigation-list-item-justify-content: stretch; + --header-navigation-list-item-align-items: stretch; +} diff --git a/assets/styles/components/header-navigation.scss b/assets/styles/components/header-navigation.scss new file mode 100644 index 00000000..68441d12 --- /dev/null +++ b/assets/styles/components/header-navigation.scss @@ -0,0 +1,34 @@ +/*---------------------------------------------------------------*/ +/*----------------- header-navigation.scss ----------------------*/ +/*---------------------------------------------------------------*/ + +body > header, +.page-header, +%header-navigation-style { + nav #main-nav-div { + ul, + ol { + li { + display: flex; + list-style: none; + padding: 0; + box-sizing: border-box; + background: none !important; + + &:before { + display: none; + /* Preventing default nav before to show up */ + } + a { + background: none !important; + &:hover, + &:focus, + &:active, + &:visited { + color: white !important; + } + } + } + } + } +} diff --git a/assets/styles/components/header-variables.scss b/assets/styles/components/header-variables.scss new file mode 100644 index 00000000..6b5a6232 --- /dev/null +++ b/assets/styles/components/header-variables.scss @@ -0,0 +1,29 @@ +/*------------------------------------------------------------------------*/ +/*-------------------------- header-variables.scss -----------------------*/ +/*------------------------------------------------------------------------*/ + +:root { + /* Layout */ + --header-flex-direction: var(--content-flex-direction); + --header-justify-content: var(--content-justify-content); + --header-align-items: center; + --header-gap: 0; + --header-padding-top: 0; + --header-padding-right: 0; + --header-padding-bottom: 0; + --header-padding-left: 0; + --header-max-width: 100%; + --header-background-color: transparent; + --header-min-height: 0; + + --header-border-width: 0; + --header-border-style: solid; + --header-border-color: transparent; + + /* Grouped content */ + --header-content-wrapper-gap: 1rem; + --header-content-wrapper-flex-direction: row; + --header-content-wrapper-justify-content: flex-end; + --header-content-wrapper-align-items: center; + --header-content-wrapper-width: 100%; +} diff --git a/assets/styles/components/header.scss b/assets/styles/components/header.scss new file mode 100644 index 00000000..63b55263 --- /dev/null +++ b/assets/styles/components/header.scss @@ -0,0 +1,67 @@ +/*---------------------------------------------------------------*/ +/*------------------- components/header.scss --------------------*/ +/*---------------------------------------------------------------*/ +@use "header-variables"; + +body > header, +.page-header, +%main-header-style { + display: flex; + flex-direction: var(--header-flex-direction); + justify-content: var(--header-justify-content); + align-items: var(--header-align-items); + gap: var(--header-gap); + width: 100%; + margin: 0 auto; + box-sizing: border-box; + + padding-top: var(--header-padding-top); + padding-right: var(--header-padding-right); + padding-bottom: var(--header-padding-bottom); + padding-left: var(--header-padding-left); + + max-width: var(--header-max-width); + background-color: var(--header-background-color); + min-height: var(--header-min-height); + + border-width: var(--header-border-width); + border-style: var(--header-border-style); + border-color: var(--header-border-color); + + > section, + > div { + display: flex; + flex-direction: var(--header-content-wrapper-flex-direction); + justify-content: var(--header-content-wrapper-justify-content); + align-items: var(--header-content-wrapper-align-items); + width: var(--header-content-wrapper-width); + margin-top: var(--header-content-wrapper-margin-top); + gap: var(--header-content-wrapper-gap); + + div { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + padding: 0; + + button { + align-self: center; + } + } + } +} + +#main-nav-div { + ul li a { + text-decoration: none; + &:hover, + &:focus { + text-decoration: underline; + } + span:before { + filter: invert(100%) sepia(0%) saturate(7470%) hue-rotate(116deg) + brightness(109%) contrast(109%); + } + } +} diff --git a/assets/styles/components/headings-variables.scss b/assets/styles/components/headings-variables.scss new file mode 100644 index 00000000..23bb51af --- /dev/null +++ b/assets/styles/components/headings-variables.scss @@ -0,0 +1,66 @@ +/*---------------------------------------------------------------*/ +/*------------------- text/headings-variables.scss --------------*/ +/*---------------------------------------------------------------*/ +:root { + /* Available variables to use within all heading types */ + --headings-font-family: var( + --headings-base-set-font-family, + var(--application-base-font-family) + ); + --headings-font-size: var(--headings-base-set-font-size, initial); + --headings-font-weight: var(--headings-base-set-font-weight, bold); + --headings-line-height: var(--application-base-line-height); + --headings-text-color: var(--application-base-text-color); + --headings-margin: var(--application-base-gap, 1rem); + --headings-text-color: #01689b; + --headings-hover-text-color: #004161; + --headings-visited-text-color: #814081; + + /* h1 */ + --h1-font-family: var(--headings-font-family); + --h1-font-size: var(--heading-xxl-font-size); + --h1-font-weight: var(--heading-xxl-font-weight); + --h1-line-height: var(--heading-xxl-line-height); + --h1-text-color: var(--heading-xxl-text-color); + --h1-margin: var(--heading-xxl-margin); + + /* h2 */ + --h2-font-family: var(--headings-font-family); + --h2-font-size: var(--heading-xl-font-size); + --h2-text-color: var(--heading-xl-text-color); + --h2-font-weight: var(--heading-xl-font-weight); + --h2-line-height: var(--heading-xl-line-height); + --h2-margin: var(--heading-xl-margin); + + /* h3 */ + --h3-font-family: var(--headings-font-family); + --h3-font-size: var(--heading-large-font-size); + --h3-text-color: var(--heading-large-text-color); + --h3-font-weight: var(--heading-large-font-weight); + --h3-line-height: var(--heading-large-line-height); + --h3-margin: var(--heading-large-margin); + + /* h4 */ + --h4-font-family: var(--headings-font-family); + --h4-font-size: var(--heading-normal-font-size); + --h4-text-color: var(--heading-normal-text-color); + --h4-font-weight: var(--heading-normal-font-weight); + --h4-line-height: var(--heading-normal-line-height); + --h4-margin: var(--heading-normal-margin); + + /* h5 */ + --h5-font-family: var(--headings-font-family); + --h5-font-size: var(--heading-small-font-size); + --h5-text-color: var(--heading-small-text-color); + --h5-font-weight: var(--heading-small-font-weight); + --h5-line-height: var(--heading-small-line-height); + --h5-margin: var(--heading-small-margin); + + /* h6 */ + --h6-font-family: var(--headings-font-family); + --h6-font-size: var(--heading-xs-font-size); + --h6-text-color: var(--heading-xs-text-color); + --h6-font-weight: var(--heading-xs-font-weight); + --h6-line-height: var(--heading-xs-line-height); + --h6-margin: var(--heading-xs-margin); +} diff --git a/assets/styles/components/headings.scss b/assets/styles/components/headings.scss new file mode 100644 index 00000000..d2319f28 --- /dev/null +++ b/assets/styles/components/headings.scss @@ -0,0 +1,126 @@ +:root { + /* Heading XXL */ + --heading-xxl-font-size: 2.88651rem; + + /* Heading XL, used for h1 */ + --heading-xl-font-size: 2rem; + + /* Heading large, used for h2 */ + --heading-large-font-size: 1.625rem; + + /* Heading normal, used for h3 */ + --heading-normal-font-size: 1.625rem; /* was 1.60181rem */ + + /* Heading small, h4 + --heading-small-font-size: 1.26562rem; */ + + /* Heading XS, h5 + --heading-xs-font-size: 1.125rem; */ +} + +.reduce-width { + max-width: 45ch; +} + +h1, +h1 > a { + font-family: var(--h1-font-family, var(--application-base-font-family)); + font-size: var(--h1-font-size, inherit); + color: var(--h1-text-color, inherit); + font-weight: var(--h1-font-weight, inherit); + line-height: var(--h1-line-height, inherit); + margin: var(--h1-margin, inherit); +} + +h2, +h2 > a { + font-family: var(--h2-font-family, var(--application-base-font-family)); + font-size: var(--h2-font-size, inherit); + color: var(--h2-text-color, inherit); + font-weight: var(--h2-font-weight, inherit); + line-height: var(--h2-line-height, inherit); + margin: var(--h2-margin, inherit); +} + +h3, +h3 > a { + font-family: var(--h3-font-family, var(--application-base-font-family)); + font-size: var(--h3-font-size, inherit); + color: var(--h3-text-color, inherit); + font-weight: var(--h3-font-weight, inherit); + line-height: var(--h3-line-height, inherit); + margin: var(--h3-margin, inherit); +} + +h4, +h4 > a { + font-family: var(--h4-font-family, var(--application-base-font-family)); + font-size: var(--h4-font-size, inherit); + color: var(--h4-text-color, inherit); + font-weight: var(--h4-font-weight, inherit); + line-height: var(--h4-line-height, inherit); + margin: var(--h4-margin, inherit); +} + +h5, +h5 > a { + font-family: var(--h5-font-family, var(--application-base-font-family)); + font-size: var(--h5-font-size); + color: var(--h5-text-color, inherit); + font-weight: var(--h5-font-weight); + line-height: var(--h5-line-height, initial); + margin: var(--h5-margin, inherit); +} + +h6, +h6 > a { + font-family: var(--h6-font-family, var(--application-base-font-family)); + font-size: var(--h6-font-size); + color: var(--h6-text-color); + font-weight: var(--h6-font-weight); + line-height: var(--h6-line-height, initial); + margin: var(--h6-margin, inherit); +} + +h1, +h2, +h3, +h4, +h5, +h6 { + hyphens: none; + > a { + color: var(--headings-text-color, inherit); + &:focus, + &:hover { + color: var(--headings-hover-text-color, inherit); + } + &:visited { + color: var(--headings-visited-text-color, inherit); + } + } +} + +h1:has(.svg-icon) { + max-width: 100%; + + .svg-icon { + align-self: baseline; + transform: translateY(3px); + } +} + +.site-title { + font-size: 2rem; + + > :last-child { + display: block; + font-size: 1.8rem; + } + + @media (min-width: 42rem) { + .no-break { + display: inline-block; + } + } +} diff --git a/assets/styles/components/icons.scss b/assets/styles/components/icons.scss new file mode 100644 index 00000000..a9cb4972 --- /dev/null +++ b/assets/styles/components/icons.scss @@ -0,0 +1,142 @@ +.svg-icon { + display: inline-block; + + &:before { + display: inline-block; + content: ""; + width: 100%; + height: 100%; + } + &.svg-home:before { + background: url("../../svg/home.svg") no-repeat center center; + } + &.svg-email:before { + background: url("../../svg/mail.svg") no-repeat top -1px center; + } + &.svg-pdf:before { + background: url("../../svg/pdf.svg") no-repeat top -1px center; + } + &.svg-map:before { + background: url("../../svg/map.svg") no-repeat top -1px center; + } + &.svg-doc:before { + background: url("../../svg/doc.svg") no-repeat top -1px center; + } + &.svg-csv:before, + &.svg-spreadsheet { + background: url("../../svg/csv.svg") no-repeat top -1px center; + } + // This icon will probably go unused + &.svg-xlsx:before { + background: url("../../svg/xlsx.svg") no-repeat top -1px center; + } + &.svg-xml:before { + background: url("../../svg/xml.svg") no-repeat top -1px center; + } + &.svg-presentation:before { + background: url("../../svg/presentation.svg") no-repeat top -1px center; + } + &.svg-unknown:before { + background: url("../../svg/unknown.svg") no-repeat top -1px center; + } + &.svg-audio:before { + background: url("../../svg/audio.svg") no-repeat top -1px center; + } + &.svg-video:before { + background: url("../../svg/video.svg") no-repeat top -1px center; + } + &.svg-image:before { + background: url("../../svg/image.svg") no-repeat top -1px center; + } + &.svg-vcard:before { + background: url("../../svg/vcard.svg") no-repeat top -1px center; + } + &.svg-database:before { + background: url("../../svg/database.svg") no-repeat top -1px center; + } + &.svg-note:before { + background: url("../../svg/note.svg") no-repeat top -1px center; + } + &.svg-html:before { + background: url("../../svg/html.svg") no-repeat top -1px center; + } + &.svg-text:before { + background: url("../../svg/text.svg") no-repeat top -1px center; + } + &.svg-chevron-right:before { + background: url("../../svg/chevron-link.svg") no-repeat center center; + } + &.svg-chevron-up:before { + background: url("../../svg/chevron-link.svg") no-repeat center center; + } + &.svg-chevron-down:before { + background: url("../../svg/chevron-link.svg") no-repeat center center; + transform: rotate(90deg); + } + &.svg-chevron-left:before { + background: url("../../svg/chevron-link.svg") no-repeat center center; + transform: rotate(180deg); + } + &.svg-chevron-up:before { + background: url("../../svg/chevron-link.svg") no-repeat center center; + transform: rotate(-90deg); + } + &.svg-chevron-light-right:before { + background: url("../../svg/chevron.svg") no-repeat center center; + } + &.svg-chevron-light-down:before { + background: url("../../svg/chevron.svg") no-repeat center center; + transform: rotate(90deg); + } + &.svg-chevron-light-left:before { + background: url("../../svg/chevron.svg") no-repeat center center; + transform: rotate(180deg); + } + &.svg-chevron-light-up:before { + background: url("../../svg/chevron.svg") no-repeat center center; + transform: rotate(-90deg); + } + &.svg-arrow-up:before { + background: url("../../svg/arrow.svg") no-repeat top -1px center; + transform: rotate(180deg); + } + &.svg-arrow-right:before { + background: url("../../svg/arrow.svg") no-repeat top -1px center; + transform: rotate(-90deg); + } + &.svg-arrow-down:before { + background: url("../../svg/arrow.svg") no-repeat top -1px center; + } + &.svg-arrow-left:before { + background: url("../../svg/arrow.svg") no-repeat top -1px center; + transform: rotate(90deg); + } + &.svg-trash:before { + background: url("../../svg/trash.svg") no-repeat top -1px center; + } + &.svg-web:before, + &.svg-internet:before { + background: url("../../svg/internet.svg") no-repeat top -1px center; + } + &.svg-delete:before { + background: url("../../svg/delete.svg") no-repeat center center; + } + &.svg-attachment:before { + background: url("../../svg/bijlage.svg") no-repeat center center; + } + &.svg-zoom:before { + background: url("../../svg/zoom.svg") no-repeat center center; + } + &.svg-message:before { + background: url("../../svg/bericht.svg") no-repeat center center; + } + &.svg-download:before { + background: url("../../svg/download.svg") no-repeat center center; + } + &.svg-externallink:before { + background: url("../../svg/externallink.svg") no-repeat top -1px center; + } + &.svg-sendusmail:before { + background: url("../../svg/sendusmail.svg") no-repeat top -1px center; + } +} diff --git a/assets/styles/components/link-focus-variables.scss b/assets/styles/components/link-focus-variables.scss new file mode 100644 index 00000000..186f213e --- /dev/null +++ b/assets/styles/components/link-focus-variables.scss @@ -0,0 +1,14 @@ +/*---------------------------------------------------------------*/ +/*-------------------- link-focus-variables.scss ----------------*/ +/*---------------------------------------------------------------*/ +@use "mixins/link"; +@use "mixins/outline"; + +:root { + /* Focus */ + @include link.styling-variables("link-focus-", "link-"); + @include outline.outline-variables("link-focus-", "link-"); + + /* Icon */ + @include link.icon-styling-variables("link-focus-icon-", "link-", "focus"); +} diff --git a/assets/styles/components/link-focus.scss b/assets/styles/components/link-focus.scss new file mode 100644 index 00000000..82f412f7 --- /dev/null +++ b/assets/styles/components/link-focus.scss @@ -0,0 +1,17 @@ +/*---------------------------------------------------------------*/ +/*------------------------- link-focus.scss ---------------------*/ +/*---------------------------------------------------------------*/ +@use "link-focus-variables"; +@use "mixins/link"; +@use "mixins/outline"; + +a { + &:focus, + &.focus + + /* Testing purposes */ { + @include link.link-and-icon-styling("link-focus-"); + @include link.link-elements-styling("link-focus-"); + @include outline.outline("link-focus-"); + } +} diff --git a/assets/styles/components/link-hover-variables.scss b/assets/styles/components/link-hover-variables.scss new file mode 100644 index 00000000..bae81af4 --- /dev/null +++ b/assets/styles/components/link-hover-variables.scss @@ -0,0 +1,14 @@ +/*---------------------------------------------------------------*/ +/*-------------------- link-hover-variables.scss ----------------*/ +/*---------------------------------------------------------------*/ +@use "mixins/link"; +@use "mixins/outline"; + +:root { + /* Hover */ + @include link.styling-variables("link-hover-", "link-"); + @include outline.outline-variables("link-hover-", "link-"); + + /* Icon */ + @include link.icon-styling-variables("link-hover-icon-", "link-", "hover"); +} diff --git a/assets/styles/components/link-hover.scss b/assets/styles/components/link-hover.scss new file mode 100644 index 00000000..dc3a52ea --- /dev/null +++ b/assets/styles/components/link-hover.scss @@ -0,0 +1,17 @@ +/*---------------------------------------------------------------*/ +/*------------------------- link-hover.scss ---------------------*/ +/*---------------------------------------------------------------*/ +@use "link-hover-variables"; +@use "mixins/link"; +@use "mixins/outline"; + +a { + &:hover, + &.hover + + /* Testing purposes */ { + @include link.link-and-icon-styling("link-hover-"); + @include link.link-elements-styling("link-hover-"); + @include outline.outline("link-hover-"); + } +} diff --git a/assets/styles/components/link-variables.scss b/assets/styles/components/link-variables.scss new file mode 100644 index 00000000..9a005d52 --- /dev/null +++ b/assets/styles/components/link-variables.scss @@ -0,0 +1,42 @@ +/*----------------------------------------------------------------------------------*/ +/*------------------------------- link-variables.scss ------------------------------*/ +/*----------------------------------------------------------------------------------*/ +:root { + /* Default */ + --link-font-size: var(--application-base-font-size); + --link-text-font-weight: var(--application-base-font-weight); + --link-line-height: var(--application-base-line-height); + --link-background-color: transparent; + --link-text-color: #01689b; + --link-hover-text-color: #004161; + --link-visited-text-color: #01689b; + --link-text-decoration: initial; + --link-border-width: 0; + --link-border-style: none; + --link-border-color: transparent; + --link-border-radius: 0; + + /* Icon styling-variables */ + --link-icon-font-family: var(--icon-font-family); + --link-icon-font-size: var(--icon-font-size); + --link-icon-font-weight: var(--icon-font-weight); + --link-icon-line-height: var(--icon-line-height); + --link-icon-background-color: var(--background-color); + --link-icon-text-color: var(--text-color); + --link-icon-padding-left: 0; + --link-icon-padding-right: 0.5rem; + --link-icon-text-decoration: none; + --link-icon-border-width: 0; + --link-icon-border-style: none; + --link-icon-border-color: transparent; + --link-icon-border-radius: 0; + + /* Icon at the end */ + --link-icon-last-padding-right: 0; + --link-icon-last-padding-left: 0.5rem; + + /* Outline variables */ + --link-focus-outline: 2px dotted #000; + --link-outline-offset: 0; + --link-z-index: auto; +} diff --git a/assets/styles/components/link-visited-variables.scss b/assets/styles/components/link-visited-variables.scss new file mode 100644 index 00000000..95241f79 --- /dev/null +++ b/assets/styles/components/link-visited-variables.scss @@ -0,0 +1,19 @@ +/*---------------------------------------------------------------*/ +/*--------------- footer-link-visited-variables.scss ------------*/ +/*---------------------------------------------------------------*/ + +@use "mixins/link"; +@use "mixins/outline"; + +:root { + /* Visited */ + @include link.styling-variables("link-visited-", "link-"); + @include outline.outline-variables("link-visited-", "link-"); + + /* Icon */ + @include link.icon-styling-variables( + "link-visited-icon-", + "link-", + "visited" + ); +} diff --git a/assets/styles/components/link-visited.scss b/assets/styles/components/link-visited.scss new file mode 100644 index 00000000..6bd4931e --- /dev/null +++ b/assets/styles/components/link-visited.scss @@ -0,0 +1,18 @@ +/*---------------------------------------------------------------*/ +/*------------------ link-visited.scss -------------------*/ +/*---------------------------------------------------------------*/ +@use "link-visited-variables"; +@use "mixins/link"; +@use "mixins/outline"; + +a { + /* states */ + &:visited, + &.visited + + /* Testing purposes */ { + @include link.link-and-icon-styling("link-visited-"); + @include link.link-elements-styling("link-visited-"); + @include outline.outline("link-visited-"); + } +} diff --git a/assets/styles/components/mixins/collapsible.scss b/assets/styles/components/mixins/collapsible.scss new file mode 100644 index 00000000..349ffbf1 --- /dev/null +++ b/assets/styles/components/mixins/collapsible.scss @@ -0,0 +1,34 @@ +/*---------------------------------------------------------------*/ +/*----------------------- collapsible.scss ----------------------*/ +/*---------------------------------------------------------------*/ + +@mixin collapsible { + &.collapsed { + padding: 0; + gap: 0; + width: var(--collapsible-width, 100%); + max-width: var(--collapsible-max-width, 100%); + margin: 0; + position: relative; + + button.collapsible-toggle { + display: flex; + max-height: 100%; + + + .collapsing-element { + display: flex; + } + + &[aria-expanded="false"] { + & + .collapsing-element { + display: none; + } + } + } + } + + /* Above breakpoint */ + button.collapsible-toggle { + display: none; + } +} diff --git a/assets/styles/components/mixins/content-wrapper.scss b/assets/styles/components/mixins/content-wrapper.scss new file mode 100644 index 00000000..a0348720 --- /dev/null +++ b/assets/styles/components/mixins/content-wrapper.scss @@ -0,0 +1,37 @@ +/*---------------------------------------------------------------------*/ +/*-------------------- mixins/content-wrapper.scss --------------------*/ +/*---------------------------------------------------------------------*/ +/* Inherit over all components, including nested */ +@mixin nested($prefix) { + display: flex; + flex-direction: var(--#{$prefix}-content-wrapper-flex-direction); + gap: var(--#{$prefix}-content-wrapper-gap); + justify-content: var(--#{$prefix}-content-wrapper-justify-content); + align-items: var(--#{$prefix}-content-wrapper-align-items); + margin: 0; + padding: 0; +} + +/* Only top level components */ +@mixin styling($prefix) { + width: 100%; + margin: 0 auto; + box-sizing: border-box; + padding-top: var(--#{$prefix}-content-wrapper-padding-top); + padding-right: var(--#{$prefix}-content-wrapper-padding-right); + padding-bottom: var(--#{$prefix}-content-wrapper-padding-bottom); + padding-left: var(--#{$prefix}-content-wrapper-padding-left); + max-width: var(--#{$prefix}-content-wrapper-max-width); +} + +@mixin content-wrapper-variables($prefix) { + --#{$prefix}-content-wrapper-flex-direction: var(--content-flex-direction); + --#{$prefix}-content-wrapper-justify-content: var(--content-justify-content); + --#{$prefix}-content-wrapper-align-items: var(--content-align-items); + --#{$prefix}-content-wrapper-gap: var(--content-gap); + --#{$prefix}-content-wrapper-padding-top: var(--content-padding-top); + --#{$prefix}-content-wrapper-padding-right: var(--content-padding-right); + --#{$prefix}-content-wrapper-padding-bottom: var(--content-padding-bottom); + --#{$prefix}-content-wrapper-padding-left: var(--content-padding-left); + --#{$prefix}-content-wrapper-max-width: var(--content-max-width); +} diff --git a/assets/styles/components/mixins/icon.scss b/assets/styles/components/mixins/icon.scss new file mode 100644 index 00000000..e689966b --- /dev/null +++ b/assets/styles/components/mixins/icon.scss @@ -0,0 +1,44 @@ +/*---------------------------------------------------------------------*/ +/*------------------------- mixins/icon.scss --------------------------*/ +/*---------------------------------------------------------------------*/ + +@mixin icon { + position: static; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 0; + font-style: normal; +} + +@mixin icon-format($prefix) { + @include icon; + + font-family: var(--#{$prefix}icon-font-family); + font-size: var(--#{$prefix}icon-font-size); + line-height: var(--#{$prefix}icon-line-height); + text-decoration: var(--#{$prefix}icon-text-decoration); + padding-right: var(--#{$prefix}icon-padding-right); + padding-left: var(--#{$prefix}icon-padding-left); + background-color: var(--#{$prefix}icon-background-color); + color: var(--#{$prefix}icon-text-color); +} + +@mixin icon-format-variables($prefix, $parentPrefix, $state) { + --#{$prefix}icon: none; + --#{$prefix}icon-font-family: var(--#{$parentPrefix}icon-font-family); + --#{$prefix}icon-font-size: var(--#{$parentPrefix}icon-font-size); + --#{$prefix}icon-line-height: var(--#{$parentPrefix}icon-line-height); + --#{$prefix}icon-text-decoration: var(--#{$parentPrefix}icon-text-decoration); + --#{$prefix}icon-padding-right: 0.5rem; + --#{$prefix}icon-padding-left: 0; + --#{$prefix}icon-background-color: var( + --#{$parentPrefix}#{$state}background-color + ); + --#{$prefix}icon-text-color: var(--#{$parentPrefix}#{$state}text-color); +} + +@mixin icon-content($prefix) { + content: var(--#{$prefix}icon); +} diff --git a/assets/styles/components/mixins/layout.scss b/assets/styles/components/mixins/layout.scss new file mode 100644 index 00000000..c2503f33 --- /dev/null +++ b/assets/styles/components/mixins/layout.scss @@ -0,0 +1,30 @@ +/*---------------------------------------------------------------------*/ +/*-------------------------- mixins/layout.scss -----------------------*/ +/*---------------------------------------------------------------------*/ +@mixin layout($prefix) { + display: flex; + flex-direction: var(--#{$prefix}-flex-direction); + justify-content: var(--#{$prefix}-justify-content); + align-items: var(--#{$prefix}-align-items); + gap: var(--#{$prefix}-gap); + width: 100%; + margin: 0 auto; + box-sizing: border-box; + padding-top: var(--#{$prefix}-padding-top); + padding-right: var(--#{$prefix}-padding-right); + padding-bottom: var(--#{$prefix}-padding-bottom); + padding-left: var(--#{$prefix}-padding-left); + max-width: var(--#{$prefix}-max-width); +} + +@mixin layout-variables($prefix) { + --#{$prefix}-flex-direction: var(--content-flex-direction); + --#{$prefix}-justify-content: var(--content-justify-content); + --#{$prefix}-align-items: var(--content-align-items); + --#{$prefix}-gap: 0; + --#{$prefix}-padding-top: 0; + --#{$prefix}-padding-right: var(--page-whitespace-right); + --#{$prefix}-padding-bottom: 0; + --#{$prefix}-padding-left: var(--page-whitespace-left); + --#{$prefix}-max-width: 100%; +} diff --git a/assets/styles/components/mixins/link.scss b/assets/styles/components/mixins/link.scss new file mode 100644 index 00000000..9cc93107 --- /dev/null +++ b/assets/styles/components/mixins/link.scss @@ -0,0 +1,81 @@ +/*---------------------------------------------------------------------*/ +/*------------------------- mixins/icon.scss --------------------------*/ +/*---------------------------------------------------------------------*/ +@use "icon"; + +$states: ("visited", "hover", "active", "focus"); + +@mixin styling($prefix) { + line-height: inherit; + font-size: inherit; + font-weight: inherit; + background-color: var(--#{$prefix}background-color); + color: var(--#{$prefix}text-color); + text-decoration: underline; + border-width: var(--#{$prefix}border-width); + border-style: var(--#{$prefix}border-style); + border-color: var(--#{$prefix}border-color); + border-radius: var(--#{$prefix}border-radius); + box-sizing: border-box; +} + +@mixin link-and-icon-styling($prefix) { + @include styling($prefix); + + &:before, + .icon:before { + @include styling("#{$prefix}icon-"); + } +} + +@mixin link($prefix) { + @include styling($prefix); + + &:before { + @include icon.icon-format($prefix); + @include icon.icon-content($prefix); + } + + > span.icon:last-of-type:not(:only-of-type) { + padding-right: var(--#{$prefix}icon-last-padding-right); + padding-left: var(--#{$prefix}icon-last-padding-left); + } +} + +@mixin link-elements-styling($prefix) { + > h1, + > h2, + > h3, + > h4, + > h5, + > h6 { + // @include link-and-icon-styling($prefix); + @content; + } +} + +@mixin styling-variables($prefix, $parentPrefix) { + --#{$prefix}font-size: inherit; + --#{$prefix}font-weight: var(--#{$parentPrefix}font-weight); + --#{$prefix}line-height: var(--#{$parentPrefix}line-height); + --#{$prefix}background-color: var(--#{$parentPrefix}background-color); + --#{$prefix}text-color: var(--#{$parentPrefix}text-color); + --#{$prefix}text-decoration: var(--#{$parentPrefix}text-decoration); + --#{$prefix}border-width: var(--#{$parentPrefix}border-width); + --#{$prefix}border-style: var(--#{$parentPrefix}border-style); + --#{$prefix}border-color: var(--#{$parentPrefix}border-color); + --#{$prefix}border-radius: var(--#{$parentPrefix}border-radius); +} + +@mixin icon-styling-variables($prefix, $parentPrefix, $state) { + --#{$prefix}font-size: var(--#{$parentPrefix}icon-font-size); + --#{$prefix}font-weight: var(--#{$parentPrefix}icon-font-weight); + --#{$prefix}line-height: var(--#{$parentPrefix}icon-line-height); + --#{$prefix}background-color: var(--#{$parentPrefix}#{$state}-background-color); + --#{$prefix}text-color: var(--#{$parentPrefix}#{$state}-text-color); + --#{$prefix}text-decoration: var(--#{$parentPrefix}icon-text-decoration); + --#{$prefix}border-width: var(--#{$parentPrefix}icon-border-width); + --#{$prefix}border-style: var(--#{$parentPrefix}icon-border-style); + --#{$prefix}border-color: var(--#{$parentPrefix}icon-border-color); + --#{$prefix}border-radius: var(--#{$parentPrefix}icon-border-radius); +} diff --git a/assets/styles/components/mixins/notification-type.scss b/assets/styles/components/mixins/notification-type.scss new file mode 100644 index 00000000..cc2f06de --- /dev/null +++ b/assets/styles/components/mixins/notification-type.scss @@ -0,0 +1,137 @@ +/*---------------------------------------------------------------------*/ +/*------------------ mixins/notification-type.scss --------------------*/ +/*---------------------------------------------------------------------*/ + +@mixin notification-type($type) { + background-color: var(--notification-#{$type}-background-color); + color: var(--notification-#{$type}-text-color); + border-width: var(--notification-#{$type}-border-width); + border-style: var(--notification-#{$type}-border-style); + border-color: var(--notification-#{$type}-border-color); + + &:before { + font-family: var(--notification-#{$type}-icon-font-family); + font-size: var(--notification-#{$type}-icon-font-size); + padding-right: var(--notification-#{$type}-icon-padding-right); + padding-left: var(--notification-#{$type}-icon-padding-left); + color: var(--notification-#{$type}-icon-text-color); + margin-right: var(--notification-#{$type}-icon-margin-right); + content: var(--notification-#{$type}-icon); + } +} + +@mixin notification-type-variables($type) { + --notification-#{$type}-background-color: var(--#{$type}-background-color); + --notification-#{$type}-text-color: var(--#{$type}-text-color); + --notification-#{$type}-border-width: var(--notification-border-width); + --notification-#{$type}-border-style: var(--notification-border-style); + --notification-#{$type}-border-color: var(--#{$type}-border-color); + + /* Icon */ + --notification-#{$type}-icon-font-family: var( + --notification-icon-font-family + ); + --notification-#{$type}-icon-font-size: var(--notification-icon-font-size); + --notification-#{$type}-icon-text-color: var(--notification-icon-text-color); + --notification-#{$type}-icon-padding-right: var( + --notification-icon-padding-right + ); + --notification-#{$type}-icon-padding-left: var( + --notification-icon-padding-left + ); + --notification-#{$type}-icon-margin-right: var( + --notification-icon-margin-right + ); + --notification-#{$type}-icon: none; +} + +/* Page notifications */ + +@mixin notification-page-type($type) { + @include notification-type($type); + padding-top: var(--notification-#{$type}-page-padding-top); + padding-right: var(--notification-#{$type}-page-padding-right); + padding-bottom: var(--notification-#{$type}-page-padding-bottom); + padding-left: var(--notification-#{$type}-page-padding-left); + gap: var(--notification-#{$type}-page-gap); + + span:first-of-type { + font-weight: var(--notification-#{$type}-page-span-font-weight); + } + + /* Content wrapper */ + > div { + padding-top: var(--notification-#{$type}-page-content-wrapper-padding-top); + padding-right: var( + --notification-#{$type}-page-content-wrapper-padding-right + ); + padding-bottom: var( + --notification-#{$type}-page-content-wrapper-padding-bottom + ); + padding-left: var( + --notification-#{$type}-page-content-wrapper-padding-left + ); + gap: var(--notification-#{$type}-page-content-wrapper-gap); + + span:first-of-type { + font-weight: var(--notification-#{$type}-page-span-font-weight); + } + } +} + +@mixin notification-page-type-variables($type) { + --notification-#{$type}-page-background-color: var( + --#{$type}-background-color + ); + --notification-#{$type}-page-text-color: var(--#{$type}-text-color); + --notification-#{$type}-page-border-width: var(--notification-border-width); + --notification-#{$type}-page-border-style: var(--notification-border-style); + --notification-#{$type}-page-border-color: var(--#{$type}-border-color); + --notification-#{$type}-page-padding-top: 0.5rem; + --notification-#{$type}-page-padding-right: var(--page-whitespace-right); + --notification-#{$type}-page-padding-bottom: 0.5rem; + --notification-#{$type}-page-padding-left: var(--page-whitespace-left); + --notification-#{$type}-page-gap: 0.5rem; + + /* First span */ + --notification-#{$type}-page-span-font-weight: var( + --notification-span-font-weight + ); + + /* Content wrapper */ + --notification-#{$type}-page-content-wrapper-padding-top: 0.5rem; + --notification-#{$type}-page-content-wrapper-padding-right: var( + --content-padding-right + ); + --notification-#{$type}-page-content-wrapper-padding-bottom: 0.5rem; + --notification-#{$type}-page-content-wrapper-padding-left: var( + --content-padding-left + ); + --notification-#{$type}-page-content-wrapper-gap: 0.5rem; + + /* First span */ + --notification-#{$type}-page-content-wrapper-span-font-weight: var( + --notification-span-font-weight + ); + + /* Icon */ + --notification-#{$type}-page-icon-font-family: var( + --notification-icon-font-family + ); + --notification-#{$type}-page-icon-font-size: var( + --notification-icon-font-size + ); + --notification-#{$type}-page-icon-text-color: var( + --notification-icon-text-color + ); + --notification-#{$type}-page-icon-padding-right: var( + --notification-icon-padding-right + ); + --notification-#{$type}-page-icon-padding-left: var( + --notification-icon-padding-left + ); + --notification-#{$type}-page-icon-margin-right: var( + --notification-icon-margin-right + ); + --notification-#{$type}-page-icon: none; +} diff --git a/assets/styles/components/mixins/outline.scss b/assets/styles/components/mixins/outline.scss new file mode 100644 index 00000000..a533650c --- /dev/null +++ b/assets/styles/components/mixins/outline.scss @@ -0,0 +1,11 @@ +@mixin outline($prefix) { + outline: var(--#{$prefix}outline); + outline-offset: var(--#{$prefix}outline-offset); + z-index: var(--#{$prefix}z-index, 1); +} + +@mixin outline-variables($prefix, $parentPrefix) { + --#{$prefix}outline: var(--#{$parentPrefix}outline); + --#{$prefix}outline-offset: var(--#{$parentPrefix}outline-offset); + --#{$prefix}z-index: var(--#{$parentPrefix}z-index); +} diff --git a/assets/styles/components/notification.scss b/assets/styles/components/notification.scss new file mode 100644 index 00000000..654a5417 --- /dev/null +++ b/assets/styles/components/notification.scss @@ -0,0 +1,10 @@ +.notification.notification--info { + background: none; + border-left: 10px solid #01689b; + display: block; + padding: 0 0 0 1rem; + + @media (min-width: 42rem) { + padding-left: 3rem; + } +} \ No newline at end of file diff --git a/assets/styles/components/pipe-after.scss b/assets/styles/components/pipe-after.scss new file mode 100644 index 00000000..a7d55b21 --- /dev/null +++ b/assets/styles/components/pipe-after.scss @@ -0,0 +1,21 @@ +ul.pipe-after { + display: flex; + flex-direction: row; + + li { + display: inline-flex; + } +} + +ul.pipe-after li:not(:last-child)::after, +span.pipe-after::after { + content: " | "; + color: white; + border-right: 1px solid #696969; + display: inline-block; + margin-inline-start: 0.3rem; + margin-inline-end: 0.5rem; + font-weight: normal; + height: 1rem; + vertical-align: middle; +} diff --git a/assets/styles/components/result-highlight.scss b/assets/styles/components/result-highlight.scss new file mode 100644 index 00000000..fed89e33 --- /dev/null +++ b/assets/styles/components/result-highlight.scss @@ -0,0 +1,3 @@ +.result-highlight { + font-weight: bold; +} diff --git a/assets/styles/components/tabs-variables.scss b/assets/styles/components/tabs-variables.scss new file mode 100644 index 00000000..c7bacc93 --- /dev/null +++ b/assets/styles/components/tabs-variables.scss @@ -0,0 +1,52 @@ +/*----------------------------------------------------------------------------------*/ +/*------------------------------ tabs-variables.scss -------------------------------*/ +/*----------------------------------------------------------------------------------*/ +:root { + --tabs-border-top-width: 0; + --tabs-border-right-width: 0; + --tabs-border-bottom-width: 1px; + --tabs-border-left-width: 0; + --tabs-border-style: solid; + --tabs-border-color: #808080; + --tabs-background-color: transparent; + --tabs-gap: 1rem; + + /* List item */ + --tabs-item-border-width: 0 0 3px 0; + --tabs-item-border-style: solid; + --tabs-item-border-color: transparent; + --tabs-item-text-color: #808080; + --tabs-item-line-height: var(--application-base-line-height); + --tabs-item-padding: 0.5rem; + --tabs-item-background-color: var(--tabs-background-color); + --tabs-item-text-decoration: none; + + /* List item hover */ + --tabs-item-hover-border-width: 0 0 3px 0; + --tabs-item-hover-border-style: solid; + --tabs-item-hover-border-color: var(--application-base-accent-color); + --tabs-item-hover-text-color: var(--application-base-accent-color); + --tabs-item-hover-background-color: var(--tabs-item-background-color); + + /* List item active */ + --tabs-item-active-border-width: 0 0 3px 0; + --tabs-item-active-border-style: solid; + --tabs-item-active-border-color: var(--application-base-accent-color); + --tabs-item-active-text-color: var( + --application-base-accent-color, + var(--tabs-border-color) + ); + --tabs-item-active-background-color: var(--tabs-item-background-color); + + /* List item active hover */ + --tabs-item-active-hover-border-width: 0 0 3px 0; + --tabs-item-active-hover-border-style: solid; + --tabs-item-active-hover-border-color: var(--application-base-accent-color); + --tabs-item-active-hover-text-color: var( + --application-base-accent-color, + var(--tabs-border-color) + ); + --tabs-item-active-hover-background-color: var( + --tabs-item-background-color + ); +} diff --git a/assets/styles/components/tabs.scss b/assets/styles/components/tabs.scss new file mode 100644 index 00000000..c9dd1527 --- /dev/null +++ b/assets/styles/components/tabs.scss @@ -0,0 +1,117 @@ +/*---------------------------------------------------------------*/ +/*-------------------------- tabs.scss --------------------------*/ +/*---------------------------------------------------------------*/ +@use "tabs-variables"; + +%tabs-link-styling { + border-width: 0 0 3px 0; + border-style: var(--tabs-item-border-style); + border-color: var(--tabs-item-border-color); + color: var(--tabs-item-text-color); + line-height: var(--tabs-item-line-height); + padding: var(--tabs-item-padding); + background-color: var(--tabs-item-background-color); + text-decoration: var(--tabs-item-text-decoration); + + &:hover, + &:focus { + border-width: var(--tabs-item-hover-border-width); + border-style: var(--tabs-item-hover-border-style); + border-color: var(--tabs-item-hover-border-color); + color: var(--tabs-item-hover-text-color); + background-color: var(--tabs-item-hover-background-color); + } +} + +%tabs-active-item-styling { + border-width: var(--tabs-item-active-border-width); + border-style: var(--tabs-item-active-border-style); + border-color: var(--tabs-item-active-border-color); + color: var(--tabs-item-active-text-color); + background-color: var(--tabs-item-active-background-color); + + &:hover, + &:focus { + border-width: var(--tabs-item-active-hover-border-width); + border-style: var(--tabs-item-active-hover-border-style); + border-color: var(--tabs-item-active-hover-border-color); + color: var(--tabs-item-active-hover-text-color); + background-color: var(--tabs-item-active-hover-background-color); + } +} + +.tabs { + background-color: var(--tabs-background-color); + width: 100%; + + >ul { + display: flex; + flex-direction: row; + justify-content: flex-start; + gap: var(--tabs-gap); + border-bottom: 1px; + border-top-width: var(--tabs-border-top-width); + border-right-width: var(--tabs-border-right-width); + border-left-width: var(--tabs-border-left-width); + border-style: var(--tabs-border-style); + border-color: var(--tabs-border-color); + padding-left: 0; + width: 100%; + + li { + list-style: none; + padding: 0; + /* to not clip focus outline around buttons: */ + position: relative; + left: 2px; + + >button { + margin-bottom: 0; + border-bottom: 3px solid transparent; + /* to not clip focus outline around buttons: */ + margin-top: 2px; + } + } + + button { + @extend %tabs-link-styling; + + &[aria-selected="true"] { + @extend %tabs-active-item-styling; + } + } + + span { + cursor: default; + } + } + + li { + font-size: 1.25rem; + font-weight: bold; + } +} + +@media (min-width: 42rem) { + .tabs td:first-child { + padding-left: 1rem; + } + + [data-tab-content]:not(hidden) { + display: block; + } +} + +.copy-desktop { + display: none; +} + +@media (min-width: 42rem) { + .copy-mobile { + display: none; + } + + .copy-desktop { + display: initial; + } +} \ No newline at end of file diff --git a/assets/svg/arrow.svg b/assets/svg/arrow.svg new file mode 100644 index 00000000..982fb0c0 --- /dev/null +++ b/assets/svg/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/audio.svg b/assets/svg/audio.svg new file mode 100644 index 00000000..79944cf9 --- /dev/null +++ b/assets/svg/audio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/bericht.svg b/assets/svg/bericht.svg new file mode 100644 index 00000000..596cbb9f --- /dev/null +++ b/assets/svg/bericht.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/bijlage.svg b/assets/svg/bijlage.svg new file mode 100644 index 00000000..f82d0dd2 --- /dev/null +++ b/assets/svg/bijlage.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/svg/check.svg b/assets/svg/check.svg new file mode 100644 index 00000000..d30a41ea --- /dev/null +++ b/assets/svg/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/chevron-link.svg b/assets/svg/chevron-link.svg new file mode 100644 index 00000000..5c8461af --- /dev/null +++ b/assets/svg/chevron-link.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/chevron.svg b/assets/svg/chevron.svg new file mode 100644 index 00000000..7b711881 --- /dev/null +++ b/assets/svg/chevron.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/csv.svg b/assets/svg/csv.svg new file mode 100644 index 00000000..a2cbcb2f --- /dev/null +++ b/assets/svg/csv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/database.svg b/assets/svg/database.svg new file mode 100644 index 00000000..cfc4c3d7 --- /dev/null +++ b/assets/svg/database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/delete.svg b/assets/svg/delete.svg new file mode 100644 index 00000000..9a86cc98 --- /dev/null +++ b/assets/svg/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/doc.svg b/assets/svg/doc.svg new file mode 100644 index 00000000..1096e6e0 --- /dev/null +++ b/assets/svg/doc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/download.svg b/assets/svg/download.svg new file mode 100644 index 00000000..93695b2d --- /dev/null +++ b/assets/svg/download.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/externallink.svg b/assets/svg/externallink.svg new file mode 100644 index 00000000..94835ba7 --- /dev/null +++ b/assets/svg/externallink.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/home.svg b/assets/svg/home.svg new file mode 100644 index 00000000..f3f06d6b --- /dev/null +++ b/assets/svg/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/html.svg b/assets/svg/html.svg new file mode 100644 index 00000000..494a8197 --- /dev/null +++ b/assets/svg/html.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/image.svg b/assets/svg/image.svg new file mode 100644 index 00000000..40b8b6dd --- /dev/null +++ b/assets/svg/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/internet.svg b/assets/svg/internet.svg new file mode 100644 index 00000000..a140451e --- /dev/null +++ b/assets/svg/internet.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/mail.svg b/assets/svg/mail.svg new file mode 100644 index 00000000..32388bd2 --- /dev/null +++ b/assets/svg/mail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/map.svg b/assets/svg/map.svg new file mode 100644 index 00000000..78cec07b --- /dev/null +++ b/assets/svg/map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/note.svg b/assets/svg/note.svg new file mode 100644 index 00000000..47671202 --- /dev/null +++ b/assets/svg/note.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/pdf.svg b/assets/svg/pdf.svg new file mode 100644 index 00000000..4f55d1a5 --- /dev/null +++ b/assets/svg/pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/presentation.svg b/assets/svg/presentation.svg new file mode 100644 index 00000000..a902a9df --- /dev/null +++ b/assets/svg/presentation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/sendusmail.svg b/assets/svg/sendusmail.svg new file mode 100644 index 00000000..f4b4a4db --- /dev/null +++ b/assets/svg/sendusmail.svg @@ -0,0 +1 @@ + diff --git a/.github/ISSUE_TEMPLATE/.gitignore b/assets/svg/text.svg similarity index 100% rename from .github/ISSUE_TEMPLATE/.gitignore rename to assets/svg/text.svg diff --git a/assets/svg/trash.svg b/assets/svg/trash.svg new file mode 100644 index 00000000..1fa5a5dd --- /dev/null +++ b/assets/svg/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/unknown.svg b/assets/svg/unknown.svg new file mode 100644 index 00000000..bb7a7a0f --- /dev/null +++ b/assets/svg/unknown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/vcard.svg b/assets/svg/vcard.svg new file mode 100644 index 00000000..375990b6 --- /dev/null +++ b/assets/svg/vcard.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/video.svg b/assets/svg/video.svg new file mode 100644 index 00000000..ce9f6fc7 --- /dev/null +++ b/assets/svg/video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/web.svg b/assets/svg/web.svg new file mode 100644 index 00000000..84164313 --- /dev/null +++ b/assets/svg/web.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/xlsx.svg b/assets/svg/xlsx.svg new file mode 100644 index 00000000..93d95d7a --- /dev/null +++ b/assets/svg/xlsx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/xml.svg b/assets/svg/xml.svg new file mode 100644 index 00000000..920ac1e3 --- /dev/null +++ b/assets/svg/xml.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/svg/zoom.svg b/assets/svg/zoom.svg new file mode 100644 index 00000000..a084b3ae --- /dev/null +++ b/assets/svg/zoom.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/tabs.js b/assets/tabs.js new file mode 100644 index 00000000..3ea512a3 --- /dev/null +++ b/assets/tabs.js @@ -0,0 +1,90 @@ +import { onDomReady } from "@minvws/manon/utils.js"; + +onDomReady(() => { + const tabButtons = document.querySelectorAll('[role="tab"]'); + const tabList = document.querySelector('[role="tablist"]'); + + const abortController = new AbortController(); + + // Add a click event handler to each tab + tabButtons.forEach((tabButton) => { + tabButton.addEventListener("click", (event) => { + window.location.hash = event.currentTarget.id; + changeTabs(event.currentTarget); + }, { signal: abortController.signal }); + }); + + displayTabByHash(); + + // Enable arrow navigation between tabs in the tab list + let tabFocus = 0; + + if (tabList) { + tabList.addEventListener("keydown", (e) => { + // Move right + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + tabs[tabFocus].setAttribute("tabindex", -1); + if (e.key === "ArrowRight") { + tabFocus++; + // If we're at the end, go to the start + if (tabFocus >= tabs.length) { + tabFocus = 0; + } + // Move left + } else if (e.key === "ArrowLeft") { + tabFocus--; + // If we're at the start, move to the end + if (tabFocus < 0) { + tabFocus = tabs.length - 1; + } + } + + tabs[tabFocus].setAttribute("tabindex", 0); + tabs[tabFocus].focus(); + } + }, { signal: abortController.signal }); + } + + function displayTabByHash() { + const { hash } = window.location; + if (hash === '') { + return; + } + + const tabButtonElement = document.querySelector(`[role="tab"][data-tab-target="${hash}"]`); + if (!tabButtonElement) { + return; + } + + changeTabs(tabButtonElement); + } + + function changeTabs(tabButtonElement) { + if (!tabButtonElement) { + return; + } + + const parent = tabButtonElement.closest('[role="tablist"]'); + const grandparent = parent.parentNode; + + // Remove all current selected tabs + parent + .querySelectorAll('[aria-selected="true"]') + .forEach((t) => t.setAttribute("aria-selected", false)); + + // Set this tab as selected + tabButtonElement.setAttribute("aria-selected", true); + + // Hide all tab panels + grandparent + .querySelectorAll('[role="tabpanel"]') + .forEach((p) => p.setAttribute("hidden", true)); + + // Show the selected panel + grandparent.parentNode + .querySelector(`#${tabButtonElement.getAttribute("aria-controls")}`) + .removeAttribute("hidden"); + } + + window.addEventListener('beforeunload', () => abortController.abort(), { once: true }); +}); diff --git a/config/elastic/mapping-v10.json b/config/elastic/mapping-v10.json new file mode 100644 index 00000000..98752dbd --- /dev/null +++ b/config/elastic/mapping-v10.json @@ -0,0 +1,199 @@ +{ + "_meta" : { + "version" : 10 + }, + "properties": { + "type": { + "type": "keyword" + }, + "document_nr": { + "type": "keyword" + }, + "file_type": { + "type": "keyword" + }, + "file_size": { + "type": "integer" + }, + "mime_type": { + "type": "keyword" + }, + "source_type": { + "type": "keyword" + }, + "date": { + "type": "date" + }, + "filename": { + "type": "keyword" + }, + "family_id": { + "type": "integer" + }, + "document_id": { + "type": "integer" + }, + "thread_id": { + "type": "integer" + }, + "judgement": { + "type": "keyword" + }, + "grounds": { + "type": "keyword" + }, + "subjects": { + "type": "keyword" + }, + "audio_duration": { + "type": "integer" + }, + "document_pages": { + "type": "integer" + }, + "dossier_nr": { + "type": "keyword" + }, + "inquiry_ids": { + "type": "keyword" + }, + "content_for_suggestions": { + "type": "text" + }, + "dossiers": { + "type": "nested", + "properties": { + "dossier_nr": { + "type": "keyword" + }, + "inquiry_ids": { + "type": "keyword" + }, + "title": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "summary": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "status": { + "type": "keyword" + }, + "document_prefix": { + "type": "keyword" + }, + "departments": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "government_officials": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "date_from": { + "type": "date" + }, + "date_to": { + "type": "date" + }, + "date_period": { + "type": "keyword" + }, + "publication_reason": { + "type": "keyword" + }, + "decision": { + "type": "keyword" + } + } + }, + "pages": { + "type": "nested", + "properties": { + "page_nr": { + "type": "integer" + }, + "content": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + } + } + }, + "title": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "summary": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "decision_content": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "status": { + "type": "keyword" + }, + "document_prefix": { + "type": "keyword" + }, + "departments": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "government_officials": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "date_from": { + "type": "date" + }, + "date_to": { + "type": "date" + }, + "date_period": { + "type": "keyword" + }, + "publication_reason": { + "type": "keyword" + }, + "decision": { + "type": "keyword" + } + } +} diff --git a/config/elastic/mapping-v8.json b/config/elastic/mapping-v8.json new file mode 100644 index 00000000..e6aa8d07 --- /dev/null +++ b/config/elastic/mapping-v8.json @@ -0,0 +1,195 @@ +{ + "_meta" : { + "version" : 8 + }, + "properties": { + "type": { + "type": "keyword" + }, + "document_nr": { + "type": "keyword" + }, + "file_type": { + "type": "keyword" + }, + "file_size": { + "type": "integer" + }, + "mime_type": { + "type": "keyword" + }, + "source_type": { + "type": "keyword" + }, + "date": { + "type": "date" + }, + "filename": { + "type": "keyword" + }, + "family_id": { + "type": "integer" + }, + "document_id": { + "type": "integer" + }, + "thread_id": { + "type": "integer" + }, + "judgement": { + "type": "keyword" + }, + "grounds": { + "type": "keyword" + }, + "subjects": { + "type": "keyword" + }, + "audio_duration": { + "type": "integer" + }, + "document_pages": { + "type": "integer" + }, + "dossier_nr": { + "type": "keyword" + }, + "inquiry_ids": { + "type": "keyword" + }, + "content_for_suggestions": { + "type": "text", + "analyzer": "dutch" + }, + "dossiers": { + "type": "nested", + "properties": { + "dossier_nr": { + "type": "keyword" + }, + "inquiry_ids": { + "type": "keyword" + }, + "title": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "summary": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "status": { + "type": "keyword" + }, + "document_prefix": { + "type": "keyword" + }, + "departments": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "government_officials": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "date_from": { + "type": "date" + }, + "date_to": { + "type": "date" + }, + "date_period": { + "type": "keyword" + }, + "publication_reason": { + "type": "keyword" + }, + "decision": { + "type": "keyword" + } + } + }, + "pages": { + "type": "nested", + "properties": { + "page_nr": { + "type": "integer" + }, + "content": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + } + } + }, + "title": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "summary": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "status": { + "type": "keyword" + }, + "document_prefix": { + "type": "keyword" + }, + "departments": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "government_officials": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "date_from": { + "type": "date" + }, + "date_to": { + "type": "date" + }, + "date_period": { + "type": "keyword" + }, + "publication_reason": { + "type": "keyword" + }, + "decision": { + "type": "keyword" + } + } +} diff --git a/config/elastic/mapping-v9.json b/config/elastic/mapping-v9.json new file mode 100644 index 00000000..95d70801 --- /dev/null +++ b/config/elastic/mapping-v9.json @@ -0,0 +1,200 @@ +{ + "_meta" : { + "version" : 9 + }, + "properties": { + "type": { + "type": "keyword" + }, + "document_nr": { + "type": "keyword" + }, + "file_type": { + "type": "keyword" + }, + "file_size": { + "type": "integer" + }, + "mime_type": { + "type": "keyword" + }, + "source_type": { + "type": "keyword" + }, + "date": { + "type": "date" + }, + "filename": { + "type": "keyword" + }, + "family_id": { + "type": "integer" + }, + "document_id": { + "type": "integer" + }, + "thread_id": { + "type": "integer" + }, + "judgement": { + "type": "keyword" + }, + "grounds": { + "type": "keyword" + }, + "subjects": { + "type": "keyword" + }, + "audio_duration": { + "type": "integer" + }, + "document_pages": { + "type": "integer" + }, + "dossier_nr": { + "type": "keyword" + }, + "inquiry_ids": { + "type": "keyword" + }, + "content_for_suggestions": { + "type": "text", + "analyzer": "dutch" + }, + "dossiers": { + "type": "nested", + "properties": { + "dossier_nr": { + "type": "keyword" + }, + "inquiry_ids": { + "type": "keyword" + }, + "title": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "summary": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "status": { + "type": "keyword" + }, + "document_prefix": { + "type": "keyword" + }, + "departments": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "government_officials": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "date_from": { + "type": "date" + }, + "date_to": { + "type": "date" + }, + "date_period": { + "type": "keyword" + }, + "publication_reason": { + "type": "keyword" + }, + "decision": { + "type": "keyword" + } + } + }, + "pages": { + "type": "nested", + "properties": { + "page_nr": { + "type": "integer" + }, + "content": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + } + } + }, + "title": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "summary": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "decision_content": { + "type": "text", + "analyzer": "dutch", + "copy_to": "content_for_suggestions" + }, + "status": { + "type": "keyword" + }, + "document_prefix": { + "type": "keyword" + }, + "departments": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "government_officials": { + "type": "object", + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "date_from": { + "type": "date" + }, + "date_to": { + "type": "date" + }, + "date_period": { + "type": "keyword" + }, + "publication_reason": { + "type": "keyword" + }, + "decision": { + "type": "keyword" + } + } +} diff --git a/config/packages/presta_sitemap.yaml b/config/packages/presta_sitemap.yaml new file mode 100644 index 00000000..3f0d606b --- /dev/null +++ b/config/packages/presta_sitemap.yaml @@ -0,0 +1,8 @@ +# config/packages/presta_sitemap.yaml +presta_sitemap: + defaults: + priority: 1 + changefreq: daily + lastmod: now + items_by_set: 50000 + dump_directory: public diff --git a/docker/balie-woopie.conf b/docker/balie-woopie.conf new file mode 100644 index 00000000..09318e6f --- /dev/null +++ b/docker/balie-woopie.conf @@ -0,0 +1,18 @@ + + ServerName balie.local + + SetEnv APP_MODE balie + + DocumentRoot /var/www/html/public + DirectoryIndex /index.php + + + AllowOverride All + Require all granted + + FallbackResource /index.php + + + ErrorLog /var/log/apache2/project_error.log + CustomLog /var/log/apache2/project_access.log combined + diff --git a/docker/open-woopie.conf b/docker/open-woopie.conf new file mode 100644 index 00000000..08ff535a --- /dev/null +++ b/docker/open-woopie.conf @@ -0,0 +1,18 @@ + + ServerName open.local + + SetEnv APP_MODE frontend + + DocumentRoot /var/www/html/public + DirectoryIndex /index.php + + + AllowOverride All + Require all granted + + FallbackResource /index.php + + + ErrorLog /var/log/apache2/project_error.log + CustomLog /var/log/apache2/project_access.log combined + diff --git a/migrations/Version20230810084928.php b/migrations/Version20230810084928.php new file mode 100644 index 00000000..14789405 --- /dev/null +++ b/migrations/Version20230810084928.php @@ -0,0 +1,26 @@ +addSql("UPDATE document SET judgement=NULL WHERE judgement=''"); + } + + public function down(Schema $schema): void + { + } +} diff --git a/migrations/Version20230814092616.php b/migrations/Version20230814092616.php new file mode 100644 index 00000000..2bdce316 --- /dev/null +++ b/migrations/Version20230814092616.php @@ -0,0 +1,29 @@ +addSql("UPDATE document SET judgement=NULL WHERE judgement=''"); + $this->addSql("UPDATE document SET judgement='public' WHERE LOWER(judgement) = 'openbaar'"); + $this->addSql("UPDATE document SET judgement='partial_public' WHERE LOWER(judgement) = 'deels openbaar'"); + $this->addSql("UPDATE document SET judgement='already_public' WHERE LOWER(judgement) = 'reeds openbaar'"); + $this->addSql("UPDATE document SET judgement='not_public' WHERE LOWER(judgement) = 'niet openbaar'"); + } + + public function down(Schema $schema): void + { + } +} diff --git a/migrations/Version20230814120620.php b/migrations/Version20230814120620.php new file mode 100644 index 00000000..2c33182a --- /dev/null +++ b/migrations/Version20230814120620.php @@ -0,0 +1,102 @@ +addSql('CREATE TABLE decision_document (id UUID NOT NULL, dossier_id UUID NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, file_mimetype VARCHAR(100) DEFAULT NULL, file_path VARCHAR(1024) DEFAULT NULL, file_size INT NOT NULL, file_type VARCHAR(255) DEFAULT NULL, file_source_type VARCHAR(255) DEFAULT NULL, file_name VARCHAR(255) DEFAULT NULL, file_uploaded BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_55B54548611C0C56 ON decision_document (dossier_id)'); + $this->addSql('COMMENT ON COLUMN decision_document.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN decision_document.dossier_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN decision_document.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN decision_document.updated_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE inventory (id UUID NOT NULL, dossier_id UUID NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, file_mimetype VARCHAR(100) DEFAULT NULL, file_path VARCHAR(1024) DEFAULT NULL, file_size INT NOT NULL, file_type VARCHAR(255) DEFAULT NULL, file_source_type VARCHAR(255) DEFAULT NULL, file_name VARCHAR(255) DEFAULT NULL, file_uploaded BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_B12D4A36611C0C56 ON inventory (dossier_id)'); + $this->addSql('COMMENT ON COLUMN inventory.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN inventory.dossier_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN inventory.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN inventory.updated_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE decision_document ADD CONSTRAINT FK_55B54548611C0C56 FOREIGN KEY (dossier_id) REFERENCES dossier (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE inventory ADD CONSTRAINT FK_B12D4A36611C0C56 FOREIGN KEY (dossier_id) REFERENCES dossier (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + $this->addSql("INSERT INTO inventory(id, dossier_id, created_at, updated_at, file_mimetype, file_path, file_size, file_type, file_source_type, file_name, file_uploaded) + SELECT d.id, dd.dossier_id, d.created_at, d.updated_at, d.mimetype, d.filepath, d.filesize, d.file_type, d.source_type, d.filename, d.uploaded + FROM document d + JOIN document_dossier dd ON dd.document_id = d.id + WHERE d.class='inventory'"); + $this->addSql("DELETE FROM document WHERE class='inventory'"); + + $this->addSql("INSERT INTO decision_document(id, dossier_id, created_at, updated_at, file_mimetype, file_path, file_size, file_type, file_source_type, file_name, file_uploaded) + SELECT d.id, dd.dossier_id, d.created_at, d.updated_at, d.mimetype, d.filepath, d.filesize, d.file_type, d.source_type, d.filename, d.uploaded + FROM document d + JOIN document_dossier dd ON dd.document_id = d.id + WHERE d.class='decision'"); + $this->addSql("DELETE FROM document WHERE class='decision'"); + + $this->addSql('ALTER TABLE document DROP class'); + $this->addSql('ALTER TABLE document ALTER file_type DROP NOT NULL'); + $this->addSql('ALTER TABLE document RENAME COLUMN filename TO file_name'); + $this->addSql('ALTER TABLE document RENAME COLUMN source_type TO file_source_type'); + $this->addSql('ALTER TABLE document RENAME COLUMN mimetype TO file_mimetype'); + $this->addSql('ALTER TABLE document RENAME COLUMN filepath TO file_path'); + $this->addSql('ALTER TABLE document RENAME COLUMN filesize TO file_size'); + $this->addSql('ALTER TABLE document RENAME COLUMN uploaded TO file_uploaded'); + $this->addSql('ALTER TABLE dossier ADD inventory_id UUID DEFAULT NULL'); + $this->addSql('ALTER TABLE dossier ADD decision_document_id UUID DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN dossier.inventory_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN dossier.decision_document_id IS \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE dossier ADD CONSTRAINT FK_3D48E0379EEA759 FOREIGN KEY (inventory_id) REFERENCES inventory (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE dossier ADD CONSTRAINT FK_3D48E0372ECDE55E FOREIGN KEY (decision_document_id) REFERENCES decision_document (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_3D48E0379EEA759 ON dossier (inventory_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_3D48E0372ECDE55E ON dossier (decision_document_id)'); + $this->addSql('ALTER TABLE inquiry_dossier DROP CONSTRAINT inquiry_dossier_pkey'); + $this->addSql('ALTER TABLE inquiry_dossier ADD PRIMARY KEY (dossier_id, inquiry_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE dossier DROP CONSTRAINT FK_3D48E0372ECDE55E'); + $this->addSql('ALTER TABLE dossier DROP CONSTRAINT FK_3D48E0379EEA759'); + $this->addSql('ALTER TABLE decision_document DROP CONSTRAINT FK_55B54548611C0C56'); + $this->addSql('ALTER TABLE inventory DROP CONSTRAINT FK_B12D4A36611C0C56'); + $this->addSql('DROP TABLE decision_document'); + $this->addSql('DROP TABLE inventory'); + $this->addSql('DROP INDEX UNIQ_3D48E0379EEA759'); + $this->addSql('DROP INDEX UNIQ_3D48E0372ECDE55E'); + $this->addSql('ALTER TABLE dossier DROP inventory_id'); + $this->addSql('ALTER TABLE dossier DROP decision_document_id'); + $this->addSql('DROP INDEX inquiry_dossier_pkey'); + $this->addSql('ALTER TABLE inquiry_dossier ADD PRIMARY KEY (inquiry_id, dossier_id)'); + $this->addSql('ALTER TABLE batch_download ALTER status SET DEFAULT \'\''); + $this->addSql('ALTER TABLE document ADD filename VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE document ADD source_type VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE document ADD class VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE document DROP file_source_type'); + $this->addSql('ALTER TABLE document DROP file_name'); + $this->addSql('ALTER TABLE document ALTER suspended SET DEFAULT false'); + $this->addSql('ALTER TABLE document ALTER withdrawn SET DEFAULT false'); + $this->addSql('ALTER TABLE document ALTER file_type SET NOT NULL'); + $this->addSql('ALTER TABLE document RENAME COLUMN file_mimetype TO mimetype'); + $this->addSql('ALTER TABLE document RENAME COLUMN file_path TO filepath'); + $this->addSql('ALTER TABLE document RENAME COLUMN file_size TO filesize'); + $this->addSql('ALTER TABLE document RENAME COLUMN file_uploaded TO uploaded'); + } +} diff --git a/migrations/Version20230814144216.php b/migrations/Version20230814144216.php new file mode 100644 index 00000000..7fb05a0f --- /dev/null +++ b/migrations/Version20230814144216.php @@ -0,0 +1,42 @@ +addSql('ALTER TABLE decision_document DROP CONSTRAINT FK_55B54548611C0C56'); + $this->addSql('ALTER TABLE decision_document ADD CONSTRAINT FK_55B54548611C0C56 FOREIGN KEY (dossier_id) REFERENCES dossier (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE document ALTER file_name DROP NOT NULL'); + $this->addSql('ALTER TABLE document ALTER file_source_type DROP NOT NULL'); + $this->addSql('ALTER TABLE inventory DROP CONSTRAINT FK_B12D4A36611C0C56'); + $this->addSql('ALTER TABLE inventory ADD CONSTRAINT FK_B12D4A36611C0C56 FOREIGN KEY (dossier_id) REFERENCES dossier (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE decision_document DROP CONSTRAINT fk_55b54548611c0c56'); + $this->addSql('ALTER TABLE decision_document ADD CONSTRAINT fk_55b54548611c0c56 FOREIGN KEY (dossier_id) REFERENCES dossier (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE document ALTER file_name SET NOT NULL'); + $this->addSql('ALTER TABLE document ALTER file_source_type SET NOT NULL'); + $this->addSql('ALTER TABLE inventory DROP CONSTRAINT fk_b12d4a36611c0c56'); + $this->addSql('ALTER TABLE inventory ADD CONSTRAINT fk_b12d4a36611c0c56 FOREIGN KEY (dossier_id) REFERENCES dossier (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } +} diff --git a/migrations/Version20230815072107.php b/migrations/Version20230815072107.php new file mode 100644 index 00000000..5d22e79e --- /dev/null +++ b/migrations/Version20230815072107.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE document ADD link VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE document ADD remark TEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE document DROP link'); + $this->addSql('ALTER TABLE document DROP remark'); + } +} diff --git a/migrations/Version20230818072749.php b/migrations/Version20230818072749.php new file mode 100644 index 00000000..a82e1da0 --- /dev/null +++ b/migrations/Version20230818072749.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE department ADD short_tag VARCHAR(20) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE department DROP short_tag'); + } +} diff --git a/migrations/Version20230828093446.php b/migrations/Version20230828093446.php new file mode 100644 index 00000000..9c2f661b --- /dev/null +++ b/migrations/Version20230828093446.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE dossier ADD default_subjects JSON DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE dossier DROP default_subjects'); + } +} diff --git a/migrations/Version20230829082733.php b/migrations/Version20230829082733.php new file mode 100644 index 00000000..c185b45c --- /dev/null +++ b/migrations/Version20230829082733.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE dossier DROP CONSTRAINT fk_3d48e0379eea759'); + $this->addSql('DROP INDEX uniq_3d48e0379eea759'); + $this->addSql('ALTER TABLE dossier DROP inventory_id'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE dossier ADD inventory_id UUID DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN dossier.inventory_id IS \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE dossier ADD CONSTRAINT fk_3d48e0379eea759 FOREIGN KEY (inventory_id) REFERENCES inventory (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE UNIQUE INDEX uniq_3d48e0379eea759 ON dossier (inventory_id)'); + } +} diff --git a/migrations/Version20230829083116.php b/migrations/Version20230829083116.php new file mode 100644 index 00000000..e08ae8f9 --- /dev/null +++ b/migrations/Version20230829083116.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE dossier DROP CONSTRAINT fk_3d48e0372ecde55e'); + $this->addSql('DROP INDEX uniq_3d48e0372ecde55e'); + $this->addSql('ALTER TABLE dossier DROP decision_document_id'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE dossier ADD decision_document_id UUID DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN dossier.decision_document_id IS \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE dossier ADD CONSTRAINT fk_3d48e0372ecde55e FOREIGN KEY (decision_document_id) REFERENCES decision_document (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE UNIQUE INDEX uniq_3d48e0372ecde55e ON dossier (decision_document_id)'); + } +} diff --git a/migrations/Version20230829101129.php b/migrations/Version20230829101129.php new file mode 100644 index 00000000..87d39503 --- /dev/null +++ b/migrations/Version20230829101129.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE inquiry_dossier DROP CONSTRAINT inquiry_dossier_pkey'); + $this->addSql('ALTER TABLE inquiry_dossier ADD PRIMARY KEY (inquiry_id, dossier_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP INDEX inquiry_dossier_pkey'); + $this->addSql('ALTER TABLE inquiry_dossier ADD PRIMARY KEY (dossier_id, inquiry_id)'); + } +} diff --git a/migrations/Version20230830091930.php b/migrations/Version20230830091930.php new file mode 100644 index 00000000..7ac62e2e --- /dev/null +++ b/migrations/Version20230830091930.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE raw_inventory (id UUID NOT NULL, dossier_id UUID NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, file_mimetype VARCHAR(100) DEFAULT NULL, file_path VARCHAR(1024) DEFAULT NULL, file_size INT NOT NULL, file_type VARCHAR(255) DEFAULT NULL, file_name VARCHAR(255) DEFAULT NULL, file_uploaded BOOLEAN NOT NULL, file_source_type VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_AE096B07611C0C56 ON raw_inventory (dossier_id)'); + $this->addSql('COMMENT ON COLUMN raw_inventory.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN raw_inventory.dossier_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN raw_inventory.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN raw_inventory.updated_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE raw_inventory ADD CONSTRAINT FK_AE096B07611C0C56 FOREIGN KEY (dossier_id) REFERENCES dossier (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE raw_inventory DROP CONSTRAINT FK_AE096B07611C0C56'); + $this->addSql('DROP TABLE raw_inventory'); + } +} diff --git a/migrations/Version20230831135909.php b/migrations/Version20230831135909.php new file mode 100644 index 00000000..6fbe8163 --- /dev/null +++ b/migrations/Version20230831135909.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE document ADD withdraw_reason VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE document ADD withdraw_explanation TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE document ADD withdraw_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE document ALTER suspended DROP DEFAULT'); + $this->addSql('ALTER TABLE document ALTER withdrawn DROP DEFAULT'); + $this->addSql('COMMENT ON COLUMN document.withdraw_date IS \'(DC2Type:datetime_immutable)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE document DROP withdraw_reason'); + $this->addSql('ALTER TABLE document DROP withdraw_explanation'); + $this->addSql('ALTER TABLE document DROP withdraw_date'); + $this->addSql('ALTER TABLE document ALTER suspended SET DEFAULT false'); + $this->addSql('ALTER TABLE document ALTER withdrawn SET DEFAULT false'); + } +} diff --git a/migrations/Version20230905082031.php b/migrations/Version20230905082031.php new file mode 100644 index 00000000..8e008072 --- /dev/null +++ b/migrations/Version20230905082031.php @@ -0,0 +1,31 @@ +addSql('CREATE UNIQUE INDEX UNIQ_3D48E037DA892FC0 ON dossier (dossier_nr)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_3D48E037DA892FC0'); + } +} diff --git a/public/img/admin/admin-logo.svg b/public/img/admin/admin-logo.svg new file mode 100644 index 00000000..98534e7f --- /dev/null +++ b/public/img/admin/admin-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/img/admin/chevron-right.svg b/public/img/admin/chevron-right.svg new file mode 100644 index 00000000..27fd4230 --- /dev/null +++ b/public/img/admin/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/admin/icon-check.svg b/public/img/admin/icon-check.svg new file mode 100644 index 00000000..1eb66748 --- /dev/null +++ b/public/img/admin/icon-check.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/admin/icon-close.svg b/public/img/admin/icon-close.svg new file mode 100644 index 00000000..3202ec73 --- /dev/null +++ b/public/img/admin/icon-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/admin/mail.svg b/public/img/admin/mail.svg new file mode 100644 index 00000000..11eab250 --- /dev/null +++ b/public/img/admin/mail.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/chevron-up.svg b/public/img/chevron-up.svg new file mode 100644 index 00000000..95745705 --- /dev/null +++ b/public/img/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Command/ExtractContent.php b/src/Command/ExtractContent.php new file mode 100644 index 00000000..abab2174 --- /dev/null +++ b/src/Command/ExtractContent.php @@ -0,0 +1,66 @@ +setName('woopie:dev:extract-content') + ->setDescription('Extracts content from a file using Tika and Tesseract') + ->setHelp('Extracts content from a file using Tika and Tesseract') + ->setDefinition([ + new InputArgument('file', InputArgument::REQUIRED, 'File to load'), + ]) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $filepath = strval($input->getArgument('file')); + if (! file_exists($filepath)) { + $output->writeln("File $filepath does not exist"); + + return 1; + } + + $io = new SymfonyStyle($input, $output); + + $tikaData = $this->tika->extract($filepath); + + $io->newLine(5); + $io->section('Tika data'); + $io->text(json_encode($tikaData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + + $io->newLine(5); + $io->section('Tika content'); + $io->text(trim($tikaData['X-TIKA:content'])); + + $tesseractData = $this->tesseract->extract($filepath); + + $io->newLine(5); + $io->section('Tesseract data'); + $io->text(trim($tesseractData)); + + $io->newLine(5); + + return 0; + } +} diff --git a/src/Command/README.md b/src/Command/README.md new file mode 100644 index 00000000..f5afcb60 --- /dev/null +++ b/src/Command/README.md @@ -0,0 +1,49 @@ +# Woopie Console Commands + +## Global commands + +Global commands that can be run on either production or development platforms. + +| command | description | +|---------------------------|-----------------------------------------------------------------| +| `woopie:check:production` | Checks if the current environment is ready for the application. | +| `woopie:document:upload` | Triggers the ingestion of an uploaded document | +| `woopie:index:regenerate` | Regenerates the search index | +| `woopie:page:check` | Checks if there are pages not yet indexed | +| `woopie:user:create` | Creates a new admin user | +| `woopie:user:reset` | Reset a user's password and 2fa | +| `woopie:user:view` | View a user's details | +| `woopie:ingest:dossier` | Ingests a dossier into the system | +| `woopie:ingest:document` | Ingests a document into the system | +| `woopie:index:alias` | Creates an alias for the current index | +| `woopie:index:create` | Creates a new index | +| `woopie:index:delete` | Deletes an index | + +## Development commands + +These commands are used solely in a development environment, and are not meant to be used in production. + +| command | description | +|-----------------------------|--------------------------------------------------------------------------------------------| +| `woopie:sql:dump` | Generates migrations SQL files based on the doctrine migrations php files | +| `woopie:load:fixture` | Loads a fixture file into the system. This can be used for testing specific scenarios. | +| `woopie:generate:documents` | Generates dossiers / documents / page content that can be used for testing and development | +| `woopie:dev:clean-sheet` | Removes all dossier/document data from the database, elasticsearch and message queue. | + +## One-off commands + +These commands are not meant to be run on a regular basis, but are used to perform a one-off task. They might be removed +from the codebase after their usage. + +| command | description | +|-------------------------|------------------------------------------------------------------------------| +| `translation:convert` | Finds dutch messages in twig files, and converts them to the english variant | +| `generate:database-key` | Generates a database key that can be used in the .env file | + +## Cron commands + +Cron commands are meant to be run on a regular basis, and are used to perform a recurring task. + +| command | description | +|------------------------------|----------------------------------------------------| +| `woopie:cron:clean-archives` | Removes expired generated archives from the system | diff --git a/src/Controller/HealthController.php b/src/Controller/HealthController.php deleted file mode 100644 index 12b79f00..00000000 --- a/src/Controller/HealthController.php +++ /dev/null @@ -1,18 +0,0 @@ -createdAt = new \DateTimeImmutable(); + $this->updatedAt = new \DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function onPreUpdate(): void + { + $this->updatedAt = new \DateTimeImmutable(); + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): self + { + $this->updatedAt = $updatedAt; + + return $this; + } +} diff --git a/src/Entity/Decision.php b/src/Entity/Decision.php deleted file mode 100644 index 229d365d..00000000 --- a/src/Entity/Decision.php +++ /dev/null @@ -1,12 +0,0 @@ -file = new FileInfo(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function setDossier(Dossier $dossier): self + { + $this->dossier = $dossier; + + return $this; + } + + public function getDossier(): Dossier + { + return $this->dossier; + } + + public function getFileInfo(): FileInfo + { + return $this->file; + } + + public function setFileInfo(FileInfo $fileInfo): self + { + $this->file = $fileInfo; + + return $this; + } + + public function getFileCacheKey(): string + { + return 'decision-' . $this->id->toBase58(); + } +} diff --git a/src/Entity/EntityWithFileInfo.php b/src/Entity/EntityWithFileInfo.php new file mode 100644 index 00000000..addcbf43 --- /dev/null +++ b/src/Entity/EntityWithFileInfo.php @@ -0,0 +1,18 @@ +mimetype; + } + + public function setMimetype(?string $mimetype): self + { + $this->mimetype = $mimetype; + + return $this; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function setPath(?string $path): self + { + $this->path = $path; + + return $this; + } + + public function getSize(): int + { + return $this->size; + } + + public function setSize(int $size): self + { + $this->size = $size; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function isUploaded(): bool + { + return $this->uploaded; + } + + public function setUploaded(bool $uploaded): self + { + $this->uploaded = $uploaded; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + public function getSourceType(): ?string + { + return $this->sourceType; + } + + public function setSourceType(string $sourceType): self + { + $this->sourceType = $sourceType; + + return $this; + } +} diff --git a/src/Entity/Judgement.php b/src/Entity/Judgement.php new file mode 100644 index 00000000..c5f466d4 --- /dev/null +++ b/src/Entity/Judgement.php @@ -0,0 +1,31 @@ + self::PUBLIC, + 'deels openbaar' => self::PARTIAL_PUBLIC, + 'reeds openbaar' => self::ALREADY_PUBLIC, + 'niet openbaar' => self::NOT_PUBLIC, + default => self::NOT_PUBLIC, + }; + } + + public function isAtLeastPartialPublic(): bool + { + return $this === self::PARTIAL_PUBLIC || $this === self::PUBLIC; + } +} diff --git a/src/Entity/RawInventory.php b/src/Entity/RawInventory.php new file mode 100644 index 00000000..427dfbbb --- /dev/null +++ b/src/Entity/RawInventory.php @@ -0,0 +1,70 @@ +id; + } + + public function __construct() + { + $this->file = new FileInfo(); + } + + public function setDossier(Dossier $dossier): self + { + $this->dossier = $dossier; + + return $this; + } + + public function getDossier(): Dossier + { + return $this->dossier; + } + + public function getFileInfo(): FileInfo + { + return $this->file; + } + + public function setFileInfo(FileInfo $fileInfo): self + { + $this->file = $fileInfo; + + return $this; + } + + public function getFileCacheKey(): string + { + return 'raw-inventory-' . $this->id->toBase58(); + } +} diff --git a/src/Entity/WithdrawReason.php b/src/Entity/WithdrawReason.php new file mode 100644 index 00000000..d654e315 --- /dev/null +++ b/src/Entity/WithdrawReason.php @@ -0,0 +1,13 @@ +appMode = strtoupper($appMode); + + if (! in_array($this->appMode, self::APP_MODES)) { + throw new \InvalidArgumentException('Invalid APP_MODE environment variable. Valid values are: BALIE, FRONTEND, BOTH'); + } + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onKernelRequest', 0], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + // If we are inside the dev firewall, we don't need to check anything + $firewallName = $event->getRequest()->attributes->get('_firewall_context'); + if ($firewallName == 'security.firewall.map.context.dev') { + return; + } + + if ($this->appMode === 'BALIE' && ! str_starts_with($event->getRequest()->getPathInfo(), '/balie')) { + $event->setResponse(new RedirectResponse('/balie')); + } + + if ($this->appMode === 'FRONTEND' && str_starts_with($event->getRequest()->getPathInfo(), '/balie')) { + $event->setResponse(new RedirectResponse('/')); + } + + // If we are in BOTH mode, we don't need to do anything + } +} diff --git a/src/EventSubscriber/SitemapSubscriber.php b/src/EventSubscriber/SitemapSubscriber.php new file mode 100644 index 00000000..6be13ca0 --- /dev/null +++ b/src/EventSubscriber/SitemapSubscriber.php @@ -0,0 +1,94 @@ +dossierRepository = $dossierRepository; + $this->documentRepository = $documentRepository; + $this->doctrine = $doctrine; + } + + public static function getSubscribedEvents(): array + { + return [ + SitemapPopulateEvent::class => ['populate', 0], + ]; + } + + public function populate(SitemapPopulateEvent $event): void + { + $this->populateDossiers($event->getUrlContainer(), $event->getUrlGenerator()); + $this->populateDocuments($event->getUrlContainer(), $event->getUrlGenerator()); + } + + protected function populateDossiers(UrlContainerInterface $urls, UrlGeneratorInterface $generator): void + { + $dossierQuery = $this->doctrine->getRepository(Dossier::class)->createQueryBuilder('d') + ->select('d') + ->where('d.status = :status') + ->setParameter('status', 'published') + ->getQuery() + ; + + foreach ($dossierQuery->toIterable() as $dossier) { + $urls->addUrl( + new UrlConcrete( + $generator->generate('app_dossier_detail', ['dossierId' => $dossier->getId()], UrlGeneratorInterface::ABSOLUTE_URL), + $dossier->getUpdatedAt(), + UrlConcrete::CHANGEFREQ_MONTHLY, + 0.8 + ), + 'dossiers', + ); + $this->doctrine->detach($dossier); + } + } + + protected function populateDocuments(UrlContainerInterface $urls, UrlGeneratorInterface $generator): void + { + $dossierQuery = $this->doctrine->getRepository(Dossier::class)->createQueryBuilder('d') + ->select('d') + ->where('d.status = :status') + ->setParameter('status', 'published') + ->getQuery() + ; + + foreach ($dossierQuery->toIterable() as $dossier) { + foreach ($dossier->getDocuments() as $document) { + $urls->addUrl( + new UrlConcrete( + $generator->generate('app_document_detail', [ + 'dossierId' => $dossier->getDossierNr(), + 'documentId' => $document->getDocumentNr(), + ], UrlGeneratorInterface::ABSOLUTE_URL), + $document->getUpdatedAt(), + UrlConcrete::CHANGEFREQ_MONTHLY, + 0.8 + ), + 'documents', + ); + } + + $this->doctrine->detach($dossier); + } + } +} diff --git a/src/Exception/InventoryReaderException.php b/src/Exception/InventoryReaderException.php new file mode 100644 index 00000000..47b2badd --- /dev/null +++ b/src/Exception/InventoryReaderException.php @@ -0,0 +1,28 @@ +getMessage()); + } + + public static function forMissingDocumentIdInRow(int $rowIndex): self + { + return new self("Missing document ID in inventory row #$rowIndex"); + } +} diff --git a/src/Form/Document/WithdrawFormType.php b/src/Form/Document/WithdrawFormType.php new file mode 100644 index 00000000..7dd3db06 --- /dev/null +++ b/src/Form/Document/WithdrawFormType.php @@ -0,0 +1,51 @@ + + */ +class WithdrawFormType extends AbstractType +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('reason', EnumType::class, [ + 'label' => 'Reden', + 'required' => true, + 'class' => WithdrawReason::class, + 'expanded' => true, + 'placeholder' => 'Choose an option', + ]) + ->add('explanation', TextareaType::class, [ + 'label' => 'Toelichting', + 'required' => true, + 'constraints' => [], + 'attr' => ['class' => 'w-full'], + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'Withdraw document', + 'attr' => [ + 'class' => 'btn btn-danger', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Form/Transformer/TextToArrayTransformer.php b/src/Form/Transformer/TextToArrayTransformer.php new file mode 100644 index 00000000..6faecaf9 --- /dev/null +++ b/src/Form/Transformer/TextToArrayTransformer.php @@ -0,0 +1,49 @@ + + */ +class TextToArrayTransformer implements DataTransformerInterface +{ + /** + * @var non-empty-string + */ + protected string $splitter; + + public function __construct(string $splitter) + { + $this->splitter = empty($splitter) ? ',' : $splitter; + } + + /** + * @return string[]|null + */ + public function transform(mixed $value): ?array + { + if (empty($value)) { + return null; + } + + return explode($this->splitter, $value); + } + + /** + * @param string[]|null $value + */ + public function reverseTransform(mixed $value): string + { + if (is_null($value)) { + return ''; + } + + return join($this->splitter, $value); + } +} diff --git a/src/Message/IngestDecisionMessage.php b/src/Message/IngestDecisionMessage.php new file mode 100644 index 00000000..d9f44747 --- /dev/null +++ b/src/Message/IngestDecisionMessage.php @@ -0,0 +1,26 @@ +uuid; + } + + public function getRefresh(): bool + { + return $this->refresh; + } +} diff --git a/src/Message/IngestDossierMessage.php b/src/Message/IngestDossierMessage.php new file mode 100644 index 00000000..cb1b4a08 --- /dev/null +++ b/src/Message/IngestDossierMessage.php @@ -0,0 +1,26 @@ +uuid; + } + + public function getRefresh(): bool + { + return $this->refresh; + } +} diff --git a/src/Message/IngestMetadataOnlyMessage.php b/src/Message/IngestMetadataOnlyMessage.php new file mode 100644 index 00000000..a94bd501 --- /dev/null +++ b/src/Message/IngestMetadataOnlyMessage.php @@ -0,0 +1,29 @@ +uuid = $uuid; + $this->forceRefresh = $forceRefresh; + } + + public function getUuid(): Uuid + { + return $this->uuid; + } + + public function forceRefresh(): bool + { + return $this->forceRefresh; + } +} diff --git a/src/Message/RemoveDossierMessage.php b/src/Message/RemoveDossierMessage.php new file mode 100644 index 00000000..b6ecc3b7 --- /dev/null +++ b/src/Message/RemoveDossierMessage.php @@ -0,0 +1,22 @@ +uuid = $uuid; + } + + public function getUuid(): Uuid + { + return $this->uuid; + } +} diff --git a/src/MessageHandler/IngestDecisionHandler.php b/src/MessageHandler/IngestDecisionHandler.php new file mode 100644 index 00000000..d975797d --- /dev/null +++ b/src/MessageHandler/IngestDecisionHandler.php @@ -0,0 +1,42 @@ +dossierRepository->find($message->getUuid()); + if (! $dossier) { + throw new \RuntimeException('Cannot find dossier with UUID ' . $message->getUuid()); + } + + $decision = $dossier->getDecisionDocument(); + if (! $decision) { + throw new \RuntimeException('No decision entity in dossier with UUID ' . $message->getUuid()); + } + + if (! $decision->getFileInfo()->isUploaded()) { + throw new \RuntimeException('Cannot ingest missing decision file for dossier with UUID ' . $message->getUuid()); + } + + $this->contentExtractor->extract($dossier, $decision, $message->getRefresh()); + } +} diff --git a/src/MessageHandler/IngestDossierHandler.php b/src/MessageHandler/IngestDossierHandler.php new file mode 100644 index 00000000..25dde8ca --- /dev/null +++ b/src/MessageHandler/IngestDossierHandler.php @@ -0,0 +1,59 @@ +doctrine->getRepository(Dossier::class)->find($message->getUuid()); + if (! $dossier) { + throw new \RuntimeException('Cannot find dossier with UUID ' . $message->getUuid()); + } + + $this->elasticService->updateDossier($dossier, false); + + $this->ingestLogger->setFlush(false); + + $options = new Options(); + $options->setForceRefresh($message->getRefresh()); + + foreach ($dossier->getDocuments() as $document) { + $this->ingester->ingest($document, $options); + } + + $this->doctrine->flush(); + + if ($dossier->getId()) { + $this->bus->dispatch( + new IngestDecisionMessage($dossier->getId(), false) + ); + } + } +} diff --git a/src/MessageHandler/IngestMetadataOnlyHandler.php b/src/MessageHandler/IngestMetadataOnlyHandler.php new file mode 100644 index 00000000..39215000 --- /dev/null +++ b/src/MessageHandler/IngestMetadataOnlyHandler.php @@ -0,0 +1,49 @@ +doctrine->getRepository(Document::class)->find($message->getUuid()); + if (! $document) { + // No document found for this message + $this->logger->warning('No document found for this message', [ + 'uuid' => $message->getUuid(), + ]); + + return; + } + + try { + // The third argument is very important: this will remove any existing page content. + $this->elasticService->updateDocument($document, [], []); + } catch (\Exception $e) { + $this->logger->error('Failed to ingest metadata-only document into ES', [ + 'document' => $document->getDocumentNr(), + 'exception' => $e->getMessage(), + ]); + } + } +} diff --git a/src/MessageHandler/RemoveDossierHandler.php b/src/MessageHandler/RemoveDossierHandler.php new file mode 100644 index 00000000..a877321c --- /dev/null +++ b/src/MessageHandler/RemoveDossierHandler.php @@ -0,0 +1,66 @@ +elasticService = $elasticService; + $this->logger = $logger; + $this->doctrine = $doctrine; + } + + public function __invoke(RemoveDossierMessage $message): void + { + $dossier = $this->doctrine->getRepository(Dossier::class)->find($message->getUuid()); + if (! $dossier) { + // No dossier found for this message + $this->logger->warning('No dossier found for this message', [ + 'uuid' => $message->getUuid(), + ]); + + return; + } + + // Remove documents that are only attached to this dossier + $orphanedDocuments = []; + foreach ($dossier->getDocuments() as $document) { + if ($document->getDossiers()->count() === 1) { + $orphanedDocuments[] = $document->getDocumentNr(); + $this->doctrine->remove($document); + } + } + + // Remove dossier + $this->doctrine->remove($dossier); + $this->doctrine->flush(); + + // Remove from elasticsearch + foreach ($orphanedDocuments as $documentNr) { + $this->elasticService->removeDocument($documentNr); + } + + $this->elasticService->removeDossier($dossier); + } +} diff --git a/src/Service/FileProcessService.php b/src/Service/FileProcessService.php new file mode 100644 index 00000000..387f38d5 --- /dev/null +++ b/src/Service/FileProcessService.php @@ -0,0 +1,145 @@ +processSingleFile($file, $dossier, $originalFile, 'audio'); + case 'zip': + return $this->processZip($file, $dossier); + case 'pdf': + return $this->processSingleFile($file, $dossier, $originalFile, 'pdf'); + default: + $this->logger->error('Unsupported filetype detected', [ + 'extension' => $ext, + 'originalFile' => $originalFile, + 'dossierId' => $dossier->getId(), + ]); + throw new \RuntimeException('Unsupported filetype detected'); + } + } + + protected function processSingleFile(\SplFileInfo $file, Dossier $dossier, string $originalFile, string $type): bool + { + // Fetch document number from the beginning of the filename. Only use digits + $originalFile = basename($originalFile); + preg_match('/^(\d+)/', $originalFile, $matches); + $documentId = $matches[1] ?? null; + + if (is_null($documentId)) { + $this->logger->error('Cannot extract document ID from the filename', [ + 'filename' => $originalFile, + 'matches' => $matches, + 'dossierId' => $dossier->getId(), + ]); + + throw new \RuntimeException('Cannot extract document id from file'); + } + + // Find matching document entity in the database + /** @var DocumentRepository $repo */ + $repo = $this->doctrine->getRepository(Document::class); + $document = $repo->findOneByDossierAndDocumentId($dossier, $documentId); + if (! $document) { + // Document does not exist. That is actually fine. + $this->logger->info('Could not find document, skipping', [ + 'filename' => $originalFile, + 'documentId' => $documentId, + 'dossierId' => $dossier->getId(), + ]); + + return false; + } + + if (! $document->shouldBeUploaded()) { + $this->logger->warning("Document with id $documentId should not be uploaded, skipping it", [ + 'documentId' => $documentId, + 'dossierId' => $dossier->getId(), + ]); + + return true; + } + + // Store document in storage + if (! $this->storage->storeDocument($file, $document)) { + $this->logger->error('Failed to store document', [ + 'documentId' => $documentId, + 'path' => $file->getRealPath(), + ]); + + throw new \RuntimeException("Failed to store document with id $documentId"); + } + + $document->getFileInfo()->setType($type); + + $this->doctrine->persist($document); + $this->doctrine->flush(); + + return true; + } + + protected function processZip(\SplFileInfo $file, Dossier $dossier): bool + { + $zip = new \ZipArchive(); + $zip->open($file->getPathname()); + + for ($i = 0; $i != $zip->numFiles; $i++) { + $filename = $zip->getNameIndex($i); + if (! $filename) { + continue; + } + $ext = pathinfo($filename, PATHINFO_EXTENSION); + if ($ext != 'pdf') { + continue; + } + + // Extract file to tmp dir + $zip->extractTo(sys_get_temp_dir(), $filename); + + try { + $tmpPath = sprintf('%s/%s', sys_get_temp_dir(), $filename); + $this->processSingleFile(new File($tmpPath), $dossier, $filename, 'pdf'); + } catch (\Exception) { + // do nothing. Seems like an extra file in the zip + } + + // Cleanup tmp file if needed + if (file_exists($tmpPath)) { + unlink($tmpPath); + } + } + + $zip->close(); + + return true; + } +} diff --git a/src/Service/FilenameSanitizer.php b/src/Service/FilenameSanitizer.php new file mode 100644 index 00000000..c9890a91 --- /dev/null +++ b/src/Service/FilenameSanitizer.php @@ -0,0 +1,15 @@ +setFilename(str_replace(["'", chr(127), '#'], '', $this->getFilename())); + + return $this; + } +} diff --git a/src/Service/Ingest/Handler/BaseHandler.php b/src/Service/Ingest/Handler/BaseHandler.php deleted file mode 100644 index 4ea1dd6c..00000000 --- a/src/Service/Ingest/Handler/BaseHandler.php +++ /dev/null @@ -1,26 +0,0 @@ -bus = $bus; - $this->doctrine = $doctrine; - $this->logger = $logger; - } -} diff --git a/src/Service/Ingest/Handler/MetadataOnlyHandler.php b/src/Service/Ingest/Handler/MetadataOnlyHandler.php new file mode 100644 index 00000000..c2bb1874 --- /dev/null +++ b/src/Service/Ingest/Handler/MetadataOnlyHandler.php @@ -0,0 +1,37 @@ +logger->info('Dispatching ingest for metadata-only document', [ + 'document' => $document->getId(), + ]); + + $message = new IngestMetadataOnlyMessage($document->getId(), $options->forceRefresh()); + $this->bus->dispatch($message); + } + + public function canHandle(FileInfo $fileInfo): bool + { + return $fileInfo->isUploaded() === false; + } +} diff --git a/src/Service/Inventory/DocumentMetadata.php b/src/Service/Inventory/DocumentMetadata.php new file mode 100644 index 00000000..f888f07e --- /dev/null +++ b/src/Service/Inventory/DocumentMetadata.php @@ -0,0 +1,146 @@ +date; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function getFamilyId(): int + { + return $this->familyId; + } + + public function getSourceType(): string + { + return $this->sourceType; + } + + /** + * @return string[] + */ + public function getGrounds(): array + { + return $this->grounds; + } + + public function getId(): int + { + return $this->id; + } + + public function getJudgement(): Judgement + { + return $this->judgement; + } + + public function getPeriod(): string + { + return $this->period; + } + + /** + * @return string[] + */ + public function getSubjects(): array + { + return $this->subjects; + } + + public function getThreadId(): int + { + return $this->threadId; + } + + /** + * @return string[] + */ + public function getCaseNumbers(): array + { + return $this->caseNumbers; + } + + public function isSuspended(): bool + { + return $this->suspended; + } + + public function getLink(): ?string + { + return $this->link; + } + + public function getRemark(): ?string + { + return $this->remark; + } + + public function getMatter(): ?string + { + return $this->matter; + } + + public function mapToDocument(Document $document, string $documentNr): void + { + $document->setDocumentDate($this->date); + $document->setFamilyId($this->familyId); + $document->setDocumentId($this->id); + $document->setThreadId($this->threadId); + $document->setJudgement($this->judgement); + $document->setGrounds($this->grounds); + $document->setSubjects($this->subjects); + $document->setPeriod($this->period); + $document->setDocumentNr($documentNr); + $document->setSuspended($this->suspended); + $document->setLink($this->link); + $document->setRemark($this->remark); + + $fileName = $this->filename; + if (empty($fileName)) { + // @TODO: FILETYPE DOES NOT HAVE TO BE PDF + $fileName = $documentNr . '.pdf'; + } + + $file = $document->getFileInfo(); + $file->setSourceType($this->sourceType); + $file->setName($fileName); + } +} diff --git a/src/Service/Inventory/InventoryDataHelper.php b/src/Service/Inventory/InventoryDataHelper.php new file mode 100644 index 00000000..8b6a7d5f --- /dev/null +++ b/src/Service/Inventory/InventoryDataHelper.php @@ -0,0 +1,42 @@ +doctrine->beginTransaction(); + try { + $result = $this->realProcessInventory($uploadedFile, $dossier); + } catch (\Exception $e) { + $this->doctrine->rollback(); + throw $e; + } + + if ($result->isSuccessful()) { + $this->doctrine->commit(); + } else { + $this->doctrine->rollback(); + } + + return $result; + } + + protected function realProcessInventory( + ?UploadedFile $uploadedFile, + Dossier $dossier + ): ProcessInventoryResult { + $result = new ProcessInventoryResult(); + + if (! $uploadedFile instanceof UploadedFile) { + $result->addGenericError('No inventory file provided'); + + return $result; + } + + $inventory = $this->storeRawInventoryFile($uploadedFile, $dossier); + if (! $inventory) { + $this->logger->error('Could not store the inventory spreadsheet.', [ + 'dossier' => $dossier->getId() ?? 'unknown', + 'filename' => $uploadedFile->getClientOriginalName(), + ]); + + $result->addGenericError('Could not store the inventory spreadsheet.'); + + return $result; + } + + // Process the spreadsheet and time it + $start = microtime(true); + + $tmpFilename = $this->documentStorage->downloadDocument($inventory); + if (! $tmpFilename) { + $result->addGenericError('Could not download the inventory from document storage.'); + + return $result; + } + + // Create reader for processing the inventory into documents + $reader = $this->createReader($tmpFilename, $dossier, $inventory->getFileInfo()->getName(), $result); + if (is_null($reader)) { + $this->documentStorage->removeDownload($tmpFilename); + + return $result; + } + $this->processDocuments($dossier, $inventory, $result, $reader); + + // Create reader for processing the inventory into a sanitized CSV + $reader = $this->createReader($tmpFilename, $dossier, $inventory->getFileInfo()->getName(), $result); + if (is_null($reader)) { + $this->documentStorage->removeDownload($tmpFilename); + + return $result; + } + $this->generateSanitizedInventoryCsv($dossier, $reader, $result); + + $this->logger->info('Processed inventory spreadsheet.', [ + 'dossier' => $dossier->getId() ?? 'unknown', + 'filename' => $uploadedFile->getClientOriginalName(), + 'duration' => microtime(true) - $start, + 'success' => true, + 'errors' => $result->getRowErrors(), + ]); + + $this->documentStorage->removeDownload($tmpFilename); + + return $result; + } + + /** + * @param iterable $inventoryReader + */ + private function processDocuments( + Dossier $dossier, + RawInventory $inventory, + ProcessInventoryResult $result, + iterable $inventoryReader + ): void { + // Store current documents, so we can see which are new and which can be removed + $tobeRemovedDocs = []; + foreach ($dossier->getDocuments() as $entry) { + $tobeRemovedDocs[$entry->getDocumentNr()] = $entry; + } + + foreach ($inventoryReader as $inventoryItem) { + $rowIndex = $inventoryItem->getIndex(); + + $exception = $inventoryItem->getException(); + if ($exception instanceof \Exception) { + $this->logger->error('Error while processing row ' . $rowIndex . ' in the spreadsheet.', [ + 'dossier' => $dossier->getId() ?? 'unknown', + 'filename' => $inventory->getFileInfo()->getName(), + 'row' => $rowIndex, + 'exception' => $inventoryItem->getException(), + ]); + + // Exception occurred, but we still continue with the next row. Just log the error + $result->addRowError($rowIndex, 'Error reading row: ' . $exception->getMessage()); + continue; + } + + $documentMetadata = $inventoryItem->getDocumentMetadata(); + if ($documentMetadata instanceof DocumentMetadata) { + // Create document or attach an existing document to the dossier + try { + $document = $this->mapToDocument($documentMetadata, $dossier); + // This document is added (again), so remove it from the tobeRemovedDocs array + if (isset($tobeRemovedDocs[$document->getDocumentNr()])) { + unset($tobeRemovedDocs[$document->getDocumentNr()]); + } + } catch (\Exception $e) { + $this->logger->error("Error while processing row $rowIndex in the spreadsheet.", [ + 'dossier' => $dossier->getId() ?? 'unknown', + 'filename' => $inventory->getFileInfo()->getName(), + 'row' => $rowIndex, + 'exception' => $e->getMessage(), + ]); + + // Exception occurred, but we still continue with the next row. Just log the error + $result->addRowError($rowIndex, 'Error while processing row: ' . $e->getMessage()); + } + } + } + + // We now have a list of old documents that are linked to the dossier, but are not in the new inventory + // Remove these documents from the dossier + foreach ($tobeRemovedDocs as $document) { + $dossier->removeDocument($document); + } + } + + /** + * Store the inventory to disk and add the inventory document to the dossier. + */ + protected function storeRawInventoryFile(UploadedFile $upload, Dossier $dossier): ?RawInventory + { + $inventory = new RawInventory(); + $inventory->setDossier($dossier); + + $file = $inventory->getFileInfo(); + $file->setSourceType(SourceType::SOURCE_SPREADSHEET); + $file->setType('pdf'); + + // Set original filename + $filename = 'raw-inventory-' . $dossier->getDossierNr() . '.' . $upload->getClientOriginalExtension(); + $file->setName($filename); + + $this->doctrine->persist($inventory); + $this->doctrine->flush(); + + if (! $this->documentStorage->storeDocument($upload, $inventory)) { + return null; + } + + return $inventory; + } + + /** + * If the document has case numbers, add it to those inquiries. If those inquiries do not exist + * yet, create them as well. + * + * @param string[] $caseNrs + */ + protected function addDocumentToCases(array $caseNrs, Document $document): void + { + if (empty($caseNrs)) { + return; + } + + foreach ($caseNrs as $caseNr) { + $inquiry = $this->doctrine->getRepository(Inquiry::class)->findOneBy(['casenr' => $caseNr]); + if (! $inquiry) { + // Create inquiry if not exists + $inquiry = new Inquiry(); + $inquiry->setCasenr($caseNr); + $inquiry->setToken(Uuid::v6()->toBase58()); + $inquiry->setCreatedAt(new \DateTimeImmutable()); + } + + $inquiry->setUpdatedAt(new \DateTimeImmutable()); + + // Add this document, and the dossiers it belongs to, to the inquiry + $inquiry->addDocument($document); + foreach ($document->getDossiers() as $dossier) { + $inquiry->addDossier($dossier); + } + + $this->doctrine->persist($inquiry); + $this->doctrine->flush(); + } + } + + /** + * Process DocumentMetadata. Creates a document if one didn't exist already, or adds an already + * existing document to the dossier. Also generates or updates inquiries/cases. + * + * NOTE: this method does not flush the changes to the database. + * + * @throws \Exception + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + protected function mapToDocument(DocumentMetadata $documentMetadata, Dossier $dossier): Document + { + if (! empty($documentMetadata->getMatter())) { + $documentNr = $dossier->getDocumentPrefix() . '-' . $documentMetadata->getMatter() . '-' . $documentMetadata->getId(); + } else { + $documentNr = $dossier->getDocumentPrefix() . '-' . $documentMetadata->getId(); + } + + // Check if this document already exists in a dossier + $document = $this->doctrine->getRepository(Document::class)->findOneBy([ + 'documentNr' => $documentNr, + ]); + + if ($document && $document->getDossiers()->contains($dossier) === false) { + // Document exists, but it is not part of the current dossier. We cannot add it + throw new \RuntimeException(sprintf('Document %s already exists in another dossier', $document->getDocumentId())); + } + + // If document didn't exist yet, create it + if (! $document) { + $document = new Document(); + } + + // Update all fields from the existing (or new) document. + $documentMetadata->mapToDocument($document, $documentNr); + + $this->doctrine->persist($document); + $this->doctrine->flush(); + + // Add document to the dossier + $dossier->addDocument($document); + + // We need to persist the dossier, because we added a document to it. This would also make the dossier + // visible in the document, so the addDocumentToCases() method can find the dossier. + $this->doctrine->persist($dossier); + $this->doctrine->flush(); + + // Add document to woo request case nr (if any), or create new woo request if not already present + $this->addDocumentToCases($documentMetadata->getCaseNumbers(), $document); + + return $document; + } + + /** + * @param iterable $inventoryReader + */ + protected function generateSanitizedInventoryCsv(Dossier $dossier, iterable $inventoryReader, ProcessInventoryResult $result): void + { + // Generate CSV with sanitized fields + $csvFilename = tempnam(sys_get_temp_dir(), 'inventory'); + if (! $csvFilename) { + $this->logger->error('Could not create temporary file for sanitized inventory CSV.'); + + return; + } + + $fp = fopen($csvFilename, 'w'); + if (! $fp) { + $this->logger->error('Could not open temporary file for sanitized inventory CSV.'); + + return; + } + + fputcsv($fp, [ + 'Document ID', + 'Document naam', + 'Beoordeling', + 'Beoordelingsgrond', + 'Toelichting', + 'Publieke link', + 'Opgeschort', + 'Definitief ID', + ]); + + foreach ($inventoryReader as $inventoryItem) { + $meta = $inventoryItem->getDocumentMetadata(); + if (! $meta) { + continue; + } + + fputcsv($fp, [ + $meta->getId(), + str_replace(';', ' ', $meta->getFilename()), + $this->translator->trans($meta->getJudgement()->value), + join(' ', $meta->getGrounds()), + $meta->getRemark(), + $meta->getLink(), + $meta->isSuspended() ? 'yes' : '', + '', + ]); + } + + fclose($fp); + + // Create or update inventory document and add to dossier + $inventory = $dossier->getInventory(); + if ($inventory == null) { + $inventory = new Inventory(); + $inventory->setDossier($dossier); + } + + $file = $inventory->getFileInfo(); + $file->setSourceType(SourceType::SOURCE_SPREADSHEET); + $file->setType('csv'); + $filename = 'inventory-' . $dossier->getDossierNr() . '.csv'; + $file->setName($filename); + + $this->doctrine->persist($inventory); + + $dossier->setInventory($inventory); + $this->doctrine->persist($dossier); + $this->doctrine->flush(); + + // Store inventory file + if (! $this->documentStorage->storeDocument(new \SplFileInfo($csvFilename), $inventory)) { + $result->addGenericError('Could not store the sanitized inventory spreadsheet.'); + } + } + + /** + * @return iterable|null + */ + protected function createReader(string $filePath, Dossier $dossier, ?string $filename, ProcessInventoryResult $result): ?iterable + { + if (! $filename) { + return null; + } + + try { + $inventoryReader = $this->readerFactory->create(); + $inventoryReader->open($filePath); + + return $inventoryReader->getDocumentMetadataGenerator($dossier); + } catch (\Exception $exception) { + $result->addGenericError('Error while trying to read the spreadsheet: ' . $exception->getMessage()); + + $this->logger->error('Error while trying to read the spreadsheet.', [ + 'dossier' => $dossier->getId() ?? 'unknown', + 'filename' => $filename, + 'exception' => $exception->getMessage(), + ]); + } + + return null; + } +} diff --git a/src/Service/Inventory/MetadataField.php b/src/Service/Inventory/MetadataField.php new file mode 100644 index 00000000..fd1e740a --- /dev/null +++ b/src/Service/Inventory/MetadataField.php @@ -0,0 +1,24 @@ + */ + private array $rowErrors = []; + + /** @var string[] */ + private array $genericErrors = []; + + public function isSuccessful(): bool + { + return count($this->rowErrors) === 0 && count($this->genericErrors) === 0; + } + + public function addGenericError(string $message): void + { + $this->genericErrors[] = $message; + } + + public function addRowError(int $rowIndex, string $message): void + { + if (! isset($this->rowErrors[$rowIndex])) { + $this->rowErrors[$rowIndex] = []; + } + + $this->rowErrors[$rowIndex][] = $message; + } + + /** + * @return array + */ + public function getRowErrors(): array + { + return $this->rowErrors; + } + + /** + * @return string[] + */ + public function getGenericErrors(): array + { + return $this->genericErrors; + } + + /** + * @return array + */ + public function getAllErrors(): array + { + return array_merge( + ['generic' => $this->genericErrors], + $this->rowErrors + ); + } +} diff --git a/src/Service/Inventory/Reader/ColumnMapping.php b/src/Service/Inventory/Reader/ColumnMapping.php new file mode 100644 index 00000000..db150de5 --- /dev/null +++ b/src/Service/Inventory/Reader/ColumnMapping.php @@ -0,0 +1,48 @@ +field; + } + + public function isRequired(): bool + { + return $this->required; + } + + /** + * @return string[] + */ + public function getColumnNames(): array + { + return $this->columnNames; + } + + public function matches(string $columnName): bool + { + foreach ($this->columnNames as $name) { + // Check if it matches the header with some fuzziness + if (levenshtein(strtolower($name), $columnName) < 2) { + return true; + } + } + + return false; + } +} diff --git a/src/Service/Inventory/Reader/ExcelInventoryReader.php b/src/Service/Inventory/Reader/ExcelInventoryReader.php new file mode 100644 index 00000000..e6f7c7d1 --- /dev/null +++ b/src/Service/Inventory/Reader/ExcelInventoryReader.php @@ -0,0 +1,213 @@ +mappings[$mapping->getField()->value] = $mapping; + } + } + + /** + * @throws \Exception + */ + public function open(string $filepath): void + { + $spreadsheet = IOFactory::load($filepath); + + // Assume only first worksheet + $this->sheet = $spreadsheet->getSheet(0); + $this->headerMapping = $this->resolveHeaderMapping($this->sheet); + } + + /** + * @return \Generator + */ + public function getDocumentMetadataGenerator(Dossier $dossier): \Generator + { + foreach ($this->sheet->getRowIterator(2) as $row) { + if ($this->isEmptyRow($row)) { + continue; + } + + $documentMetadata = null; + $exception = null; + try { + $documentMetadata = $this->processRow($this->sheet, $this->headerMapping, $row->getRowIndex(), $dossier); + } catch (\Exception $exception) { + // Exception occurred, but we still continue with the next row to discover and report any other errors + // To not break the generator yield instead of throwing the exception + $exception = InventoryReaderException::forRowProcessingException($row->getRowIndex(), $exception); + } + + yield new InventoryReadItem($documentMetadata, $row->getRowIndex(), $exception); + } + } + + /** + * Resolve the header mapping into an array of mapped headers (name => column) + * Will throw an exception for missing mandatory headers. + * + * @return array + * + * @throws InventoryReaderException|\PhpOffice\PhpSpreadsheet\Exception + */ + protected function resolveHeaderMapping(Worksheet $sheet): array + { + $headerMapping = []; + $missingHeaders = $this->mappings; + + foreach ($sheet->getRowIterator(1, 1) as $row) { + foreach ($row->getCellIterator() as $cell) { + $columnName = strval($cell->getValue()); + $columnName = trim(strtolower($columnName)); + $columnName = ltrim($columnName, '0123456789'); + if (empty($columnName)) { + continue; + } + + foreach ($missingHeaders as $key => $mapping) { + if ($mapping->matches($columnName)) { + $headerMapping[$key] = $cell->getColumn(); + unset($missingHeaders[$key]); + } + } + } + } + + $missingHeaders = array_filter( + $missingHeaders, + static fn (ColumnMapping $mapping): bool => $mapping->isRequired() + ); + + if (count($missingHeaders) > 0) { + throw InventoryReaderException::forMissingHeaders(array_keys($missingHeaders)); + } + + return $headerMapping; + } + + /** + * Process a single row of the spreadsheet, maps data to DocumentMetadata VO. + * + * @param string[] $headers + * + * @throws \Exception + */ + protected function processRow(Worksheet $sheet, array $headers, int $rowIdx, Dossier $dossier): DocumentMetadata + { + $documentId = intval($sheet->getCell($headers['id'] . $rowIdx)->getValue()); + if (empty($documentId)) { + throw InventoryReaderException::forMissingDocumentIdInRow($rowIdx); + } + + $documentDate = new \DateTimeImmutable(strval($sheet->getCell($headers[MetadataField::DATE->value] . $rowIdx)->getValue())); + $fileName = strval($sheet->getCell($headers[MetadataField::DOCUMENT->value] . $rowIdx)->getValue()); + $familyId = intval($sheet->getCell($headers[MetadataField::FAMILY->value] . $rowIdx)->getValue()); + $threadId = intval($sheet->getCell($headers[MetadataField::THREADID->value] . $rowIdx)->getValue()); + $judgement = InventoryDataHelper::judgement($sheet->getCell($headers[MetadataField::JUDGEMENT->value] . $rowIdx)->getValue()); + $grounds = InventoryDataHelper::separateValues($sheet->getCell($headers[MetadataField::GROUND->value] . $rowIdx)->getValue()); + $subjects = InventoryDataHelper::separateValues($sheet->getCell($headers[MetadataField::SUBJECT->value] . $rowIdx)->getValue()); + $period = strval($sheet->getCell($headers[MetadataField::PERIOD->value] . $rowIdx)->getValue()); + $sourceType = SourceType::getType(strval($sheet->getCell($headers[MetadataField::SOURCETYPE->value] . $rowIdx)->getValue())); + + // Set default subjects from the dossier when no subjects have been found in the document + if (count($subjects) == 0) { + $subjects = $dossier->getDefaultSubjects(); + } + + $matter = null; + if (isset($headers[MetadataField::MATTER->value])) { + $matter = strval($sheet->getCell($headers[MetadataField::MATTER->value] . $rowIdx)->getValue()); + } + + $link = null; + if (isset($headers[MetadataField::LINK->value])) { + $link = strval($sheet->getCell($headers[MetadataField::LINK->value] . $rowIdx)->getValue()); + } + $remark = null; + if (isset($headers[MetadataField::REMARK->value])) { + $remark = strval($sheet->getCell($headers[MetadataField::REMARK->value] . $rowIdx)->getValue()); + } + + // In old documents, it's possible that the link is in the remark column + if (empty($link) && str_starts_with($remark ?? '', 'http')) { + $link = $remark; + $remark = null; + } + + $caseNrs = []; + if (isset($headers[MetadataField::CASENR->value])) { + $caseNrs = InventoryDataHelper::separateValues($sheet->getCell($headers[MetadataField::CASENR->value] . $rowIdx)->getValue()); + } + + $suspended = false; + if (isset($headers[MetadataField::SUSPENDED->value])) { + $suspended = InventoryDataHelper::isTrue($sheet->getCell($headers[MetadataField::SUSPENDED->value] . $rowIdx)->getValue()); + } + + return new DocumentMetadata( + $documentDate, + $fileName, + $familyId, + $sourceType, + $grounds, + $documentId, + $judgement, + $period, + $subjects ?? [], + $threadId, + $caseNrs, + $suspended, + $link, + $remark, + $matter, + ); + } + + public function isEmptyRow(Row $row): bool + { + $cellIterator = $row->getCellIterator(); + $cellIterator->setIterateOnlyExistingCells(true); + foreach ($cellIterator as $cell) { + $value = $cell->getValue(); + if ($value !== null && trim(strval($value)) !== '') { + return false; + } + } + + return true; + } +} diff --git a/src/Service/Inventory/Reader/InventoryReadItem.php b/src/Service/Inventory/Reader/InventoryReadItem.php new file mode 100644 index 00000000..1352db2d --- /dev/null +++ b/src/Service/Inventory/Reader/InventoryReadItem.php @@ -0,0 +1,33 @@ +exception; + } + + public function getIndex(): int + { + return $this->index; + } + + public function getDocumentMetadata(): ?DocumentMetadata + { + return $this->documentMetadata; + } +} diff --git a/src/Service/Inventory/Reader/InventoryReaderFactory.php b/src/Service/Inventory/Reader/InventoryReaderFactory.php new file mode 100644 index 00000000..bcbc5e54 --- /dev/null +++ b/src/Service/Inventory/Reader/InventoryReaderFactory.php @@ -0,0 +1,37 @@ + + */ + public function getDocumentMetadataGenerator(Dossier $dossier): \Generator; +} diff --git a/src/Service/InventoryService.php b/src/Service/InventoryService.php deleted file mode 100644 index e6fa48c7..00000000 --- a/src/Service/InventoryService.php +++ /dev/null @@ -1,522 +0,0 @@ - */ - protected array $headers = [ - self::FIELD_DATE => ['date', 'datum'], - self::FIELD_DOCUMENT => ['document', 'document id', 'documentnr', 'document nr', 'documentnr.', 'document nr.'], - self::FIELD_FAMILY => ['family', 'familie', 'family id'], - self::FIELD_FILETYPE => ['file type', 'filetype'], - self::FIELD_GROUND => ['beoordelingsgrond', 'grond'], - self::FIELD_ID => ['id'], - self::FIELD_JUDGEMENT => ['beoordeling'], - self::FIELD_PERIOD => ['periode', 'period'], - self::FIELD_SUBJECT => ['onderwerp', 'subject'], - self::FIELD_THREADID => ['thread id', 'email thread id', 'email thread'], - self::FIELD_CASENR => ['zaaknr', 'casenr', 'zaak', 'case'], - self::FIELD_SUSPENDED => ['opgeschort', 'suspended'], - ]; - - /** @var array */ - protected array $errors = []; - - public function __construct(EntityManagerInterface $doctrine, DocumentStorageService $documentStorage, LoggerInterface $logger) - { - $this->doctrine = $doctrine; - $this->documentStorage = $documentStorage; - $this->logger = $logger; - } - - /** - * Store the inventory spreadsheet on disk, process the sheet and attach found documents to the dossier. - * - * @return array errors - */ - public function processInventory(UploadedFile $excel, Dossier $dossier): array - { - $inventory = $this->storeInventoryFile($excel, $dossier); - if (! $inventory) { - $this->logger->error('Could not store the inventory spreadsheet.', [ - 'dossier' => $dossier->getId(), - 'filename' => $excel->getClientOriginalName(), - ]); - - $this->addError(0, 'Could not store the inventory spreadsheet.'); - - return $this->getErrors(); - } - - // Process the spreadsheet and time it - $start = microtime(true); - $result = $this->processSheet($inventory, $dossier); - - $this->logger->info('Processed inventory spreadsheet.', [ - 'dossier' => $dossier->getId(), - 'filename' => $excel->getClientOriginalName(), - 'duration' => microtime(true) - $start, - 'success' => $result, - 'errors' => $this->getErrors(), - ]); - - return $this->getErrors(); - } - - /** - * Process spreadsheet and attach found documents to the dossier. Will return an - * array of issues in case of failure. - */ - protected function processSheet(Inventory $inventory, Dossier $dossier): bool - { - $tmpFilename = $this->documentStorage->downloadDocument($inventory); - if (! $tmpFilename) { - $this->addError(0, 'Could not store the inventory spreadsheet.'); - - return false; - } - - // Load the spreadsheet from the local temporary file - $spreadsheet = IOFactory::load($tmpFilename); - - try { - // Assume only first worksheet - $sheet = $spreadsheet->getSheet(0); - $headers = $this->validateHeaders($sheet); - } catch (\Exception $e) { - $this->documentStorage->removeDownload($tmpFilename); - - $this->logger->error('Error while validating the headers in the spreadsheet.', [ - 'dossier' => $dossier->getId(), - 'filename' => $inventory->getFilename(), - 'exception' => $e->getMessage(), - ]); - - // Could not find the correct headers - $this->addError(0, 'Error while validating the headers in the spreadsheet.'); - - return false; - } - - if (count($headers['missing']) > 0) { - $this->documentStorage->removeDownload($tmpFilename); - - $this->logger->error('Could not find the correct headers in the spreadsheet.', [ - 'dossier' => $dossier->getId(), - 'filename' => $inventory->getFilename(), - 'missing' => $headers['missing'], - 'found' => $headers['found'], - ]); - - // Could not find the correct headers - $this->addError(0, 'Could not find the correct headers in the spreadsheet. Missing: ' . implode(', ', $headers['missing'])); - - return false; - } - - $this->processRows($sheet, $dossier, $headers, $inventory); - - // Remove the temporary file - $this->documentStorage->removeDownload($tmpFilename); - - if (count($this->getErrors()) > 0) { - // Errors encountered, don't persist dossier - return false; - } - - // Persist dossier with new documents - $this->doctrine->persist($dossier); - $this->doctrine->flush(); - - return true; - } - - /** - * @param array{found: string[], missing: string[]} $headers - * - * @return string[] errors - */ - protected function processRows(Worksheet $sheet, Dossier $dossier, array $headers, Inventory $inventory): array - { - $errors = []; - - // Store current documents, so we can see which are new and which can be removed - $tobeRemovedDocs = []; - foreach ($dossier->getDocuments() as $entry) { - if ($entry instanceof Inventory) { - continue; - } - - $tobeRemovedDocs[$entry->getDocumentNr()] = $entry; - } - - // Process each row - foreach ($sheet->getRowIterator(2) as $row) { - // Create document or attach an existing document to the dossier - try { - $document = $this->processRow($sheet, $headers['found'], $row->getRowIndex(), $dossier); - if (! $document) { - // Seems like an empty line - continue; - } - - // This document is added (again), so remove it from the tobeRemovedDocs array - if (isset($tobeRemovedDocs[$document->getDocumentNr()])) { - unset($tobeRemovedDocs[$document->getDocumentNr()]); - } - } catch (\Exception $e) { - $this->logger->error('Error while processing row ' . $row->getRowIndex() . ' in the spreadsheet.', [ - 'dossier' => $dossier->getId(), - 'filename' => $inventory->getFilename(), - 'row' => $row->getRowIndex(), - 'exception' => $e->getMessage(), - ]); - - // Exception occurred, but we still continue with the next row. Just log the error - $errors[] = 'Error while processing row ' . $row->getRowIndex() . ' in the spreadsheet: ' . $e->getMessage(); - } - } - - // We now have a list of old documents that are linked to the dossier, but are not in the new inventory - // Remove these documents from the dossier - foreach ($tobeRemovedDocs as $document) { - $dossier->removeDocument($document); - } - - return $errors; - } - - /** - * Validate a list of headers in the spreadsheet. Will return an array of mapped headers and missing headers, so - * we can safely use headers['found']['id'] to fetch the column number of the ID column. - * - * @return array{found: string[], missing: string[]} - * - * @throws \PhpOffice\PhpSpreadsheet\Exception - */ - protected function validateHeaders(Worksheet $sheet): array - { - $foundHeaders = []; - $missingHeaders = $this->headers; - - foreach ($sheet->getRowIterator(1, 1) as $row) { - foreach ($row->getCellIterator() as $cell) { - $val = strval($cell->getValue()); - $val = trim(strtolower($val)); - while (ctype_digit($val[0])) { - $val = substr($val, 1); - } - if (empty($val)) { - continue; - } - - foreach ($missingHeaders as $k => $names) { - // Remove digit prefixes from the beginning of the string if any - - foreach ($names as $name) { - // Check if it matches the header with some fuzziness - if (levenshtein(strtolower($name), $val) < 2) { - $foundHeaders[$k] = $cell->getColumn(); - unset($missingHeaders[$k]); - } - } - } - } - } - - // Optional headers are never missing headers - foreach ($this->optionalHeaders as $header) { - unset($missingHeaders[$header]); - } - - return [ - 'found' => $foundHeaders, - 'missing' => array_keys($missingHeaders), - ]; - } - - /** - * Store the inventory to disk and add the inventory document to the dossier. - */ - protected function storeInventoryFile(UploadedFile $file, Dossier $dossier): ?Inventory - { - $inventory = $this->doctrine->getRepository(Inventory::class)->findOneBy([ - 'documentNr' => $dossier->getDossierNr(), - ]); - - // If the document is not contained in any of the dossiers, simply add it to the list. - if ($inventory && ! $inventory->getDossiers()->contains($dossier)) { - $dossier->addDocument($inventory); - } - - // if no document is found, create a new document - if (! $inventory) { - // Create inventory document if not exists - $inventory = new Inventory(); - $inventory->setCreatedAt(new \DateTimeImmutable()); - $dossier->addDocument($inventory); - } - - // From here, we can update the current (new) document - $inventory->setDocumentDate(new \DateTimeImmutable()); - $inventory->setDocumentNr($dossier->getDossierNr()); - $inventory->setUpdatedAt(new \DateTimeImmutable()); - // @TODO: these fields should not be inside an inventory - $inventory->setPageCount(0); - $inventory->setDuration(0); - $inventory->setDocumentId(0); - $inventory->setFamilyId(0); - $inventory->setThreadId(0); - $inventory->setGrounds([]); - $inventory->setJudgement(''); - $inventory->setPeriod(''); - $inventory->setSubjects([]); - $inventory->setSuspended(false); - $inventory->setWithdrawn(false); - - $inventory->setSourceType(SourceType::SOURCE_SPREADSHEET); - $inventory->setFileType('pdf'); - - // Set original filename - $filename = 'inventory-' . $dossier->getDossierNr() . '.' . $file->getClientOriginalExtension(); - $inventory->setFilename($filename); - - $this->doctrine->persist($inventory); - $this->doctrine->persist($dossier); - - if (! $this->documentStorage->storeDocument($file, $inventory)) { - return null; - } - - // All is well, flush the changes - $this->doctrine->flush(); - - return $inventory; - } - - /** - * If the document has case numbers, add it to those inquiries. If those inquiries do not exist - * yet, create them as well. - * - * @param string $caseNrs semicolon separated list of case numbers - */ - protected function addDocumentToCases(string $caseNrs, Document $document): void - { - if (empty($caseNrs)) { - return; - } - - $caseNrs = explode(';', $caseNrs); - foreach ($caseNrs as $caseNr) { - $caseNr = trim($caseNr); - - $inquiry = $this->doctrine->getRepository(Inquiry::class)->findOneBy(['casenr' => $caseNr]); - if (! $inquiry) { - // Create inquiry if not exists - $inquiry = new Inquiry(); - $inquiry->setCasenr($caseNr); - $inquiry->setToken(Uuid::v6()->toBase58()); - $inquiry->setCreatedAt(new \DateTimeImmutable()); - } - - $inquiry->setUpdatedAt(new \DateTimeImmutable()); - - // Add this document, and the dossiers it belongs to, to the inquiry - $inquiry->addDocument($document); - foreach ($document->getDossiers() as $dossier) { - $inquiry->addDossier($dossier); - } - - $this->doctrine->persist($inquiry); - $this->doctrine->flush(); - } - } - - /** - * Process a single row of the spreadsheet. Creates a document if one didn't exist already, or adds an already - * existing document to the dossier. Also generates or updates inquiries/cases. - * - * NOTE: this method does not flush the changes to the database. - * - * @param string[] $headers - * - * @throws \Exception - * - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - */ - protected function processRow(Worksheet $sheet, array $headers, int $rowIdx, Dossier $dossier): ?Document - { - $documentId = intval($sheet->getCell($headers['id'] . $rowIdx)->getValue()); - if (empty($documentId)) { - return null; - } - - $documentDate = new \DateTimeImmutable(strval($sheet->getCell($headers[self::FIELD_DATE] . $rowIdx)->getValue())); - $fileName = strval($sheet->getCell($headers[self::FIELD_DOCUMENT] . $rowIdx)->getValue()); - $familyId = intval($sheet->getCell($headers[self::FIELD_FAMILY] . $rowIdx)->getValue()); - $threadId = intval($sheet->getCell($headers[self::FIELD_THREADID] . $rowIdx)->getValue()); - $judgement = strval($sheet->getCell($headers[self::FIELD_JUDGEMENT] . $rowIdx)->getValue()); - $grounds = explode(';', strval($sheet->getCell($headers[self::FIELD_GROUND] . $rowIdx)->getValue())); - $subjects = explode(';', strval($sheet->getCell($headers[self::FIELD_SUBJECT] . $rowIdx)->getValue())); - $period = strval($sheet->getCell($headers[self::FIELD_PERIOD] . $rowIdx)->getValue()); - - $documentNr = $dossier->getDocumentPrefix() . '-' . $documentId; - $sourceType = SourceType::getType(strval($sheet->getCell($headers[self::FIELD_FILETYPE] . $rowIdx)->getValue())); - - if (isset($headers[self::FIELD_CASENR])) { - $caseNrs = strval($sheet->getCell($headers[self::FIELD_CASENR] . $rowIdx)->getValue()); - } else { - $caseNrs = ''; - } - - if (isset($headers[self::FIELD_SUSPENDED])) { - $suspended = strval($sheet->getCell($headers[self::FIELD_SUSPENDED] . $rowIdx)->getValue()); - } else { - $suspended = false; - } - - if (empty($fileName)) { - // @TODO: FILETYPE DOES NOT HAVE TO BE PDF - $fileName = $documentNr . '.pdf'; - } - - // Trim and remove empty elements - $grounds = array_map('trim', $grounds); - $grounds = array_filter($grounds); - $subjects = array_map('trim', $subjects); - $subjects = array_filter($subjects); - - // Check if document already exists in the dossier - $document = $this->doctrine->getRepository(Document::class)->findOneBy([ - 'documentNr' => $documentNr, - ]); - - if ($document && $document->getDossiers()->contains($dossier) === false) { - // Document exists, but it is not part of the current dossier. We cannot add it - $this->addError($rowIdx, sprintf('Document %s already exists in another dossier', $document->getDocumentId())); - - return null; - } - - // If document didn't exist yet, create it - if (! $document) { - $document = new Document(); - $document->setCreatedAt(new \DateTimeImmutable()); - } - - // Update all fields from the existing (or new) document. - $document->setUpdatedAt(new \DateTimeImmutable()); - $document->setDocumentDate($documentDate); - $document->setFilename($fileName); - $document->setFamilyId($familyId); - $document->setDocumentId($documentId); - $document->setThreadId($threadId); - $document->setJudgement($judgement); - $document->setGrounds($grounds); - $document->setSubjects($subjects); - $document->setPeriod($period); - $document->setSourceType($sourceType); - $document->setDocumentNr($documentNr); - - $document->setWithdrawn(false); - $document->setSuspended($this->isTrue($suspended)); - - // We don't know what type of file we will upload. So we set all fields to empty - $document->setFileType(''); - $document->setMimetype(''); - $document->setFilepath(''); - $document->setFilesize(0); - $document->setUploaded(false); - - $this->doctrine->persist($document); - - // Add document to the dossier - $dossier->addDocument($document); - - // We need to persist the dossier, because we added a document to it. This would also make the dossier - // visible in the document, so the addDocumentToCases() method can find the dossier. - $this->doctrine->persist($dossier); - - // Add document to woo request case nr (if any), or create new woo request if not already present - $this->addDocumentToCases($caseNrs, $document); - - return $document; - } - - /** - * Returns true when the given value resembles a value that can be considered to be true. - */ - protected function isTrue(string|bool $value): bool - { - if (is_bool($value)) { - return $value; - } - - return in_array(strtolower($value), ['true', 'ja', 'yes', '1', 'y', 'j']); - } - - protected function addError(int $rowIdx, string $message): void - { - if (! isset($this->errors[$rowIdx])) { - $this->errors[$rowIdx] = []; - } - - $this->errors[$rowIdx][] = $message; - } - - /** - * @return array - */ - protected function getErrors(): array - { - return $this->errors; - } -} diff --git a/src/Service/Logging/EnrichedPsrLogger.php b/src/Service/Logging/EnrichedPsrLogger.php new file mode 100644 index 00000000..e206beed --- /dev/null +++ b/src/Service/Logging/EnrichedPsrLogger.php @@ -0,0 +1,104 @@ +logger instanceof Logger) { + foreach ($this->logger->getHandlers() as $handler) { + if ($handler instanceof FormattableHandlerInterface) { + $formatter = $handler->getFormatter(); + if ($formatter instanceof NormalizerFormatter) { + $formatter->setMaxNormalizeDepth(20); + } + } + } + } + } + + public function emergency($message, array $context = []): void + { + $this->logger->emergency($message, $this->enriched($context)); + } + + public function alert($message, array $context = []): void + { + $this->logger->alert($message, $this->enriched($context)); + } + + public function critical($message, array $context = []): void + { + $this->logger->critical($message, $this->enriched($context)); + } + + public function error($message, array $context = []): void + { + $this->logger->error($message, $this->enriched($context)); + } + + public function warning($message, array $context = []): void + { + $this->logger->warning($message, $this->enriched($context)); + } + + public function notice($message, array $context = []): void + { + $this->logger->notice($message, $this->enriched($context)); + } + + public function info($message, array $context = []): void + { + $this->logger->info($message, $this->enriched($context)); + } + + public function debug($message, array $context = []): void + { + $this->logger->debug($message, $this->enriched($context)); + } + + public function log($level, $message, array $context = []): void + { + $this->logger->log($level, $message, $this->enriched($context)); + } + + /** + * @param mixed[] $context + * + * @return mixed[] + */ + protected function enriched(array $context): array + { + $userInfo = [ + 'ip' => $this->requestStack->getCurrentRequest()?->getClientIps() ?? [], + ]; + + $token = $this->tokenStorage->getToken(); + if ($token) { + $user = $token->getUser(); + if ($user) { + /** @var User $user */ + $userInfo['id'] = (string) $user->getId(); + $userInfo['roles'] = $user->getRoles(); + } + } + + return array_merge($context, [ + 'user_info' => $userInfo, + ]); + } +} diff --git a/src/Service/Search/Model/Facet.php b/src/Service/Search/Model/Facet.php deleted file mode 100644 index 80e6cea3..00000000 --- a/src/Service/Search/Model/Facet.php +++ /dev/null @@ -1,48 +0,0 @@ - 'dep', - self::FACET_OFFICIAL => 'off', - self::FACET_SUBJECT => 'sub', - self::FACET_SOURCE => 'src', - self::FACET_PERIOD => 'prd', - self::FACET_GROUNDS => 'gnd', - self::FACET_JUDGEMENT => 'jdg', - self::FACET_DATE_FROM => 'df', - self::FACET_DATE_TO => 'dt', - self::FACET_DOSSIER_NR => 'dnr', - ]; - } - - public static function getQueryVarForFacet(string $facet): string - { - $mapping = self::getQueryMapping(); - - return $mapping[$facet] ?? ''; - } -} diff --git a/src/Service/Search/Model/FacetKey.php b/src/Service/Search/Model/FacetKey.php new file mode 100644 index 00000000..34039fc7 --- /dev/null +++ b/src/Service/Search/Model/FacetKey.php @@ -0,0 +1,20 @@ +innerStrategies as $innerStrategy) { - if (! $innerStrategy instanceof AggregationStrategyInterface) { - throw new \TypeError('All elements of $innerStrategies must be instances of AggregationStrategyInterface'); - } - } - } - - /** - * @return array - */ - public function getQuery(): array - { - $aggs = []; - foreach ($this->innerStrategies as $inner) { - $innerQuery = $inner->getQuery(); - $aggs = array_merge_recursive($aggs, $innerQuery); - } - - return [ - $this->tagName => [ - 'nested' => [ - 'path' => $this->path, - ], - 'aggs' => $aggs, - ], - ]; - } -} diff --git a/src/Service/Search/Query/Aggregation/NestedTermsAggregationStrategy.php b/src/Service/Search/Query/Aggregation/NestedTermsAggregationStrategy.php new file mode 100644 index 00000000..dd328be7 --- /dev/null +++ b/src/Service/Search/Query/Aggregation/NestedTermsAggregationStrategy.php @@ -0,0 +1,55 @@ +searchType === Config::TYPE_DOSSIER) { + return new TermsAggregationWithMinDocCount( + name: $facet->getFacetKey(), + fieldOrSource: $facet->getPath(), + minDocCount: 1, + orderField: '_count', + orderValue: SortDirections::DESC, + size: $maxCount, + ); + } + + return new NestedAggregation( + name: sprintf('%s-%s', $this->path, $facet->getFacetKey()), + path: $this->path, + aggregations: [ + new TermsAggregationWithMinDocCount( + name: $facet->getFacetKey(), + fieldOrSource: sprintf('%s.%s', $this->path, $facet->getPath()), + minDocCount: 1, + orderField: '_count', + orderValue: SortDirections::DESC, + size: $maxCount, + ), + ] + ); + } + + public function excludeOwnFilters(): bool + { + return $this->excludeOwnFilters; + } +} diff --git a/src/Service/Search/Query/AggregationGenerator.php b/src/Service/Search/Query/AggregationGenerator.php new file mode 100644 index 00000000..08f495c1 --- /dev/null +++ b/src/Service/Search/Query/AggregationGenerator.php @@ -0,0 +1,127 @@ +aggregations) { + return; + } + + // First split the facets into two groups: 'facets affected by filters' and 'regular facets'. + // All facets that have no selected value(s) in the Config object are not affected by filters. + // Additionally, some aggregations don't exclude their own filters (AND) so are also not affected by filters. + $regularFacets = []; + $filterAffectedFacets = []; + foreach ($this->facetMapping->getAll() as $facet) { + if ($config->hasFacetValues($facet) && $facet->getAggregationStrategy()?->excludeOwnFilters() === true) { + $filterAffectedFacets[] = $facet; + } else { + $regularFacets[] = $facet; + } + } + + // Regular facets are not affected by facet filters, so can be added directly + foreach ($regularFacets as $facet) { + $aggregation = $facet->getAggregationStrategy()?->getAggregation($facet, $config, $maxCount); + if ($aggregation) { + $queryBuilder->addAggregation($aggregation); + } + } + + // Filter affected facets need special handling to exclude their own filter. + // Unfortunately ES has no tag/exclude mechanism, so we need to exclude the main query/filter and set specific + // filters to apply per facet. + if (count($filterAffectedFacets) > 0) { + // Add a 'global' aggregation, this basically excludes all main query conditions + $globalAggregation = new GlobalAggregation('all'); + + // Because the main query is excluded we need to re-apply all non-facet conditions. + // As a small optimization we can do this for all active facets at once, instead of repeating for each. + $baseConditionsQuery = new BoolQuery(); + $this->accessConditions->applyToQuery($config, $baseConditionsQuery); + $this->searchTermConditions->applyToQuery($config, $baseConditionsQuery); + $baseFilterAgg = new FilterAggregation( + 'facet-base-filter', + $baseConditionsQuery, + ); + + // Now add aggregations for all active facets within the base filter aggregation + foreach ($filterAffectedFacets as $facet) { + $this->addAggregationToParent($facet, $config, $baseFilterAgg, $maxCount); + } + + $globalAggregation->addAggregation($baseFilterAgg); + $queryBuilder->addAggregation($globalAggregation); + } + } + + public function addDocTypeAggregations(QueryBuilder $queryBuilder): void + { + $queryBuilder->addAggregation( + new CardinalityAggregation('unique_dossiers', 'dossier_nr') + ); + + $queryBuilder->addAggregation( + new CardinalityAggregation('unique_documents', 'document_nr') + ); + } + + private function addAggregationToParent( + FacetDefinition $facet, + Config $config, + FilterAggregation $parentAggregation, + int $maxCount + ): void { + // Some facet definitions have no strategy (only used for filtering, not actual faceting). In that case skip. + $aggregation = $facet->getAggregationStrategy()?->getAggregation($facet, $config, $maxCount); + if (! $aggregation) { + return; + } + + $filterQuery = new BoolQuery(); + + // Apply the filters of all other active facets to this one, except its own filter. + $this->facetConditions->applyToQuery($config, $filterQuery, $facet->getFacetKey()); + + // If there are no other facet filter no filter sub-query is needed, directly add the aggregation. + if ($filterQuery->isEmpty()) { + $parentAggregation->addAggregation($aggregation); + + return; + } + + // Wrap the aggregation with the filters for the other active facets and add it to the parent aggregation + $parentAggregation->addAggregation( + new FilterAggregation( + 'facet-filter-' . $facet->getFacetKey(), + $filterQuery, + [$aggregation] + ) + ); + } +} diff --git a/src/Service/Search/Query/Condition/ContentAccessConditions.php b/src/Service/Search/Query/Condition/ContentAccessConditions.php new file mode 100644 index 00000000..3bb6c549 --- /dev/null +++ b/src/Service/Search/Query/Condition/ContentAccessConditions.php @@ -0,0 +1,120 @@ +searchType) { + case Config::TYPE_DOCUMENT: + $query->addFilter($this->createDocumentQuery($config)); + break; + case Config::TYPE_DOSSIER: + $query->addFilter($this->createDossierQuery($config)); + break; + default: + $query->addFilter( + new BoolQuery( + should: [ + $this->createDocumentQuery($config), + $this->createDossierQuery($config), + ], + params: ['minimum_should_match' => 1], + ) + ); + break; + } + } + + private function createDocumentQuery(Config $config): BoolQuery + { + $query = new BoolQuery( + filter: [ + new TermQuery( + field: 'type', + value: Config::TYPE_DOCUMENT + ), + ], + ); + + if (! empty($config->dossierInquiries) || ! empty($config->documentInquiries)) { + $statuses = [ + Dossier::STATUS_PUBLISHED, + Dossier::STATUS_PREVIEW, + ]; + + if (! empty($config->documentInquiries)) { + $query->addFilter( + new TermsQuery( + field: 'inquiry_ids', + values: $config->documentInquiries + ) + ); + } + } else { + $statuses = [ + Dossier::STATUS_PUBLISHED, + ]; + } + + $query->addFilter( + new NestedQuery( + path: 'dossiers', + query: new TermsQuery( + field: 'dossiers.status', + values: $statuses, + ) + ) + ); + + return $query; + } + + private function createDossierQuery(Config $config): BoolQuery + { + $query = new BoolQuery( + filter: [ + new TermQuery( + field: 'type', + value: Config::TYPE_DOSSIER + ), + ], + ); + + if (! empty($config->dossierInquiries)) { + $statuses = [ + Dossier::STATUS_PUBLISHED, + Dossier::STATUS_PREVIEW, + ]; + $query->addFilter( + new TermsQuery( + field: 'inquiry_ids', + values: $config->dossierInquiries + ), + ); + } else { + $statuses = [ + Dossier::STATUS_PUBLISHED, + ]; + } + + $query->addFilter( + new TermsQuery( + field: 'status', + values: $statuses, + ) + ); + + return $query; + } +} diff --git a/src/Service/Search/Query/Condition/FacetConditions.php b/src/Service/Search/Query/Condition/FacetConditions.php new file mode 100644 index 00000000..9958e487 --- /dev/null +++ b/src/Service/Search/Query/Condition/FacetConditions.php @@ -0,0 +1,34 @@ +facetMapping->getActiveFacets($config) as $facet) { + if ($facet->getFacetKey() === $facetToSkip) { + continue; + } + + $facetFilterQuery = new BoolQuery(); + + $facet->getFilter()?->addToQuery($facet, $facetFilterQuery, $config); + + if (! $facetFilterQuery->isEmpty()) { + $query->addFilter($facetFilterQuery); + } + } + } +} diff --git a/src/Service/Search/Query/Condition/QueryConditions.php b/src/Service/Search/Query/Condition/QueryConditions.php new file mode 100644 index 00000000..5048a6e3 --- /dev/null +++ b/src/Service/Search/Query/Condition/QueryConditions.php @@ -0,0 +1,13 @@ +query === '') { + $query->addShould(new MatchAllQuery()); + + return; + } + + $query->addShould( + $this->createDocumentQuery($config) + ); + + $query->addShould( + $this->createDossierQuery($config) + ); + + $query->setParams(['minimum_should_match' => 1]); + } + + public function createDocumentQuery(Config $config): BoolQuery + { + return new BoolQuery( + should: [ + new NestedQuery( + path: 'dossiers', + query: new BoolQuery( + should: [ + new QueryStringQuery( + query: $config->query, + fields: ['dossiers.title'], + boost: 3, + ), + new QueryStringQuery( + query: $config->query, + fields: ['dossiers.summary'], + boost: 2, + ), + ], + params: ['minimum_should_match' => 1], + ) + ), + new NestedQuery( + path: 'pages', + query: new QueryStringQuery( + query: $config->query, + fields: ['pages.content'], + boost: 1, + ), + ), + new QueryStringQuery( + query: $config->query, + fields: ['filename'], + boost: 4, + ), + ], + filter: [ + new TermQuery( + field: 'type', + value: Config::TYPE_DOCUMENT + ), + ], + params: ['minimum_should_match' => 1] + ); + } + + public function createDossierQuery(Config $config): BoolQuery + { + return new BoolQuery( + should: [ + new QueryStringQuery( + query: $config->query, + fields: ['title'], + boost: 5, + ), + new QueryStringQuery( + query: $config->query, + fields: ['summary'], + boost: 4, + ), + new QueryStringQuery( + query: $config->query, + fields: ['decision_content'], + boost: 3, + ), + ], + filter: [ + new TermQuery( + field: 'type', + value: Config::TYPE_DOSSIER, + ), + ], + params: ['minimum_should_match' => 1] + ); + } +} diff --git a/src/Service/Search/Query/DocumentQueryGenerator.php b/src/Service/Search/Query/DocumentQueryGenerator.php deleted file mode 100644 index 55e21a72..00000000 --- a/src/Service/Search/Query/DocumentQueryGenerator.php +++ /dev/null @@ -1,136 +0,0 @@ - - */ - public function getConditions(Config $config): array - { - $filterConditions = $this->getFilterConditions($config); - $mustConditions = $this->getMustConditions($config); - - return $this->getFinalConditions($filterConditions, $mustConditions); - } - - /** - * @example output - * - * [ - * { - * "terms": { - * "source_type": ["pdf"] - * } - * }, - * { - * "term": { - * "type": "document" - * } - * }, - * ], - * - * @return array - */ - private function getFilterConditions(Config $config): array - { - $columnMapping = [ - Facet::FACET_SUBJECT => new AndTermFilter('subjects'), - Facet::FACET_SOURCE => new OrTermFilter('source_type'), - Facet::FACET_GROUNDS => new OrTermFilter('grounds'), - Facet::FACET_JUDGEMENT => new OrTermFilter('judgement'), - ]; - $valueMapping = array_intersect_key($config->facets, $columnMapping); - - $mustConditions = []; - foreach ($valueMapping as $key => $values) { - $filter = $columnMapping[$key]->getQuery($values); - if ($filter !== null) { - $mustConditions[] = $filter; - } - } - - $mustConditions[] = ['term' => ['type' => Config::TYPE_DOCUMENT]]; - - // Filtering on inquiries on Document layer - if ($config->documentInquiries) { - $mustConditions[] = ['terms' => ['inquiry_ids' => $config->documentInquiries]]; - } - - return $mustConditions; - } - - /** - * @return array - */ - private function getMustConditions(Config $config): array - { - $mustConditions = []; - - // Nested dossiers query - // Which will check if the document has a valid dossier - // We use the same dossierQueryGenerator with a prefix to the field keys - $dosQueryCon = $this->dosQueryGen->getConditions( - $config, - new NestedDossierStrategy(), - ); - $mustConditions[] = [ - 'nested' => [ - 'path' => 'dossiers', - 'query' => $dosQueryCon, - ], - ]; - - // Adds the text search against the content of the nested pages - if ($config->query != '') { - $mustConditions[] = [ - 'nested' => [ - 'path' => 'pages', - 'query' => [ - 'match' => [ - 'pages.content' => [ - 'query' => $config->query, - 'boost' => 1, - ], - ], - ], - ], - ]; - } - - return $mustConditions; - } - - /** - * @param array $filterConfitions - * @param array $mustConditions - * - * @return array - */ - private function getFinalConditions(array $filterConfitions, array $mustConditions): array - { - if (empty($filterConfitions) && empty($mustConditions)) { - return []; - } - - return [ - 'bool' => [ - 'filter' => $filterConfitions, - 'must' => $mustConditions, - ], - ]; - } -} diff --git a/src/Service/Search/Query/DossierQueryGenerator.php b/src/Service/Search/Query/DossierQueryGenerator.php deleted file mode 100644 index 6495d48f..00000000 --- a/src/Service/Search/Query/DossierQueryGenerator.php +++ /dev/null @@ -1,114 +0,0 @@ - - */ - public function getConditions( - Config $config, - DossierStrategyInterface $strategy, - ): array { - $filterConditions = $this->getFilterConditions($config, $strategy); - $shouldConditions = $this->getShouldConditions($config, $strategy); - - return $this->getFinalConditions( - $filterConditions, - $shouldConditions, - $strategy->getMinimumShouldMatch(), - ); - } - - /** - * @return array - */ - private function getFilterConditions(Config $config, DossierStrategyInterface $strategy): array - { - $columnMapping = [ - Facet::FACET_DEPARTMENT => new OrTermFilter($strategy->getPath('departments.name')), - Facet::FACET_OFFICIAL => new OrTermFilter($strategy->getPath('government_officials.name')), - Facet::FACET_PERIOD => new OrTermFilter($strategy->getPath('period')), - Facet::FACET_DATE_FROM => new DateRangeFilter($strategy->getPath('date_from'), 'gte'), - Facet::FACET_DATE_TO => new DateRangeFilter($strategy->getPath('date_to'), 'lte'), - Facet::FACET_DOSSIER_NR => new OrTermFilter($strategy->getPath('dossier_nr')), - ]; - $valueMapping = array_intersect_key($config->facets, $columnMapping); - - $mustConditions = []; - foreach ($valueMapping as $key => $values) { - $filter = $columnMapping[$key]->getQuery($values); - if ($filter !== null) { - $mustConditions[] = $filter; - } - } - - if ($strategy->mustTypeCheck()) { - $mustConditions[] = ['term' => [$strategy->getPath('type') => 'dossier']]; - } - - $validStatuses = $strategy->getStatusValues($config); - $mustConditions[] = ['terms' => [$strategy->getPath('status') => $validStatuses]]; - - // Filtering on inquiries on Dossier layer - if ($config->dossierInquiries) { - $mustConditions[] = [ - 'terms' => [ - $strategy->getPath('inquiry_ids') => $config->dossierInquiries, - ], - ]; - } - - return $mustConditions; - } - - /** - * @return array - */ - private function getShouldConditions(Config $config, DossierStrategyInterface $strategy): array - { - if ($config->query == '') { - return []; - } - - return [ - ['match' => [$strategy->getPath('title') => ['query' => $config->query, 'boost' => 3]]], - ['match' => [$strategy->getPath('summary') => ['query' => $config->query, 'boost' => 2]]], - ]; - } - - /** - * @param array $filterConditions - * @param array $shouldConditions - * - * @return array - */ - private function getFinalConditions(array $filterConditions, array $shouldConditions, int $minimumShouldMatch): array - { - if (empty($filterConditions) && empty($shouldConditions)) { - return []; - } - - $conditions = [ - 'bool' => [ - 'filter' => $filterConditions, - ], - ]; - - if (! empty($shouldConditions)) { - $conditions['bool']['should'] = $shouldConditions; - $conditions['bool']['minimum_should_match'] = min($minimumShouldMatch, count($shouldConditions)); - } - - return $conditions; - } -} diff --git a/src/Service/Search/Query/DossierStrategy/DossierStrategyInterface.php b/src/Service/Search/Query/DossierStrategy/DossierStrategyInterface.php deleted file mode 100644 index 1288bea4..00000000 --- a/src/Service/Search/Query/DossierStrategy/DossierStrategyInterface.php +++ /dev/null @@ -1,21 +0,0 @@ -dossierInquiries) || ! empty($config->documentInquiries)) { - return [ - Dossier::STATUS_PUBLISHED, - Dossier::STATUS_PREVIEW, - ]; - } - - return [ - Dossier::STATUS_PUBLISHED, - ]; - } -} diff --git a/src/Service/Search/Query/DossierStrategy/TopLevelDossierStrategy.php b/src/Service/Search/Query/DossierStrategy/TopLevelDossierStrategy.php deleted file mode 100644 index a0d246f3..00000000 --- a/src/Service/Search/Query/DossierStrategy/TopLevelDossierStrategy.php +++ /dev/null @@ -1,40 +0,0 @@ -dossierInquiries)) { - return [ - Dossier::STATUS_PUBLISHED, - Dossier::STATUS_PREVIEW, - ]; - } - - return [ - Dossier::STATUS_PUBLISHED, - ]; - } -} diff --git a/src/Service/Search/Query/Dsl/GlobalAggregation.php b/src/Service/Search/Query/Dsl/GlobalAggregation.php new file mode 100644 index 00000000..85277fea --- /dev/null +++ b/src/Service/Search/Query/Dsl/GlobalAggregation.php @@ -0,0 +1,41 @@ + + */ + public function build(): array + { + $data = [ + 'global' => new \stdClass(), + ]; + + $this->buildAggregationsTo($data); + + return $data; + } + + /** + * @return array + */ + protected function buildAggregation(): array + { + return []; + } +} diff --git a/src/Service/Search/Query/Dsl/MatchAllQuery.php b/src/Service/Search/Query/Dsl/MatchAllQuery.php new file mode 100644 index 00000000..acae5fae --- /dev/null +++ b/src/Service/Search/Query/Dsl/MatchAllQuery.php @@ -0,0 +1,18 @@ + + */ + public function build(): array + { + return ['match_all' => new \stdClass()]; + } +} diff --git a/src/Service/Search/Query/Dsl/TermsAggregationWithMinDocCount.php b/src/Service/Search/Query/Dsl/TermsAggregationWithMinDocCount.php new file mode 100644 index 00000000..dd700578 --- /dev/null +++ b/src/Service/Search/Query/Dsl/TermsAggregationWithMinDocCount.php @@ -0,0 +1,54 @@ + + */ + protected function buildAggregation(): array + { + $build = parent::buildAggregation(); + $build['min_doc_count'] = $this->minDocCount; + + return $build; + } +} diff --git a/src/Service/Search/Query/Facet/FacetDefinition.php b/src/Service/Search/Query/Facet/FacetDefinition.php new file mode 100644 index 00000000..12d1d15e --- /dev/null +++ b/src/Service/Search/Query/Facet/FacetDefinition.php @@ -0,0 +1,46 @@ +key->value; + } + + public function getPath(): string + { + return $this->path; + } + + public function getFilter(): ?FilterInterface + { + return $this->filter; + } + + public function getQueryParam(): string + { + return $this->queryParam; + } + + public function getAggregationStrategy(): ?AggregationStrategyInterface + { + return $this->aggregationStrategy; + } +} diff --git a/src/Service/Search/Query/Facet/FacetMappingService.php b/src/Service/Search/Query/Facet/FacetMappingService.php new file mode 100644 index 00000000..e7f34baf --- /dev/null +++ b/src/Service/Search/Query/Facet/FacetMappingService.php @@ -0,0 +1,136 @@ +mapping = [ + new FacetDefinition( + key: FacetKey::SUBJECT, + path: 'subjects', + queryParam: 'sub', + filter: new DocumentOnlyFilter(new OrTermFilter()), + aggregationStrategy: new TermsAggregationStrategy(), + ), + new FacetDefinition( + key: FacetKey::SOURCE, + path: 'source_type', + queryParam: 'src', + filter: new DocumentOnlyFilter(new OrTermFilter()), + aggregationStrategy: new TermsAggregationStrategy(), + ), + new FacetDefinition( + key: FacetKey::GROUNDS, + path: 'grounds', + queryParam: 'gnd', + filter: new DocumentOnlyFilter(new OrTermFilter()), + aggregationStrategy: new TermsAggregationStrategy(), + ), + new FacetDefinition( + key: FacetKey::JUDGEMENT, + path: 'judgement', + queryParam: 'jdg', + filter: new DocumentOnlyFilter(new OrTermFilter()), + aggregationStrategy: new TermsAggregationStrategy(), + ), + new FacetDefinition( + key: FacetKey::DEPARTMENT, + path: 'departments.name', + queryParam: 'dep', + filter: new DossierAndNestedDossierFilter(new OrTermFilter()), + aggregationStrategy: new NestedTermsAggregationStrategy('dossiers'), + ), + new FacetDefinition( + key: FacetKey::OFFICIAL, + path: 'government_officials.name', + queryParam: 'off', + filter: new DossierAndNestedDossierFilter(new OrTermFilter()), + aggregationStrategy: new NestedTermsAggregationStrategy('dossiers'), + ), + new FacetDefinition( + key: FacetKey::PERIOD, + path: 'date_period', + queryParam: 'prd', + filter: new DossierAndNestedDossierFilter(new OrTermFilter()), + aggregationStrategy: new NestedTermsAggregationStrategy('dossiers'), + ), + new FacetDefinition( + key: FacetKey::DATE, + path: '', // Paths are not used for period filters + queryParam: 'dt', + filter: new PeriodFilter(), + // Intentionally no agg. strategy: only exists for filtering + ), + new FacetDefinition( + key: FacetKey::DOSSIER_NR, + path: 'dossier_nr', + queryParam: 'dnr', + filter: new DossierAndNestedDossierFilter(new OrTermFilter()), + // Intentionally no agg. strategy: only exists for filtering + ), + new FacetDefinition( + key: FacetKey::INQUIRY_DOSSIERS, + path: 'inquiry_ids', + queryParam: 'dsi', + filter: new DossierOnlyFilter(new OrTermFilter()), + // Intentionally no agg. strategy: only exists for filtering + ), + new FacetDefinition( + key: FacetKey::INQUIRY_DOCUMENTS, + path: 'inquiry_ids', + queryParam: 'dci', + filter: new DocumentOnlyFilter(new OrTermFilter()), + // Intentionally no agg. strategy: only exists for filtering + ), + ]; + } + + /** + * @return FacetDefinition[] + */ + public function getActiveFacets(Config $config): array + { + return array_filter( + $this->mapping, + static fn (FacetDefinition $facet): bool => $config->hasFacetValues($facet), + ); + } + + public function getFacetByKey(string $key): FacetDefinition + { + foreach ($this->mapping as $definition) { + if ($definition->getFacetKey() === $key) { + return $definition; + } + } + + throw new \RuntimeException('Cannot find facet mapping by key ' . $key); + } + + /** + * @return FacetDefinition[] + */ + public function getAll(): array + { + return $this->mapping; + } +} diff --git a/src/Service/Search/Query/Filter/DocumentOnlyFilter.php b/src/Service/Search/Query/Filter/DocumentOnlyFilter.php new file mode 100644 index 00000000..a95c2857 --- /dev/null +++ b/src/Service/Search/Query/Filter/DocumentOnlyFilter.php @@ -0,0 +1,39 @@ +getFacetValues($facet); + if (count($values) === 0) { + return; + } + + $query->addFilter( + new TermQuery( + field: 'type', + value: Config::TYPE_DOCUMENT, + ), + ); + + $this->subFilter->addToQuery($facet, $query, $config); + } +} diff --git a/src/Service/Search/Query/Filter/DossierAndNestedDossierFilter.php b/src/Service/Search/Query/Filter/DossierAndNestedDossierFilter.php new file mode 100644 index 00000000..5bf4a5e7 --- /dev/null +++ b/src/Service/Search/Query/Filter/DossierAndNestedDossierFilter.php @@ -0,0 +1,66 @@ +getFacetValues($facet); + if (count($values) === 0) { + return; + } + + $dossierQuery = new BoolQuery(); + $this->subFilter->addToQuery($facet, $dossierQuery, $config); + + $nestedDossierQuery = new BoolQuery(); + $this->subFilter->addToQuery($facet, $nestedDossierQuery, $config, 'dossiers.'); + + $query->addFilter( + new BoolQuery( + should: [ + new BoolQuery( + filter: [ + new TermQuery( + field: 'type', + value: Config::TYPE_DOCUMENT, + ), + new NestedQuery( + path: 'dossiers', + query: $nestedDossierQuery, + ), + ] + ), + new BoolQuery( + filter: [ + new TermQuery( + field: 'type', + value: Config::TYPE_DOSSIER, + ), + $dossierQuery, + ] + ), + ], + params: ['minimum_should_match' => 1], + ) + ); + } +} diff --git a/src/Service/Search/Query/Filter/DossierOnlyFilter.php b/src/Service/Search/Query/Filter/DossierOnlyFilter.php new file mode 100644 index 00000000..7a06b9a1 --- /dev/null +++ b/src/Service/Search/Query/Filter/DossierOnlyFilter.php @@ -0,0 +1,40 @@ +getFacetValues($facet); + if (count($values) === 0) { + return; + } + + $query->addFilter( + new TermQuery( + field: 'type', + value: Config::TYPE_DOSSIER, + ), + ); + + $this->subFilter->addToQuery($facet, $query, $config); + } +} diff --git a/src/Service/Search/Query/Filter/PeriodFilter.php b/src/Service/Search/Query/Filter/PeriodFilter.php new file mode 100644 index 00000000..3fb49dee --- /dev/null +++ b/src/Service/Search/Query/Filter/PeriodFilter.php @@ -0,0 +1,125 @@ +getFacetValues($facet); + $fromDate = $this->asDate($values['from'] ?? null); + $toDate = $this->asDate($values['to'] ?? null); + + if ($fromDate == null && $toDate == null) { + return; + } + + $query->addFilter( + new BoolQuery( + should: [ + new BoolQuery( + filter: [ + new TermQuery( + field: 'type', + value: Config::TYPE_DOCUMENT, + ), + $this->getDocumentDateQuery($fromDate, $toDate), + ] + ), + new BoolQuery( + filter: [ + new TermQuery( + field: 'type', + value: Config::TYPE_DOSSIER, + ), + $this->getDossierDateQuery($fromDate, $toDate), + ] + ), + ], + params: ['minimum_should_match' => 1], + ) + ); + } + + private function getDocumentDateQuery(?\DateTimeImmutable $fromDate, ?\DateTimeImmutable $toDate): RangeQuery + { + $query = new RangeQuery('date'); + if ($toDate) { + $query->lte($toDate->format('Y-m-d')); + } + if ($fromDate) { + $query->gte($fromDate->format('Y-m-d')); + } + + return $query; + } + + private function getDossierDateQuery(?\DateTimeImmutable $fromDate, ?\DateTimeImmutable $toDate): BoolQuery + { + $rangeFromQuery = new RangeQuery('date_from'); + if ($fromDate) { + $rangeFromQuery->gte($fromDate->format('Y-m-d')); + } + if ($toDate) { + $rangeFromQuery->lte($toDate->format('Y-m-d')); + } + + $rangeToQuery = new RangeQuery('date_to'); + if ($fromDate) { + $rangeToQuery->gte($fromDate->format('Y-m-d')); + } + if ($toDate) { + $rangeToQuery->lte($toDate->format('Y-m-d')); + } + + $rangeOverspanQuery = null; + if ($fromDate && $toDate) { + $fromQuery = new RangeQuery('date_from'); + $fromQuery->lt($fromDate->format('Y-m-d')); + + $toQuery = new RangeQuery('date_to'); + $toQuery->gt($toDate->format('Y-m-d')); + + $rangeOverspanQuery = new BoolQuery(); + $rangeOverspanQuery->addMust($fromQuery); + $rangeOverspanQuery->addMust($toQuery); + } + + $query = new BoolQuery(); + $query->addShould($rangeFromQuery); + $query->addShould($rangeToQuery); + if ($rangeOverspanQuery) { + $query->addShould($rangeOverspanQuery); + } + $query->setParams(['minimum_should_match' => 1]); + + return $query; + } + + private function asDate(mixed $value): ?\DateTimeImmutable + { + if (! is_string($value)) { + return null; + } + + try { + return new \DateTimeImmutable($value); + } catch (\Exception) { + return null; + } + } +} diff --git a/src/Service/Search/Query/QueryGeneratorFactory.php b/src/Service/Search/Query/QueryGeneratorFactory.php deleted file mode 100644 index 463da127..00000000 --- a/src/Service/Search/Query/QueryGeneratorFactory.php +++ /dev/null @@ -1,27 +0,0 @@ -docQueryGen, - $this->dosQueryGen, - $config - ); - - return $generator; - } -} diff --git a/src/Service/Search/Result/AggregationMapper.php b/src/Service/Search/Result/AggregationMapper.php new file mode 100644 index 00000000..6c7aa2eb --- /dev/null +++ b/src/Service/Search/Result/AggregationMapper.php @@ -0,0 +1,64 @@ + $buckets + */ + public function map(string $name, iterable $buckets): Aggregation + { + $entries = []; + foreach ($buckets as $bucket) { + $key = $bucket->getString('[key]'); + + if ($this->shouldSkip($name, $key)) { + continue; + } + + $entries[] = new AggregationBucketEntry( + $key, + $bucket->getInt('[doc_count]'), + $this->getDisplayValue($name, $key) + ); + } + + return new Aggregation($name, $entries); + } + + private function getDisplayValue(string $facetKey, string $value): string + { + $value = trim($value); + + return match ($facetKey) { + FacetKey::GROUNDS->value => trim($value . ' ' . Citation::toClassification($value)), + FacetKey::SOURCE->value, FacetKey::JUDGEMENT->value => $this->translator->trans($value), + default => $value === '' ? 'none' : $value, + }; + } + + private function shouldSkip(string $facetKey, string $value): bool + { + // Special case: the 'ground' value 'dubbel' should be excluded from the facet + if ($facetKey === FacetKey::GROUNDS->value && $value === Citation::DUBBEL) { + return true; + } + + return false; + } +} diff --git a/src/Service/Worker/Pdf/Extractor/DecisionContentExtractor.php b/src/Service/Worker/Pdf/Extractor/DecisionContentExtractor.php new file mode 100644 index 00000000..a81be3d4 --- /dev/null +++ b/src/Service/Worker/Pdf/Extractor/DecisionContentExtractor.php @@ -0,0 +1,82 @@ +isCached($decision)) { + $content = $this->extractContent($decision); + $this->setCachedContent($decision, $content); + } + + $content = $this->getCachedContent($decision); + + $this->elasticService->updateDossierDecisionContent($dossier, $content); + } + + private function extractContent(DecisionDocument $decision): string + { + $localFilePath = $this->documentStorage->downloadDocument($decision); + if (! $localFilePath) { + throw new \RuntimeException('Failed to file to local storage for DecisionDocument ' . $decision->getId()->toBase58()); + } + + $tikaData = $this->tika->extract($localFilePath); + $content = $tikaData['X-TIKA:content'] ?? ''; + $content .= "\n"; + $content .= $this->tesseract->extract($localFilePath); + + $this->documentStorage->removeDownload($localFilePath); + + return $content; + } + + protected function getCachedContent(EntityWithFileInfo $entity): string + { + $key = $this->getCacheKey($entity); + + return strval($this->redis->get($key)); + } + + protected function setCachedContent(EntityWithFileInfo $entity, string $content): void + { + $this->redis->set( + $this->getCacheKey($entity), + $content + ); + } + + protected function isCached(EntityWithFileInfo $entity): bool + { + $key = $this->getCacheKey($entity); + + return $this->redis->exists($key) === 1; + } + + protected function getCacheKey(EntityWithFileInfo $entity): string + { + return $entity->getFileCacheKey() . '-content'; + } +} diff --git a/src/Session/EncryptedSessionProxy.php b/src/Session/EncryptedSessionProxy.php new file mode 100644 index 00000000..5d5b0d76 --- /dev/null +++ b/src/Session/EncryptedSessionProxy.php @@ -0,0 +1,43 @@ +key = new EncryptionKey(new HiddenString($key)); + } + + public function read($sessionId): string + { + $data = parent::read($sessionId); + if (empty($data)) { + return ''; + } + + return Crypto::decrypt($data, $this->key)->getString(); + } + + public function write($sessionId, $data): bool + { + $data = Crypto::encrypt(new HiddenString($data), $this->key); + + return parent::write($sessionId, $data); + } +} diff --git a/src/Twig/Extension/CspExtension.php b/src/Twig/Extension/CspExtension.php new file mode 100644 index 00000000..f7e8db50 --- /dev/null +++ b/src/Twig/Extension/CspExtension.php @@ -0,0 +1,29 @@ +runtime = $runtime; + } + + public function getFunctions(): array + { + return [ + new TwigFunction('csp_nonce', [$this->runtime, 'getCspNonce']), + ]; + } +} diff --git a/src/Twig/Runtime/CspExtensionRuntime.php b/src/Twig/Runtime/CspExtensionRuntime.php new file mode 100644 index 00000000..f0b1e36c --- /dev/null +++ b/src/Twig/Runtime/CspExtensionRuntime.php @@ -0,0 +1,27 @@ +requestStack = $requestStack; + } + + public function getCspNonce(): string + { + if ($this->requestStack->getCurrentRequest() == null) { + return ''; + } + + return strval($this->requestStack->getCurrentRequest()->attributes->get('csp_nonce')); + } +} diff --git a/src/ValueObject/DossierUploadStatus.php b/src/ValueObject/DossierUploadStatus.php new file mode 100644 index 00000000..4acb0306 --- /dev/null +++ b/src/ValueObject/DossierUploadStatus.php @@ -0,0 +1,54 @@ +getExpectedDocuments()->count(); + } + + public function getActualUploadCount(): int + { + return $this->dossier->getDocuments()->filter( + /* @phpstan-ignore-next-line */ + static fn (Document $doc): bool => $doc->shouldBeUploaded() && $doc->isUploaded() + )->count(); + } + + public function isComplete(): bool + { + return $this->dossier->getDocuments()->filter( + /* @phpstan-ignore-next-line */ + static fn (Document $doc): bool => $doc->shouldBeUploaded() && ! $doc->isUploaded() + )->count() === 0; + } + + public function getUploadedDocuments(): ReadableCollection + { + return $this->dossier->getDocuments()->filter( + /* @phpstan-ignore-next-line */ + static fn (Document $doc): bool => $doc->isUploaded() + ); + } + + public function getExpectedDocuments(): ReadableCollection + { + return $this->dossier->getDocuments()->filter( + /* @phpstan-ignore-next-line */ + static fn (Document $doc): bool => $doc->shouldBeUploaded() + ); + } +} diff --git a/src/ValueObject/FilterDetails.php b/src/ValueObject/FilterDetails.php new file mode 100644 index 00000000..684ee94e --- /dev/null +++ b/src/ValueObject/FilterDetails.php @@ -0,0 +1,42 @@ +dossierInquiries; + } + + /** + * @return InquiryDescription[] + */ + public function getDocumentInquiries(): array + { + return $this->documentInquiries; + } + + /** + * @return string[] + */ + public function getDossierNumbers(): array + { + return $this->dossierNumbers; + } +} diff --git a/src/ValueObject/InquiryDescription.php b/src/ValueObject/InquiryDescription.php new file mode 100644 index 00000000..e8f6abf2 --- /dev/null +++ b/src/ValueObject/InquiryDescription.php @@ -0,0 +1,34 @@ +id; + } + + public function getCasenumber(): string + { + return $this->casenumber; + } + + public static function fromEntity(Inquiry $inquiry): self + { + return new self( + $inquiry->getId()->toRfc4122(), + $inquiry->getCasenr(), + ); + } +} diff --git a/templates/admin/dossier/document-withdraw.html.twig b/templates/admin/dossier/document-withdraw.html.twig new file mode 100644 index 00000000..ce4f1486 --- /dev/null +++ b/templates/admin/dossier/document-withdraw.html.twig @@ -0,0 +1,23 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Withdraw document" | trans() %} + +{% block body %} +
+
+ +

{{ "Withdraw document" | trans() }} {{ document.documentNr }}

+ +
+
+
+ {{ form(form) }} +
+
+
+ + Annuleren +
+
+ +{% endblock %} diff --git a/templates/admin/dossier/search.html.twig b/templates/admin/dossier/search.html.twig new file mode 100644 index 00000000..3c2cf19e --- /dev/null +++ b/templates/admin/dossier/search.html.twig @@ -0,0 +1,15 @@ + diff --git a/templates/admin/dossier/view.html.twig b/templates/admin/dossier/view.html.twig new file mode 100644 index 00000000..7151e7ba --- /dev/null +++ b/templates/admin/dossier/view.html.twig @@ -0,0 +1,19 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Modify dossier" | trans() ~ ' ' ~ dossier.dossierNr %} + +{% block body %} + +
+ +
+

{{ "Dossier" | trans() }} {{ dossier.dossierNr }}

+ + edit + + upload docs +
+ +
+ +{% endblock %} diff --git a/templates/bundles/TwigBundle/Exception/error.html.twig b/templates/bundles/TwigBundle/Exception/error.html.twig new file mode 100644 index 00000000..c729cb0f --- /dev/null +++ b/templates/bundles/TwigBundle/Exception/error.html.twig @@ -0,0 +1,19 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Page or document not available" | trans() %} + +{% block body %} +
+
+

{{ "Page or document not available" | trans() }}

+ + + +

+ {{ "The requested page or document is not available." | trans() }} +

+ + {{ "Go back to the homepage" | trans() }} +
+
+{% endblock %} diff --git a/templates/bundles/TwigBundle/Exception/error403.html.twig b/templates/bundles/TwigBundle/Exception/error403.html.twig new file mode 100644 index 00000000..a6887b89 --- /dev/null +++ b/templates/bundles/TwigBundle/Exception/error403.html.twig @@ -0,0 +1,19 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Access denied" | trans() %} + +{% block body %} +
+
+

{{ "Access denied" | trans() }}

+ + + +

+ {{ "You do not have the required access rights to view this page or document." | trans() }} +

+ + {{ "Go back to the homepage" | trans() }} +
+
+{% endblock %} diff --git a/templates/bundles/TwigBundle/Exception/error404.html.twig b/templates/bundles/TwigBundle/Exception/error404.html.twig new file mode 100644 index 00000000..140be181 --- /dev/null +++ b/templates/bundles/TwigBundle/Exception/error404.html.twig @@ -0,0 +1,20 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Page not found" | trans() %} + +{% block body %} +
+
+

{{ "Page not found" | trans() }}

+ + + +

+ {{ "The page/document you are looking for does not exist or is not available." | trans() }} +

+ + {{ "Go back to the homepage" | trans() }} +
+
+{% endblock %} + diff --git a/templates/components/admin-logo.twig b/templates/components/admin-logo.twig new file mode 100644 index 00000000..0d1c5729 --- /dev/null +++ b/templates/components/admin-logo.twig @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/templates/components/admin-step-done.twig b/templates/components/admin-step-done.twig new file mode 100644 index 00000000..6035e5b1 --- /dev/null +++ b/templates/components/admin-step-done.twig @@ -0,0 +1,3 @@ + + + diff --git a/templates/components/admin-step.twig b/templates/components/admin-step.twig new file mode 100644 index 00000000..cb6d66fb --- /dev/null +++ b/templates/components/admin-step.twig @@ -0,0 +1,4 @@ + + + + diff --git a/templates/components/admin-steps.twig b/templates/components/admin-steps.twig new file mode 100644 index 00000000..126bbc43 --- /dev/null +++ b/templates/components/admin-steps.twig @@ -0,0 +1,29 @@ +{% set step_links = [ + '/balie/dossier/new', + '#', + '#', + '#', +] %} +{% set step_captions = [ + 'Basisgegevens', + 'Besluit', + 'Documenten', + 'Publiceren', +] %} + diff --git a/templates/components/collapsable-component.twig b/templates/components/collapsable-component.twig new file mode 100644 index 00000000..272b7bfe --- /dev/null +++ b/templates/components/collapsable-component.twig @@ -0,0 +1,16 @@ +{% set open_label = open_label ?? 'Open' %} +{% set close_label = close_label ?? 'Close' %} +{% set aria_label = aria_label ?? 'Toggle' %} +{% set toggle_classes = 'collapsible' ~ (toggle_classes ? ' ' ~ (toggle_classes | join(' '))) %} + +
+ +
+ {% block collapsing %}{% endblock %} +
+
\ No newline at end of file diff --git a/templates/components/document-search.twig b/templates/components/document-search.twig new file mode 100644 index 00000000..3ac9bd1a --- /dev/null +++ b/templates/components/document-search.twig @@ -0,0 +1,30 @@ +
+ + {% embed 'components/collapsable-component.twig' with { open_label : 'Filters' | trans(), close_label : 'Close Filters' | trans(), toggle_classes : ['search-decisions__filter', 'secondary']} %} + {% block collapsing %} + {{ form_start(form) }} +
+
+ {{ 'Organization' | trans() }} + {{ form_widget(form.department) }} +
+
+ {{ 'Status' | trans() }} + {{ form_widget(form.status) }} +
+ {{ form_widget(form.submit) }} +
+ {{ form_end(form) }} + {% endblock %} + {% endembed %} + +
+ + + +
+
+ +
+
diff --git a/templates/components/tabs.twig b/templates/components/tabs.twig new file mode 100644 index 00000000..9e075880 --- /dev/null +++ b/templates/components/tabs.twig @@ -0,0 +1,11 @@ +{% set classes = ['tabs']|merge(classes) %} +
+ {% block tabs %} + + {% endblock %} +
diff --git a/templates/document/snippets/notifications.html.twig b/templates/document/snippets/notifications.html.twig new file mode 100644 index 00000000..3d6d3751 --- /dev/null +++ b/templates/document/snippets/notifications.html.twig @@ -0,0 +1,71 @@ +{% import "document_macros.html.twig" as macro %} + +{% if document.withdrawn %} +
+
+
+ + {{ + "Document withdrawn" | trans({ + '{date}': document.withdrawDate | format_date('long'), + '{reason}': document.withdrawReason.name | trans(), + }) + }} +
+
+
+{% endif %} + +{% if document.suspended %} +
+
+
+ {{ "Document suspended" | trans }} +
+
+
+{% endif %} + +{% if document.judgement.value is defined and document.judgement.value is same as ('not_public') %} +
+
+
+

{{ + "Document determined to be not public" | trans({ + '{dossier_type}': dossier.publicationReason | trans(), + '{organisation}': dossier.departments.first.name, + }) + }}

+ + {% if document.grounds | length > 0 %} +

{{ "Reasons are" | trans }}:

+
    + {% for ground in document.grounds %} +
  • + {{ macro.document_ground(ground) }} +
  • + {% endfor %} +
+ {% endif %} +
+
+
+{% endif %} + +{% if document.judgement.value is defined and document.judgement.value is same as ('already_public') %} +
+
+
+ {{ "Document already public" | trans }} {{ document.link }} +
+
+
+{% endif %} + +{% if document.isUploaded and not ingested %} +
+
+ {{ "Warning" | trans }}: {{ "This document is not yet processed. It is not possible to view individual pages, but you can download the complete document." | trans }} +
+
+{% endif %} diff --git a/templates/document_macros.html.twig b/templates/document_macros.html.twig new file mode 100644 index 00000000..be84b055 --- /dev/null +++ b/templates/document_macros.html.twig @@ -0,0 +1,131 @@ +{# + document_tabs displays two tabs with both public and not public documents. + + public_docs: a list of at least partially public documents + not_public_docs: a list of not public documents (already_public or not_public) + fragment: the fragment to use for the pagination links, like #documenten +#} +{% macro document_tabs(public_docs, not_public_docs, fragment) %} + {% set hasPublicDocuments = public_docs | length > 0 %} + {% set hasNotPublicDocuments = not_public_docs | length > 0 %} + + {% if hasPublicDocuments and hasNotPublicDocuments %} +
+
    +
  • + +
  • +
  • + +
  • +
+ +
+ {{ _self.documents_table(public_docs, "Documents that match the request", '#tabcontrol-1') }} +
+ + +
+ {% elseif hasPublicDocuments %} + {{ _self.documents_table(public_docs, "Documents that match the request", fragment) }} + {% elseif hasNotPublicDocuments %} + {{ _self.documents_table(not_public_docs, "Not made public", fragment) }} + {% endif %} +{% endmacro %} + +{# + documents_table displays a table with the provided documents. + + documents: a list of documents to display + tableTitleTranslationKey: the translation key of the table caption +#} +{% macro documents_table(documents, tableTitleTranslationKey, fragment) %} + + + + + + + + + + + + {% for doc in documents %} + + + + + + + {% endfor %} + +
+ {{ tableTitleTranslationKey | trans() }} +
{{ "Document number" | trans() }}{{ "Type" | trans() }}{{ "Name" | trans() }}{{ "Date" | trans() }}
+ {{ doc.documentId }} + + {% if doc.judgement.value is defined and doc.judgement.value is same as ('already_public') %} + + {% else %} + + {% endif %} + {{ doc.fileInfo.sourceType | trans() }} + + + + +
+ +
+ {{ knp_pagination_render(documents, null, {}, { 'fragment': fragment }) }} +
+{% endmacro %} + +{# + document_ground displays a ground with a possible link pointing to more information. + + ground: a list of documents to display +#} +{% macro document_ground(ground) %} + {% if get_citation_type(ground) == "woo" %} + {{ ground }} {{ ground|classification }} + {% elseif get_citation_type(ground) == "wob" %} + {{ ground }} {{ ground|classification }} + {% else %} + {{ ground }} + {% endif %} +{% endmacro %} diff --git a/templates/piwik.html.twig b/templates/piwik.html.twig new file mode 100644 index 00000000..cd9a6770 --- /dev/null +++ b/templates/piwik.html.twig @@ -0,0 +1 @@ + diff --git a/templates/static/about.en.html.twig b/templates/static/about.en.html.twig deleted file mode 100644 index 965d5e2b..00000000 --- a/templates/static/about.en.html.twig +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "About Wobcovid19" %} - -{% block body %} - -
-
- -

Over Wobcovid19

- -

Op deze site worden vanaf 1 januari 2021 alle Woo-besluiten en documenten gepubliceerd over de Covid-19 crisis die openbaar zijn - gemaakt n.a.v. verzoeken in het kader van de Wet open overheid, zogenaamde Woo-verzoeken. De Wob-besluiten en documenten die voor - 1 januari 2021 gepubliceerd zijn staan op https://www.rijksoverheid.nl/documenten.

- -

Meer informatie:

- - - -
-
-{% endblock %} diff --git a/templates/static/about.html.twig b/templates/static/about.html.twig new file mode 100644 index 00000000..81286ff5 --- /dev/null +++ b/templates/static/about.html.twig @@ -0,0 +1,63 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "About the VWS Woo publication platform" | trans %} + +{% block body %} + +
+
+ +

{{ "About the VWS Woo publication platform" | trans }}

+ +

+ {{ "On this website you will find documents that the Ministry of Health, Welfare and Sport has made public on the basis of the Open Government Act (Woo). These documents have been made public for requests submitted under the Woo and are about the corona period." | trans() }} +

+ +

+ {{ "From these documents it can be concluded how VWS has tackled the corona fight in collaboration with other ministries, institutions and organizations. Anyone can submit a request to make documents public under the Woo. Documents are still being added." | trans() }} +

+ +

+ {{ "The published documents of the Ministry of Health, Welfare and Sport can currently still be found on various websites:" | trans() }} +

+ +
    +
  • {{ "General decisions (not related to COVID-19) of the Ministry of Health, Welfare and Sport can be found on rijksoverheid.nl" | trans() }}
  • +
  • {{ "COVID-19 related decisions from before September 1, 2023 can be found on wobcovid19.rijksoverheid.nl" | trans() }}
  • +
  • {{ "COVID-19 related decisions after September 1, 2023 can be found on this website" | trans() }}
  • +
+ +

+ {{ "On OpenVWS, the documents are more accessible and searchable. We are working hard to make the documents from wobcovid19.rijksoverheid.nl available on this platform as well." | trans() }} +

+ +

{{ "Submit a freedom of information request" | trans() }}

+ +

+ {{ "You may request information about what the government does. This is stated in the Open Government Act (Woo). You do this via a Woo request. Good to know in advance:" | trans() | raw }} +

+ +
    +
  • + {{ "Before making a request, check if the information you are looking for is already public. Check, for example, rijksoverheid.nl, wobcovid19.rijksoverheid.nl, the archive of the Senate and the House of Representatives or open.overheid.nl." | trans() | raw }} +
  • +
  • + {{ "Check which government organization you should submit the request to. Do you have a request for the Ministry of Health, Welfare and Sport (VWS):" | trans() | raw }} + +{# {{ "Check which government organization you should submit the request to. Do you have a request for the Ministry of VWS? Send the request to us." | trans() | raw }}#} +
  • +
  • + {{ "Please indicate as precisely as possible what information you are looking for. About what subject, with what people involved, from what period?" | trans() | raw }} +
  • +
  • + {{ "After submitting your request, the ministry may call you with some additional questions. It is therefore useful if you also provide your telephone number with the request." | trans() | raw }} +
  • +
+ + +
+
+{% endblock %} diff --git a/templates/static/about.nl.html.twig b/templates/static/about.nl.html.twig deleted file mode 100644 index cdba3c84..00000000 --- a/templates/static/about.nl.html.twig +++ /dev/null @@ -1,27 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Over Wobcovid19" %} - -{% block body %} - -
-
- -

Over Wobcovid19

- -

Op deze site worden vanaf 1 januari 2021 alle Woo-besluiten en documenten gepubliceerd over de Covid-19 crisis die openbaar zijn - gemaakt n.a.v. verzoeken in het kader van de Wet open overheid, zogenaamde Woo-verzoeken. De Wob-besluiten en documenten die voor - 1 januari 2021 gepubliceerd zijn staan op https://www.rijksoverheid.nl/documenten.

- -

Meer informatie:

- - - -
-
-{% endblock %} diff --git a/templates/static/accessibility.en.html.twig b/templates/static/accessibility.en.html.twig deleted file mode 100644 index 31c6416b..00000000 --- a/templates/static/accessibility.en.html.twig +++ /dev/null @@ -1,34 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Accessibility" %} - -{% block body %} -
-
- -

Toegankelijkheid

-

Wij willen graag dat iedereen deze website kan gebruiken. Komt u toch een pagina tegen die niet toegankelijk is? Dan - kunt u dit aan ons melden.

-

Wat is een toegankelijke website?

-

Een toegankelijke website is voor alle doelgroepen beter te gebruiken. Daarom gelden er functioneel-technische en - redactionele toegankelijkheidseisen of (voorheen: webrichtlijnen) voor - websites van de overheid. Deze zijn vastgelegd in de toegankelijkheidsstandaard - Digitoegankelijk EN 301 549.

-

Borging van toegankelijkheid

-

Wij borgen goede toegankelijkheid door diverse maatregelen binnen onze (dagelijkse) processen:

-
    -
  • Toegankelijkheid ‘by design’: toegankelijkheid is vanaf de start onderdeel van alle stappen in het ontwerp-, bouw en redactionele - proces van deze website. -
  • -
  • Onderzoek: wij toetsen regelmatig (onderdelen van) onze website op toegankelijkheid. Zowel voor de functioneel-technische - onderdelen als de redactionele aspecten. Gevonden knelpunten lossen wij duurzaam op. -
  • -
  • Kennis medewerkers: onze medewerkers houden hun kennis over toegankelijkheid op peil en passen dit toe waar nodig.
  • -
-

Problemen met toegankelijkheid melden

-

Heeft u vragen of opmerkingen? Of wilt u een pagina gebruiken die niet toegankelijk is? Neem dan contact met ons op.

- -
-
-{% endblock %} diff --git a/templates/static/accessibility.html.twig b/templates/static/accessibility.html.twig new file mode 100644 index 00000000..1faf058a --- /dev/null +++ b/templates/static/accessibility.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Accessibility" | trans() %} + +{% block body %} +
+
+ +

{{ "Accessibility" | trans() }}

+ +

+ {{ "We would like everyone to be able to use this website. Do you come across a page that is not accessible? Or do you have another question? Please contact us." | trans() | raw }} +

+ +

{{ "Accessibility statement open.minvws.nl" | trans() }}.

+ +

{{ "What is an accessible website?" | trans() }}

+ +

{{ "An accessible website is easier to use for all visitors. That is why functional-technical and editorial accessibility requirements or (formerly: web guidelines) apply to government websites. These are described in the accessibility standard Digiaccessible EN 301 549." | trans() | raw }}

+ +

{{ "How do we ensure this website is accessible?" | trans() }}

+ +

{{ "We guarantee good accessibility in various ways within our (daily) processes:" | trans() }}

+
    +
  • {{ "Accessibility by design: Accessibility is part of all steps in the design, construction and editorial process of this website from the start." | trans() }} +
  • +
  • {{ "Research: we regularly check (parts of) our website for accessibility. Both for the functional-technical parts and the editorial aspects. We solve any bottlenecks found in a sustainable manner." | trans() }} +
  • +
  • {{ "Employee knowledge: our employees keep their knowledge about accessibility up to date and apply it where necessary." | trans() }}
  • +
+ +
+
+{% endblock %} diff --git a/templates/static/accessibility.nl.html.twig b/templates/static/accessibility.nl.html.twig deleted file mode 100644 index ec1133af..00000000 --- a/templates/static/accessibility.nl.html.twig +++ /dev/null @@ -1,30 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Toegankelijkheid" %} - -{% block body %} -
-
- -

Toegankelijkheid

-

Wij willen graag dat iedereen deze website kan gebruiken. Komt u toch een pagina tegen die niet toegankelijk is? Dan - kunt u dit aan ons melden.

-

Wat is een toegankelijke website?

-

Een toegankelijke website is voor alle doelgroepen beter te gebruiken. Daarom gelden er functioneel-technische en redactionele toegankelijkheidseisen of (voorheen: webrichtlijnen) voor websites van de overheid. Deze zijn vastgelegd in de toegankelijkheidsstandaard Digitoegankelijk EN 301 549.

-

Borging van toegankelijkheid

-

Wij borgen goede toegankelijkheid door diverse maatregelen binnen onze (dagelijkse) processen:

-
    -
  • Toegankelijkheid ‘by design’: toegankelijkheid is vanaf de start onderdeel van alle stappen in het ontwerp-, bouw en redactionele - proces van deze website. -
  • -
  • Onderzoek: wij toetsen regelmatig (onderdelen van) onze website op toegankelijkheid. Zowel voor de functioneel-technische - onderdelen als de redactionele aspecten. Gevonden knelpunten lossen wij duurzaam op. -
  • -
  • Kennis medewerkers: onze medewerkers houden hun kennis over toegankelijkheid op peil en passen dit toe waar nodig.
  • -
-

Problemen met toegankelijkheid melden

-

Heeft u vragen of opmerkingen? Of wilt u een pagina gebruiken die niet toegankelijk is? Neem dan contact met ons op.

- -
-
-{% endblock %} diff --git a/templates/static/contact.en.html.twig b/templates/static/contact.en.html.twig deleted file mode 100644 index 49e9b15c..00000000 --- a/templates/static/contact.en.html.twig +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Contact" %} - -{% block body %} -
-
-

Contact

-
-
-{% endblock %} diff --git a/templates/static/contact.html.twig b/templates/static/contact.html.twig new file mode 100644 index 00000000..bd3d036a --- /dev/null +++ b/templates/static/contact.html.twig @@ -0,0 +1,22 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Contact" | trans() %} + +{% block body %} +
+
+

{{ "Contact" | trans() }}

+ +

{{ "We make every effort to supplement this website with relevant information." | trans() }}

+ +

{{ "Would you like to contact us? Send an e-mail to _dienstpostbusWoo-corona-ondersteuning@minvws.nl. We can help you with the following:" | trans() | raw }}

+ +
    +
  • {{ "submit a Woo request" | trans() }}
  • +
  • {{ "questions or comments about the website" | trans() }}
  • +
  • {{ "report a document on this website where additional information needs to be obscured (e.g. a person's name or contact details)." | trans() }}
  • +
+

{{ "For feedback, tips and suggestions on how we can improve this website, please email us at woo-platform@irealisatie.nl" | trans() | raw }}

+
+
+{% endblock %} diff --git a/templates/static/contact.nl.html.twig b/templates/static/contact.nl.html.twig deleted file mode 100644 index 49e9b15c..00000000 --- a/templates/static/contact.nl.html.twig +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Contact" %} - -{% block body %} -
-
-

Contact

-
-
-{% endblock %} diff --git a/templates/static/cookies.html.twig b/templates/static/cookies.html.twig new file mode 100644 index 00000000..d3b59b0e --- /dev/null +++ b/templates/static/cookies.html.twig @@ -0,0 +1,43 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Cookies" | trans() %} + +{% block body %} +
+
+ +

{{ "Cookies" | trans() }}

+

+ {{ "We use the Piwik statistics program to analyze which pages are most visited, how visitors came to this website and which search terms they use in our search engine." | trans() }} +

+

+ {{ "Like any website, we collect IP addresses from our visitors. These are stored in so-called log files. The log files are kept on the web server for 5 days, so that they are available to Piwik. After that, the log files are kept for 90 days for security reasons only and are only viewed for that purpose." | trans() }} +

+

+ {{ "We use the Piwik statistics program to analyze which pages are most visited, how visitors came to this website and which search terms they use in our search engine." | trans() }} +

+

+ {{ "The Dutch Data Protection Authority has taken measures to limit the traceability of visitors to our website as much as possible. We do this by discarding the last 2 octets (digit groups) of each IP address immediately after importing the log files into Piwik. This is done in a temporary memory, before the IP addresses are stored in Piwik." | trans() }} +

+ +

{{ "We collect the following data with the log files:" | trans() }} + +

    +
  • {{ "Cookies" | trans() }}
  • +
  • {{ "IP address" | trans() }}
  • +
  • {{ "user agents (browsers, operating system)" | trans() }}
  • +
  • {{ "search terms used to reach our website via external search engines, search terms used in the search engine on the website itself" | trans() }}
  • +
  • {{ "used links within the website" | trans() }}
  • +
  • {{ "used links to get to our website." | trans() }}
  • +
+ +

+ {{ "Piwik retrieves this information from the web server's log files. These log files remain in the Piwik database for 31 days. Then they are deleted. Only a merged log file remains. It provides us with an annual report on website visits." | trans() }} +

+

+ {{ "We do not share personal data with third parties, unless this is necessary to report criminal offences. Read more about the use of cookies by Rijksoverheid.nl." | trans() | raw }} +

+ +
+
+{% endblock %} diff --git a/templates/static/cookies.nl.html.twig b/templates/static/cookies.nl.html.twig deleted file mode 100644 index 7ae78897..00000000 --- a/templates/static/cookies.nl.html.twig +++ /dev/null @@ -1,38 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Cookies" %} - -{% block body %} -
-
- -

Cookies

-

Wij gebruiken het statistiekenprogramma Piwik om te analyseren welke pagina's het meest bezocht worden, hoe bezoekers op - onze website zijn gekomen en welke zoektermen er gebruikt worden in onze zoekmachine.

-

Hiertoe verzamelen wij, net als elke website, IP-adressen van onze bezoekers. Deze worden opgeslagen in zogeheten - logfiles. De logfiles worden 5 dagen op de webserver bewaard zodat ze beschikbaar zijn voor Piwik. Daarna blijven de logfiles 90 dagen - bewaard voor uitsluitend beveiligingsredenen en worden ze ook alleen daarvoor bekeken.

-

De Autoriteit Persoonsgegevens heeft maatregelen getroffen om de herleidbaarheid van bezoekers aan onze website zo veel - mogelijk te beperken. Dit doen we door onmiddellijk na het importeren van de logfiles in Piwik de laatste 2 octetten (cijfergroepen) - van elk IP-adres weg te gooien. Dit gebeurt in een tijdelijk geheugen, voordat de IP-adressen in Piwik worden opgeslagen.

-

Wij verzamelen de volgende gegevens met de logfiles:

-
    -
  • Cookies.
  • -
  • IP-adres.
  • -
  • User agents (browsers, operating system).
  • -
  • Gebruikte zoektermen om via externe zoekmachines op onze website te komen.
  • -
  • Gebruikte zoektermen in de zoekmachine op de website zelf.
  • -
  • Gebruikte links binnen de website.
  • -
  • Gebruikte links om op onze website te komen.
  • -
-

Deze gegevens haalt Piwik uit de logfiles van de webserver. Deze logfiles blijven 31 dagen in de database van Piwik - staan. Daarna worden ze verwijderd. Er blijft dan alleen een geaggregeerde (samengevoegde) logfile over. Die geeft ons een - jaarrapportage over het websitebezoek.

-

Wij verstrekken geen persoonsgegevens aan derden, tenzij dat noodzakelijk is om aangifte te doen van strafbare - feiten.

-

Lees meer over het cookiegebruik door Rijksoverheid.nl: https://www.rijksoverheid.nl/cookies -

- -
-
-{% endblock %} diff --git a/templates/static/copyright.en.html.twig b/templates/static/copyright.en.html.twig deleted file mode 100644 index 11f06ac8..00000000 --- a/templates/static/copyright.en.html.twig +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Copyright" %} - -{% block body %} -
-
- -

Copyright

-

Iedereen mag op grond van de Wet Hergebruik van Overheidsinformatie de informatie op deze website hergebruiken, tenzij - anders is aangegeven.

-

Het recht op hergebruik geldt voor alle soorten informatie zoals teksten, rapporten, foto’s, grafieken en afbeeldingen. - Het hergebruik geldt zowel voor commerciële als een niet-commerciële doelen.

-

Hergebruik is toegestaan tenzij:

-
    -
  • via het copyrightteken (©) is aangegeven dat er op een foto wél copyright zit;
  • -
  • het gaat om content van derden. Controleer daarom altijd de afzender van documenten.
  • -
- -
-
-{% endblock %} diff --git a/templates/static/copyright.html.twig b/templates/static/copyright.html.twig new file mode 100644 index 00000000..1d8fa169 --- /dev/null +++ b/templates/static/copyright.html.twig @@ -0,0 +1,24 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Copyright" | trans %} + +{% block body %} +
+
+ +

{{ "Copyright" | trans }}

+

+ {{ "Anyone may reuse the information on this website under the Reuse of Government Information Act, unless otherwise indicated." | trans }} +

+

+ {{ "The right to reuse applies to all types of information. For example, texts, reports, photos, graphs and images. The reuse applies to both commercial and non-commercial purposes." | trans }} +

+

{{ "Reuse is allowed unless:" | trans }}

+
    +
  • {{ "the copyright sign (©) indicates that a photo is copyrighted;" | trans }}
  • +
  • {{ "it concerns third-party content. Therefore, always check the sender of documents." | trans }}
  • +
+ +
+
+{% endblock %} diff --git a/templates/static/copyright.nl.html.twig b/templates/static/copyright.nl.html.twig deleted file mode 100644 index 11f06ac8..00000000 --- a/templates/static/copyright.nl.html.twig +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Copyright" %} - -{% block body %} -
-
- -

Copyright

-

Iedereen mag op grond van de Wet Hergebruik van Overheidsinformatie de informatie op deze website hergebruiken, tenzij - anders is aangegeven.

-

Het recht op hergebruik geldt voor alle soorten informatie zoals teksten, rapporten, foto’s, grafieken en afbeeldingen. - Het hergebruik geldt zowel voor commerciële als een niet-commerciële doelen.

-

Hergebruik is toegestaan tenzij:

-
    -
  • via het copyrightteken (©) is aangegeven dat er op een foto wél copyright zit;
  • -
  • het gaat om content van derden. Controleer daarom altijd de afzender van documenten.
  • -
- -
-
-{% endblock %} diff --git a/templates/static/privacy.en.html.twig b/templates/static/privacy.en.html.twig deleted file mode 100644 index 1d11e533..00000000 --- a/templates/static/privacy.en.html.twig +++ /dev/null @@ -1,48 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Privacy statement" %} - -{% block body %} -
-
- -

Privacy

-

Privacyverklaring Wet openbaarheid van bestuur (WOB)

-

Persoonsgegevens die wij verwerken

-

AZ verwerkt persoonsgegevens die u ons verstrekt in uw WOB-verzoek. Dit zijn bijvoorbeeld uw naw gegevens (naam, adres, - woonplaats).

-

Wie is verantwoordelijk voor de verwerking van uw persoonsgegevens?

-

De minister-president, de minister van Algemene Zaken, is de zogenaamde verwerkingsverantwoordelijke in de zin van - de Algemene verordening gegevensbescherming (AVG). De Privacyverklaring - van AZ vindt u elders op Rijksoverheid.nl. AZ heeft een functionaris voor gegevensbescherming (FG) als interne toezichthouder, - die toezicht houdt op het zorgvuldig omgaan met persoonsgegevens door AZ. De FG is bereikbaar per e-mail fg@minAZ.nl. -

-

Met welk doel en op basis van welke grondslag verwerken wij persoonsgegevens?

-

Uw gegevens worden alleen gebruikt om uw verzoek te kunnen behandelen. De rechtsgrondslag voor verwerkingen in het kader - van WOB-verzoeken is om te voldoen aan een wettelijke verplichting (de WOB).

-

Hoe lang bewaren wij persoonsgegevens?

-

AZ bewaart uw persoonsgegevens 5 jaarin het kader van de Archiefwet.

-

Met wie delen wij persoonsgegevens?

-

AZ verkoopt of deelt uw gegevens niet.

-

Persoonsgegevens langer bewaren

-

Het recht op verwijdering en het recht om vergeten te worden zijn niet absoluut, maar moeten gewogen worden tegen andere - rechten en belangen. In een beperkt aantal gevallen is het toegestaan om gegevens langer te bewaren . Bijvoorbeeld als iemand - strafbare feiten pleegt en de vastgelegde gegevens noodzakelijk zijn voor opsporing. Dit kan een verlenging van de bewaartermijn tot - gevolg hebben.

-

Hoe wij persoonsgegevens beveiligen

-

AZ neemt de bescherming van uw gegevens serieus en neemt passende maatregelen om misbruik, verlies, onbevoegde toegang, - ongewenste openbaarmaking en ongeoorloofde wijziging tegen te gaan.

-

Gegevens inzien, aanpassen of verwijderen

-

U heeft het recht om uw persoonsgegevens in te zien, te corrigeren of te verwijderen. Daarnaast heeft u het recht om uw - eventuele toestemming voor de gegevensverwerking in te trekken, of bezwaar te maken tegen de verwerking van uw persoonsgegevens door - AZ.

-

U kunt een verzoek indienen met behulp van ons privacyformulier. - We reageren zo snel mogelijk, maar uiterlijk binnen vier weken, op uw verzoek.
U kunt ook een melding doen bij de Autoriteit - Persoonsgegevens.

- -
-
-{% endblock %} diff --git a/templates/static/privacy.html.twig b/templates/static/privacy.html.twig new file mode 100644 index 00000000..f42082b0 --- /dev/null +++ b/templates/static/privacy.html.twig @@ -0,0 +1,65 @@ +{% extends 'base.html.twig' %} + +{% set page_title = "Privacyverklaring" %} + +{% block body %} +
+
+ +

{{ "Privacy Statement Open Government Act (Woo)" | trans() }}

+ +

{{ "Which personal data do we process?" | trans() }}

+ +

+ {{ "The Ministry of Health, Welfare and Sport (VWS) processes personal data that you share with us via your Woo request. These are, for example, your name and address details (name, address, place of residence)." | trans() }} +

+ +

{{ "Who is responsible for the processing of your personal data?" | trans() }}

+ +

+ {{ "The Minister of Health, Welfare and Sport is the so-called controller within the meaning of the General Data Protection Regulation (GDPR). The Privacy Statement of the Ministry of General Affairs (AZ) can be found elsewhere on Rijksoverheid.nl. VWS has a data protection officer (FG) as internal supervisor, who supervises the careful handling of personal data by VWS. The DPO can be reached by e-mail: FG-VWS@minvws.nl." | trans() | raw }} +

+ +

{{ "For what purpose and on what basis do we process personal data?" | trans() }}

+ +

+ {{ "Your data will only be used to handle your request. The legal basis for processing in the context of Woo requests is to comply with a legal obligation (the Woo)." | trans() }} +

+ +

{{ "How long do we store personal data?" | trans() }}

+ +

+ {{ "VWS stores your personal data for 5 years in the context of the Archives Act." | trans() }} +

+ +

{{ "With whom do we share personal data?" | trans() }}

+ +

+ {{ "VWS does not share or sell your data." | trans() }} +

+ +

{{ "Can personal data be kept longer?" | trans() }}

+ +

+ {{ "Personal data is not automatically deleted in all situations. In a limited number of cases we may keep this data longer. For example, if someone commits criminal offenses and the stored data is necessary for investigation. Such a case may lead to an extension of the retention period." | trans() }} +

+ +

{{ "How do we secure personal data?" | trans() }}

+ +

+ {{ "VWS takes the protection of your data seriously and takes appropriate measures to prevent misuse, loss, unauthorized access, unwanted disclosure and unauthorized changes." | trans() }} +

+ +

{{ "Can you view, change or delete data?" | trans() }}

+ +

+ {{ "You have the right to view, change or delete your personal data. You also have the right to withdraw your consent to the data processing, or to object to the processing of your personal data by VWS." | trans() }} +

+ +

+ {{ "You can also report this to the Dutch Data Protection Authority." | trans() | raw }} +

+ +
+
+{% endblock %} diff --git a/templates/static/privacy.nl.html.twig b/templates/static/privacy.nl.html.twig deleted file mode 100644 index dabc69bc..00000000 --- a/templates/static/privacy.nl.html.twig +++ /dev/null @@ -1,48 +0,0 @@ -{% extends 'base.html.twig' %} - -{% set page_title = "Privacyverklaring" %} - -{% block body %} -
-
- -

Privacy

-

Privacyverklaring Wet openbaarheid van bestuur (WOB)

-

Persoonsgegevens die wij verwerken

-

AZ verwerkt persoonsgegevens die u ons verstrekt in uw WOB-verzoek. Dit zijn bijvoorbeeld uw naw gegevens (naam, adres, - woonplaats).

-

Wie is verantwoordelijk voor de verwerking van uw persoonsgegevens?

-

De minister-president, de minister van Algemene Zaken, is de zogenaamde verwerkingsverantwoordelijke in de zin van - de Algemene verordening gegevensbescherming (AVG). De Privacyverklaring - van AZ vindt u elders op Rijksoverheid.nl. AZ heeft een functionaris voor gegevensbescherming (FG) als interne toezichthouder, - die toezicht houdt op het zorgvuldig omgaan met persoonsgegevens door AZ. De FG is bereikbaar per e-mail fg@minAZ.nl. -

-

Met welk doel en op basis van welke grondslag verwerken wij persoonsgegevens?

-

Uw gegevens worden alleen gebruikt om uw verzoek te kunnen behandelen. De rechtsgrondslag voor verwerkingen in het kader - van WOB-verzoeken is om te voldoen aan een wettelijke verplichting (de WOB).

-

Hoe lang bewaren wij persoonsgegevens?

-

AZ bewaart uw persoonsgegevens 5 jaarin het kader van de Archiefwet.

-

Met wie delen wij persoonsgegevens?

-

AZ verkoopt of deelt uw gegevens niet.

-

Persoonsgegevens langer bewaren

-

Het recht op verwijdering en het recht om vergeten te worden zijn niet absoluut, maar moeten gewogen worden tegen andere - rechten en belangen. In een beperkt aantal gevallen is het toegestaan om gegevens langer te bewaren . Bijvoorbeeld als iemand - strafbare feiten pleegt en de vastgelegde gegevens noodzakelijk zijn voor opsporing. Dit kan een verlenging van de bewaartermijn tot - gevolg hebben.

-

Hoe wij persoonsgegevens beveiligen

-

AZ neemt de bescherming van uw gegevens serieus en neemt passende maatregelen om misbruik, verlies, onbevoegde toegang, - ongewenste openbaarmaking en ongeoorloofde wijziging tegen te gaan.

-

Gegevens inzien, aanpassen of verwijderen

-

U heeft het recht om uw persoonsgegevens in te zien, te corrigeren of te verwijderen. Daarnaast heeft u het recht om uw - eventuele toestemming voor de gegevensverwerking in te trekken, of bezwaar te maken tegen de verwerking van uw persoonsgegevens door - AZ.

-

U kunt een verzoek indienen met behulp van ons privacyformulier. - We reageren zo snel mogelijk, maar uiterlijk binnen vier weken, op uw verzoek.
U kunt ook een melding doen bij de Autoriteit - Persoonsgegevens.

- -
-
-{% endblock %} diff --git a/templates/woo_form_theme.html.twig b/templates/woo_form_theme.html.twig new file mode 100644 index 00000000..8a7bd113 --- /dev/null +++ b/templates/woo_form_theme.html.twig @@ -0,0 +1,42 @@ +{% extends 'tailwind_2_layout.html.twig' %} + +{%- block form_errors -%} + {%- if errors|length > 0 -%} +
+
    + {%- for error in errors -%} +
  • {{ error.message }}
  • + {%- endfor -%} +
+
+ {%- endif -%} +{%- endblock form_errors -%} + +{%- block form_help -%} + {%- set help_attr = help_attr|merge({ class: help_attr.class|default(help_class|default('mb-3 text-independence leading-tight text-sm')) }) -%} + {{- parent() -}} +{%- endblock form_help -%} + +{%- block form_label -%} + {%- set label_attr = label_attr|merge({ class: label_attr.class|default(label_class|default('block text-lg font-bold leading-tight mt-0 mb-2')) }) -%} + {{- parent() -}} +{%- endblock form_label -%} + +{%- block form_row -%} + {%- set row_attr = row_attr|merge({ class: row_attr.class|default(row_class|default('mb-6')) }) -%} + {%- set widget_attr = {} -%} + {%- if help is not empty -%} + {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%} + {%- endif -%} + + {{- form_label(form) -}} + {{- form_help(form) -}} + {{- form_errors(form) -}} + {{- form_widget(form, widget_attr) -}} + +{%- endblock form_row -%} + +{%- block widget_attributes -%} + {%- set attr = attr|merge({ class: attr.class|default(widget_class|default('w-6/12')) ~ (disabled ? ' ' ~ widget_disabled_class|default('border-gray-300 text-gray-500')) ~ (errors|length ? ' ' ~ widget_errors_class|default('border-red-700')) }) -%} + {{- parent() -}} +{%- endblock widget_attributes -%} diff --git a/tests/Fixtures/001-inventory-004.xlsx b/tests/Fixtures/001-inventory-004.xlsx new file mode 100644 index 00000000..f81b1b6a Binary files /dev/null and b/tests/Fixtures/001-inventory-004.xlsx differ diff --git a/tests/Fixtures/001-inventory-005.xlsx b/tests/Fixtures/001-inventory-005.xlsx new file mode 100644 index 00000000..7d728a9f Binary files /dev/null and b/tests/Fixtures/001-inventory-005.xlsx differ diff --git a/tests/Fixtures/001-inventory-006.xlsx b/tests/Fixtures/001-inventory-006.xlsx new file mode 100644 index 00000000..c6faf1b8 Binary files /dev/null and b/tests/Fixtures/001-inventory-006.xlsx differ diff --git a/tests/Fixtures/001-inventory-007.xlsx b/tests/Fixtures/001-inventory-007.xlsx new file mode 100644 index 00000000..6c4cf4cb Binary files /dev/null and b/tests/Fixtures/001-inventory-007.xlsx differ diff --git a/tests/Fixtures/001-inventory-008.xlsx b/tests/Fixtures/001-inventory-008.xlsx new file mode 100644 index 00000000..f8e2e7b7 Binary files /dev/null and b/tests/Fixtures/001-inventory-008.xlsx differ diff --git a/tests/Fixtures/002-inventory-001.xlsx b/tests/Fixtures/002-inventory-001.xlsx new file mode 100644 index 00000000..f1d6ff4c Binary files /dev/null and b/tests/Fixtures/002-inventory-001.xlsx differ diff --git a/tests/Fixtures/002-inventory-002.xlsx b/tests/Fixtures/002-inventory-002.xlsx new file mode 100644 index 00000000..272b2f30 Binary files /dev/null and b/tests/Fixtures/002-inventory-002.xlsx differ diff --git a/tests/Fixtures/002-private-threads.json b/tests/Fixtures/002-private-threads.json new file mode 100644 index 00000000..d20d91d6 --- /dev/null +++ b/tests/Fixtures/002-private-threads.json @@ -0,0 +1,34 @@ +{ + "name": "private threads test #533", + "description": "This test will create two dossiers with emails with the same thread id. One dossier is published, the other is not published.", + "dossiers": [ + { + "id": "VWS-0001", + "document_prefix": "MINVWS-COVID19", + "title": "Test dossier 1 published", + "summary": "summary", + "department": "Ministerie van Volksgezondheid, Welzijn en Sport", + "official": "Minister Hugo de Jonge", + "period_from": "2021-09-01", + "period_to": "2021-12-31", + "decision": "partial_public", + "publication_reason": "", + "inventory_path": "002-inventory-001.xlsx", + "status": "published" + }, + { + "id": "VWS-0002", + "document_prefix": "MINVWS-COVID19", + "title": "Test dossier 2 unpublished", + "summary": "summary", + "department": "Ministerie van Volksgezondheid, Welzijn en Sport", + "official": "Minister Hugo de Jonge", + "period_from": "2021-09-01", + "period_to": "2021-12-31", + "decision": "partial_public", + "publication_reason": "", + "inventory_path": "002-inventory-002.xlsx", + "status": "draft" + } + ] +} diff --git a/tests/Unit/Entity/DocumentTest.php b/tests/Unit/Entity/DocumentTest.php new file mode 100644 index 00000000..557719dd --- /dev/null +++ b/tests/Unit/Entity/DocumentTest.php @@ -0,0 +1,73 @@ +setJudgement($judgement); + $document->setSuspended($suspended); + + $this->assertEquals( + $expectedResult, + $document->shouldBeUploaded() + ); + } + + public static function shouldBeUploadedProvider(): array + { + return [ + 'public' => [ + 'judgement' => Judgement::PUBLIC, + 'suspended' => false, + 'expectedResult' => true, + ], + 'public-suspended' => [ + 'judgement' => Judgement::PUBLIC, + 'suspended' => true, + 'expectedResult' => false, + ], + 'partial-public' => [ + 'judgement' => Judgement::PARTIAL_PUBLIC, + 'suspended' => false, + 'expectedResult' => true, + ], + 'partial-public-suspended' => [ + 'judgement' => Judgement::PARTIAL_PUBLIC, + 'suspended' => true, + 'expectedResult' => false, + ], + 'already-public' => [ + 'judgement' => Judgement::ALREADY_PUBLIC, + 'suspended' => false, + 'expectedResult' => false, + ], + 'already-public-suspended' => [ + 'judgement' => Judgement::ALREADY_PUBLIC, + 'suspended' => true, + 'expectedResult' => false, + ], + 'not-public' => [ + 'judgement' => Judgement::NOT_PUBLIC, + 'suspended' => false, + 'expectedResult' => false, + ], + 'not-public-suspended' => [ + 'judgement' => Judgement::NOT_PUBLIC, + 'suspended' => true, + 'expectedResult' => false, + ], + ]; + } +} diff --git a/tests/Unit/Service/Inventory/InventoryReaderTest.php b/tests/Unit/Service/Inventory/InventoryReaderTest.php new file mode 100644 index 00000000..f3b23e45 --- /dev/null +++ b/tests/Unit/Service/Inventory/InventoryReaderTest.php @@ -0,0 +1,60 @@ +create(); + + $dossier = new Dossier(); + + $reader->open(__DIR__ . '/inventory-link-remark-1.xlsx'); + $item = $reader->getDocumentMetadataGenerator($dossier)->current(); + $this->assertEquals('https://www.example.org', $item->getDocumentMetaData()->getLink()); + $this->assertNull($item->getDocumentMetaData()->getRemark()); + + $reader->open(__DIR__ . '/inventory-link-remark-2.xlsx'); + $item = $reader->getDocumentMetadataGenerator($dossier)->current(); + $this->assertEquals('https://www.example.org', $item->getDocumentMetaData()->getLink()); + $this->assertNull($item->getDocumentMetaData()->getRemark()); + + $reader->open(__DIR__ . '/inventory-link-remark-3.xlsx'); + $item = $reader->getDocumentMetadataGenerator($dossier)->current(); + $this->assertNull($item->getDocumentMetaData()->getLink()); + $this->assertEquals('foo bar', $item->getDocumentMetaData()->getRemark()); + + $reader->open(__DIR__ . '/inventory-link-remark-4.xlsx'); + $item = $reader->getDocumentMetadataGenerator($dossier)->current(); + $this->assertEquals('https://www.example.org', $item->getDocumentMetaData()->getLink()); + $this->assertEquals('https://notok.example.org', $item->getDocumentMetaData()->getRemark()); + + $reader->open(__DIR__ . '/inventory-link-remark-5.xlsx'); + $item = $reader->getDocumentMetadataGenerator($dossier)->current(); + $this->assertEquals('https://example.org', $item->getDocumentMetaData()->getLink()); + $this->assertEquals('foo bar', $item->getDocumentMetaData()->getRemark()); + } + + public function testAreDefaultSubjectsSet(): void + { + $factory = new InventoryReaderFactory(); + $reader = $factory->create(); + + $dossier = new Dossier(); + $dossier->setDefaultSubjects(['foo', 'bar']); + + $reader->open(__DIR__ . '/inventory-subjects-1.xlsx'); + + $result = iterator_to_array($reader->getDocumentMetadataGenerator($dossier)); + $this->assertEquals(['subject 1', 'subject 2'], $result[0]->getDocumentMetadata()->getSubjects()); + $this->assertEquals(['foo', 'bar'], $result[1]->getDocumentMetadata()->getSubjects()); + } +} diff --git a/tests/Unit/Service/Inventory/InventoryServiceTest.php b/tests/Unit/Service/Inventory/InventoryServiceTest.php new file mode 100644 index 00000000..6be4ac3e --- /dev/null +++ b/tests/Unit/Service/Inventory/InventoryServiceTest.php @@ -0,0 +1,414 @@ +entityManager = \Mockery::mock(EntityManagerInterface::class); + + $this->documentStorage = \Mockery::mock(DocumentStorageService::class); + + $this->logger = \Mockery::mock(LoggerInterface::class); + $this->logger->shouldReceive('info'); + + $this->translator = \Mockery::mock(TranslatorInterface::class); + $this->translator->shouldReceive('trans')->andReturn('test')->zeroOrMoreTimes(); + + $this->inventoryService = new InventoryService( + $this->entityManager, + $this->documentStorage, + new InventoryReaderFactory(), + $this->translator, + $this->logger, + ); + + $this->uploadedFile = \Mockery::mock(UploadedFile::class); + $this->uploadedFile->expects('getClientOriginalExtension')->andReturn('xlsx'); + $this->uploadedFile->shouldReceive('getClientOriginalName')->andReturn('test-inventory'); + + $file = \Mockery::mock(FileInfo::class); + $file->shouldReceive('setSourceType'); + $file->shouldReceive('setType'); + $file->shouldReceive('setName'); + $file->shouldReceive('getName')->zeroOrMoreTimes()->andReturn('test123'); + + $rawFile = \Mockery::mock(FileInfo::class); + $rawFile->shouldReceive('setSourceType'); + $rawFile->shouldReceive('setType'); + $rawFile->shouldReceive('setName'); + $rawFile->shouldReceive('getName')->zeroOrMoreTimes()->andReturn('rawtest123'); + + $this->inventory = \Mockery::mock(Inventory::class); + $this->inventory + ->shouldReceive('getFileInfo') + ->andReturns($file); + + $this->dossier = \Mockery::mock(Dossier::class); + $this->dossier + ->shouldReceive('getId') + ->andReturns(Uuid::v6()); + $this->dossier + ->shouldReceive('getDossierNr') + ->andReturns('dossier-123'); + $this->dossier + ->shouldReceive('addDocument') + ->with(\Mockery::type(Inventory::class)); + $this->dossier + ->shouldReceive('getDocumentPrefix') + ->andReturns('FOOBAR'); + $this->dossier + ->shouldReceive('getInventory') + ->andReturns($this->inventory); + $this->dossier + ->shouldReceive('setInventory'); + + $this->inventory->shouldReceive('getDossiers')->andReturn(new ArrayCollection([$this->dossier])); + + $this->inventoryRepository = \Mockery::mock(EntityRepository::class); + + $this->documentRepository = \Mockery::mock(EntityRepository::class); + + $this->inquiryRepository = \Mockery::mock(InquiryRepository::class); + + $this->entityManager->expects('flush')->zeroOrMoreTimes(); + $this->entityManager->expects('beginTransaction'); + + $this->entityManager + ->shouldReceive('getRepository') + ->with(Inventory::class) + ->andReturns($this->inventoryRepository); + $this->entityManager + ->shouldReceive('getRepository') + ->with(Document::class) + ->andReturns($this->documentRepository); + $this->entityManager + ->shouldReceive('getRepository') + ->with(Inquiry::class) + ->andReturns($this->inquiryRepository); + + parent::setUp(); + } + + public function testProcessInventoryReturnsErrorWhenInventoryFileCannotBeStored(): void + { + $this->entityManager->expects('rollback'); + + $filename = __DIR__ . '/inventory-missing-document-id.xlsx'; + $this->documentStorage + ->expects('storeDocument') + ->with($this->uploadedFile, \Mockery::type(RawInventory::class)) + ->andReturnTrue(); + + $this->entityManager->expects('persist')->with(\Mockery::type(RawInventory::class)); + $this->entityManager->expects('persist')->with(\Mockery::type(Inventory::class)); + $this->entityManager->expects('persist')->with(\Mockery::type(Dossier::class)); + + $this->documentStorage + ->expects('downloadDocument') + ->with(\Mockery::type(RawInventory::class)) + ->andReturn($filename); + + $this->documentStorage + ->expects('removeDownload'); + + $this->documentStorage + ->expects('storeDocument') + ->with(\Mockery::type(\SplFileInfo::class), \Mockery::type(Inventory::class)) + ->andReturnFalse(); + + $this->dossier + ->shouldReceive('getDocuments') + ->andReturn(new ArrayCollection([])); + + $this->logger->shouldReceive('error'); + + $result = $this->inventoryService->processInventory( + $this->uploadedFile, + $this->dossier + ); + + $this->assertFalse($result->isSuccessful()); + $this->assertEquals( + [ + 'Could not store the sanitized inventory spreadsheet.', + ], + $result->getGenericErrors() + ); + } + + public function testProcessInventoryReturnsErrorWhenRawInventoryFileCannotBeStored(): void + { + $this->entityManager->expects('rollback'); + + $filename = __DIR__ . '/inventory-missing-document-id.xlsx'; + $this->documentStorage + ->expects('storeDocument') + ->with($this->uploadedFile, \Mockery::type(RawInventory::class)) + ->andReturnFalse(); + + $this->entityManager->expects('persist')->with(\Mockery::type(RawInventory::class)); + + $this->logger->shouldReceive('error'); + + $result = $this->inventoryService->processInventory( + $this->uploadedFile, + $this->dossier + ); + + $this->assertFalse($result->isSuccessful()); + $this->assertEquals( + [ + 'Could not store the inventory spreadsheet.', + ], + $result->getGenericErrors() + ); + } + + public function testProcessInventoryReturnsErrorsWhenInventoryFileHasMissingHeaders(): void + { + $this->entityManager->expects('rollback'); + + $filename = __DIR__ . '/inventory-missing-columns.xlsx'; + $this->documentStorage + ->expects('downloadDocument') + ->with(\Mockery::type(RawInventory::class)) + ->andReturn($filename); + + $this->documentStorage + ->expects('storeDocument') + ->with($this->uploadedFile, \Mockery::type(RawInventory::class)) + ->andReturnTrue(); + + $this->documentStorage + ->expects('removeDownload') + ->with($filename); + + $this->logger->shouldReceive('error'); + + $this->entityManager->expects('persist')->with(\Mockery::type(RawInventory::class)); + + $result = $this->inventoryService->processInventory( + $this->uploadedFile, + $this->dossier, + ); + + $this->assertFalse($result->isSuccessful()); + $this->assertEquals( + [ + 'Error while trying to read the spreadsheet: Could not find the correct headers in the spreadsheet. Missing: date, document, sourcetype, id, threadid, matter', + ], + $result->getGenericErrors() + ); + } + + public function testProcessInventoryReturnsErrorsWhenInventoryFileIsMissingADocumentIdForOneRow(): void + { + $this->entityManager->expects('rollback'); + + $filename = __DIR__ . '/inventory-missing-document-id.xlsx'; + $this->documentStorage + ->expects('downloadDocument') + ->with(\Mockery::type(RawInventory::class)) + ->andReturn($filename); + + $this->documentStorage + ->expects('storeDocument') + ->with($this->uploadedFile, \Mockery::type(RawInventory::class)) + ->andReturnTrue(); + + $this->documentStorage + ->expects('storeDocument') + ->with(\Mockery::type(\SplFileInfo::class), \Mockery::type(Inventory::class)) + ->andReturnTrue(); + + $this->documentStorage + ->expects('removeDownload') + ->with($filename); + + $this->entityManager->expects('persist')->with(\Mockery::type(RawInventory::class)); + $this->entityManager->expects('persist')->with(\Mockery::type(Inventory::class)); + $this->entityManager->expects('persist')->with(\Mockery::type(Dossier::class)); + + $this->logger->shouldReceive('error'); + + $this->dossier + ->shouldReceive('getDocuments') + ->andReturn(new ArrayCollection([])); + + $this->documentRepository + ->expects('findOneBy') + ->with(['documentNr' => 'FOOBAR-56789-5034']) + ->andReturnNull(); + + $this->dossier->expects('addDocument')->with(\Mockery::type(Document::class)); + $this->entityManager->expects('persist')->with(\Mockery::type(Document::class)); + + $dummyInquiry = \Mockery::mock(Inquiry::class); + $dummyInquiry->expects('addDocument')->with(\Mockery::type(Document::class)); + $dummyInquiry->expects('setUpdatedAt')->andReturnSelf(); + + $this->inquiryRepository + ->expects('findOneBy') + ->with(['casenr' => '11-111']) + ->andReturn($dummyInquiry); + + $this->entityManager->expects('persist')->with($dummyInquiry); + $this->entityManager->expects('persist')->with($this->dossier); + + $result = $this->inventoryService->processInventory( + $this->uploadedFile, + $this->dossier + ); + + $this->assertFalse($result->isSuccessful()); + $this->assertEquals( + [ + 2 => [ + 'Error reading row: Error while processing row 2 in the spreadsheet: Missing document ID in inventory row #2', + ], + ], + $result->getRowErrors() + ); + } + + public function testProcessInventoryReturnsNoErrorsWhenInventoryFileIsValid(): void + { + $this->entityManager->expects('commit'); + + $filename = __DIR__ . '/inventory-valid.xlsx'; + + $this->documentStorage + ->expects('downloadDocument') + ->with(\Mockery::type(RawInventory::class)) + ->andReturn($filename); + + $this->documentStorage + ->expects('storeDocument') + ->with($this->uploadedFile, \Mockery::type(RawInventory::class)) + ->andReturnTrue(); + + $this->documentStorage + ->expects('storeDocument') + ->with(\Mockery::type(\SplFileInfo::class), \Mockery::type(Inventory::class)) + ->andReturnTrue(); + + $this->documentStorage + ->expects('removeDownload') + ->with($filename); + + $this->entityManager->expects('persist')->with(\Mockery::type(RawInventory::class)); + $this->entityManager->expects('persist')->with(\Mockery::type(Inventory::class)); + $this->entityManager->expects('persist')->with($this->dossier)->times(3); + + $this->logger->shouldReceive('error'); + + $dummyDocToBeRemoved = \Mockery::mock(Document::class); + $dummyDocToBeRemoved->expects('getDocumentNr')->andReturn(789); + + $file = \Mockery::mock(FileInfo::class); + $file->shouldReceive('setSourceType'); + $file->shouldReceive('setType'); + $file->shouldReceive('setName'); + $file->shouldReceive('getName')->zeroOrMoreTimes()->andReturn('test456'); + + $dummyDocExisting = \Mockery::mock(Document::class); + $dummyDocExisting->expects('getDocumentNr')->andReturn(5034)->times(3); + $dummyDocExisting->expects('getDossiers')->andReturn(new ArrayCollection([$this->dossier]))->times(2); + $dummyDocExisting->expects('setDocumentDate')->andReturnSelf(); + $dummyDocExisting->expects('setFamilyId')->andReturnSelf(); + $dummyDocExisting->expects('setDocumentId')->andReturnSelf(); + $dummyDocExisting->expects('setThreadId')->andReturnSelf(); + $dummyDocExisting->expects('setJudgement')->andReturnSelf(); + $dummyDocExisting->expects('setGrounds')->andReturnSelf(); + $dummyDocExisting->expects('setSubjects')->andReturnSelf(); + $dummyDocExisting->expects('setPeriod')->andReturnSelf(); + $dummyDocExisting->expects('setDocumentNr')->andReturnSelf(); + $dummyDocExisting->expects('setSuspended')->andReturnSelf(); + $dummyDocExisting->expects('getFileInfo')->andReturns($file); + $dummyDocExisting->expects('setLink')->andReturnSelf(); + $dummyDocExisting->expects('setRemark')->andReturnSelf(); + + $dummyInquiry = \Mockery::mock(Inquiry::class); + $dummyInquiry->expects('setUpdatedAt')->andReturnSelf()->twice(); + $dummyInquiry->expects('addDocument')->with($dummyDocExisting); + $dummyInquiry->expects('addDocument')->with(\Mockery::type(Document::class)); + $dummyInquiry->expects('addDossier')->with($this->dossier); + + $this->dossier + ->shouldReceive('getDocuments') + ->andReturn( + new ArrayCollection([ + $dummyDocToBeRemoved, + $dummyDocExisting, + ]) + ); + + $this->documentRepository + ->expects('findOneBy') + ->with(['documentNr' => 'FOOBAR-123-5033']) + ->andReturnNull(); + + $this->documentRepository + ->expects('findOneBy') + ->with(['documentNr' => 'FOOBAR-123-5034']) + ->andReturn($dummyDocExisting); + + $this->inquiryRepository + ->expects('findOneBy') + ->with(['casenr' => '11-111']) + ->andReturn($dummyInquiry) + ->times(2); + + $this->dossier->expects('removeDocument')->with($dummyDocToBeRemoved); + $this->dossier->expects('addDocument')->with($dummyDocExisting); + $this->dossier->expects('addDocument')->with(\Mockery::type(Document::class)); + + $this->entityManager->expects('persist')->with(\Mockery::type(Document::class))->zeroOrMoreTimes(); + $this->entityManager->expects('persist')->with($dummyInquiry)->times(2); + + $result = $this->inventoryService->processInventory( + $this->uploadedFile, + $this->dossier, + ); + + $this->assertTrue($result->isSuccessful()); + $this->assertEquals([], $result->getGenericErrors()); + $this->assertEquals([], $result->getRowErrors()); + } +} diff --git a/tests/Unit/Service/Inventory/inventory-link-remark-1.xlsx b/tests/Unit/Service/Inventory/inventory-link-remark-1.xlsx new file mode 100644 index 00000000..ace24470 Binary files /dev/null and b/tests/Unit/Service/Inventory/inventory-link-remark-1.xlsx differ diff --git a/tests/Unit/Service/Inventory/inventory-link-remark-2.xlsx b/tests/Unit/Service/Inventory/inventory-link-remark-2.xlsx new file mode 100644 index 00000000..17cec2e0 Binary files /dev/null and b/tests/Unit/Service/Inventory/inventory-link-remark-2.xlsx differ diff --git a/tests/Unit/Service/Inventory/inventory-link-remark-3.xlsx b/tests/Unit/Service/Inventory/inventory-link-remark-3.xlsx new file mode 100644 index 00000000..b818c18e Binary files /dev/null and b/tests/Unit/Service/Inventory/inventory-link-remark-3.xlsx differ diff --git a/tests/Unit/Service/Inventory/inventory-link-remark-4.xlsx b/tests/Unit/Service/Inventory/inventory-link-remark-4.xlsx new file mode 100644 index 00000000..1d46dc0f Binary files /dev/null and b/tests/Unit/Service/Inventory/inventory-link-remark-4.xlsx differ diff --git a/tests/Unit/Service/Inventory/inventory-link-remark-5.xlsx b/tests/Unit/Service/Inventory/inventory-link-remark-5.xlsx new file mode 100644 index 00000000..023ade6b Binary files /dev/null and b/tests/Unit/Service/Inventory/inventory-link-remark-5.xlsx differ diff --git a/tests/Unit/Service/Inventory/inventory-missing-columns.xlsx b/tests/Unit/Service/Inventory/inventory-missing-columns.xlsx new file mode 100644 index 00000000..083eb9e3 Binary files /dev/null and b/tests/Unit/Service/Inventory/inventory-missing-columns.xlsx differ diff --git a/tests/Unit/Service/Inventory/inventory-missing-document-id.xlsx b/tests/Unit/Service/Inventory/inventory-missing-document-id.xlsx new file mode 100644 index 00000000..75d50d6b Binary files /dev/null and b/tests/Unit/Service/Inventory/inventory-missing-document-id.xlsx differ diff --git a/tests/Unit/Service/Inventory/inventory-subjects-1.xlsx b/tests/Unit/Service/Inventory/inventory-subjects-1.xlsx new file mode 100644 index 00000000..006e1205 Binary files /dev/null and b/tests/Unit/Service/Inventory/inventory-subjects-1.xlsx differ diff --git a/tests/Unit/Service/Inventory/inventory-valid.xlsx b/tests/Unit/Service/Inventory/inventory-valid.xlsx new file mode 100644 index 00000000..c4201116 Binary files /dev/null and b/tests/Unit/Service/Inventory/inventory-valid.xlsx differ diff --git a/tests/Unit/Service/Search/Query/QueryGeneratorTest.php b/tests/Unit/Service/Search/Query/QueryGeneratorTest.php new file mode 100644 index 00000000..ae6bb884 --- /dev/null +++ b/tests/Unit/Service/Search/Query/QueryGeneratorTest.php @@ -0,0 +1,616 @@ +queryGenerator = new QueryGenerator( + new AggregationGenerator( + $facetMapping, + $contentAccessConditions, + $facetConditions, + $searchTermConditions + ), + $contentAccessConditions, + $facetConditions, + $searchTermConditions, + ); + } + + public function testCreateQueryWithMinimalConfig(): void + { + $result = $this->queryGenerator->createQuery( + new Config( + pagination: false, + aggregations: false, + ) + ); + + $this->assertJsonStringEqualsJsonString( + <<index}", + "from": 0, + "size": 0 +} +END, + json_encode($result->build(), JSON_PRETTY_PRINT) + ); + } + + public function testCreateQueryWithDossierOnlyConfig(): void + { + $result = $this->queryGenerator->createQuery( + new Config( + searchType: Config::TYPE_DOSSIER, + pagination: false, + aggregations: false, + ) + ); + + $this->assertJsonStringEqualsJsonString( + <<index}", + "from": 0, + "size": 0 +} +END, + json_encode($result->build(), JSON_PRETTY_PRINT) + ); + } + + public function testCreateQueryWithComplexConfig(): void + { + $result = $this->queryGenerator->createQuery( + new Config( + limit: 15, + offset: 6, + query: 'search terms', + documentInquiries: ['doc-inq-1'], + dossierInquiries: ['dos-inq-1', 'dos-inq-2'] + ) + ); + + $this->assertJsonStringEqualsJsonString( + <<" + ], + "post_tags": [ + "<\/span>" + ], + "fields": { + "pages.content": { + "fragment_size": 50, + "number_of_fragments": 5, + "type": "unified" + }, + "dossiers.title": { + "fragment_size": 50, + "number_of_fragments": 5, + "type": "unified" + }, + "dossiers.summary": { + "fragment_size": 50, + "number_of_fragments": 5, + "type": "unified" + }, + "title": { + "fragment_size": 50, + "number_of_fragments": 5, + "type": "unified" + }, + "summary": { + "fragment_size": 50, + "number_of_fragments": 5, + "type": "unified" + }, + "decision_content": { + "fragment_size": 50, + "number_of_fragments": 5, + "type": "unified" + } + }, + "require_field_match": true, + "highlight_query": { + "query_string": { + "query": "search terms", + "fields": [ + "title", + "summary", + "decision_content", + "dossiers.summary", + "dossiers.title", + "pages.content" + ] + } + } + }, + "aggs": { + "subject": { + "terms": { + "field": "subjects", + "size": 25, + "order": { + "_count": "desc" + }, + "min_doc_count": 1 + } + }, + "source": { + "terms": { + "field": "source_type", + "size": 25, + "order": { + "_count": "desc" + }, + "min_doc_count": 1 + } + }, + "grounds": { + "terms": { + "field": "grounds", + "size": 25, + "order": { + "_count": "desc" + }, + "min_doc_count": 1 + } + }, + "judgement": { + "terms": { + "field": "judgement", + "size": 25, + "order": { + "_count": "desc" + }, + "min_doc_count": 1 + } + }, + "dossiers-department": { + "nested": { + "path": "dossiers" + }, + "aggs": { + "department": { + "terms": { + "field": "dossiers.departments.name", + "size": 25, + "order": { + "_count": "desc" + }, + "min_doc_count": 1 + } + } + } + }, + "dossiers-official": { + "nested": { + "path": "dossiers" + }, + "aggs": { + "official": { + "terms": { + "field": "dossiers.government_officials.name", + "size": 25, + "order": { + "_count": "desc" + }, + "min_doc_count": 1 + } + } + } + }, + "dossiers-period": { + "nested": { + "path": "dossiers" + }, + "aggs": { + "period": { + "terms": { + "field": "dossiers.date_period", + "size": 25, + "order": { + "_count": "desc" + }, + "min_doc_count": 1 + } + } + } + }, + "unique_dossiers": { + "cardinality": { + "field": "dossier_nr" + } + }, + "unique_documents": { + "cardinality": { + "field": "document_nr" + } + } + } + }, + "index": "{$this->index}", + "from": 6, + "size": 15 +} +END, + json_encode($result->build(), JSON_PRETTY_PRINT) + ); + } +} diff --git a/tests/Unit/Service/Search/Result/AggregationMapperTest.php b/tests/Unit/Service/Search/Result/AggregationMapperTest.php new file mode 100644 index 00000000..99c4a9a0 --- /dev/null +++ b/tests/Unit/Service/Search/Result/AggregationMapperTest.php @@ -0,0 +1,114 @@ +translator = \Mockery::mock(TranslatorInterface::class); + + $this->mapper = new AggregationMapper($this->translator); + } + + public function testMapGrounds(): void + { + $result = $this->mapper->map( + FacetKey::GROUNDS->value, + [ + new TypeArray(['key' => Citation::DUBBEL, 'doc_count' => 123]), + new TypeArray(['key' => '5.1.1a', 'doc_count' => 456]), + new TypeArray(['key' => 'foo.bar', 'doc_count' => 789]), + ] + ); + + // Citation 'dubbel' should be skipped, citation '5.1.1a' translated, unknown citations outputted as-is. + $expectedEntries = [ + new AggregationBucketEntry( + '5.1.1a', + 456, + '5.1.1a Eenheid van de Kroon', + ), + new AggregationBucketEntry( + 'foo.bar', + 789, + 'foo.bar', + ), + ]; + + $this->assertEquals( + new Aggregation(FacetKey::GROUNDS->value, $expectedEntries), + $result + ); + } + + public function testMapSource(): void + { + $this->translator->expects('trans')->with(SourceType::SOURCE_EMAIL)->andReturn('foo-bar'); + + $result = $this->mapper->map( + FacetKey::SOURCE->value, + [ + new TypeArray(['key' => SourceType::SOURCE_EMAIL, 'doc_count' => 123]), + ] + ); + + $expectedEntries = [ + new AggregationBucketEntry( + SourceType::SOURCE_EMAIL, + 123, + 'foo-bar', + ), + ]; + + $this->assertEquals( + new Aggregation(FacetKey::SOURCE->value, $expectedEntries), + $result + ); + } + + public function testMapEmptyValueReturnsNone(): void + { + $result = $this->mapper->map( + 'dummy', + [ + new TypeArray(['key' => '', 'doc_count' => 123]), + new TypeArray(['key' => 'a', 'doc_count' => 456]), + ] + ); + + $expectedEntries = [ + new AggregationBucketEntry( + '', + 123, + 'none', + ), + new AggregationBucketEntry( + 'a', + 456, + 'a', + ), + ]; + + $this->assertEquals( + new Aggregation('dummy', $expectedEntries), + $result + ); + } +} diff --git a/tests/Unit/Twig/Runtime/WooExtensionRuntimeTest.php b/tests/Unit/Twig/Runtime/WooExtensionRuntimeTest.php new file mode 100644 index 00000000..064b5be2 --- /dev/null +++ b/tests/Unit/Twig/Runtime/WooExtensionRuntimeTest.php @@ -0,0 +1,89 @@ +requestStack = \Mockery::mock(RequestStack::class); + + $this->runtime = new WooExtensionRuntime( + $this->requestStack, + \Mockery::mock(ThumbnailStorageService::class), + \Mockery::mock(TranslatorInterface::class), + \Mockery::mock(DocumentRepository::class), + \Mockery::mock(UrlGeneratorInterface::class), + \Mockery::mock(FacetMappingService::class), + ); + } + + /** + * @dataProvider queryStringWithoutParamProvider + */ + public function testQueryStringWithoutParam( + string $queryString, + string $paramToRemove, + string $valueToRemove, + string $expectedQuery + ): void { + $request = \Mockery::mock(Request::class); + $request->expects('getQueryString')->zeroOrMoreTimes()->andReturn($queryString); + + $this->requestStack->expects('getCurrentRequest')->zeroOrMoreTimes()->andReturn($request); + + $this->assertEquals( + $expectedQuery, + urldecode($this->runtime->queryStringWithoutParam($paramToRemove, $valueToRemove)) + ); + } + + /** + * @return array + */ + public static function queryStringWithoutParamProvider(): array + { + return [ + 'remove-a-non-existing-param-does-nothing' => [ + 'queryString' => '?a=1&b[]=2&b[]=3&c[x]=4', + 'paramToRemove' => 'foo', + 'value' => '', + 'expectedQuery' => '?a=1&b[]=2&b[]=3&c[x]=4', + ], + 'remove-a-basic-param' => [ + 'queryString' => '?a=1&b=2&c=3', + 'paramToRemove' => 'b', + 'value' => '', + 'expectedQuery' => '?a=1&c=3', + ], + 'remove-a-single-value-from-a-multivalue-param' => [ + 'queryString' => '?a=1&b[]=2&b[]=3', + 'paramToRemove' => 'b', + 'value' => '2', + 'expectedQuery' => '?a=1&b[]=3', + ], + 'remove-only-one-named-subparam' => [ + 'queryString' => '?a=1&dt[from]=a&dt[to]=b', + 'paramToRemove' => 'dt[from]', + 'value' => '', + 'expectedQuery' => '?a=1&dt[to]=b', + ], + ]; + } +} diff --git a/tests/Unit/ValueObject/DossierUploadStatusTest.php b/tests/Unit/ValueObject/DossierUploadStatusTest.php new file mode 100644 index 00000000..3cb6866a --- /dev/null +++ b/tests/Unit/ValueObject/DossierUploadStatusTest.php @@ -0,0 +1,128 @@ +missingUpload = \Mockery::mock(Document::class); + $this->missingUpload->shouldReceive('shouldBeUploaded')->andReturnTrue(); + $this->missingUpload->shouldReceive('isUploaded')->andReturnFalse(); + + $this->completedUpload = \Mockery::mock(Document::class); + $this->completedUpload->shouldReceive('shouldBeUploaded')->andReturnTrue(); + $this->completedUpload->shouldReceive('isUploaded')->andReturnTrue(); + + $this->unwantedUpload = \Mockery::mock(Document::class); + $this->unwantedUpload->shouldReceive('shouldBeUploaded')->andReturnFalse(); + $this->unwantedUpload->shouldReceive('isUploaded')->andReturnFalse(); + + $this->dossier = \Mockery::mock(Dossier::class); + + $this->dossierUploadStatus = new DossierUploadStatus($this->dossier); + + parent::setUp(); + } + + public function testGetExpectedDocuments(): void + { + $this->dossier->shouldReceive('getDocuments')->andReturn(new ArrayCollection([ + $this->missingUpload, + $this->completedUpload, + $this->unwantedUpload, + ])); + + $this->assertEquals( + new ArrayCollection([ + $this->missingUpload, + $this->completedUpload, + ]), + $this->dossierUploadStatus->getExpectedDocuments() + ); + } + + public function testGetUploadedDocuments(): void + { + $this->dossier->shouldReceive('getDocuments')->andReturn(new ArrayCollection([ + $this->missingUpload, + $this->completedUpload, + $this->unwantedUpload, + ])); + + $this->assertEqualsCanonicalizing( + [ + $this->completedUpload, + ], + $this->dossierUploadStatus->getUploadedDocuments()->toArray() + ); + } + + public function testGetExpectedUploadCount(): void + { + $this->dossier->shouldReceive('getDocuments')->andReturn(new ArrayCollection([ + $this->missingUpload, + $this->completedUpload, + $this->unwantedUpload, + ])); + + $this->assertEquals( + 2, + $this->dossierUploadStatus->getExpectedUploadCount() + ); + } + + public function testGetActualUploadCount(): void + { + $this->dossier->shouldReceive('getDocuments')->andReturn(new ArrayCollection([ + $this->missingUpload, + $this->completedUpload, + $this->unwantedUpload, + ])); + + $this->assertEquals( + 1, + $this->dossierUploadStatus->getActualUploadCount() + ); + } + + public function testIsCompleteReturnsFalseWithMissingUpload(): void + { + $this->dossier->shouldReceive('getDocuments')->andReturn(new ArrayCollection([ + $this->missingUpload, + $this->completedUpload, + $this->unwantedUpload, + ])); + + $this->assertFalse( + $this->dossierUploadStatus->isComplete() + ); + } + + public function testIsCompleteReturnsTrueWhenAllExpectedUploadsAreDone(): void + { + $this->dossier->shouldReceive('getDocuments')->andReturn(new ArrayCollection([ + $this->completedUpload, + $this->unwantedUpload, + ])); + + $this->assertTrue( + $this->dossierUploadStatus->isComplete() + ); + } +} diff --git a/tests/robot_framework/keywords.resource b/tests/robot_framework/keywords.resource new file mode 100644 index 00000000..6ff36a80 --- /dev/null +++ b/tests/robot_framework/keywords.resource @@ -0,0 +1,162 @@ +*** Keywords *** +Cleanup script + IF ${RUN_IN_DOCKER} + ${cleanup_db} Set Variable docker-compose exec app bin/console woopie:dev:clean-sheet -u --force + ELSE + ${cleanup_db} Set Variable php bin/console woopie:dev:clean-sheet -u --force + END + Run Process ${cleanup_db} shell=True alias=cleanup + ${stdout} ${stderr} Get Process Result cleanup stdout=True stderr=True + Should Be Empty ${stderr} Error creating admin ${stderr} + +Woo Suite Setup + Cleanup script + Create Woo Admin User + Fill Testdata from Fixture + Run Consume Worker + First Time Login With Admin + Open Browser and BaseUrl + +Open Browser and BaseUrl + New Browser chromium headless=${headless} args=["--ignore-certificate-errors", "--lang=nl"] + # New Context locale=nl-NL + New Page ${base_url} + + +Search For + [Arguments] ${SEARCH_TERM}= ${SEARCH_RESULTS}= ${SEARCH_RESULTS2}= ${SEARCH_RESULTS3}= ${SEARCH_RESULTS4}= ${SEARCH_RESULTS5}= ${SEARCH_RESULTS6}= ${SEARCH_RESULTS7}= ${NOT_VISIBLE1}=default + Go To ${base_url}/search?q= + Fill Text id=search-field ${SEARCH_TERM} + Keyboard Key press Enter + Get Text //*[@id="main-content"] *= ${SEARCH_RESULTS} + Get Text //body *= ${SEARCH_RESULTS2} + Get Text //body *= ${SEARCH_RESULTS3} + Get Text //body *= ${SEARCH_RESULTS4} + Get Text //body *= ${SEARCH_RESULTS5} + Get Text //body *= ${SEARCH_RESULTS6} + Get Text //body *= ${SEARCH_RESULTS7} + IF "${NOT_VISIBLE1}" != "default" + Get Text //*[@id="main-content"]/div not contains ${NOT_VISIBLE1} + END + + +Create Woo Admin User + IF ${RUN_IN_DOCKER} + ${make_user_command} Set Variable docker-compose exec app bin/console woopie:user:create "${chosen_email}" "full name" --super-admin + ELSE + ${make_user_command} Set Variable php bin/console woopie:user:create "${chosen_email}" "full name" --super-admin + END + Run Process ${make_user_command} shell=True alias=create_admin + ${stdout} ${stderr} Get Process Result create_admin stdout=True stderr=True + Should Be Empty ${stderr} Error creating admin ${stderr} + ${regel_ww} Get Line ${stdout} 1 + ${ww} Get Substring ${regel_ww} 13 + ${otp_line} Get Line ${stdout} 3 + ${otp_code} Get Substring ${otp_line} 13 + Should Not Be Empty ${otp_code} No otp code found in: ${stdout} + Set Suite Variable ${otp_code} ${otp_code} + Set Suite Variable ${ww} ${ww} + +Fill Testdata from Fixture + IF ${RUN_IN_DOCKER} + ${insert_testdata} Set Variable docker-compose exec app php bin/console woopie:load:fixture tests/Fixtures/001-inquiry.json + ELSE + ${insert_testdata} Set Variable php bin/console woopie:load:fixture tests/Fixtures/001-inquiry.json + END + Run Process ${insert_testdata} shell=True alias=add_data + ${stdout} ${stderr} Get Process Result add_data stdout=True stderr=True + Should Be Empty ${stderr} Error adding data ${stderr} + +Run Consume Worker + IF ${RUN_IN_DOCKER} + ${consume_worker} Set Variable docker-compose exec app make consume + ELSE + ${consume_worker} Set Variable php bin/console woopie:load:fixture tests/Fixtures/001-inquiry.json + END + Start Process ${consume_worker} shell=True alias=add_data + # ${stdout} ${stderr} Get Process Result add_data stdout=True stderr=True + # Should Be Empty ${stderr} Error adding data ${stderr} + +First Time Login With Admin + New Browser chromium headless=${headless} args=["--ignore-certificate-errors"] + New Page ${base_url}/balie/login + Fill Text id=inputEmail ${chosen_email} + Fill Text id=inputPassword ${ww} + Click " Inloggen " + ${otp} get otp ${otp_code} + Fill Text id=_auth_code ${otp} + Click " Controleren " + Go To ${base_url}/balie/login #nodig omdat redirect niet werkt, werkt bij joshua zonder docker wel + Fill Text id=change_password_current_password ${ww} + Fill Text id=change_password_plainPassword_first ${chosen_password} + Fill Text id=change_password_plainPassword_second ${chosen_password} + Click " Wachtwoord aanpassen " + Get Text //body *= Uitloggen + Click " Uitloggen " + Close Page CURRENT + +Login With Admin + New Browser chromium headless=${headless} args=["--ignore-certificate-errors"] + New Page ${base_url}/balie/login + Fill Text id=inputEmail ${chosen_email} + Fill Text id=inputPassword ${chosen_password} + Click " Inloggen " + ${otp} get otp ${otp_code} + Fill Text id=_auth_code ${otp} + Click " Controleren " + Go To ${base_url}/balie/login #nodig omdat redirect niet werkt, werkt bij joshua zonder docker wel + Get Text //body *= Uitloggen + +Create a new prefix + Click " Counter" + Click "Prefix beheer" + Get Text //body *= Prefix beheer + Click "Nieuwe prefix" + Get Text //body *= Nieuwe prefix aanmaken + Fill Text id=document_prefix_prefix RobotPrefixTitel + Fill Text id=document_prefix_description Robot_Prefix_Omschrijving + Click "Opslaan" + Get Text //body *= ROBOTPREFIXTITEL + Get Text //body *= Robot_Prefix_Omschrijving + +Create a new dossier + Click " Counter" + Click "Dossier management" + Get Text //body *= Alle besluitdossiers + Take Screenshot fullPage=True + Click "Nieuw besluitdossier aanmaken" + Get Text //body *= Nieuw besluitdossier aanmaken + Fill Text id=dossier_title Robot_Dossier_Titel + Fill Text id=dossier_summary Robot_Dossier_Omschrijving + Select Options By id=dossier_departments text Ministerie van Algemene Zaken + Select Options By id=dossier_governmentofficials text Minister-President Mark Rutte + Select Options By id=dossier_documentPrefix text ROBOTPREFIXTITEL + Select Options By id=dossier_publication_reason text Woo-verzoek + Select Options By id=dossier_decision text Openbaar + Type Text id=dossier_date_from 2/2/2019 + Type Text id=dossier_date_to 2/2/2020 + Sleep 2s + Upload File By Selector id=dossier_inventory tests/robot_framework/files/sample.xlsx + # ${promise}= Promise To Upload File tests/robot_framework/files/sample.xlsx + # Click id=dossier_inventory + # ${upload_result}= Wait For ${promise} + Take Screenshot fullPage=True + Wait Until Network Is Idle timeout=10s + Sleep 2s + Click "Opslaan" + Sleep 2s + Wait Until Network Is Idle timeout=10s + Take Screenshot fullPage=True + Get Text //body *= Robot_Dossier_Titel + Get Console Log full=True + +Woo Suite Teardown + ${BROWSER_LOGS} Close Page + Close Browser + Log ${BROWSER_LOGS} + ${LOG_FILE} List Directory storage/logs *.log True + IF ${LOG_FILE} + ${LOGS} Get File var/log/dev.log + Log ${LOGS} + Copy File var/log/dev.log ${OUTPUT DIR} + END diff --git a/tooling/clean-sheet.sh b/tooling/clean-sheet.sh new file mode 100755 index 00000000..1cbfe88c --- /dev/null +++ b/tooling/clean-sheet.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +# +# Creates a clean sheet for the application. It deleted all dossiers and documents from the database, +# purges the rabbitmq for pending messages and creates a new elasticsearch index. +# +read -p "*** Are you sure you want to clear elasticsearch, rabbitmq and the database? " prompt + +lowercase_prompt=$(echo "$prompt" | tr '[:upper:]' '[:lower:]') +if [ "$lowercase_prompt" != "y" ] && [ "$lowercase_prompt" != "yes" ] ; then + echo "Cancelled the action." + exit 1 +fi + +# If we are running inside docker, we should use the service names as hostnames +if grep -q docker /proc/1/cgroup; then + DB_HOST=postgres + RABBITMQ_HOST=rabbitmq +else + # No docker, use localhosts + DB_HOST=127.0.0.1 + RABBITMQ_HOST=localhost +fi + + +reset="\e[0m" +expand="\e[K" +notice="\e[1;33;44m" + +# Delete the elasticsearch index +echo -e "${notice}* Creating new elasticsearch index${expand}${reset}" +INDEX_NAME=$(date +"%Y%m%d%H%M") +./bin/console woopie:index:create woopie_$INDEX_NAME latest --read --write +echo + +# Delete all data from the database +echo -e "${notice}* Deleting all data from the database${expand}${reset}" +echo "TRUNCATE dossier CASCADE" | PGPASSWORD=postgres psql -h $DB_HOST --user postgres +echo "TRUNCATE document CASCADE" | PGPASSWORD=postgres psql -h $DB_HOST --user postgres +echo "TRUNCATE ingest_log CASCADE" | PGPASSWORD=postgres psql -h $DB_HOST --user postgres +echo "TRUNCATE inquiry CASCADE" | PGPASSWORD=postgres psql -h $DB_HOST --user postgres +echo + +# Delete all messages from RabbitMQ +echo -e "${notice}* Deleting all messages from RabbitMQ${expand}${reset}" +curl -q -u guest:guest -XDELETE http://${RABBITMQ_HOST}:15672/api/queues/%2f/messages/contents +echo