Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard 398045b519 Merge remote-tracking branch 'origin/master' into codex/github-hosted-test-runners
# Conflicts:
#	.github/workflows/tests.yml
#	tests/e2e/self-serve-wash.spec.js
#	tests/unit/playwright-full-workflow.spec.js
2026-07-06 22:37:52 +02:00
Jeppe BandJeppe Bundgaard acbbac588f [codex] disable invoice downloads until invoices exist (#158)
* disable invoice downloads until invoices exist

* enable github-hosted frontend ci runners

* Update POS invoice download visual expectations

* Fix full E2E i18n and self-serve coverage

* Review dynamic view i18n keys

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 22:35:58 +02:00
Jeppe Bundgaard bfb1386d49 Merge remote-tracking branch 'origin/master' into codex/github-hosted-test-runners
# Conflicts:
#	.github/workflows/tests.yml
#	src/i18n/generated/de-v2.json
#	src/i18n/generated/global-v2.json
#	src/i18n/generated/no-v2.json
#	src/i18n/generated/sv-v2.json
#	src/i18n/source/de/phrases/compat/departments/index.json
#	src/i18n/source/de/phrases/compat/objects/index.json
#	src/i18n/source/de/phrases/compat/vehicles/index.json
#	src/i18n/source/global/shared/superuser_dashboard/index.json
#	src/i18n/source/no/phrases/compat/departments/index.json
#	src/i18n/source/no/phrases/compat/objects/index.json
#	src/i18n/source/no/phrases/compat/vehicles/index.json
#	src/i18n/source/sv/phrases/compat/departments/index.json
#	src/i18n/source/sv/phrases/compat/objects/index.json
#	src/i18n/source/sv/phrases/compat/vehicles/index.json
#	tests/e2e/self-serve-wash.spec.js
#	tests/unit/playwright-full-workflow.spec.js
2026-07-06 22:35:31 +02:00
Jeppe B f75ff97042 Merge pull request #139 from copenhagentruckwash/codex/backoffice-title-department-selector
[codex] Move backoffice department selector into title
2026-07-06 22:35:13 +02:00
Jeppe Bundgaard 4aa7af9716 Merge remote-tracking branch 'origin/master' into codex/backoffice-title-department-selector
# Conflicts:
#	.github/workflows/tests.yml
2026-07-06 22:34:43 +02:00
Jeppe BandJeppe Bundgaard d2682da3cc [codex] Improve admin notifications page UI (#145)
* Improve admin notifications page UI

* Stabilize notifications page E2E bootstrap wait

* Fix PR Playwright diff head ref

* Guard MyWashStart timers after test teardown

* Stabilize self-serve wash E2E timing

* Refresh edge gateway fixture runtime state

* Run Playwright E2E on GitHub-hosted runners

* Run all frontend CI on GitHub-hosted runners

* Increase hosted full E2E parallelism

* Stabilize full E2E validation

* Preserve superuser gateway navigation label

* Add targeted Playwright dispatch workflow

* Integrate targeted Playwright dispatch into tests workflow

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 22:33:40 +02:00
Jeppe Bundgaard aa1d2ab623 Merge remote-tracking branch 'origin/master' into codex/backoffice-title-department-selector
# Conflicts:
#	.github/workflows/tests.yml
#	src/i18n/generated/de-v2.json
#	src/i18n/generated/global-v2.json
#	src/i18n/generated/no-v2.json
#	src/i18n/generated/sv-v2.json
#	src/i18n/source/de/phrases/compat/departments/index.json
#	src/i18n/source/de/phrases/compat/objects/index.json
#	src/i18n/source/de/phrases/compat/vehicles/index.json
#	src/i18n/source/global/shared/superuser_dashboard/index.json
#	src/i18n/source/no/phrases/compat/departments/index.json
#	src/i18n/source/no/phrases/compat/objects/index.json
#	src/i18n/source/no/phrases/compat/vehicles/index.json
#	src/i18n/source/sv/phrases/compat/objects/index.json
#	src/i18n/source/sv/phrases/compat/vehicles/index.json
#	tests/e2e/self-serve-wash.spec.js
#	tests/unit/playwright-full-workflow.spec.js
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 22:33:27 +02:00
Jeppe Bundgaard e495a099e4 Gate full E2E on focused regressions 2026-07-06 22:30:39 +02:00
Jeppe Bundgaard 929333d264 Add focused E2E dispatch scope 2026-07-06 22:21:30 +02:00
Jeppe B d2cbf823d8 Fix action wheel menu placement stability
Stabilizes the action settings wheel dropdown placement so the menu does not flip above/below after opening, and adds regression coverage plus hosted full-matrix workflow support.
2026-07-06 22:15:46 +02:00
Jeppe Bundgaard ba580e43e6 Align backoffice selector with title 2026-07-06 21:34:44 +02:00
Jeppe Bundgaard c4e29a896a Stabilize release assignment E2E selection 2026-07-06 21:26:16 +02:00
Jeppe Bundgaard 3c49cec213 Review dynamic i18n view keys 2026-07-06 20:47:51 +02:00
Jeppe Bundgaard 9f7ebad5fe Fix hosted full E2E i18n failures 2026-07-06 20:16:16 +02:00
Jeppe B c237fce38d Fix null product query parameters
Fixes product detail requests so null optional query params are omitted while final_price=false is preserved.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard 8acecc61cd Fix hosted full E2E catalog failures 2026-07-06 20:12:12 +02:00
Jeppe B 63149b7597 Merge pull request #162 from copenhagentruckwash/codex/daily-report-product-targets-ui
[codex] Add daily report product target controls
2026-07-06 19:36:03 +02:00
Jeppe Bundgaard 98d0fbc8c9 Merge remote-tracking branch 'origin/master' into codex/github-hosted-test-runners
# Conflicts:
#	.github/workflows/tests.yml
2026-07-06 19:31:21 +02:00
Jeppe Bundgaard a331eeadcb Speed up hosted full E2E dispatch 2026-07-06 19:27:37 +02:00
Jeppe Bundgaard 33e109b131 Fix rebased limited backoffice home handler 2026-07-06 19:27:37 +02:00
Jeppe Bundgaard 68b4328c72 Allow manual test runs on GitHub-hosted runners 2026-07-06 19:27:35 +02:00
Jeppe Bundgaard dd8a0a1952 Move backoffice department selector into title 2026-07-06 19:27:05 +02:00
Jeppe Bundgaard fef1fc3c12 Allow hosted runners for manual frontend tests 2026-07-06 19:26:28 +02:00
48 changed files with 1846 additions and 221 deletions
+240 -12
View File
@@ -6,6 +6,39 @@ on:
branches:
- master
workflow_dispatch:
inputs:
mode:
description: "What to run for a manual dispatch."
required: true
type: choice
default: full
options:
- full
- targeted
- targeted-then-full
target_specs:
description: "Comma- or newline-separated Playwright spec paths under tests/e2e."
required: false
type: string
default: "tests/e2e/superuser-department-overview.spec.js"
target_projects:
description: "JSON array of Playwright projects for targeted mode."
required: false
type: string
default: '["chromium-desktop","chromium-mobile","chromium-tablet","webkit-mobile","webkit-desktop"]'
target_grep:
description: "Optional Playwright grep pattern for targeted mode."
required: false
type: string
default: ""
runner:
description: "Runner pool for this manually dispatched test run"
required: false
default: "self-hosted"
type: choice
options:
- self-hosted
- github-hosted
schedule:
- cron: "0 2 * * *"
@@ -13,16 +46,22 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }}
cancel-in-progress: true
# Repository variables used as CI runner and credit controls:
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
# GitHub-hosted example: ["ubuntu-22.04"], with PR parallelism 2 and full parallelism 1.
jobs:
format-tests:
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, pleno, frontend]
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
timeout-minutes: 15
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
@@ -55,10 +94,11 @@ jobs:
build-and-unit:
needs: format-tests
runs-on: [self-hosted, Linux, X64, pleno, frontend]
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
timeout-minutes: 30
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
@@ -97,15 +137,195 @@ jobs:
env:
VITEST_BATCH_SIZE: 5
e2e-pr:
if: github.event_name != 'schedule'
e2e-targeted:
if: >
github.event_name == 'workflow_dispatch' &&
(inputs.mode == 'targeted' || inputs.mode == 'targeted-then-full')
needs: build-and-unit
name: E2E-targeted-${{ matrix.project }}
# Use GitHub-hosted runners to avoid self-hosted desktop contention and sleep/power events.
runs-on: ubuntu-24.04
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
project: ${{ fromJSON(inputs.target_projects || '["chromium-desktop"]') }}
env:
MATRIX_PROJECT: ${{ matrix.project }}
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-targeted-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_VIDEO_MODE: on-first-retry
TARGET_GREP: ${{ inputs.target_grep }}
TARGET_SPECS: ${{ inputs.target_specs }}
RUN_ID: ${{ github.run_id }}
steps:
- name: Normalize workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
- name: Run targeted Playwright specs in container
shell: bash
run: |
set -euo pipefail
case "$MATRIX_PROJECT" in
chromium-mobile) project_offset=1 ;;
chromium-desktop) project_offset=2 ;;
chromium-tablet) project_offset=3 ;;
webkit-mobile) project_offset=31 ;;
webkit-desktop) project_offset=32 ;;
webkit-tablet) project_offset=33 ;;
firefox-mobile) project_offset=61 ;;
firefox-desktop) project_offset=62 ;;
firefox-tablet) project_offset=63 ;;
*) echo "Unsupported Playwright project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + project_offset))
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
for ((candidate = port_seed; candidate < port_seed + 1000; candidate += 1)); do
lock_dir="${lock_root}/${candidate}.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
continue
fi
if ss -H -ltn "sport = :${candidate}" 2>/dev/null | grep -q .; then
rmdir "$lock_dir" || true
continue
fi
playwright_port_lock="$lock_dir"
playwright_dev_port="$candidate"
break
done
if [[ -z "$playwright_dev_port" ]]; then
echo "Unable to find a free Playwright dev-server port." >&2
exit 1
fi
trap 'if [[ -n "${playwright_port_lock:-}" ]]; then rmdir "$playwright_port_lock" 2>/dev/null || true; fi' EXIT
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
scripts/ci/runner-diagnostics.sh "before targeted Playwright ${MATRIX_PROJECT}" -- "${docker_cmd[@]}"
SYSTEMD_INHIBIT_REASON="Frontend targeted Playwright ${MATRIX_PROJECT}" \
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
--env TARGET_GREP="$TARGET_GREP" \
--env TARGET_SPECS="$TARGET_SPECS" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
install_dependencies() {
local attempt
for attempt in 1 2 3; do
if npm ci --legacy-peer-deps --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000; then
return 0
fi
if [[ "$attempt" == "3" ]]; then
return 1
fi
echo "npm ci failed on attempt ${attempt}; retrying..." >&2
sleep 20
done
}
install_dependencies
ulimit -n 16384 || true
mapfile -t spec_args < <(printf "%s\n" "$TARGET_SPECS" | tr "," "\n" | sed "s/^[[:space:]]*//;s/[[:space:]]*$//;/^$/d")
if [[ "${#spec_args[@]}" -eq 0 && -z "${TARGET_GREP:-}" ]]; then
echo "Provide at least one spec path or grep pattern." >&2
exit 1
fi
for spec_path in "${spec_args[@]}"; do
if [[ "$spec_path" == /* || "$spec_path" == *".."* || "$spec_path" != tests/e2e/* ]]; then
echo "Targeted spec must stay under tests/e2e: $spec_path" >&2
exit 1
fi
if [[ ! -f "$spec_path" ]]; then
echo "Targeted spec does not exist: $spec_path" >&2
exit 1
fi
done
args=("${spec_args[@]}")
if [[ -n "${TARGET_GREP:-}" ]]; then
args+=(--grep "$TARGET_GREP")
fi
args+=(--project="$MATRIX_PROJECT")
npx playwright test "${args[@]}"
'
- name: Runner diagnostics after Playwright failure
if: failure() || cancelled()
continue-on-error: true
run: scripts/ci/runner-diagnostics.sh "after targeted Playwright ${{ matrix.project }}"
- name: Upload Playwright report
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-targeted-${{ matrix.project }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore
retention-days: 1
e2e-pr:
if: >
always() &&
github.event_name != 'schedule' &&
needs.build-and-unit.result == 'success' &&
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
(
github.event_name != 'workflow_dispatch' ||
inputs.mode == 'full' ||
needs.e2e-targeted.result == 'success'
)
needs: [build-and-unit, e2e-targeted]
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: ubuntu-latest
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: 4
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
@@ -116,6 +336,7 @@ jobs:
PLAYWRIGHT_VIDEO_MODE: on-first-retry
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
@@ -291,15 +512,21 @@ jobs:
if: >
always() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr]
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
(
github.event_name != 'workflow_dispatch' ||
inputs.mode == 'full' ||
needs.e2e-targeted.result == 'success'
)
needs: [build-and-unit, e2e-pr, e2e-targeted]
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 2
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
matrix:
browser: [chromium, webkit, firefox]
device: [mobile, desktop, tablet]
@@ -321,6 +548,7 @@ jobs:
PLAYWRIGHT_VIDEO_MODE: off
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
+5 -4
View File
@@ -29,8 +29,8 @@ export const sourceMappings = [
projects: chromiumProjects,
},
{
name: "backoffice",
patterns: [/^src\/views\/backoffice\//u],
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\//u, /^src\/services\/limitedBackoffice\.js$/u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
@@ -96,8 +96,8 @@ export const sourceMappings = [
{
name: "pos",
patterns: [
/\/pos[/-]/iu,
/POS/iu,
/(?:^|[/_.-])pos(?:[/_.-]|$)/iu,
/(?:^|\/)(?:POS|Pos)[A-Z][^/]*\.(?:vue|js|ts)$/u,
/^src\/assets\/pos\.css$/u,
/^src\/components\/displays\/boxes\/ProductBox\.vue$/u,
/^src\/features\/customer\/customerProductRules\.js$/u,
@@ -120,6 +120,7 @@ export const sourceMappings = [
{
name: "admin-department-notifications",
patterns: [
/^src\/views\/dashboards\/departmentDashboard\/modules\/notifications\/DepartmentNotifications\.vue$/u,
/^src\/components\/displays\/department\/notifications\//u,
/^src\/components\/displays\/pagination\/models\/DepartmentPos\/NotificationsPhonePagination\.vue$/u,
],
@@ -126,6 +126,8 @@ const dropdownRoot = ref(null);
const dropdownContent = ref(null);
const isDropdownOpen = ref(false);
const dropdownInstanceId = Math.random().toString(36).substring(2, 15);
const dropdownPlacement = ref("bottom");
const isDropdownPlacementLocked = ref(false);
const shouldOpenDropdownUp = ref(false);
const dropdownMaxHeight = ref(null);
const isDesktopFlyoutLayout = ref(false);
@@ -140,6 +142,7 @@ const previewRequestsInFlight = new Set();
const generatedObjectUrls = new Set();
let previewStateGeneration = 0;
let contentResizeObserver = null;
let dropdownLayoutUpdateId = 0;
const desktopFlyoutMinViewportWidth = 1400;
const desktopFlyoutRootPanelWidthRem = 15;
const desktopFlyoutSubmenuWidthRem = 17;
@@ -230,6 +233,8 @@ const dropdownContentStyle = computed(() => {
});
const resetDropdownLayout = () => {
dropdownPlacement.value = "bottom";
isDropdownPlacementLocked.value = false;
shouldOpenDropdownUp.value = false;
dropdownMaxHeight.value = null;
isFixedPosition.value = false;
@@ -291,7 +296,35 @@ const getViewportInsets = () => {
};
};
const getDropdownPlacementSpaces = (triggerRect, viewportInsets = getViewportInsets()) => ({
bottom: Math.max(window.innerHeight - triggerRect.bottom - viewportInsets.bottom, 0),
top: Math.max(triggerRect.top - viewportInsets.top, 0),
});
const resolveDropdownPlacement = (triggerRect, menuHeight, viewportInsets = getViewportInsets()) => {
const spaces = getDropdownPlacementSpaces(triggerRect, viewportInsets);
return menuHeight > spaces.bottom && spaces.top > spaces.bottom ? "top" : "bottom";
};
const getDropdownAvailableHeight = (triggerRect, viewportInsets = getViewportInsets()) => {
const spaces = getDropdownPlacementSpaces(triggerRect, viewportInsets);
return dropdownPlacement.value === "top" ? spaces.top : spaces.bottom;
};
const lockDropdownPlacement = (triggerRect, menuHeight, viewportInsets = getViewportInsets()) => {
if (!isDropdownPlacementLocked.value) {
dropdownPlacement.value = resolveDropdownPlacement(triggerRect, menuHeight, viewportInsets);
isDropdownPlacementLocked.value = true;
}
shouldOpenDropdownUp.value = dropdownPlacement.value === "top";
};
const updateDropdownLayout = async () => {
const updateId = ++dropdownLayoutUpdateId;
if (!isDropdownOpen.value || !dropdownRoot.value || !dropdownContent.value) {
resetDropdownLayout();
return;
@@ -299,35 +332,41 @@ const updateDropdownLayout = async () => {
await nextTick();
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownRoot.value || !dropdownContent.value) {
return;
}
const triggerElement = dropdownRoot.value.querySelector(".dropdown-trigger");
const triggerRect = (triggerElement ?? dropdownRoot.value).getBoundingClientRect();
syncDesktopFlyoutState(triggerRect);
syncDesktopFlyoutPosition();
await nextTick();
if (!dropdownContent.value) {
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownContent.value) {
return;
}
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = window.innerHeight - viewportInsets.bottom;
const spaceBelow = Math.max(window.innerHeight - triggerRect.bottom - viewportInsets.bottom, 0);
const spaceAbove = Math.max(triggerRect.top - viewportInsets.top, 0);
const menuHeight = Math.ceil(dropdownContent.value.scrollHeight);
const openUpward = menuHeight > spaceBelow && spaceAbove > spaceBelow;
const availableHeight = openUpward ? spaceAbove : spaceBelow;
lockDropdownPlacement(triggerRect, menuHeight, viewportInsets);
const availableHeight = getDropdownAvailableHeight(triggerRect, viewportInsets);
const nextMaxHeight = availableHeight > 0 && menuHeight > availableHeight ? Math.floor(availableHeight) : null;
shouldOpenDropdownUp.value = openUpward;
dropdownMaxHeight.value = nextMaxHeight;
await nextTick();
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownContent.value) {
return;
}
syncDesktopFlyoutPosition();
await nextTick();
if (!dropdownContent.value) {
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownContent.value) {
return;
}
@@ -342,6 +381,12 @@ const updateDropdownLayout = async () => {
const boundedHeight = Math.floor(renderedMenuRect.height - overflowAbove - overflowBelow);
dropdownMaxHeight.value = boundedHeight > 0 ? boundedHeight : 1;
await nextTick();
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value) {
return;
}
syncDesktopFlyoutPosition();
};
@@ -1465,7 +1510,7 @@ onMounted(() => {
contentResizeObserver = new ResizeObserver(() => {
if (isDropdownOpen.value) {
syncDesktopFlyoutPosition();
void updateDropdownLayout();
}
});
@@ -2399,7 +2444,7 @@ watch(
() => {
if (isDropdownOpen.value) {
void nextTick(() => {
syncDesktopFlyoutPosition();
void updateDropdownLayout();
});
}
}
@@ -2507,7 +2552,6 @@ const syncDesktopFlyoutPosition = () => {
const triggerRect = triggerEl.getBoundingClientRect();
const contentHeight = dropdownContentEl.scrollHeight;
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const contentRect = dropdownContentEl.getBoundingClientRect();
@@ -2537,12 +2581,13 @@ const syncDesktopFlyoutPosition = () => {
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = viewportHeight - viewportInsets.bottom;
let top = triggerRect.bottom;
if (top + contentHeight > viewportBottom) {
top = Math.max(viewportTop, triggerRect.top - contentHeight);
}
const effectiveContentHeight = dropdownMaxHeight.value
? Math.min(contentHeight, dropdownMaxHeight.value)
: contentHeight;
const top =
dropdownPlacement.value === "top"
? Math.max(viewportTop, triggerRect.top - effectiveContentHeight)
: triggerRect.bottom;
const nextFixedStyles = {
top: `${top}px`,
@@ -2558,18 +2603,8 @@ const syncDesktopFlyoutPosition = () => {
fixedPositionStyles.value = nextFixedStyles;
}
} else {
const contentRect = dropdownContentEl.getBoundingClientRect();
const contentBottom = contentRect.top + contentRect.height;
if (contentBottom > viewportHeight) {
const nextTop = `${triggerRect.top - contentRect.height}px`;
if (dropdownContentEl.style.top !== nextTop) {
dropdownContentEl.style.top = nextTop;
}
} else {
if (dropdownContentEl.style.top !== "") {
dropdownContentEl.style.top = "";
}
if (dropdownContentEl.style.top !== "") {
dropdownContentEl.style.top = "";
}
}
};
@@ -38,7 +38,8 @@ if (router.currentRoute.value.params.departmentId) {
<TableLabeledPagination :label="SessionUser.objects.department_notification_sms.meta.title">
<template #buttons="{ loadList }">
<button
class="button is-info is-small"
class="button is-primary is-small"
data-testid="department-notification-sms-add-button"
@click="SessionUser.objects.department_notification_sms.showCreateObjectForm(() => loadList(), {department: parseInt(router.currentRoute.value.params.departmentId)})"
>
<span class="icon is-small">
@@ -1,17 +1,25 @@
<script setup>
const props = defineProps(['invoice_id'])
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { handleEconomicError } from "@/components/request/HandleEconomicError.vue";
import { ref } from "vue";
import { computed } from "vue";
const props = defineProps(["invoice_id"]);
const hasInvoice = computed(() => {
return props.invoice_id !== null && props.invoice_id !== undefined && String(props.invoice_id).trim() !== "";
});
const getOrderPDF = async () => {
await authenticatedRequest('/invoices/pdf?id=' + props.invoice_id,
'GET',{
}) .then((response) => {
if (!hasInvoice.value) {
return;
}
await authenticatedRequest("/invoices/pdf", "GET", {
id: props.invoice_id,
}).then((response) => {
// Open the PDF in a new tab
window.open(response.data.data.url, '_blank');
window.open(response.data.data.url, "_blank");
}).catch((error) => {
handleEconomicError(error);
});
@@ -19,9 +27,17 @@ const getOrderPDF = async () => {
</script>
<template>
<LoadButtonWhileAwait class="is-dark" :loadFunction="getOrderPDF" icon="fas fa-file-pdf"> Hent faktura PDF </LoadButtonWhileAwait>
<LoadButtonWhileAwait
class="is-dark"
:loadFunction="getOrderPDF"
:disabled="!hasInvoice"
actionKey="economic-invoice-pdf-download"
icon="fas fa-file-pdf"
>
Hent faktura PDF
</LoadButtonWhileAwait>
</template>
<style scoped>
</style>
</style>
@@ -32,6 +32,23 @@ const normalizeDateTimeLocalValue = (value) => {
return `${datePart}T${timePart.slice(0, 5)}`;
};
const sanitizeQueryParams = (params = {}) => {
if (!params || typeof params !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== null && value !== undefined)
);
};
const buildQueryString = (params = {}) => {
const queryParams = sanitizeQueryParams(params);
const queryString = new URLSearchParams(queryParams).toString();
return queryString ? `?${queryString}` : '';
};
/**
* The Global Objects object, which contains global functions for objects.
*/
@@ -431,20 +448,15 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise
*/
object: async (endpoint, id, optionsObject = {}) => {
const cacheKey = JSON.stringify({endpoint, id, optionsObject});
const requestParams = typeof id === 'object' && id !== null
? sanitizeQueryParams(id)
: {id};
const cacheKey = JSON.stringify({endpoint, id: requestParams, optionsObject});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
let requestEndpoint = endpoint;
if (typeof id === 'object') {
// If the id is an object, we need to convert it to a query string
const params = new URLSearchParams(id).toString();
requestEndpoint = `${endpoint}?${params}`;
} else {
// If the id is a number, we can just append it to the endpoint
requestEndpoint = `${endpoint}?id=${id}`;
}
const requestEndpoint = `${endpoint}${buildQueryString(requestParams)}`;
let options = ObjectsGlobal.requestOptions.applyOptions(optionsObject);
let promise;
if (options.authenticated) {
@@ -499,23 +511,24 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise
*/
objects: async (endpoint, data = {}) => {
const cacheKey = JSON.stringify({endpoint, data});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
if (data.filters && typeof data.filters === 'object') {
data.filters = Object.entries(data.filters).flatMap(([key, value]) => {
const requestData = sanitizeQueryParams(data);
if (requestData.filters && typeof requestData.filters === 'object') {
requestData.filters = Object.entries(requestData.filters).flatMap(([key, value]) => {
if (Array.isArray(value)) {
return value.map(v => `${key}:${v}`);
}
return `${key}:${value}`;
}).join(',');
}
const cacheKey = JSON.stringify({endpoint, data: requestData});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
const promise = authenticatedRequest(
endpoint,
"GET",
data
requestData
).then((response) => {
return response.data.data;
}).catch((error) => {
@@ -8,6 +8,17 @@ import i18n from '@/i18n';
const t = (key) => i18n.global.t(key);
const normalizeProductQueryOptions = (options = {}) => {
const queryOptions = { ...options };
if (!Object.prototype.hasOwnProperty.call(queryOptions, "final_price")) {
queryOptions.final_price = false;
}
return Object.fromEntries(
Object.entries(queryOptions).filter(([, value]) => value !== null && value !== undefined)
);
};
/**
* Local products cache
* @type {ref<null>}
@@ -269,19 +280,19 @@ export const Products = {
},
},
get: {
all: async (options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => {
return ObjectsGlobal.get.objects(Products.meta.endpoint, {...options});
all: async (options = {}) => {
return ObjectsGlobal.get.objects(Products.meta.endpoint, normalizeProductQueryOptions(options));
},
single: async (id, options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...options});
single: async (id, options = {}) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...normalizeProductQueryOptions(options)});
},
category: async (category_id, department_id, customer_id = null, final_price = false) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {
return ObjectsGlobal.get.object(Products.meta.endpoint, normalizeProductQueryOptions({
category: category_id,
department_id: department_id,
...(customer_id !== null ? {customer_id: customer_id} : {}),
...{final_price: final_price}
});
}));
},
},
delete: async (id) => {
@@ -324,4 +335,4 @@ export const Products = {
);
},
};
</script>
</script>
+22
View File
@@ -3595,6 +3595,13 @@
"opening_hours": {
"closed": "@:{'templates.generated.compat.global.closed'}"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiviert",
"custom_pricing_enabled": "Nur eigene Preise",
"custom_pricing_missing_price": "Fehlende Abteilungspreise werden zu 999999.",
"custom_pricing_only": "Keine Fallback-Preise",
"effective_department_price": "Effektiver Abteilungspreis"
},
"search_placeholder": "@.capitalize:{'words.generated.nach'} @:{'words.generated.abteilungsname'} @:{'words.generated.suchen'}",
"select_department": "@:{'templates.generated.compat.nav.select_department'}",
"subtitle": "@:{'words.generated.abteilungen'} @:{'words.generated.verwalten'}",
@@ -4434,6 +4441,7 @@
"departments": {
"columns": {
"archived": "Archiviert",
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Abmessung",
"economic_department": "@:{'templates.generated.compat.objects.economic_departments.single'}",
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
@@ -6106,6 +6114,20 @@
},
"vehicles": {
"add": "@:{'words.generated.fahrzeug'} @:{'words.generated.hinzuf'}?@:{'words.generated.gen'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Nach Kundenname oder Kundennummer suchen",
"error": "Fahrzeug konnte nicht hinzugefuegt werden.",
"no_customer_results": "Keine Kunden gefunden",
"reference_placeholder": "Optionale Referenz",
"registration_placeholder": "Kennzeichen",
"selected_customer": "Ausgewaehlter Kunde",
"submit": "Fahrzeug hinzufuegen",
"title": "Fahrzeug hinzufuegen",
"type_load_error": "Fahrzeugtypen konnten nicht geladen werden.",
"type_placeholder": "Fahrzeugtyp auswaehlen",
"validation_error": "Kunde, Kennzeichen und Fahrzeugtyp auswaehlen."
},
"brand": "@:{'templates.generated.compat.admin.pos.make'}",
"color": "Farbe",
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
+9 -9
View File
@@ -4590,8 +4590,8 @@
"overview": "Overview",
"modules": "Modules",
"branding": "Profile & Branding",
"gateways": "Gateways",
"stripe": "Stripe",
"gateways": "@.capitalize:{'words.generated.gateways'}",
"stripe": "@:{'words.generated.stripe'}",
"pricing": "Pricing",
"categories": "Categories"
},
@@ -4634,7 +4634,7 @@
"department_id": "Department ID",
"economic_department_id": "Economic department",
"branding": "Branding",
"created_at": "Created",
"created_at": "@:common.created",
"updated_at": "Updated"
},
"hardware": {
@@ -4650,12 +4650,12 @@
"quick_links": {
"title": "Department tools",
"subtitle": "Open the focused setup areas for this department",
"modules": "Modules",
"branding": "Branding",
"gateways": "Gateways",
"stripe": "Stripe",
"pricing": "Pricing",
"categories": "Categories"
"modules": "@:superuser_dashboard.department_navigation.modules",
"branding": "@:superuser_dashboard.department_overview.profile.branding",
"gateways": "@:superuser_dashboard.department_navigation.gateways",
"stripe": "@:superuser_dashboard.department_navigation.stripe",
"pricing": "@:superuser_dashboard.department_navigation.pricing",
"categories": "@:superuser_dashboard.department_navigation.categories"
},
"errors": {
"invalid_department": "A valid department is required",
+22
View File
@@ -3598,6 +3598,13 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Kun egendefinerte priser",
"custom_pricing_missing_price": "Manglende avdelingspriser blir 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"effective_department_price": "Effektiv avdelingspris"
},
"search_placeholder": "@.capitalize:{'words.generated.søk'} @:{'words.generated.etter'} @:{'words.generated.avdelingsnavn'}",
"select_department": "@.capitalize:{'words.generated.velg'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.avdeling'}",
"subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.avdelinger'}",
@@ -4437,6 +4444,7 @@
"departments": {
"columns": {
"archived": "Arkivert",
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Dimensjon",
"economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.økonomisk'} @:{'words.generated.avdeling'}",
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
@@ -6109,6 +6117,20 @@
},
"vehicles": {
"add": "@:{'words.generated.lagg'} @:{'words.generated.till'} @:{'words.generated.fordon'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Søk etter kundenavn eller kundenummer",
"error": "Kunne ikke legge til kjøretøy.",
"no_customer_results": "Ingen kunder funnet",
"reference_placeholder": "Valgfri referanse",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Valgt kunde",
"submit": "Legg til kjøretøy",
"title": "Legg til kjøretøy",
"type_load_error": "Kunne ikke laste kjøretøytyper.",
"type_placeholder": "Velg kjøretøytype",
"validation_error": "Velg kunde, registreringsnummer og kjøretøytype."
},
"brand": "Märke",
"color": "Färg",
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
+22
View File
@@ -3648,6 +3648,13 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Endast egna priser",
"custom_pricing_missing_price": "Saknade avdelningspriser blir 999999.",
"custom_pricing_only": "Inga fallback-priser",
"effective_department_price": "Effektivt avdelningspris"
},
"search_placeholder": "@:{'words.generated.sok'} @:{'words.generated.efter'} @:{'words.generated.avdelningsnamn'}",
"select_department": "@:{'words.generated.velg'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.avdeling'}",
"subtitle": "@:{'words.generated.administrer'} @:{'words.generated.avdelinger'}",
@@ -4487,6 +4494,7 @@
"departments": {
"columns": {
"archived": "Arkiverad",
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Dimension",
"economic_department": "@:{'templates.generated.compat.objects.economic_departments.single'}",
"latitude": "Latitude",
@@ -6159,6 +6167,20 @@
},
"vehicles": {
"add": "@:{'words.generated.lagg'} @:{'words.generated.till'} @:{'words.generated.fordon'}",
"add_modal": {
"customer_label": "Kund",
"customer_placeholder": "Sök efter kundnamn eller kundnummer",
"error": "Det gick inte att lägga till fordon.",
"no_customer_results": "Inga kunder hittades",
"reference_placeholder": "Valfri referens",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Vald kund",
"submit": "Lägg till fordon",
"title": "Lägg till fordon",
"type_load_error": "Det gick inte att läsa in fordonstyper.",
"type_placeholder": "Välj fordonstyp",
"validation_error": "Välj kund, registreringsnummer och fordonstyp."
},
"brand": "Märke",
"color": "Färg",
"delete_vehicle": "@.capitalize:{'words.generated.ta'} @:{'words.generated.bort'} @:{'words.generated.fordon'}",
@@ -21,6 +21,13 @@
"opening_hours": {
"closed": "@:{'phrases.compat.global.closed'}"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiviert",
"custom_pricing_enabled": "Nur eigene Preise",
"custom_pricing_missing_price": "Fehlende Abteilungspreise werden zu 999999.",
"custom_pricing_only": "Keine Fallback-Preise",
"effective_department_price": "Effektiver Abteilungspreis"
},
"search_placeholder": "@.capitalize:{'terms.glossary.nach'} @:{'terms.glossary.abteilungsname'} @:{'terms.glossary.suchen'}",
"select_department": "@:{'phrases.compat.nav.select_department'}",
"subtitle": "@:{'terms.glossary.abteilungen'} @:{'terms.glossary.verwalten'}",
@@ -140,6 +140,7 @@
"departments": {
"columns": {
"archived": "Archiviert",
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Abmessung",
"economic_department": "@:{'phrases.compat.objects.economic_departments.single'}",
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
@@ -2,6 +2,20 @@
"compat": {
"vehicles": {
"add": "@:{'terms.glossary.fahrzeug'} @:{'terms.glossary.hinzuf'}?@:{'terms.glossary.gen'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Nach Kundenname oder Kundennummer suchen",
"error": "Fahrzeug konnte nicht hinzugefuegt werden.",
"no_customer_results": "Keine Kunden gefunden",
"reference_placeholder": "Optionale Referenz",
"registration_placeholder": "Kennzeichen",
"selected_customer": "Ausgewaehlter Kunde",
"submit": "Fahrzeug hinzufuegen",
"title": "Fahrzeug hinzufuegen",
"type_load_error": "Fahrzeugtypen konnten nicht geladen werden.",
"type_placeholder": "Fahrzeugtyp auswaehlen",
"validation_error": "Kunde, Kennzeichen und Fahrzeugtyp auswaehlen."
},
"brand": "@:{'phrases.compat.admin.pos.make'}",
"color": "Farbe",
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
@@ -44,8 +44,8 @@
"overview": "Overview",
"modules": "Modules",
"branding": "Profile & Branding",
"gateways": "Gateways",
"stripe": "Stripe",
"gateways": "@.capitalize:{'terms.glossary.gateways'}",
"stripe": "@:{'terms.glossary.stripe'}",
"pricing": "Pricing",
"categories": "Categories"
},
@@ -88,7 +88,7 @@
"department_id": "Department ID",
"economic_department_id": "Economic department",
"branding": "Branding",
"created_at": "Created",
"created_at": "@:common.created",
"updated_at": "Updated"
},
"hardware": {
@@ -104,12 +104,12 @@
"quick_links": {
"title": "Department tools",
"subtitle": "Open the focused setup areas for this department",
"modules": "Modules",
"branding": "Branding",
"gateways": "Gateways",
"stripe": "Stripe",
"pricing": "Pricing",
"categories": "Categories"
"modules": "@:superuser_dashboard.department_navigation.modules",
"branding": "@:superuser_dashboard.department_overview.profile.branding",
"gateways": "@:superuser_dashboard.department_navigation.gateways",
"stripe": "@:superuser_dashboard.department_navigation.stripe",
"pricing": "@:superuser_dashboard.department_navigation.pricing",
"categories": "@:superuser_dashboard.department_navigation.categories"
},
"errors": {
"invalid_department": "A valid department is required",
@@ -21,6 +21,13 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Kun egendefinerte priser",
"custom_pricing_missing_price": "Manglende avdelingspriser blir 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"effective_department_price": "Effektiv avdelingspris"
},
"search_placeholder": "@.capitalize:{'terms.glossary.søk'} @:{'terms.glossary.etter'} @:{'terms.glossary.avdelingsnavn'}",
"select_department": "@.capitalize:{'terms.glossary.velg'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.avdeling'}",
"subtitle": "@.capitalize:{'terms.glossary.administrer'} @:{'terms.glossary.avdelinger'}",
@@ -140,6 +140,7 @@
"departments": {
"columns": {
"archived": "Arkivert",
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Dimensjon",
"economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.økonomisk'} @:{'terms.glossary.avdeling'}",
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
@@ -2,6 +2,20 @@
"compat": {
"vehicles": {
"add": "@:{'terms.glossary.lagg'} @:{'terms.glossary.till'} @:{'terms.glossary.fordon'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Søk etter kundenavn eller kundenummer",
"error": "Kunne ikke legge til kjøretøy.",
"no_customer_results": "Ingen kunder funnet",
"reference_placeholder": "Valgfri referanse",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Valgt kunde",
"submit": "Legg til kjøretøy",
"title": "Legg til kjøretøy",
"type_load_error": "Kunne ikke laste kjøretøytyper.",
"type_placeholder": "Velg kjøretøytype",
"validation_error": "Velg kunde, registreringsnummer og kjøretøytype."
},
"brand": "Märke",
"color": "Färg",
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
@@ -21,6 +21,13 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Endast egna priser",
"custom_pricing_missing_price": "Saknade avdelningspriser blir 999999.",
"custom_pricing_only": "Inga fallback-priser",
"effective_department_price": "Effektivt avdelningspris"
},
"search_placeholder": "@:{'terms.glossary.sok'} @:{'terms.glossary.efter'} @:{'terms.glossary.avdelningsnamn'}",
"select_department": "@:{'terms.glossary.velg'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.avdeling'}",
"subtitle": "@:{'terms.glossary.administrer'} @:{'terms.glossary.avdelinger'}",
@@ -140,6 +140,7 @@
"departments": {
"columns": {
"archived": "Arkiverad",
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Dimension",
"economic_department": "@:{'phrases.compat.objects.economic_departments.single'}",
"latitude": "Latitude",
@@ -2,6 +2,20 @@
"compat": {
"vehicles": {
"add": "@:{'terms.glossary.lagg'} @:{'terms.glossary.till'} @:{'terms.glossary.fordon'}",
"add_modal": {
"customer_label": "Kund",
"customer_placeholder": "Sök efter kundnamn eller kundnummer",
"error": "Det gick inte att lägga till fordon.",
"no_customer_results": "Inga kunder hittades",
"reference_placeholder": "Valfri referens",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Vald kund",
"submit": "Lägg till fordon",
"title": "Lägg till fordon",
"type_load_error": "Det gick inte att läsa in fordonstyper.",
"type_placeholder": "Välj fordonstyp",
"validation_error": "Välj kund, registreringsnummer och fordonstyp."
},
"brand": "Märke",
"color": "Färg",
"delete_vehicle": "@.capitalize:{'terms.glossary.ta'} @:{'terms.glossary.bort'} @:{'terms.glossary.fordon'}",
+6
View File
@@ -442,6 +442,12 @@ export const router = createRouter({
},
{
name: 'limitedBackofficeEmployees',
path: '/backoffice/departments/:departmentId/employees',
component: LimitedBackofficeEmployees,
meta: { middleware: authMiddleware, titleKey: 'templates.limited_backoffice.employees.title' }
},
{
name: 'limitedBackofficeEmployeesLegacy',
path: '/backoffice/employees',
component: LimitedBackofficeEmployees,
meta: { middleware: authMiddleware, titleKey: 'templates.limited_backoffice.employees.title' }
@@ -1,6 +1,7 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import LimitedBackofficeLayout from "@/views/backoffice/components/LimitedBackofficeLayout.vue";
@@ -33,6 +34,14 @@ const loadQRCodeModule = () => {
};
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const countryCodeLabels = {
denmark: () => t("templates.limited_backoffice.employees.country_codes.denmark"),
sweden: () => t("templates.limited_backoffice.employees.country_codes.sweden"),
norway: () => t("templates.limited_backoffice.employees.country_codes.norway"),
finland: () => t("templates.limited_backoffice.employees.country_codes.finland"),
};
const departments = ref([]);
const roles = ref([]);
const employees = ref([]);
@@ -60,6 +69,19 @@ const form = ref({
department_ids: [],
});
const selectedDepartmentId = computed(() => {
const departmentId = Number.parseInt(String(route.params.departmentId ?? ""), 10);
return Number.isInteger(departmentId) && departmentId > 0 ? departmentId : null;
});
const selectedDepartment = computed(() =>
departments.value.find((department) => Number(department.id) === selectedDepartmentId.value) || null
);
const hasForbiddenDepartment = computed(
() => selectedDepartmentId.value !== null && departments.value.length > 0 && !selectedDepartment.value
);
const roleMessages = computed(() => ({
viewer: {
label: t("templates.limited_backoffice.roles.viewer.label"),
@@ -256,7 +278,7 @@ const countryCodeLabel = (countryCode) => {
if (!option) {
return "";
}
return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;
return `${option.flag} +${option.value} ${countryCodeLabels[option.labelKey]?.() || ""}`;
};
const formatEmployeePhone = (employee) => {
@@ -272,11 +294,23 @@ const sanitizePhone = (event) => {
const absoluteLoginLink = (loginPath) => new URL(String(loginPath || ""), window.location.origin).toString();
const visibleEmployees = computed(() => {
if (!selectedDepartmentId.value) {
return employees.value;
}
return employees.value.filter((employee) =>
(employee.departments || []).some((department) => Number(department.id) === selectedDepartmentId.value)
);
});
const loadEmployees = async () => {
const response = await getLimitedBackofficeEmployees({ includeInactive: includeInactive.value });
employees.value = unwrapLimitedBackofficeResponse(response) || [];
};
const defaultDepartmentIds = () => (selectedDepartment.value?.id ? [Number(selectedDepartment.value.id)] : []);
const loadData = async () => {
loading.value = true;
errorMessage.value = "";
@@ -291,6 +325,18 @@ const loadData = async () => {
if (!ROLE_KEYS.includes(form.value.role_key) || !roles.value.some((role) => role.key === form.value.role_key)) {
form.value.role_key = roles.value[0]?.key || "viewer";
}
const firstDepartment = departments.value[0] || null;
if (!selectedDepartmentId.value && firstDepartment?.id) {
await router.replace(`/backoffice/departments/${firstDepartment.id}/employees`);
}
resetForm();
if (hasForbiddenDepartment.value) {
employees.value = [];
return;
}
await loadEmployees();
} catch (error) {
errorMessage.value = limitedBackofficeErrorMessage(
@@ -312,7 +358,7 @@ const resetForm = () => {
phone: "",
password: "",
role_key: roles.value[0]?.key || "viewer",
department_ids: [],
department_ids: defaultDepartmentIds(),
};
};
@@ -479,7 +525,16 @@ const closeEmployeeLoginLink = () => {
loginLinkCopyMessage.value = "";
};
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/employees`);
};
const reloadEmployees = async () => {
if (hasForbiddenDepartment.value) {
employees.value = [];
return;
}
loading.value = true;
errorMessage.value = "";
try {
@@ -497,12 +552,36 @@ const reloadEmployees = async () => {
onMounted(() => {
void loadData();
});
watch(
() => selectedDepartmentId.value,
async (nextDepartmentId, previousDepartmentId) => {
if (nextDepartmentId === previousDepartmentId || loading.value || departments.value.length === 0) {
return;
}
resetForm();
await reloadEmployees();
}
);
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.hasPermission('limited_backoffice_access')">
<LimitedBackofficeLayout active-tab="employees" :departments="departments">
<div class="columns is-variable is-5">
<LimitedBackofficeLayout
active-tab="employees"
:departments="departments"
:selected-department-id="selectedDepartmentId"
:loading-departments="loading"
show-department-switcher
@change-department="changeDepartment"
>
<div v-if="hasForbiddenDepartment" class="notification is-danger is-light" data-testid="limited-employees-forbidden">
<strong>{{ t("templates.limited_backoffice.forbidden.title") }}</strong>
<p>{{ t("templates.limited_backoffice.forbidden.department") }}</p>
</div>
<div v-else class="columns is-variable is-5">
<div class="column is-5">
<form class="box limited-employee-form" data-testid="limited-employee-form" @submit.prevent="saveEmployee">
<h2 class="title is-5">
@@ -726,11 +805,15 @@ onMounted(() => {
{{ successMessage }}
</div>
<div v-if="!loading && employees.length === 0" class="notification is-light" data-testid="limited-employees-empty">
<div
v-if="!loading && visibleEmployees.length === 0"
class="notification is-light"
data-testid="limited-employees-empty"
>
{{ t("templates.limited_backoffice.employees.empty") }}
</div>
<div v-if="!loading && employees.length > 0" class="table-container">
<div v-if="!loading && visibleEmployees.length > 0" class="table-container">
<table class="table is-fullwidth is-hoverable limited-employees-table" data-testid="limited-employees-table">
<thead>
<tr>
@@ -743,7 +826,7 @@ onMounted(() => {
</tr>
</thead>
<tbody>
<tr v-for="employee in employees" :key="employee.id" :data-testid="`limited-employee-row-${employee.id}`">
<tr v-for="employee in visibleEmployees" :key="employee.id" :data-testid="`limited-employee-row-${employee.id}`">
<td>
<span class="tag is-light" :data-testid="`limited-employee-user-id-${employee.id}`">
{{ employee.user_id || employee.id }}
@@ -17,6 +17,10 @@ const departments = ref([]);
const loading = ref(false);
const errorMessage = ref("");
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
const loadDepartments = async () => {
loading.value = true;
errorMessage.value = "";
@@ -38,10 +42,6 @@ const loadDepartments = async () => {
}
};
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
onMounted(() => {
void loadDepartments();
});
@@ -35,6 +35,12 @@ const pricesRoute = computed(() =>
: "/backoffice"
);
const employeesRoute = computed(() =>
props.selectedDepartmentId
? `/backoffice/departments/${encodeURIComponent(String(props.selectedDepartmentId))}/employees`
: "/backoffice/employees"
);
const tabs = computed(() => [
{
key: "prices",
@@ -44,7 +50,7 @@ const tabs = computed(() => [
{
key: "employees",
label: t("templates.limited_backoffice.nav.employees"),
to: "/backoffice/employees",
to: employeesRoute.value,
},
]);
@@ -62,8 +68,37 @@ const selectDepartment = (event) => {
<section class="section limited-backoffice" data-testid="limited-backoffice">
<PageTitle
:title="t('templates.limited_backoffice.title')"
:subtitle="t('templates.limited_backoffice.subtitle')"
/>
>
<template #buttons>
<div v-if="props.showDepartmentSwitcher" class="field limited-backoffice__department-field">
<label class="label is-sr-only" for="limited-backoffice-department">
{{ t("templates.limited_backoffice.department") }}
</label>
<div class="control">
<div class="select is-small is-fullwidth">
<select
id="limited-backoffice-department"
:value="props.selectedDepartmentId || ''"
:disabled="props.loadingDepartments || props.departments.length === 0"
data-testid="limited-backoffice-department-select"
@change="selectDepartment"
>
<option value="" disabled>
{{ t("templates.limited_backoffice.select_department") }}
</option>
<option v-for="department in props.departments" :key="department.id" :value="department.id">
{{ department.name }}
</option>
</select>
</div>
</div>
</div>
</template>
<p class="is-size-6 limited-backoffice__subtitle">
{{ t("templates.limited_backoffice.subtitle") }}
</p>
</PageTitle>
<div class="limited-backoffice__toolbar">
<div class="tabs is-toggle is-small limited-backoffice__tabs" data-testid="limited-backoffice-tabs">
@@ -75,30 +110,6 @@ const selectDepartment = (event) => {
</li>
</ul>
</div>
<div v-if="props.showDepartmentSwitcher" class="field limited-backoffice__department-field">
<label class="label" for="limited-backoffice-department">
{{ t("templates.limited_backoffice.department") }}
</label>
<div class="control">
<div class="select is-fullwidth">
<select
id="limited-backoffice-department"
:value="props.selectedDepartmentId || ''"
:disabled="props.loadingDepartments || props.departments.length === 0"
data-testid="limited-backoffice-department-select"
@change="selectDepartment"
>
<option value="" disabled>
{{ t("templates.limited_backoffice.select_department") }}
</option>
<option v-for="department in props.departments" :key="department.id" :value="department.id">
{{ department.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<slot />
@@ -112,10 +123,8 @@ const selectDepartment = (event) => {
.limited-backoffice__toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
margin-bottom: 1.25rem;
}
@@ -123,8 +132,12 @@ const selectDepartment = (event) => {
margin-bottom: 0;
}
.limited-backoffice__subtitle {
margin-top: -0.75rem;
}
.limited-backoffice__department-field {
min-width: min(100%, 260px);
min-width: min(42vw, 280px);
margin-bottom: 0;
}
@@ -138,11 +151,18 @@ const selectDepartment = (event) => {
align-items: stretch;
}
.limited-backoffice__tabs,
.limited-backoffice__department-field {
.limited-backoffice__tabs {
width: 100%;
}
.limited-backoffice__department-field {
min-width: 9rem;
}
.limited-backoffice__subtitle {
margin-top: -0.5rem;
}
.limited-backoffice__tabs :deep(ul) {
width: 100%;
}
+73 -2
View File
@@ -2,9 +2,10 @@
import SessionUser from "@/components/session/token/SessionUser.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
import {ref, onMounted, nextTick, computed, onUnmounted} from "vue";
import {ref, onMounted, nextTick, computed, onUnmounted, watch} from "vue";
import { IS_DEV } from '@/config.js';
import {BLoading} from "buefy";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
/**
* Subuser grant selection gate
@@ -76,6 +77,59 @@ const canShowVehicleShortcut = computed(() => SessionUser.canAccessCustomerFeatu
const canShowOrderShortcut = computed(() => SessionUser.canAccessCustomerFeature("orders", "list"));
const canShowSelfServeShortcut = computed(() => SessionUser.canAccessCustomerFeature("selfserve", "list"));
const canShowInvoiceShortcut = computed(() => canShowClassicCustomerShortcut());
const hasInvoicesAvailable = ref(false);
const isCheckingInvoiceAvailability = ref(true);
const canDownloadInvoices = computed(() => canShowInvoiceShortcut.value && hasInvoicesAvailable.value && !isCheckingInvoiceAvailability.value);
const responseHasInvoices = (response: any) => {
const total = Number(response?.data?.meta?.pagination?.total);
if (Number.isFinite(total) && total > 0) {
return true;
}
return Array.isArray(response?.data?.data) && response.data.data.length > 0;
};
const resetInvoiceAvailability = () => {
hasInvoicesAvailable.value = false;
isCheckingInvoiceAvailability.value = false;
};
const loadInvoiceAvailability = async () => {
if (!canShowInvoiceShortcut.value) {
resetInvoiceAvailability();
return;
}
isCheckingInvoiceAvailability.value = true;
try {
const response = await authenticatedRequest("/user/invoices", "GET", {
page: 1,
limit: 1,
order: "created_at:desc",
});
hasInvoicesAvailable.value = responseHasInvoices(response);
} catch (_error) {
hasInvoicesAvailable.value = false;
} finally {
isCheckingInvoiceAvailability.value = false;
setEqualHeights();
}
};
watch(
canShowInvoiceShortcut,
(canShow) => {
if (canShow) {
void loadInvoiceAvailability();
return;
}
resetInvoiceAvailability();
},
{ immediate: true }
);
const windowInnerWidth = ref(window.innerWidth);
const updateWindowInnerWidth = () => {
@@ -291,9 +345,26 @@ const isPermissionsLoading = computed(() => {
</template>
<template #footer>
<div class="card-footer-item">
<router-link to="/user/invoices" class="button is-link is-fullwidth" :class="classes.button" id="download-invoices-button">
<router-link
v-if="canDownloadInvoices"
to="/user/invoices"
class="button is-link is-fullwidth"
:class="classes.button"
id="download-invoices-button"
>
{{ $t('user_home.download_invoices') }}
</router-link>
<button
v-else
class="button is-link is-fullwidth"
:class="[classes.button, { 'is-loading': isCheckingInvoiceAvailability }]"
id="download-invoices-button"
type="button"
disabled
aria-disabled="true"
>
{{ $t('user_home.download_invoices') }}
</button>
</div>
</template>
</WhiteBoxCard>
@@ -370,8 +370,12 @@ const isInvoiced = () => {
return isInvoicedWithEconomic() || isInvoicedWithStripe();
};
const isBookedWithEconomic = () => {
return economicModule.value.invoice_id !== null;
const hasEconomicInvoiceDownloadContext = () => {
const module = economicModule.value || {};
return (
Object.prototype.hasOwnProperty.call(module, 'invoice_id') ||
Object.prototype.hasOwnProperty.call(module, 'invoice_draft_id')
);
};
const isCompleted = () => {
@@ -1376,7 +1380,11 @@ const isDisplayingReceipt = () => {
<template #rail-actions>
<ButtonsBox class="pos-actions pos-actions--rail">
<GetOrderInvoicePDFButton :invoice_id="economicModule.invoice_id" class="is-fullwidth" v-if="isBookedWithEconomic()" />
<GetOrderInvoicePDFButton
:invoice_id="economicModule.invoice_id"
class="is-fullwidth"
v-if="hasEconomicInvoiceDownloadContext()"
/>
<div
v-if="isEconomicExportBlocked"
class="message is-warning is-light"
@@ -14,6 +14,16 @@ import NotificationsPhonePagination
:title="$t('admin.notifications.title')"
:subtitle="$t('admin.notifications.subtitle')"
>
<header class="mb-5" data-testid="department-notifications-page-header">
<h1 class="title is-3 mb-2" data-testid="department-notifications-title">
{{ $t('admin.notifications.title') }}
</h1>
<p class="subtitle is-6 mb-0" data-testid="department-notifications-subtitle">
<span>{{ $t('admin.notifications.sms_overview') }}</span>
<br>
<span>{{ $t('admin.notifications.sms_activated_description') }}</span>
</p>
</header>
<NotFoundFallBackPageWrapper :exists="SessionUser.functions.getDepartmentIdFromUrl() && SessionUser.canAccessDepartment(SessionUser.functions.getDepartmentIdFromUrl())" :error="$t('admin.errors.select_department')">
<element-tabs-box
defaultActiveTab="sms"
@@ -24,16 +34,6 @@ import NotificationsPhonePagination
]"
>
<template #sms>
<!-- What is this? -->
<div class="message">
<div class="message-header">
<p>{{ $t('admin.notifications.sms_notifications') }}</p>
</div>
<div class="message-body">
<p>{{ $t('admin.notifications.sms_overview') }}</p>
<p>{{ $t('admin.notifications.sms_activated_description') }}</p>
</div>
</div>
<NotificationsPhonePagination />
</template>
</element-tabs-box>
@@ -44,4 +44,4 @@ import NotificationsPhonePagination
<style scoped>
</style>
</style>
@@ -18,6 +18,8 @@ const json = (body: unknown, status = 200) => ({
});
const pageReadyTimeout = process.env.CI ? 30_000 : 15_000;
const pageBootstrapTimeoutMs = 45_000;
const setupNotificationSmsApi = async (page: Page) => {
let listRequestCount = 0;
const mutations: Array<{ method: string; body?: Record<string, unknown>; id?: number }> = [];
@@ -118,6 +120,21 @@ test("@smoke department notification SMS active toggle and delete refresh the ta
await page.goto("/admin/1/modules/notifications", { waitUntil: "domcontentloaded" });
await expect.poll(() => notificationSmsApi.listRequestCount, { timeout: pageReadyTimeout }).toBeGreaterThan(0);
const pageTitle = page.getByTestId("department-notifications-title");
await expect(pageTitle).toBeVisible({ timeout: pageBootstrapTimeoutMs });
await expect(pageTitle).toContainText(/notifikationer|notifications/i);
const pageSubtitle = page.getByTestId("department-notifications-subtitle");
await expect(pageSubtitle).toBeVisible();
await expect(pageSubtitle).toContainText(/sms/i);
await expect(page.locator(".message").filter({ hasText: /sms/i })).toHaveCount(0);
const addPhoneButton = page.getByTestId("department-notification-sms-add-button");
await expect(addPhoneButton).toBeVisible();
await expect(addPhoneButton).toHaveClass(/is-primary/);
await expect(addPhoneButton).not.toHaveClass(/is-info/);
await expect(addPhoneButton.locator(".fa-plus")).toBeVisible();
const row = page.locator("tr", { hasText: "Dispatch line" });
await expect(row).toBeVisible({ timeout: pageReadyTimeout });
const enabledToggle = page.getByTestId("department-notification-sms-enabled-toggle-501");
+24 -1
View File
@@ -1844,7 +1844,7 @@ test("shows live Stripe payment status and cancellation for open card payment or
await expect(page.getByTestId("pos-order-stripe-email-cancel")).toBeVisible();
await expect(page.getByTestId("pos-order-print-receipt")).toHaveCount(0);
await expect(page.getByRole("button", { name: /Fuldfør ordre|Complete order/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Hent faktura|Get invoice/ })).toHaveCount(0);
await expect(page.locator('[data-action-key="economic-invoice-pdf-download"]')).toBeDisabled();
await page.getByTestId("pos-order-stripe-email-cancel").click();
await deleteStripeInvoiceRequest;
@@ -2950,6 +2950,28 @@ test.describe("Admin POS Orders - desktop locked states", () => {
await expectOrderTotal(page, 1372);
});
test("disables invoice PDF download while the Economic invoice id is missing", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: createPosFixture({
economicModuleOrdersByOrderId: {
54518: {
invoice_id: null,
invoice_draft_id: 88012,
},
},
}),
});
await primeOperatorSession(page, "pos-orders-draft-invoice-token");
await openOrderDetail(page);
const downloadButton = page.locator('[data-action-key="economic-invoice-pdf-download"]');
await expect(downloadButton).toBeVisible();
await expect(downloadButton).toBeDisabled();
});
test("hides item mutation controls when the order is already invoiced", async ({ page }) => {
await mockApi(page, {
authenticated: true,
@@ -2970,6 +2992,7 @@ test.describe("Admin POS Orders - desktop locked states", () => {
await expect(page.getByTestId("pos-order-item-edit-9101")).toHaveCount(0);
await expect(page.getByTestId("pos-order-item-delete-9101")).toHaveCount(0);
await expect(page.getByTestId("pos-order-add-item")).toHaveCount(0);
await expect(page.locator('[data-action-key="economic-invoice-pdf-download"]')).toBeEnabled();
await expectOrderTotal(page, 1372);
});
});
+4 -2
View File
@@ -825,7 +825,7 @@ test.describe("Coolify infrastructure management", () => {
await expect(actions.getByRole("button", { name: "Reconcile Coolify" })).toBeVisible();
await expect(actions.getByRole("button", { name: "Coolify failover" })).toBeVisible();
await actions.getByRole("button", { name: "Reconcile Coolify" }).click();
expect(state.reconciledTargets.length).toBeGreaterThan(0);
await expect.poll(() => state.reconciledTargets.length).toBeGreaterThan(0);
});
test("resumes Coolify-backed provisioning after page reload", async ({ page }) => {
@@ -1044,7 +1044,9 @@ test.describe("Coolify infrastructure management", () => {
await actions.locator(".action-settings-wheel-trigger").click();
await actions.getByRole("button", { name: /Rename|Omdøb/ }).click();
expect(state.renamedHosts).toContainEqual({ kind: "database", id: 2, label: "mariadb-manual-replica" });
await expect
.poll(() => state.renamedHosts)
.toContainEqual({ kind: "database", id: 2, label: "mariadb-manual-replica" });
await expect(page.getByTestId("replication-host-database-2")).toContainText("mariadb-manual-replica");
});
});
+6
View File
@@ -350,6 +350,10 @@ async function readStoredToken(page: Page) {
}
}
async function waitForAppRootMounted(page: Page) {
await expect.poll(async () => page.locator("#app > *").count(), { timeout: AUTH_TIMEOUT }).toBeGreaterThan(0);
}
async function settleAuthenticatedNavigation(page: Page, targetPath: string, targetUrl: RegExp) {
await expect.poll(() => readStoredToken(page), { timeout: AUTH_TIMEOUT }).not.toBeNull();
const navigateToTarget = async () => {
@@ -376,6 +380,8 @@ async function settleAuthenticatedNavigation(page: Page, targetPath: string, tar
await navigateToTarget();
await expect.poll(() => page.url(), { timeout: AUTH_TIMEOUT }).toMatch(targetUrl);
}
await waitForAppRootMounted(page);
}
/**
+18
View File
@@ -6,7 +6,12 @@ import { scanViewTranslationKeys, type ViewTranslationKeyUsage } from "./support
const ACTIVE_LOCALES = ["da", "en", "sv", "de", "no"] as const;
const GENERATED_LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "generated");
const REVIEWED_NON_LITERAL_CALLS = new Set([
"src/views/backoffice/LimitedBackofficeEmployees.vue|t|return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;",
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue|t|const translated = t(key, params);",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|return t(`replication.status.${status || "unknown"}`);',
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|te|if (key && te(key)) {",
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|return t(key, params);",
@@ -14,9 +19,18 @@ const REVIEWED_NON_LITERAL_CALLS = new Set([
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<li v-for="key in redisComposeInstructionKeys" :key="key">{{ t(key) }}</li>',
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<li v-for="key in minioComposeInstructionKeys" :key="key">{{ t(key) }}</li>',
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<td>{{ t(`replication.roles.${host.role}`) }}</td>",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
"src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue|t|const tr = (key, params = {}) => t(`configuration.release_manager.${key}`, params);",
"src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue|t|const translated = t(fullKey, params);",
"src/views/dashboards/superUserDashboard/ErrorReports.vue|t|const value = t(key);",
"src/views/backoffice/LimitedBackofficeEmployees.vue|t|return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
'src/views/dashboards/userDashboard/wash/components/LaneSelectionSection.vue|$t|:title="option.titleKey ? $t(option.titleKey) : null"',
'src/views/dashboards/userDashboard/wash/components/LaneSelectionSection.vue|$t|:aria-label="option.titleKey ? $t(option.titleKey) : null"',
"src/views/dashboards/userDashboard/wash/components/LaneSelectionSection.vue|$t|{{ $t(option.statusLabelKey) }}",
@@ -24,6 +38,10 @@ const REVIEWED_NON_LITERAL_CALLS = new Set([
"src/views/dashboards/userDashboard/wash/components/WashTypeSelector.vue|$t|{{ $t(option.statusLabelKey) }}",
'src/views/dashboards/userDashboard/wash/MyWashStart.vue|$t|:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"',
"src/views/dashboards/userDashboard/wash/MyWashStart.vue|$t|{{ $t(confirmActionLabelKey) }}",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
]);
const readGeneratedJson = (fileName: string) => {
+112 -13
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { API_HOST, seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
@@ -14,6 +14,7 @@ const limitedManagerPermissions = [
"limited_backoffice_prices_manage",
"limited_backoffice_employees_manage",
"department_access_1",
"department_access_2",
];
const sessionData = {
@@ -62,7 +63,10 @@ const adminLimitedSessionData = {
permissions: ["user", "admin", "limited_backoffice_access", "department_access_1"],
};
const assignedDepartments = [{ id: 1, name: "Assigned Depot", description: "", visible: true, archived: false }];
const assignedDepartments = [
{ id: 1, name: "Assigned Depot", description: "", visible: true, archived: false },
{ id: 2, name: "Remote Depot", description: "", visible: true, archived: false },
];
const pricePayload = {
department: { id: 1, name: "Assigned Depot", description: "" },
@@ -235,6 +239,11 @@ const rolePermissionGroups = {
],
};
const remotePricePayload = {
...pricePayload,
department: { id: 2, name: "Remote Depot", description: "" },
};
const rolesPayload = [
{ key: "viewer", label: "Viewer", description: "Can view.", permission_groups: rolePermissionGroups.viewer },
{ key: "cashier", label: "Cashier", description: "Can sell.", permission_groups: rolePermissionGroups.cashier },
@@ -274,6 +283,17 @@ const employeesPayload = [
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
},
{
id: 502,
customer_number: 900000502,
display_name: "Riley Remote",
email: "riley@example.com",
active: true,
role: { key: "viewer", label: "Viewer", description: "Can view." },
departments: [{ id: 2, name: "Remote Depot" }],
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
},
];
async function seedLimitedBackofficeSession(page, token = "limited-backoffice-token") {
@@ -350,6 +370,11 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
return;
}
if (pathname.endsWith("/limited-backoffice/departments/2/prices") && method === "GET") {
await route.fulfill(json({ data: remotePricePayload }));
return;
}
if (pathname.endsWith("/limited-backoffice/departments/1/prices") && method === "PUT") {
const body = request.postDataJSON?.() || null;
priceUpdateCalls.push(body);
@@ -462,6 +487,20 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
};
}
async function expectDepartmentSelectorInTitleRow(page: Page) {
const headingBox = await page.getByRole("heading", { name: "Backoffice" }).boundingBox();
const selectBox = await page.getByTestId("limited-backoffice-department-select").boundingBox();
expect(headingBox).not.toBeNull();
expect(selectBox).not.toBeNull();
if (!headingBox || !selectBox) {
return;
}
expect(selectBox.x).toBeGreaterThan(headingBox.x);
expect(Math.abs(selectBox.y + selectBox.height / 2 - (headingBox.y + headingBox.height / 2))).toBeLessThan(48);
}
test.describe("Limited backoffice", () => {
test("shows the limited backoffice header shortcut only on department-scoped admin pages", async ({
page,
@@ -539,7 +578,9 @@ test.describe("Limited backoffice", () => {
await page.goto("/backoffice/departments/1/prices");
await expect(page.getByTestId("limited-prices-title")).toBeVisible();
await expectDepartmentSelectorInTitleRow(page);
await expect(page.getByTestId("limited-backoffice-department-select")).toContainText("Assigned Depot");
await expect(page.getByTestId("limited-backoffice-department-select")).toContainText("Remote Depot");
await expect(page.getByTestId("limited-backoffice-department-select")).not.toContainText("Other Depot");
await expect(page.getByTestId("limited-price-row-101")).toContainText("Truck wash");
await expect(page.getByTestId("limited-price-input-101")).toHaveValue("125");
@@ -718,12 +759,16 @@ test.describe("Limited backoffice", () => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await page.goto("/backoffice/departments/1/employees");
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expectDepartmentSelectorInTitleRow(page);
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Clerk");
await expect(page.getByTestId("limited-employee-user-id-501")).toHaveText("501");
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+45 12345678");
await expect(page.getByTestId("limited-employee-row-502")).toHaveCount(0);
await expect(page.getByTestId("limited-employee-department-1")).toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).not.toBeChecked();
await expect(page.locator("#limited-employee-role option")).toHaveCount(5);
await expect(page.getByTestId("limited-employee-role")).not.toContainText("Superuser");
await expect(page.locator("body")).not.toContainText("department_access_1");
@@ -834,15 +879,14 @@ test.describe("Limited backoffice", () => {
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
await expect(page.getByTestId("limited-employee-save")).toHaveClass(/is-fullwidth/);
await expect(page.getByTestId("limited-employee-departments").locator(".switch")).toHaveCount(1);
await expect(page.getByTestId("limited-employee-departments").locator(".switch")).toHaveCount(2);
await expect(page.getByTestId("limited-employee-department-1")).toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).not.toBeChecked();
await expect(page.getByTestId("limited-employee-phone-country-code")).toContainText("+45");
await page.getByTestId("limited-employee-name").fill("No Phone Worker");
await page.getByTestId("limited-employee-email").fill("no-phone@example.com");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
await page.getByTestId("limited-employee-department-1").click();
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
@@ -856,14 +900,13 @@ test.describe("Limited backoffice", () => {
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-902")).toContainText("No Phone Worker");
await expect(page.getByTestId("limited-employee-row-903")).toContainText("No Phone Worker");
await page.getByTestId("limited-employee-name").fill("Phone Worker");
await page.getByTestId("limited-employee-email").fill("phone@example.com");
await page.getByTestId("limited-employee-phone-country-code").selectOption("358");
await page.getByTestId("limited-employee-phone").fill("87654321");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await page.getByTestId("limited-employee-department-1").click();
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
@@ -877,8 +920,8 @@ test.describe("Limited backoffice", () => {
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-903")).toContainText("Phone Worker");
await expect(page.getByTestId("limited-employee-phone-903")).toHaveText("+358 87654321");
await expect(page.getByTestId("limited-employee-row-904")).toContainText("Phone Worker");
await expect(page.getByTestId("limited-employee-phone-904")).toHaveText("+358 87654321");
});
test("edits employee contact details without requiring a new password", async ({ page }) => {
@@ -912,19 +955,75 @@ test.describe("Limited backoffice", () => {
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+358 87654321");
});
test("keeps department context across employee access navigation", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await expect(page).toHaveURL(/\/backoffice\/departments\/1\/employees$/);
await expect(page.getByTestId("limited-backoffice-tab-prices")).toHaveAttribute(
"href",
"/backoffice/departments/1/prices"
);
await expect(page.getByTestId("limited-backoffice-tab-employees")).toHaveAttribute(
"href",
"/backoffice/departments/1/employees"
);
await page.getByTestId("limited-backoffice-department-select").selectOption("2");
await expect(page).toHaveURL(/\/backoffice\/departments\/2\/employees$/);
await expect(page.getByTestId("limited-employee-row-501")).toHaveCount(0);
await expect(page.getByTestId("limited-employee-row-502")).toContainText("Riley Remote");
await expect(page.getByTestId("limited-employee-department-1")).not.toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).toBeChecked();
await expect(page.getByTestId("limited-backoffice-tab-prices")).toHaveAttribute(
"href",
"/backoffice/departments/2/prices"
);
await page.getByTestId("limited-backoffice-tab-prices").click();
await expect(page).toHaveURL(/\/backoffice\/departments\/2\/prices$/);
await expect(page.getByTestId("limited-backoffice-tab-employees")).toHaveAttribute(
"href",
"/backoffice/departments/2/employees"
);
expect(api.forbiddenCalls).toEqual([]);
});
test("does not render data for a department outside the manager scope", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/2/prices");
await page.goto("/backoffice/departments/3/prices");
await expect(page.getByTestId("limited-prices-forbidden")).toBeVisible();
await expect(page.locator("body")).not.toContainText("Secret Depot");
await expect(page.getByTestId("limited-prices-table")).toHaveCount(0);
expect(api.calls.some((call) => call.includes("/limited-backoffice/departments/2/prices"))).toBe(false);
expect(api.calls.some((call) => call.includes("/limited-backoffice/departments/3/prices"))).toBe(false);
expect(api.forbiddenCalls).toEqual([]);
});
test("does not render employee data for a department outside the manager scope", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/3/employees");
await expect(page.getByTestId("limited-employees-forbidden")).toBeVisible();
await expect(page.locator("body")).not.toContainText("Secret Depot");
await expect(page.getByTestId("limited-employees-table")).toHaveCount(0);
expect(api.calls.some((call) => call.includes("/limited-backoffice/employees"))).toBe(false);
expect(api.forbiddenCalls).toEqual([]);
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 107 KiB

+5 -1
View File
@@ -2328,7 +2328,11 @@ test("superusers manage release settings, assignments, integrations, and sync op
await expect(page.getByTestId("release-assignment-subject-manual-user-909")).toContainText("User #909");
await expect(page.getByTestId("release-assignment-subject-manual-subuser-909")).toContainText("Subuser #909");
await expect(page.getByTestId("release-assignment-subject-manual-customer-909")).toContainText("Customer #909");
await page.getByTestId("release-assignment-subject-manual-subuser-909").click({ force: true });
await page
.getByTestId("release-assignment-subject-autocomplete")
.getByRole("button", { name: /Subuser #909/ })
.click();
await expect(assignmentSubjectInput).toHaveValue("Subuser #909 - Use typed value subuser:909");
await page.getByTestId("release-assignment-form").getByRole("button", { name: "Assign" }).click();
expect(state.assignmentPayloads[state.assignmentPayloads.length - 1]).toMatchObject({
subject_type: "subuser",
+23 -2
View File
@@ -27,7 +27,7 @@ async function primeSession(page, { token, permissions, sessionData = {} }) {
await seedAuthenticatedState(page, token);
}
async function mockSelectedSubuserGrant(page, { customerNumber = 12345679, permissions = ["user"] } = {}) {
async function mockSelectedSubuserGrant(page, { customerNumber = 12345679, permissions = ["SELFSERVE_LIST"] } = {}) {
await page.route("**/subusers/me", async (route) => {
await route.fulfill({
status: 200,
@@ -249,6 +249,7 @@ test.describe("Self-serve wash", () => {
},
selfServe: true,
});
api.selfServe.commandResponseDelayMs = [500, 0];
await primeSession(page, {
token: "self-serve-user-token",
permissions: ["user"],
@@ -1045,7 +1046,7 @@ test.describe("Self-serve wash", () => {
customerNumberInput: "777",
});
await mockApi(page, {
const api = await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
@@ -1063,6 +1064,25 @@ test.describe("Self-serve wash", () => {
],
},
});
api.selfServe.previewByKey["7:ZZ00000"] = {
...api.selfServe.previewByKey["7:ZZ00000"],
questions: [],
tasks: [],
};
api.selfServe.summaryBySessionId[601] = {
...api.selfServe.summaryBySessionId[601],
questions: [],
tasks: [],
};
api.selfServe.summaryByKey["7:ZZ00000"] = {
session: api.selfServe.previewByKey["7:ZZ00000"].session,
lane: api.selfServe.previewByKey["7:ZZ00000"].lane,
questions: [],
conditions: [],
rules: [],
tasks: [],
events: [{ id: 4, type: "STARTED", created_at: "2026-01-01T11:01:00.000Z" }],
};
await primeSession(page, {
token: "self-serve-start-retry-token",
permissions: ["user"],
@@ -1163,6 +1183,7 @@ test.describe("Self-serve wash", () => {
authenticated: true,
permissions: ["user"],
selfServe: {
commandResponseDelayMs: [500, 0],
commandResponses: [
{
success: false,
+21 -1
View File
@@ -15,6 +15,7 @@ type ProductRouteOptions = {
categories?: unknown;
productOptions?: unknown;
economicProducts?: unknown;
onProductsRequest?: (url: URL) => void;
};
const collectBrowserErrors = (page: Page) => {
@@ -42,6 +43,7 @@ const stubSuperuserProductRoutes = async (
categories = [],
productOptions = [],
economicProducts = [],
onProductsRequest,
}: ProductRouteOptions
) => {
await seedAuthenticatedState(page, token);
@@ -154,6 +156,7 @@ const stubSuperuserProductRoutes = async (
}
if (pathname.endsWith("/products") && method === "GET") {
onProductsRequest?.(url);
const perPage = Number(url.searchParams.get("limit") || "100");
await route.fulfill(
@@ -273,7 +276,14 @@ test.describe("Superuser products layout", () => {
];
await page.setViewportSize({ width: 1280, height: 720 });
await stubSuperuserProductRoutes(page, { products, categories, productOptions, economicProducts });
const productRequestUrls: string[] = [];
await stubSuperuserProductRoutes(page, {
products,
categories,
productOptions,
economicProducts,
onProductsRequest: (url) => productRequestUrls.push(url.toString()),
});
await page.goto("/superuser/products");
@@ -316,6 +326,16 @@ test.describe("Superuser products layout", () => {
await expect(page.getByTestId("superuser-product-name")).toContainText("Trækker");
await expect(page.getByRole("heading", { name: "Trækker" })).toBeVisible();
const detailRequest = productRequestUrls
.map((url) => new URL(url))
.find((url) => url.searchParams.get("id") === "1");
expect(detailRequest).toBeDefined();
expect(detailRequest?.searchParams.get("department_id")).toBeNull();
expect(detailRequest?.searchParams.get("customer_id")).toBeNull();
expect(detailRequest?.searchParams.get("category_id")).toBeNull();
expect(detailRequest?.searchParams.get("final_price")).toBe("false");
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
@@ -25,6 +25,44 @@ async function bootSuperuser(page, { permissions = ["superuser", "user"], edgeGa
await primeSuperuserSession(page);
}
async function waitForLayoutFrame(page) {
await page.evaluate(
() =>
new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
})
);
}
async function getActionMenuVerticalPlacement(trigger, menu) {
const [triggerBox, menuBox] = await Promise.all([trigger.boundingBox(), menu.boundingBox()]);
expect(triggerBox).not.toBeNull();
expect(menuBox).not.toBeNull();
const belowGap = menuBox.y - (triggerBox.y + triggerBox.height);
const aboveGap = triggerBox.y - (menuBox.y + menuBox.height);
const isBelowButton = belowGap >= -2 && belowGap <= 16;
const isAboveButton = aboveGap >= -2 && aboveGap <= 16;
expect(isBelowButton || isAboveButton).toBe(true);
expect(menuBox.y >= triggerBox.y + triggerBox.height - 2 || menuBox.y + menuBox.height <= triggerBox.y + 2).toBe(
true
);
return isBelowButton ? "below" : "above";
}
async function getSettledActionMenuVerticalPlacement(page, trigger, menu) {
await waitForLayoutFrame(page);
const firstPlacement = await getActionMenuVerticalPlacement(trigger, menu);
await waitForLayoutFrame(page);
const secondPlacement = await getActionMenuVerticalPlacement(trigger, menu);
expect(secondPlacement).toBe(firstPlacement);
return secondPlacement;
}
async function installSystemStatusMock(page, snapshot) {
await page.route(/\/superuser\/system\/status(?:\?.*)?$/, async (route) => {
await route.fulfill(
@@ -815,6 +853,38 @@ test.describe("Superuser system status smoke", () => {
await expect(page.getByTestId("database-status-card")).toContainText("8.0.36");
});
test("@smoke @pr superuser replication action menu keeps stable vertical placement", async ({ page }) => {
await bootSuperuser(page);
await installReplicationMock(page);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: PAGE_READY_TIMEOUT });
await expect(page.getByTestId("replication-host-database-2")).toContainText("mysql-replica-1");
const replicaActions = page.getByTestId("replication-actions-database-2");
const replicaActionTrigger = replicaActions.locator(".action-settings-wheel-trigger");
const replicaActionMenu = replicaActions.locator(".dropdown-content");
await expect(replicaActionTrigger).toBeVisible();
await replicaActionTrigger.click();
await expect(replicaActions.locator(".dropdown-item-action:visible")).toHaveCount(4);
const initialPlacement = await getSettledActionMenuVerticalPlacement(page, replicaActionTrigger, replicaActionMenu);
await page.keyboard.press("Escape");
await expect(replicaActionTrigger).not.toHaveClass(/action-settings-wheel-trigger--active/);
await replicaActionTrigger.click();
await expect(replicaActions.locator(".dropdown-item-action:visible")).toHaveCount(4);
const reopenedPlacement = await getSettledActionMenuVerticalPlacement(
page,
replicaActionTrigger,
replicaActionMenu
);
expect(reopenedPlacement).toBe(initialPlacement);
});
test("@smoke superuser replication management page renders topology and percentages", async ({ page }) => {
await bootSuperuser(page);
const replicationRequests = await installReplicationMock(page);
@@ -863,15 +933,11 @@ test.describe("Superuser system status smoke", () => {
await replicaActionTrigger.click();
await expect(replicaActions.locator(".dropdown-item-action:visible")).toHaveCount(4);
await expect(replicaActions.getByRole("button", { name: "Omdøb" })).toBeVisible();
const [triggerBox, menuBox] = await Promise.all([
replicaActionTrigger.boundingBox(),
replicaActions.locator(".dropdown-content").boundingBox(),
]);
expect(triggerBox).not.toBeNull();
expect(menuBox).not.toBeNull();
const isMenuBelowButton = Math.abs(menuBox.y - (triggerBox.y + triggerBox.height)) <= 12;
const isMenuAboveButton = Math.abs(menuBox.y + menuBox.height - triggerBox.y) <= 12;
expect(isMenuBelowButton || isMenuAboveButton).toBe(true);
await getSettledActionMenuVerticalPlacement(
page,
replicaActionTrigger,
replicaActions.locator(".dropdown-content")
);
await replicaActions.getByRole("button", { name: "Test" }).click();
expect(replicationRequests.hostTestKinds).toContain("database");
const addHostTabs = page.getByTestId("replication-add-host-tabs");
+61
View File
@@ -1317,6 +1317,7 @@ function createSelfServeFixture(overrides = {}) {
answerRequests: [],
commandResponse: { success: true },
commandResponses: null,
commandResponseDelayMs: 0,
commandRequests: [],
forceStopResponse: null,
forceStopResponses: null,
@@ -1483,6 +1484,14 @@ function buildEdgeGatewayOperationSummary(operations = []) {
);
}
function invalidateEdgeGatewayRuntimeSnapshot(gateway) {
delete gateway.active_operation;
delete gateway.recent_operations_summary;
delete gateway.version_drift;
delete gateway.diagnostics;
delete gateway.error_state;
}
function buildEdgeGatewayRuntimeFixture(gateway) {
const relayHealth = (gateway.bindings || []).map((binding) => {
const fallbackMode = binding.fallback_mode || "PREFER_LOCAL";
@@ -1850,6 +1859,7 @@ function settleEdgeGatewayWork(edgeGatewayFixture, gatewayId) {
: operation
);
delete edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId];
invalidateEdgeGatewayRuntimeSnapshot(gateway);
}
}
@@ -5401,6 +5411,7 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
{ id: Date.now(), created_at: now, action: "GATEWAY_OPERATION_CANCELLED", actor_type: "USER" },
...(gateway.audit_logs || []),
];
invalidateEdgeGatewayRuntimeSnapshot(gateway);
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
await route.fulfill(
json({
@@ -5567,6 +5578,7 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
{ id: Date.now(), created_at: now, action: "GATEWAY_OPERATION_QUEUED", actor_type: "USER" },
...(gateway.audit_logs || []),
];
invalidateEdgeGatewayRuntimeSnapshot(gateway);
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
await route.fulfill(
json(
@@ -6697,6 +6709,10 @@ export async function mockApi(page, options = {}) {
Array.isArray(selfServe.commandResponses) && selfServe.commandResponses.length > 0
? selfServe.commandResponses.shift()
: selfServe.commandResponse;
const commandResponseDelayMs = Array.isArray(selfServe.commandResponseDelayMs)
? selfServe.commandResponseDelayMs.shift() || 0
: selfServe.commandResponseDelayMs;
await maybeDelayFixtureResponse(commandResponseDelayMs);
await route.fulfill(json(commandResponse));
return;
}
@@ -6724,6 +6740,51 @@ export async function mockApi(page, options = {}) {
return;
}
if (pathname.endsWith("/user/invoices") && method === "GET") {
const invoiceFixture = posFixture || { collectedInvoices: [] };
const customerNumber = Number(options.sessionData?.customer_number || 0);
const collectedInvoices = (invoiceFixture.collectedInvoices || []).filter((invoice) => {
if (!customerNumber) {
return true;
}
return Number(invoice.customer_number) === customerNumber;
});
const page = Number(parsedUrl.searchParams.get("page") || 1);
const limit = Number(parsedUrl.searchParams.get("limit") || collectedInvoices.length || 100);
await route.fulfill(
json({
data: collectedInvoices.slice((page - 1) * limit, page * limit),
meta: {
pagination: {
page,
per_page: limit,
total: collectedInvoices.length,
},
},
})
);
return;
}
if (pathname.endsWith("/invoices/pdf") && method === "GET") {
const invoiceId = String(parsedUrl.searchParams.get("id") || "").trim();
if (!invoiceId) {
await route.fulfill(json({ message: "Invoice not found" }, 404));
return;
}
await route.fulfill(
json({
data: {
url: `https://pdf.example.test/invoices/${invoiceId}.pdf`,
},
})
);
return;
}
if (pathname.endsWith("/collected-invoices") && method === "GET") {
const invoiceFixture = posFixture || { collectedInvoices: [] };
const filters = parseFilterExpressions(parsedUrl.searchParams.get("filters") || "");
+48 -22
View File
@@ -1,20 +1,25 @@
import { test, expect } from "@playwright/test";
import {
userCredentials,
subuserPhoneCredentials,
subuserUsernameCredentials,
operatorCredentials,
customerRegistrationData,
driverRegistrationData,
invalidCredentials,
loginAsUser,
loginAsSubuserByPhone,
loginAsSubuserByUsername,
loginAsOperator,
goToUserLogin,
goToSubuserLogin,
goToOperatorLogin,
} from "./fixtures";
import { test, expect, type Page } from "@playwright/test";
import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/network.js";
import { loginAsUser } from "./fixtures";
const USER_HOME_CUSTOMER_NUMBER = 12345679;
async function gotoUserHomeWithInvoices(page: Page, collectedInvoices: Array<Record<string, unknown>>) {
await mockApi(page, {
authenticated: true,
sessionData: {
customer_number: USER_HOME_CUSTOMER_NUMBER,
display_name: "E2E User",
permissions: ["user"],
},
pos: createPosFixture({
collectedInvoices,
}),
});
await seedAuthenticatedState(page, "user-home-invoice-shortcut-token");
await page.goto("/user", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/user(?:\/)?(?:[?#].*)?$/);
}
/** User home Tests */
// User home page content tests
@@ -41,9 +46,30 @@ test("[PAGES][User][/user] should display the download certificate button", asyn
// Check if the "download certificate" element is visible
await expect(page.locator("a#download-certificates-button")).toBeVisible();
});
// Download invoices button visibility test
test("[PAGES][User][/user] should display the download invoices button", async ({ page }) => {
await loginAsUser(page);
// Check if the "download invoices" element is visible
await expect(page.locator("a#download-invoices-button")).toBeVisible();
test("[PAGES][User][/user] should disable the download invoices button when no invoice exists", async ({ page }) => {
await gotoUserHomeWithInvoices(page, []);
const button = page.locator("button#download-invoices-button");
await expect(button).toBeVisible();
await expect(button).toBeDisabled();
await expect(button).not.toHaveClass(/is-loading/);
await expect(page.locator("a#download-invoices-button")).toHaveCount(0);
});
test("[PAGES][User][/user] should enable the download invoices button when an invoice exists", async ({ page }) => {
await gotoUserHomeWithInvoices(page, [
{
id: 7001,
customer_number: USER_HOME_CUSTOMER_NUMBER,
customer_name: "E2E User",
total_net_amount: 1250,
created_at: "2026-07-01",
closed_at: "2026-07-01",
},
]);
const link = page.locator("a#download-invoices-button");
await expect(link).toBeVisible();
await expect(link).toHaveAttribute("href", "/user/invoices");
await expect(page.locator("button#download-invoices-button")).toHaveCount(0);
});
@@ -316,6 +316,26 @@ const flushMicrotasks = async () => {
await Promise.resolve();
};
const flushDropdownLayout = async () => {
for (let index = 0; index < 8; index += 1) {
await Promise.resolve();
}
};
const createRect = ({ left = 0, top = 0, width = 0, height = 0 }) => ({
x: left,
y: top,
top,
left,
right: left + width,
bottom: top + height,
width,
height,
toJSON() {
return this;
},
});
const setDesktopFlyoutViewport = () => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
@@ -395,6 +415,95 @@ const mockMenuGeometry = () =>
};
});
const mockDropdownPlacementGeometry = ({
contentHeight = 180,
contentWidth = 240,
triggerHeight = 40,
triggerLeft = 880,
triggerTop = 520,
triggerWidth = 80,
} = {}) => {
let currentContentHeight = contentHeight;
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
if (this.classList?.contains("dropdown-content")) {
return currentContentHeight;
}
if (originalScrollHeightDescriptor?.get) {
return originalScrollHeightDescriptor.get.call(this);
}
return 0;
},
});
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function rectMock() {
if (this.classList?.contains("dropdown-trigger")) {
return createRect({
left: triggerLeft,
top: triggerTop,
width: triggerWidth,
height: triggerHeight,
});
}
if (this.classList?.contains("dropdown-content")) {
const dropdown = this.closest(".dropdown");
const menu = this.closest(".dropdown-menu");
const fixedTop = Number.parseFloat(menu?.style?.top || "");
const maxHeight = Number.parseFloat(this.style?.maxHeight || "");
const height = Number.isFinite(maxHeight) ? Math.min(currentContentHeight, maxHeight) : currentContentHeight;
const top = Number.isFinite(fixedTop)
? fixedTop
: dropdown?.classList.contains("is-up")
? triggerTop - height
: triggerTop + triggerHeight;
return createRect({
left: triggerLeft + triggerWidth - contentWidth,
top,
width: contentWidth,
height,
});
}
if (this.classList?.contains("action-wheel-clip-host")) {
return createRect({
left: 0,
top: 480,
width: 1280,
height: 120,
});
}
return createRect({
left: 0,
top: 0,
width: 1280,
height: 900,
});
});
return {
restore() {
rectSpy.mockRestore();
if (originalScrollHeightDescriptor) {
Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeightDescriptor);
} else {
delete HTMLElement.prototype.scrollHeight;
}
},
setContentHeight(nextHeight) {
currentContentHeight = nextHeight;
},
};
};
const mountDesktopFlyoutButton = (props = {}) => {
setDesktopFlyoutViewport();
@@ -416,6 +525,29 @@ const mountDesktopFlyoutButton = (props = {}) => {
});
};
const mountPositionedFlatDropdownButton = (options = {}) => {
setCompactViewport();
return mount(ActionSettingsWheelButton, {
props: {
order_id: null,
refreshFunction: vi.fn(() => Promise.resolve()),
...(options.props || {}),
},
slots: {
actions:
options.actions || '<button type="button" class="dropdown-item dropdown-item-action">Custom action</button>',
},
attachTo: options.attachTo,
global: {
plugins: [i18n],
stubs: {
CustomerModal: { template: "<div />" },
},
},
});
};
const mountFlatDropdownButton = (props = {}) => {
setCompactViewport();
@@ -564,6 +696,93 @@ describe("ActionSettingsWheelButton", () => {
wrapper.unmount();
});
it("uses the same vertical side when the dropdown is reopened with unchanged geometry", async () => {
const geometry = mockDropdownPlacementGeometry({
contentHeight: 420,
triggerTop: 520,
});
const wrapper = mountPositionedFlatDropdownButton();
await flushDropdownLayout();
const trigger = wrapper.get(".action-settings-wheel-trigger");
await trigger.trigger("click");
await flushDropdownLayout();
expect(wrapper.get(".dropdown").classes()).toContain("is-up");
await trigger.trigger("click");
await flushDropdownLayout();
expect(wrapper.get(".dropdown").classes()).not.toContain("is-active");
await trigger.trigger("click");
await flushDropdownLayout();
expect(wrapper.get(".dropdown").classes()).toContain("is-up");
wrapper.unmount();
geometry.restore();
});
it("keeps the initially selected side when the menu grows after opening", async () => {
const geometry = mockDropdownPlacementGeometry({
contentHeight: 120,
triggerTop: 520,
});
const wrapper = mountPositionedFlatDropdownButton();
await flushDropdownLayout();
await wrapper.get(".action-settings-wheel-trigger").trigger("click");
await flushDropdownLayout();
expect(wrapper.get(".dropdown").classes()).not.toContain("is-up");
geometry.setContentHeight(420);
window.dispatchEvent(new Event("resize"));
await flushDropdownLayout();
expect(wrapper.get(".dropdown").classes()).not.toContain("is-up");
expect(wrapper.get(".dropdown-content").element.style.maxHeight).toBe("328px");
wrapper.unmount();
geometry.restore();
});
it("keeps fixed-position fallback on the chosen side and clears stale content top", async () => {
const host = document.createElement("div");
host.className = "action-wheel-clip-host";
host.style.overflow = "hidden";
document.body.appendChild(host);
const geometry = mockDropdownPlacementGeometry({
contentHeight: 420,
triggerTop: 520,
});
const wrapper = mountPositionedFlatDropdownButton({ attachTo: host });
await flushDropdownLayout();
await wrapper.get(".action-settings-wheel-trigger").trigger("click");
await flushDropdownLayout();
const dropdownContent = wrapper.get(".dropdown-content").element;
const dropdownMenu = wrapper.get(".dropdown-menu").element;
expect(wrapper.get(".dropdown").classes()).toContain("is-up");
expect(dropdownMenu.style.position).toBe("fixed");
expect(dropdownMenu.style.top).toBe("100px");
expect(dropdownContent.style.top).toBe("");
dropdownContent.style.top = "24px";
window.dispatchEvent(new Event("resize"));
await flushDropdownLayout();
expect(wrapper.get(".dropdown").classes()).toContain("is-up");
expect(dropdownMenu.style.position).toBe("fixed");
expect(dropdownMenu.style.top).toBe("100px");
expect(dropdownContent.style.top).toBe("");
wrapper.unmount();
geometry.restore();
host.remove();
});
it("resolves customer user id and reloads customer rules only when the target changes in direct mode", async () => {
const wrapper = mount(ActionSettingsWheelButton, {
props: {
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { mount, flushPromises } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticatedRequest: vi.fn(),
handleEconomicError: vi.fn(),
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: mocks.authenticatedRequest,
}));
vi.mock("@/components/request/HandleEconomicError.vue", () => ({
handleEconomicError: mocks.handleEconomicError,
}));
import GetOrderInvoicePDFButton from "@/components/search/economic/getOrderInvoicePDFButton.vue";
const LoadButtonWhileAwaitStub = {
props: ["disabled", "loadFunction", "actionKey"],
template: `
<button
type="button"
:disabled="disabled"
:data-action-key="actionKey"
@click="loadFunction"
>
<slot />
</button>
`,
};
describe("GetOrderInvoicePDFButton", () => {
beforeEach(() => {
mocks.authenticatedRequest.mockReset();
mocks.handleEconomicError.mockReset();
vi.stubGlobal("open", vi.fn());
});
it.each([null, undefined, ""])("disables the PDF download when invoice id is %s", async (invoiceId) => {
const wrapper = mount(GetOrderInvoicePDFButton, {
props: {
invoice_id: invoiceId,
},
global: {
stubs: {
LoadButtonWhileAwait: LoadButtonWhileAwaitStub,
},
},
});
const button = wrapper.get('[data-action-key="economic-invoice-pdf-download"]');
expect(button.attributes("disabled")).toBeDefined();
await button.trigger("click");
await flushPromises();
expect(mocks.authenticatedRequest).not.toHaveBeenCalled();
expect(window.open).not.toHaveBeenCalled();
});
it("requests and opens the invoice PDF when invoice id exists", async () => {
mocks.authenticatedRequest.mockResolvedValue({
data: {
data: {
url: "https://pdf.example.test/invoices/99101.pdf",
},
},
});
const wrapper = mount(GetOrderInvoicePDFButton, {
props: {
invoice_id: 99101,
},
global: {
stubs: {
LoadButtonWhileAwait: LoadButtonWhileAwaitStub,
},
},
});
const button = wrapper.get('[data-action-key="economic-invoice-pdf-download"]');
expect(button.attributes("disabled")).toBeUndefined();
await button.trigger("click");
await flushPromises();
expect(mocks.authenticatedRequest).toHaveBeenCalledWith("/invoices/pdf", "GET", {
id: 99101,
});
expect(window.open).toHaveBeenCalledWith("https://pdf.example.test/invoices/99101.pdf", "_blank");
});
});
@@ -25,14 +25,125 @@ vi.mock("@/components/session/token/SessionUser/Objects/systemUserIds.js", () =>
getSystemUserIds: () => [],
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
global: {
language: {
no_data: "No data",
},
},
products: {
get: {
all: vi.fn(),
},
},
},
},
}));
import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { Products } from "@/components/session/token/SessionUser/Objects/Products.vue";
beforeEach(() => {
vi.clearAllMocks();
ObjectsGlobal.clearCache();
document.body.innerHTML = "";
});
describe("ObjectsGlobal query parameters", () => {
it("omits nullish object GET parameters while preserving false and zero", async () => {
authenticatedRequest.mockResolvedValueOnce({
data: {
data: {
id: 40,
},
},
});
await expect(
ObjectsGlobal.get.object("/products", {
id: 40,
department_id: null,
customer_id: undefined,
category_id: null,
final_price: false,
page: 0,
})
).resolves.toEqual({
id: 40,
});
expect(authenticatedRequest).toHaveBeenCalledWith("/products?id=40&final_price=false&page=0", "GET");
});
it("omits nullish object list parameters while preserving false", async () => {
authenticatedRequest.mockResolvedValueOnce({
data: {
data: [],
},
});
await expect(
ObjectsGlobal.get.objects("/products", {
department_id: null,
customer_id: undefined,
final_price: false,
})
).resolves.toEqual([]);
expect(authenticatedRequest).toHaveBeenCalledWith("/products", "GET", {
final_price: false,
});
});
});
describe("Products query parameters", () => {
it("omits nullish default pricing parameters for single product GETs", async () => {
authenticatedRequest.mockResolvedValueOnce({
data: {
data: {
id: 40,
},
},
});
await expect(Products.get.single(40)).resolves.toEqual({
id: 40,
});
expect(authenticatedRequest).toHaveBeenCalledWith("/products?id=40&final_price=false", "GET");
});
it("preserves explicit product pricing parameters", async () => {
authenticatedRequest.mockResolvedValueOnce({
data: {
data: {
id: 40,
},
},
});
await expect(
Products.get.single(40, {
department_id: 2,
customer_id: 12345,
category_id: 8,
final_price: true,
})
).resolves.toEqual({
id: 40,
});
expect(authenticatedRequest).toHaveBeenCalledWith(
"/products?id=40&department_id=2&customer_id=12345&category_id=8&final_price=true",
"GET"
);
});
});
describe("ObjectsGlobal select editor escaping", () => {
it("escapes option ids and names before rendering SweetAlert HTML", async () => {
const object = {
+50 -6
View File
@@ -30,32 +30,76 @@ describe("Playwright full E2E workflow grouping", () => {
);
});
it("keeps full-suite runner pressure bounded and diagnosable", () => {
it("keeps self-hosted full-suite runner pressure bounded while hosted dispatch can fan out", () => {
const source = workflowSource();
expect(source).toMatch(/e2e-full:[\s\S]*?max-parallel: 2/u);
expect(source).toContain("FRONTEND_CI_STANDARD_RUNNER");
expect(source).toContain("FRONTEND_CI_E2E_RUNNER");
expect(source).toContain("FRONTEND_CI_PR_E2E_MAX_PARALLEL");
expect(source).toContain("FRONTEND_CI_FULL_E2E_MAX_PARALLEL");
expect(source).toMatch(/e2e-pr:[\s\S]*?max-parallel: \$\{\{ fromJSON\(vars\.FRONTEND_CI_PR_E2E_MAX_PARALLEL/u);
expect(source).toMatch(/e2e-full:[\s\S]*?max-parallel: \$\{\{ fromJSON\(vars\.FRONTEND_CI_FULL_E2E_MAX_PARALLEL/u);
expect(source).toMatch(/e2e-full:[\s\S]*?PLAYWRIGHT_WORKERS: 1/u);
expect(source).toMatch(/e2e-full:[\s\S]*?PLAYWRIGHT_VIDEO_MODE: off/u);
expect(source).toContain("scripts/ci/with-systemd-inhibit.sh");
expect(source).toContain("scripts/ci/runner-diagnostics.sh");
});
it("diffs pull request changed-area tests against the PR head instead of the merge commit", () => {
const source = workflowSource();
expect(source).toContain("PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}");
expect(source).toMatch(
/if \[\[ "\$EVENT_NAME" == "pull_request" && -n "\$PR_BASE_SHA" \]\]; then[\s\S]*?head_ref="\$PR_HEAD_SHA"/u
);
expect(source).toContain('echo "head=$head_ref" >> "$GITHUB_OUTPUT"');
});
it("keeps PR E2E runner pressure bounded and diagnosable", () => {
const source = workflowSource();
expect(source).toMatch(/e2e-pr:[\s\S]*?runs-on: ubuntu-latest/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?max-parallel: 4/u);
expect(source).toContain("FRONTEND_CI_E2E_RUNNER");
expect(source).toMatch(
/e2e-pr:[\s\S]*?max-parallel: \$\{\{ fromJSON\(vars\.FRONTEND_CI_PR_E2E_MAX_PARALLEL \|\| '2'\) \}\}/u
);
expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_WORKERS: 1/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_VIDEO_MODE: on-first-retry/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_WORKERS="\$PLAYWRIGHT_WORKERS"/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_VIDEO_MODE="\$PLAYWRIGHT_VIDEO_MODE"/u);
});
it("supports repo-variable runner controls while keeping targeted reruns on Ubuntu", () => {
const source = workflowSource();
expect(source).toContain("FRONTEND_CI_STANDARD_RUNNER");
expect(source).toContain("FRONTEND_CI_E2E_RUNNER");
expect(source).toContain('["self-hosted","Linux","X64","pleno","frontend"]');
expect(source).toMatch(/e2e-targeted:[\s\S]*?runs-on: ubuntu-24\.04/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?runs-on: \$\{\{ fromJSON\(vars\.FRONTEND_CI_E2E_RUNNER/u);
expect(source).toMatch(/e2e-full:[\s\S]*?frontend","docker"\]/u);
});
it("supports targeted manual Playwright reruns on GitHub-hosted runners", () => {
const source = workflowSource();
expect(source).toContain("workflow_dispatch:");
expect(source).toContain("targeted-then-full");
expect(source).toContain("target_specs:");
expect(source).toContain("target_projects:");
expect(source).toContain("e2e-targeted:");
expect(source).toContain("matrix:");
expect(source).toContain("project: ${{ fromJSON(inputs.target_projects || '[\"chromium-desktop\"]') }}");
expect(source).toMatch(/e2e-targeted:[\s\S]*?runs-on: ubuntu-24\.04/u);
expect(source).toContain('npx playwright test "${args[@]}"');
expect(source).toContain('"$spec_path" != tests/e2e/*');
expect(source).toMatch(/e2e-pr:[\s\S]*?needs: \[build-and-unit, e2e-targeted\]/u);
});
it("uses a machine-wide Playwright port lock across self-hosted runner processes", () => {
const source = workflowSource();
expect(source.match(/PLAYWRIGHT_PORT_LOCK_ROOT:-\/tmp\/pleno-playwright-port-locks/gu)).toHaveLength(2);
expect(source.match(/chmod 1777 "\$lock_root"/gu)).toHaveLength(2);
expect(source.match(/PLAYWRIGHT_PORT_LOCK_ROOT:-\/tmp\/pleno-playwright-port-locks/gu)).toHaveLength(3);
expect(source.match(/chmod 1777 "\$lock_root"/gu)).toHaveLength(3);
expect(source).not.toContain("${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks");
});
+14 -4
View File
@@ -31,12 +31,20 @@ describe("Playwright PR mapping", () => {
});
it("maps department notification table changes to the admin notification E2E coverage", () => {
const notificationPaginationSpecs = specsFor(
"src/components/displays/pagination/models/DepartmentPos/NotificationsPhonePagination.vue"
);
expect(
specsFor("src/views/dashboards/departmentDashboard/modules/notifications/DepartmentNotifications.vue")
).toContain("tests/e2e/admin-department-notifications.spec.ts");
expect(
specsFor("src/components/displays/department/notifications/departmentNotificationsPhoneTable.vue")
).toContain("tests/e2e/admin-department-notifications.spec.ts");
expect(
specsFor("src/components/displays/pagination/models/DepartmentPos/NotificationsPhonePagination.vue")
).toContain("tests/e2e/admin-department-notifications.spec.ts");
expect(notificationPaginationSpecs).toContain("tests/e2e/admin-department-notifications.spec.ts");
expect(notificationPaginationSpecs).not.toContain("tests/e2e/pos-flow.spec.js");
expect(notificationPaginationSpecs).not.toContain("tests/e2e/pos-mobile-order-flow.spec.js");
expect(notificationPaginationSpecs).not.toContain("tests/e2e/admin-pos-orders.spec.ts");
});
it("maps admin daily-report changes to daily-report E2E coverage", () => {
@@ -50,13 +58,14 @@ describe("Playwright PR mapping", () => {
);
});
it("maps limited backoffice view changes to limited backoffice E2E coverage", () => {
it("maps limited backoffice view and service changes to limited backoffice E2E coverage", () => {
expect(specsFor("src/views/backoffice/LimitedBackofficePrices.vue")).toContain(
"tests/e2e/limited-backoffice.spec.ts"
);
expect(specsFor("src/views/backoffice/components/LimitedBackofficeLayout.vue")).toContain(
"tests/e2e/limited-backoffice.spec.ts"
);
expect(specsFor("src/services/limitedBackoffice.js")).toContain("tests/e2e/limited-backoffice.spec.ts");
});
it("maps superuser role permission page changes to role permissions E2E coverage", () => {
@@ -95,6 +104,7 @@ describe("Playwright PR mapping", () => {
it("maps customer product rule changes to POS customer rule E2E coverage", () => {
expect(specsFor("src/features/customer/customerProductRules.js")).toContain("tests/e2e/pos-customer-rules.spec.js");
expect(specsFor("src/components/displays/boxes/ProductBox.vue")).toContain("tests/e2e/pos-customer-rules.spec.js");
expect(specsFor("src/components/shop/POSDepartmentProcess.vue")).toContain("tests/e2e/admin-pos-orders.spec.ts");
});
it("maps limited backoffice changes to the limited backoffice E2E coverage", () => {
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { flushPromises } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const mocks = vi.hoisted(() => {
const device = {
isMobile: vi.fn(() => false),
isTablet: vi.fn(() => false),
isDesktop: vi.fn(() => true),
isWidescreen: vi.fn(() => false),
isUltraWideScreen: vi.fn(() => false),
};
return {
authenticatedRequest: vi.fn(),
sessionUser: {
isSubuser: {
value: false,
},
subuser: {
selectedGrantCustomerNumber: {
value: null,
},
},
permissions: {
value: ["user"],
},
hasPermission: vi.fn((permission) => permission === "user"),
canAccessCustomerFeature: vi.fn(() => true),
canManageSubusers: vi.fn(() => false),
getName: vi.fn(() => "E2E User"),
functions: {
device,
},
},
};
});
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: mocks.authenticatedRequest,
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
default: mocks.sessionUser,
}));
import UserDashboard from "@/views/dashboards/UserDashboard.vue";
const flushDashboard = async () => {
await flushPromises();
await flushPromises();
};
const RouterLinkStub = {
props: ["to"],
template: "<a v-bind=\"$attrs\" :href=\"typeof to === 'string' ? to : '#'\"><slot /></a>",
};
const mountDashboard = () =>
mountWithApp(UserDashboard, {
global: {
stubs: {
RouterLink: RouterLinkStub,
"router-link": RouterLinkStub,
},
},
});
describe("UserDashboard invoice shortcut", () => {
beforeEach(() => {
mocks.authenticatedRequest.mockReset();
mocks.sessionUser.isSubuser.value = false;
mocks.sessionUser.permissions.value = ["user"];
mocks.sessionUser.hasPermission.mockReset();
mocks.sessionUser.hasPermission.mockImplementation((permission) => permission === "user");
mocks.sessionUser.canAccessCustomerFeature.mockReset();
mocks.sessionUser.canAccessCustomerFeature.mockReturnValue(true);
mocks.sessionUser.canManageSubusers.mockReset();
mocks.sessionUser.canManageSubusers.mockReturnValue(false);
});
it("keeps the invoice shortcut disabled while invoice availability is loading", () => {
mocks.authenticatedRequest.mockReturnValue(new Promise(() => {}));
const wrapper = mountDashboard();
const button = wrapper.get("button#download-invoices-button");
expect(button.attributes("disabled")).toBeDefined();
expect(button.classes()).toContain("is-loading");
expect(wrapper.find("a#download-invoices-button").exists()).toBe(false);
wrapper.unmount();
});
it("keeps the invoice shortcut disabled when no invoice exists", async () => {
mocks.authenticatedRequest.mockResolvedValue({
data: {
data: [],
meta: {
pagination: {
total: 0,
},
},
},
});
const wrapper = mountDashboard();
await flushDashboard();
const button = wrapper.get("button#download-invoices-button");
expect(button.attributes("disabled")).toBeDefined();
expect(button.classes()).not.toContain("is-loading");
expect(wrapper.find("a#download-invoices-button").exists()).toBe(false);
expect(mocks.authenticatedRequest).toHaveBeenCalledWith("/user/invoices", "GET", {
page: 1,
limit: 1,
order: "created_at:desc",
});
wrapper.unmount();
});
it("enables the invoice shortcut when at least one invoice exists", async () => {
mocks.authenticatedRequest.mockResolvedValue({
data: {
data: [{ id: 501 }],
meta: {
pagination: {
total: 1,
},
},
},
});
const wrapper = mountDashboard();
await flushDashboard();
const link = wrapper.get("a#download-invoices-button");
expect(link.attributes("href")).toBe("/user/invoices");
expect(wrapper.find("button#download-invoices-button").exists()).toBe(false);
wrapper.unmount();
});
it("fails closed when invoice availability cannot be loaded", async () => {
mocks.authenticatedRequest.mockRejectedValue(new Error("Network unavailable"));
const wrapper = mountDashboard();
await flushDashboard();
const button = wrapper.get("button#download-invoices-button");
expect(button.attributes("disabled")).toBeDefined();
expect(wrapper.find("a#download-invoices-button").exists()).toBe(false);
wrapper.unmount();
});
});