Merge pull request #1950 from GSA/1927-user-story-brief-description-of-the-user-story
Backstop is implemented locally
5
.gitignore
vendored
@@ -134,3 +134,8 @@ playwright/
|
||||
|
||||
# Nodenv
|
||||
.node-version
|
||||
|
||||
# BackstopJS
|
||||
backstop_data/bitmaps_test/
|
||||
backstop_data/html_report/
|
||||
backstop_data/engine_scripts/playwright/
|
||||
|
||||
3
.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -64,11 +64,11 @@ def _get_access_token(code, state): # pragma: no cover
|
||||
url = f"{base_url}{cli_assert}&{cli_assert_type}&{code_param}&grant_type=authorization_code"
|
||||
headers = {"Authorization": "Bearer %s" % token}
|
||||
response = requests.post(url, headers=headers)
|
||||
|
||||
response_json = response.json()
|
||||
try:
|
||||
encoded_id_token = response_json["id_token"]
|
||||
except KeyError as e:
|
||||
# Capture the response json here so it hopefully shows up in error reports
|
||||
current_app.logger.exception(f"Error when getting id token {response_json}")
|
||||
raise KeyError(f"'access_token' {response.json()}") from e
|
||||
|
||||
@@ -97,7 +97,6 @@ def _get_access_token(code, state): # pragma: no cover
|
||||
try:
|
||||
access_token = response_json["access_token"]
|
||||
except KeyError as e:
|
||||
# Capture the response json here so it hopefully shows up in error reports
|
||||
current_app.logger.exception(
|
||||
f"Error when getting access token {response.json()} #notify-admin-1505"
|
||||
)
|
||||
@@ -157,8 +156,9 @@ def _do_login_dot_gov(): # $ pragma: no cover
|
||||
current_app.logger.info(f"activating user {usr.id} #notify-admin-1505")
|
||||
activate_user(usr.id)
|
||||
except BaseException as be: # noqa B036
|
||||
current_app.logger.exception(f"Error signing in: {be} #notify-admin-1505 ")
|
||||
current_app.logger.error(f"Error signing in: {be} #notify-admin-1505 ")
|
||||
error(401)
|
||||
|
||||
return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url))
|
||||
|
||||
# end login.gov
|
||||
@@ -181,16 +181,22 @@ def _handle_e2e_tests(redirect_url): # pragma: no cover
|
||||
)
|
||||
user = user_api_client.get_user_by_email(os.getenv("NOTIFY_E2E_TEST_EMAIL"))
|
||||
activate_user(user["id"])
|
||||
|
||||
# Check if the redirect URL is present and safe before proceeding further
|
||||
if redirect_url and is_safe_redirect_url(redirect_url):
|
||||
return redirect(redirect_url)
|
||||
|
||||
return redirect(
|
||||
url_for(
|
||||
"main.show_accounts_or_dashboard",
|
||||
next="EMAIL_IS_OK",
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
stre = str(e)
|
||||
stre = stre.replace(" ", "_")
|
||||
# Trying to get a message back to playwright somehow since we can't see the admin logs
|
||||
# Trying to get a message back to playwright somehow since we can't raise an error
|
||||
return redirect(url_for(f"https://{stre}"))
|
||||
|
||||
|
||||
@@ -200,7 +206,7 @@ def sign_in(): # pragma: no cover
|
||||
redirect_url = request.args.get("next")
|
||||
|
||||
if os.getenv("NOTIFY_E2E_TEST_EMAIL"):
|
||||
return _handle_e2e_tests(None)
|
||||
return _handle_e2e_tests(redirect_url)
|
||||
|
||||
# If we have to revalidated the email, send the message
|
||||
# via email and redirect to the "verify your email page"
|
||||
|
||||
61
backstop.config.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const { urls, baseUrl } = require('./urls');
|
||||
|
||||
const MISMATCH_THRESHOLD = 0.2;
|
||||
const SCREENSHOT_DELAY = 2000;
|
||||
|
||||
const createScenariosFromUrls = (urls, delay = SCREENSHOT_DELAY) => {
|
||||
return Object.keys(urls).map((label) => ({
|
||||
label,
|
||||
url: urls[label],
|
||||
selectors: ['document'],
|
||||
misMatchThreshold: MISMATCH_THRESHOLD,
|
||||
requireSameDimensions: true,
|
||||
delay,
|
||||
}));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
id: 'backstop_test',
|
||||
viewports: [
|
||||
{
|
||||
label: 'desktop',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
},
|
||||
],
|
||||
scenarios: [
|
||||
...createScenariosFromUrls(urls),
|
||||
{
|
||||
label: 'Choose Service - Accounts',
|
||||
url: `${baseUrl}/accounts`,
|
||||
selectors: ['h1.heading-large', 'a.usa-button[href="/add-service"]'],
|
||||
misMatchThreshold: MISMATCH_THRESHOLD,
|
||||
requireSameDimensions: true,
|
||||
delay: SCREENSHOT_DELAY,
|
||||
},
|
||||
// example page with script
|
||||
{
|
||||
label: 'Get Started Page - Highlight Trial Mode',
|
||||
url: `${baseUrl}/using-notify/get-started`,
|
||||
selectors: ['document'],
|
||||
misMatchThreshold: MISMATCH_THRESHOLD,
|
||||
requireSameDimensions: true,
|
||||
onBeforeScript: 'puppeteer/countFeatureLinks.js',
|
||||
delay: SCREENSHOT_DELAY,
|
||||
},
|
||||
],
|
||||
paths: {
|
||||
bitmaps_reference: 'backstop_data/bitmaps_reference',
|
||||
bitmaps_test: 'backstop_data/bitmaps_test',
|
||||
engine_scripts: 'backstop_data/engine_scripts',
|
||||
html_report: 'backstop_data/html_report',
|
||||
ci_report: 'backstop_data/ci_report',
|
||||
},
|
||||
engine: 'puppeteer',
|
||||
engineOptions: {
|
||||
browser: 'chromium',
|
||||
timeout: 30000,
|
||||
},
|
||||
report: ['browser'],
|
||||
debug: false,
|
||||
};
|
||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 908 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 190 KiB |
18
backstop_data/engine_scripts/puppeteer/countFeatureLinks.js
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = async (page, scenario) => {
|
||||
await page.goto(scenario.url, { waitUntil: 'networkidle2' });
|
||||
|
||||
console.log('Page loaded.');
|
||||
|
||||
// Count the number of items in the side navigation and log their text
|
||||
const navItems = await page.$$eval('nav ul li.usa-sidenav__item a', (items) =>
|
||||
items.map((item) => item.textContent.trim())
|
||||
);
|
||||
|
||||
console.log(`Found ${navItems.length} navigation items:`);
|
||||
navItems.forEach((itemText, index) => {
|
||||
console.log(`${index + 1}: ${itemText}`);
|
||||
});
|
||||
|
||||
// Wait a moment for the logging to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
};
|
||||
65
docs/backstop.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# BackstopJS Getting Started Guide
|
||||
|
||||
## Overview
|
||||
|
||||
BackstopJS is currently optional in this project and is used for visual regression testing to ensure consistency in the UI. To get started with BackstopJS, follow the steps below. The official guide can be found at the [BackstopJS GitHub Repository](https://github.com/garris/BackstopJS).
|
||||
|
||||
## Goal
|
||||
|
||||
Eventually BackstopJS will be implemented in the pipeline process to help catch errors before they make their way to higher environments.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running BackstopJS, ensure the following:
|
||||
|
||||
- Both the API and Admin projects must be running.
|
||||
- In the .env file of both the API and Admin projects, make sure the following environment variables are uncommented and set to matching values:
|
||||
- `NOTIFY_E2E_TEST_EMAIL`
|
||||
- `NOTIFY_E2E_TEST_PASSWORD`
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
NOTIFY_E2E_TEST_EMAIL=your-email@example.com
|
||||
NOTIFY_E2E_TEST_PASSWORD=your-password
|
||||
```
|
||||
|
||||
### How to Run BackstopJS
|
||||
|
||||
#### Step 1: Install Gulp Globally
|
||||
|
||||
First, make sure all dependencies are installed and updated:
|
||||
|
||||
```
|
||||
make bootstrap
|
||||
```
|
||||
|
||||
#### Step 2: Run the Gulp Test Task
|
||||
|
||||
To run the visual regression tests, use the following command:
|
||||
|
||||
```
|
||||
gulp backstopTest
|
||||
```
|
||||
|
||||
If there are new expected changes in the UI, one will need to update the reference images before testing. To do this, run the following command:
|
||||
|
||||
```
|
||||
gulp backstopReference
|
||||
```
|
||||
|
||||
**_Note: After running the `gulp backstopReference` command, immediately running the test (`gulp backstopTest`) should pass all tests, assuming the changes are expected._**
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
The entry point for BackstopJS configuration can be found in the `backstop.config.js` file.
|
||||
|
||||
To add a new feature or page to be tested, modify the `scenarios` array. Refer to the [official documentation](https://github.com/garris/BackstopJS) for details on the structure of the object.
|
||||
|
||||
In most cases, one will be testing entire pages instead of individual components. To reduce redundancy, helper functions were created with our default settings that will be applied to every URL in the `urls.js` file. To add a new URL, one would add an object like such:
|
||||
|
||||
```
|
||||
{ label: 'Support', path: '/support' }
|
||||
```
|
||||
|
||||
For unique cases that involve UI interaction, scripts linked to the `engine_scripts/puppeteer` directory can be used to simulate user interactions during tests. An example can be found with `countFeatureLinks.js`
|
||||
95
gulpfile.js
@@ -5,8 +5,8 @@ const rollupPluginNodeResolve = require('@rollup/plugin-node-resolve');
|
||||
const source = require('vinyl-source-stream');
|
||||
const buffer = require('vinyl-buffer');
|
||||
const gulpMerge = require('gulp-merge');
|
||||
const uswds = require("@uswds/compile");
|
||||
|
||||
const uswds = require('@uswds/compile');
|
||||
const { exec } = require('child_process');
|
||||
const plugins = {};
|
||||
plugins.addSrc = require('gulp-add-src');
|
||||
plugins.babel = require('gulp-babel');
|
||||
@@ -21,7 +21,7 @@ const paths = {
|
||||
dist: 'app/static/',
|
||||
npm: 'node_modules/',
|
||||
toolkit: 'node_modules/govuk_frontend_toolkit/',
|
||||
govuk_frontend: 'node_modules/govuk-frontend/'
|
||||
govuk_frontend: 'node_modules/govuk-frontend/',
|
||||
};
|
||||
|
||||
const javascripts = () => {
|
||||
@@ -29,28 +29,30 @@ const javascripts = () => {
|
||||
input: paths.src + 'javascripts/modules/all.mjs',
|
||||
plugins: [
|
||||
rollupPluginNodeResolve({
|
||||
mainFields: ['module', 'main']
|
||||
mainFields: ['module', 'main'],
|
||||
}),
|
||||
rollupPluginCommonjs({
|
||||
include: 'node_modules/**'
|
||||
})
|
||||
include: 'node_modules/**',
|
||||
}),
|
||||
],
|
||||
output: {
|
||||
format: 'iife',
|
||||
name: 'GOVUK'
|
||||
}
|
||||
name: 'GOVUK',
|
||||
},
|
||||
})
|
||||
.pipe(source('all.mjs'))
|
||||
.pipe(buffer())
|
||||
.pipe(plugins.addSrc.prepend([
|
||||
paths.npm + 'hogan.js/dist/hogan-3.0.2.js',
|
||||
paths.npm + 'jquery/dist/jquery.min.js',
|
||||
paths.npm + 'query-command-supported/dist/queryCommandSupported.min.js',
|
||||
paths.npm + 'timeago/jquery.timeago.js',
|
||||
paths.npm + 'textarea-caret/index.js',
|
||||
paths.npm + 'cbor-js/cbor.js',
|
||||
paths.npm + 'd3/dist/d3.min.js'
|
||||
]));
|
||||
.pipe(
|
||||
plugins.addSrc.prepend([
|
||||
paths.npm + 'hogan.js/dist/hogan-3.0.2.js',
|
||||
paths.npm + 'jquery/dist/jquery.min.js',
|
||||
paths.npm + 'query-command-supported/dist/queryCommandSupported.min.js',
|
||||
paths.npm + 'timeago/jquery.timeago.js',
|
||||
paths.npm + 'textarea-caret/index.js',
|
||||
paths.npm + 'cbor-js/cbor.js',
|
||||
paths.npm + 'd3/dist/d3.min.js',
|
||||
])
|
||||
);
|
||||
|
||||
const local = src([
|
||||
paths.toolkit + 'javascripts/govuk/modules.js',
|
||||
@@ -81,9 +83,11 @@ const javascripts = () => {
|
||||
paths.src + 'javascripts/activityChart.js',
|
||||
])
|
||||
.pipe(plugins.prettyerror())
|
||||
.pipe(plugins.babel({
|
||||
presets: ['@babel/preset-env']
|
||||
}));
|
||||
.pipe(
|
||||
plugins.babel({
|
||||
presets: ['@babel/preset-env'],
|
||||
})
|
||||
);
|
||||
|
||||
return gulpMerge(vendored, local)
|
||||
.pipe(plugins.uglify())
|
||||
@@ -93,24 +97,21 @@ const javascripts = () => {
|
||||
|
||||
// Task to copy `gtm_head.js`
|
||||
const copyGtmHead = () => {
|
||||
return src(paths.src + 'js/gtm_head.js')
|
||||
.pipe(dest(paths.dist + 'js/'));
|
||||
return src(paths.src + 'js/gtm_head.js').pipe(dest(paths.dist + 'js/'));
|
||||
};
|
||||
|
||||
// Task to copy `setTimezone.js`
|
||||
const copySetTimezone = () => {
|
||||
return src(paths.src + 'js/setTimezone.js')
|
||||
.pipe(dest(paths.dist + 'js/'));
|
||||
return src(paths.src + 'js/setTimezone.js').pipe(dest(paths.dist + 'js/'));
|
||||
};
|
||||
|
||||
|
||||
// Task to copy images
|
||||
const copyImages = () => {
|
||||
return src(paths.src + 'images/**/*', { encoding: false })
|
||||
.pipe(dest(paths.dist + 'images/'));
|
||||
return src(paths.src + 'images/**/*', { encoding: false }).pipe(
|
||||
dest(paths.dist + 'images/')
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Configure USWDS paths
|
||||
uswds.settings.version = 3;
|
||||
uswds.paths.dist.css = paths.dist + 'css';
|
||||
@@ -129,4 +130,40 @@ const copyAssets = async () => {
|
||||
await uswds.copyAssets();
|
||||
};
|
||||
|
||||
exports.default = series(styles, javascripts, copyGtmHead, copySetTimezone, copyImages, copyAssets);
|
||||
// Optional backstopJS task
|
||||
// Install gulp globally and run `gulp backstopTest`
|
||||
const backstopTest = (done) => {
|
||||
exec(
|
||||
'npx backstop test --configPath=backstop.config.js',
|
||||
(err, stdout, stderr) => {
|
||||
console.log(stdout);
|
||||
console.error(stderr);
|
||||
done(err);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Optional backstopJS reference task
|
||||
// Install gulp globally and run `gulp backstopReference`
|
||||
const backstopReference = (done) => {
|
||||
exec(
|
||||
'npx backstop reference --configPath=backstop.config.js',
|
||||
(err, stdout, stderr) => {
|
||||
console.log(stdout);
|
||||
console.error(stderr);
|
||||
done(err);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Export tasks
|
||||
exports.default = series(
|
||||
styles,
|
||||
javascripts,
|
||||
copyGtmHead,
|
||||
copySetTimezone,
|
||||
copyImages,
|
||||
copyAssets
|
||||
);
|
||||
exports.backstopTest = backstopTest;
|
||||
exports.backstopReference = backstopReference;
|
||||
|
||||
1941
package-lock.json
generated
@@ -25,8 +25,8 @@
|
||||
"graceful-fs": "^4.2.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rollup/plugin-commonjs": "^26.0.1",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-commonjs": "^26.0.3",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@rollup/stream": "^3.0.1",
|
||||
"@uswds/uswds": "^3.8.2",
|
||||
"cbor-js": "0.1.0",
|
||||
@@ -37,6 +37,7 @@
|
||||
"hogan": "1.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"morphdom": "^2.7.4",
|
||||
"playwright": "^1.47.2",
|
||||
"python": "^0.0.4",
|
||||
"query-command-supported": "1.0.0",
|
||||
"sass-embedded": "^1.79.3",
|
||||
@@ -49,6 +50,7 @@
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@uswds/compile": "^1.2.0",
|
||||
"backstopjs": "^6.3.25",
|
||||
"better-npm-audit": "^3.11.0",
|
||||
"gulp": "^5.0.0",
|
||||
"gulp-add-src": "^1.0.0",
|
||||
|
||||
5
poetry.lock
generated
@@ -1326,9 +1326,13 @@ files = [
|
||||
{file = "lxml-5.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:edcfa83e03370032a489430215c1e7783128808fd3e2e0a3225deee278585196"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28bf95177400066596cdbcfc933312493799382879da504633d16cf60bba735b"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a745cc98d504d5bd2c19b10c79c61c7c3df9222629f1b6210c0368177589fb8"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b590b39ef90c6b22ec0be925b211298e810b4856909c8ca60d27ffbca6c12e6"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b336b0416828022bfd5a2e3083e7f5ba54b96242159f83c7e3eebaec752f1716"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:c2faf60c583af0d135e853c86ac2735ce178f0e338a3c7f9ae8f622fd2eb788c"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:4bc6cb140a7a0ad1f7bc37e018d0ed690b7b6520ade518285dc3171f7a117905"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7ff762670cada8e05b32bf1e4dc50b140790909caa8303cfddc4d702b71ea184"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:57f0a0bbc9868e10ebe874e9f129d2917750adf008fe7b9c1598c0fbbfdde6a6"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:a6d2092797b388342c1bc932077ad232f914351932353e2e8706851c870bca1f"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:60499fe961b21264e17a471ec296dcbf4365fbea611bf9e303ab69db7159ce61"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-win32.whl", hash = "sha256:d9b342c76003c6b9336a80efcc766748a333573abf9350f4094ee46b006ec18f"},
|
||||
{file = "lxml-5.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b16db2770517b8799c79aa80f4053cd6f8b716f21f8aca962725a9565ce3ee40"},
|
||||
@@ -2481,6 +2485,7 @@ files = [
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
|
||||
|
||||
30
urls.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const baseUrl = 'http://localhost:6012';
|
||||
|
||||
// List of routes with paths and labels
|
||||
const sublinks = [
|
||||
{ label: 'Homepage', path: '/' },
|
||||
{ label: 'Add Service', path: '/add-service' },
|
||||
{ label: 'Get Started', path: '/using-notify/get-started' },
|
||||
{ label: 'Trial Mode', path: '/using-notify/trial-mode' },
|
||||
{ label: 'Pricing', path: '/using-notify/pricing' },
|
||||
{ label: 'Delivery Status', path: '/using-notify/delivery-status' },
|
||||
{ label: 'Guidance', path: '/using-notify/guidance' },
|
||||
{ label: 'Features', path: '/features' },
|
||||
{ label: 'Roadmap', path: '/features/roadmap' },
|
||||
{ label: 'Security', path: '/features/security' },
|
||||
{ label: 'Support', path: '/support' },
|
||||
// Add more links here as needed
|
||||
];
|
||||
|
||||
const createFullUrl = (base, path) => `${base}${path}`;
|
||||
|
||||
// Build url using base and path
|
||||
const constructUrls = (base, sublinks) =>
|
||||
sublinks.reduce((acc, { label, path }) => {
|
||||
return { ...acc, [label]: createFullUrl(base, path) };
|
||||
}, {});
|
||||
|
||||
module.exports = {
|
||||
baseUrl,
|
||||
urls: constructUrls(baseUrl, sublinks),
|
||||
};
|
||||