diff --git a/README.md b/README.md index 587c65cf..227925a6 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,11 @@ The environment variables are as follows: * GH_API_ACCESS_TOKEN: Used to authenticate with GitHub when adding or getting information about Github Issues. Must correspond to the `access_token` provided by GitHub. * GH_ISSUE_REPO_NAME: Identifies the repository, in `/` format, where the issue will be created, and where information about issues will be retrieved. Can be customized for local development. * VALIDATE_EMAIL_SECRET_KEY: Used to sign and verify JWT tokens for the validate email feature. Used to identify that any JWT tokens for the validate email feature produced are recognizable by this version of the app and no other. +V2 Feature gating +* VITE_V2_FEATURE_ENHANCED_SEARCH: 'enabled' shows enhanced search features (like follow) in the client app, 'disabled' hides them +* VITE_V2_FEATURE_AUTHENTICATE: 'enabled' allows sign in and sign out of the web app, 'disabled' disallows. +* VITE_V2_FEATURE_CREATE_RECORDS: 'enabled' allows user to create data sources and requests from within the app, 'disabled' changes the links to lead to the current Airtable forms + #### .env Example diff --git a/client/src/api/auth.js b/client/src/api/auth.js index 1c9dc071..78b9501c 100644 --- a/client/src/api/auth.js +++ b/client/src/api/auth.js @@ -155,6 +155,17 @@ export async function resetPassword(password, token) { ); } +export async function generateAPIKey() { + const auth = useAuthStore(); + + return await axios.post(`${AUTH_BASE}/${ENDPOINTS.AUTH.API_KEY}`, null, { + headers: { + ...HEADERS, + Authorization: `Bearer ${auth.$state.tokens.accessToken.value}`, + }, + }); +} + /** * @deprecated validation now done by parsing JWT directly */ diff --git a/client/src/api/search.js b/client/src/api/search.js index 7eef6574..07044b4d 100644 --- a/client/src/api/search.js +++ b/client/src/api/search.js @@ -14,6 +14,7 @@ const HEADERS_BASIC = { }; export async function search(params) { + const authStore = useAuthStore(); const searchStore = useSearchStore(); const cached = searchStore.getSearchFromCache(JSON.stringify(params)); @@ -32,7 +33,14 @@ export async function search(params) { `${SEARCH_BASE}/${ENDPOINTS.SEARCH.RESULTS}`, { params, - headers: HEADERS_BASIC, + headers: { + ...HEADERS_BASIC, + ...(authStore.isAuthenticated() + ? { + Authorization: `Bearer ${authStore.$state.tokens.accessToken.value}`, + } + : {}), + }, }, ); @@ -86,9 +94,11 @@ export async function getFollowedSearch(location_id) { try { const response = await getFollowedSearches(); - return response.data.data.find( - ({ id: followed_id }) => Number(followed_id) === Number(location_id), + const found = response.data.data.find( + ({ location_id: followed_id }) => + Number(followed_id) === Number(location_id), ); + return found; } catch (error) { return null; } diff --git a/client/src/api/user.js b/client/src/api/user.js index 3b303677..a410ef44 100644 --- a/client/src/api/user.js +++ b/client/src/api/user.js @@ -25,3 +25,15 @@ export async function changePassword(oldPassword, newPassword) { return await auth.signInWithEmail(user.email, newPassword); } + +export async function getUser() { + const auth = useAuthStore(); + const user = useUserStore(); + + return await axios.get(`${USER_BASE}/${user.id}`, { + headers: { + ...HEADERS, + Authorization: `Bearer ${auth.$state.tokens.accessToken.value}`, + }, + }); +} diff --git a/client/src/components/AuthWrapper.vue b/client/src/components/AuthWrapper.vue index 7950f80a..4e1cb347 100644 --- a/client/src/components/AuthWrapper.vue +++ b/client/src/components/AuthWrapper.vue @@ -10,6 +10,35 @@ import { useAuthStore } from '@/stores/auth'; import { useRoute, useRouter } from 'vue-router'; import { useUserStore } from '@/stores/user'; import { refreshTokens, signOut } from '@/api/auth'; +import { updateGlobalOptions, globalOptions } from 'vue3-toastify'; +import { watch, ref, onMounted } from 'vue'; + +const isHeaderVisible = ref(true); + +watch(isHeaderVisible, (visible) => { + updateGlobalOptions({ + ...globalOptions.value, + style: { + ...globalOptions.style, + top: visible ? '120px' : '20px', + }, + theme: 'auto', + }); +}); + +onMounted(() => { + const observer = new IntersectionObserver( + ([entry]) => { + isHeaderVisible.value = entry.isIntersecting; + }, + { threshold: 0 }, + ); + + const navbar = document.querySelector('.pdap-header'); + if (navbar) { + observer.observe(navbar); + } +}); const route = useRoute(); const router = useRouter(); diff --git a/client/src/components/SearchForm.vue b/client/src/components/SearchForm.vue index ad9636c2..ad6b4976 100644 --- a/client/src/components/SearchForm.vue +++ b/client/src/components/SearchForm.vue @@ -67,9 +67,22 @@

If you have a question to answer, we can help

- + Make a Request + + Make a request + @@ -82,11 +95,16 @@ import { } from 'pdap-design-system'; import TypeaheadInput from '@/components/TypeaheadInput.vue'; import { computed, onMounted, ref } from 'vue'; -import { getFullLocationText } from '@/util/locationFormatters'; +import { + getFullLocationText, + mapLocationToSearchParams, + mapSearchParamsToLocation, +} from '@/util/locationFormatters'; import _debounce from 'lodash/debounce'; import _isEqual from 'lodash/isEqual'; import { useRouter, RouterLink, useRoute } from 'vue-router'; import { getTypeaheadLocations } from '@/api/typeahead'; +import { getIsV2FeatureEnabled } from '@/util/featureFlagV2'; const router = useRouter(); @@ -186,17 +204,7 @@ const isButtonDisabled = computed(() => { onMounted(() => { // Set up selected state based on params if (params.state) { - const record = (({ - state_name, - county_name, - locality_name, - location_id, - }) => ({ - state: state_name, - county: county_name, - locality: locality_name, - location_id, - }))(params); + const record = mapSearchParamsToLocation(params); selectedRecord.value = record; initiallySearchedRecord.value = record; @@ -223,18 +231,9 @@ function buildParams(values) { const obj = {}; /* Handle record from typeahead input */ - const recordFilteredByParamsKeys = (({ - state_name, - county_name, - locality_name, - location_id, - }) => ({ - state: state_name, - county: county_name, - locality: locality_name, - location_id, - // If no selected record, fall back to the initial search - }))(selectedRecord.value ?? initiallySearchedRecord.value); + const recordFilteredByParamsKeys = mapLocationToSearchParams( + selectedRecord.value ?? initiallySearchedRecord.value, + ); Object.keys(recordFilteredByParamsKeys).forEach((key) => { if (recordFilteredByParamsKeys[key]) diff --git a/client/src/pages/data-request/[id].vue b/client/src/pages/data-request/[id].vue index baed4fba..f6e472c8 100644 --- a/client/src/pages/data-request/[id].vue +++ b/client/src/pages/data-request/[id].vue @@ -90,9 +90,6 @@ - - diff --git a/client/src/pages/profile/_components/APIKey.vue b/client/src/pages/profile/_components/APIKey.vue new file mode 100644 index 00000000..6525321e --- /dev/null +++ b/client/src/pages/profile/_components/APIKey.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/client/src/pages/profile/_components/ThreeColumnTable.vue b/client/src/pages/profile/_components/ThreeColumnTable.vue new file mode 100644 index 00000000..3a8dc27a --- /dev/null +++ b/client/src/pages/profile/_components/ThreeColumnTable.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/client/src/pages/profile/index.vue b/client/src/pages/profile/index.vue new file mode 100644 index 00000000..5a33ba5c --- /dev/null +++ b/client/src/pages/profile/index.vue @@ -0,0 +1,402 @@ + + { + meta: { + auth: true + } + } + + + + + + + + + + + diff --git a/client/src/pages/search/results.vue b/client/src/pages/search/results.vue index 3c9d2535..364d7bd9 100644 --- a/client/src/pages/search/results.vue +++ b/client/src/pages/search/results.vue @@ -9,12 +9,16 @@ >

Results - {{ searchData && 'for ' + getFullLocationText(searchData.params) }} + {{ + searchData && + !isLoading && + 'for ' + getMinimalLocationText(searchData.params) + }}

Sign in - to follow this search + to follow this location

@@ -39,9 +43,9 @@ v-if="isAuthenticated()" class="text-med text-neutral-500 max-w-full md:text-right" > - You are following this search.
- Review saved searches in - your profile + + Following this location
+ See your profile for more.

@@ -69,19 +73,27 @@ > {{ getAnchorLinkText(locale) }} - ({{ searchData.results[locale].count }}) + ({{ searchData?.results[locale].count }})
- - - @@ -122,8 +134,8 @@ // Data loader import { defineBasicLoader } from 'unplugin-vue-router/data-loaders/basic'; import { useSearchStore } from '@/stores/search'; -import { NavigationResult } from 'unplugin-vue-router/runtime'; -import { onMounted, onUnmounted, onUpdated, reactive, ref } from 'vue'; +// import { NavigationResult } from 'unplugin-vue-router/runtime'; +import { onMounted, onUnmounted, onUpdated, reactive, ref, watch } from 'vue'; import { ALL_LOCATION_TYPES } from '@/util/constants'; import { groupResultsByAgency, @@ -136,14 +148,12 @@ import { getMostNarrowSearchLocationWithResults, getMinimalLocationText, } from '@/util/locationFormatters'; -import _isEqual from 'lodash/isEqual'; import { DataLoaderErrorPassThrough } from '@/util/errors'; const searchStore = useSearchStore(); import { search, getFollowedSearch, followSearch } from '@/api/search'; import { getLocationDataRequests } from '@/api/locations'; +import { mapSearchParamsToLocation } from '@/util/locationFormatters'; -const query = ref(); -const data = ref(); const previousRoute = ref(); const isPreviousRouteFollowed = ref(false); @@ -152,40 +162,22 @@ export const useSearchData = defineBasicLoader( '/search/results', async (route) => { try { - const searchLocation = (({ state, county, locality, location_id }) => ({ - state_name: state, - county_name: county, - locality_name: locality, - location_id, - }))(route.query); - + const searchLocation = mapSearchParamsToLocation(route.query); const searched = getMostNarrowSearchLocationWithResults(searchLocation); + const response = await search(route.query); - const response = - // Local caching to skip even the pinia method in case of only the hash changing while on the route. - _isEqual(searchLocation, query.value) && data.value - ? data.value - : await search(route.query); - - // On initial fetch - get hash - const hash = normalizeLocaleForHash(searched, response.data); - if (!route.hash && hash) { - return new NavigationResult({ ...route, hash: `#${hash}` }); - } - - data.value = response; - query.value = searchLocation; - - const ret = { + return { results: groupResultsByAgency(response.data), + response: response.data, searched, params: searchLocation, + hash: normalizeLocaleForHash(searched, response.data), }; - return ret; } catch (error) { throw new DataLoaderErrorPassThrough(error); } }, + { lazy: true }, ); export const useFollowedData = defineBasicLoader( @@ -201,6 +193,9 @@ export const useFollowedData = defineBasicLoader( throw new DataLoaderErrorPassThrough(error); } }, + { + lazy: true, + }, ); export const useRequestsData = defineBasicLoader( @@ -214,6 +209,9 @@ export const useRequestsData = defineBasicLoader( throw new DataLoaderErrorPassThrough(error); } }, + { + lazy: true, + }, ); // function isOnlyHashChanged(currentRoute, previousRoute) { @@ -239,30 +237,46 @@ import { Button } from 'pdap-design-system'; import SearchForm from '@/components/SearchForm.vue'; import SearchResults from './_components/SearchResults.vue'; import Requests from './_components/Requests.vue'; +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; +import { faUserPlus, faUserCheck } from '@fortawesome/free-solid-svg-icons'; import { toast } from 'vue3-toastify'; import { useAuthStore } from '@/stores/auth'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; +import { getIsV2FeatureEnabled } from '@/util/featureFlagV2'; + const { isAuthenticated } = useAuthStore(); const { data: searchData, isLoading, error } = useSearchData(); const { data: isFollowed, reload: reloadFollowed } = useFollowedData(); const { data: requestData, error: requestsError } = useRequestsData(); const route = useRoute(); +const router = useRouter(); const searchResultsRef = ref(); const isSearchShown = ref(false); const dims = reactive({ width: window.innerWidth, height: window.innerHeight }); const hasDisplayedErrorByRouteParams = ref(new Map()); +watch( + () => route, + (newRoute) => { + if (newRoute.hash && !route.hash) { + const hash = `#${normalizeLocaleForHash(searchData.searched, searchData.response)}`; + router.replace({ ...route, hash }); + } + }, + { immediate: true, deep: true }, +); + // lifecycle methods onMounted(() => { if (window.innerWidth > 1280) isSearchShown.value = true; - if (searchData.value) { + if (searchData?.value) { searchStore.setMostRecentSearchIds( - getAllIdsSearched(searchData.value.results), + getAllIdsSearched(searchData?.value?.results), ); } - if (requestData.value) { + if (requestData?.value) { searchStore.setMostRecentRequestIds(requestData.value.map((req) => req.id)); } @@ -272,7 +286,7 @@ onMounted(() => { onUpdated(async () => { if (error.value) { toast.error( - `Error fetching search results for ${getMinimalLocationText(searchData.value.params)}. Please try again!`, + `Error fetching search results for ${getMinimalLocationText(searchData?.value?.params)}. Please try again!`, { autoClose: false, onClose() { @@ -281,17 +295,17 @@ onUpdated(async () => { }, ); hasDisplayedErrorByRouteParams.value.set( - JSON.stringify(searchData.value.params), + JSON.stringify(searchData?.value?.params), true, ); } - if (searchData.value) + if (searchData?.value) searchStore.setMostRecentSearchIds( - getAllIdsSearched(searchData.value.results), + getAllIdsSearched(searchData?.value?.results), ); - if (requestData.value) { + if (requestData?.value) { searchStore.setMostRecentRequestIds(requestData.value.map((req) => req.id)); } }); @@ -305,13 +319,13 @@ onUnmounted(() => { async function follow() { try { await followSearch(route.query.location_id); + await reloadFollowed(); toast.success( - `Search followed for ${getMinimalLocationText(searchData.value.params)}.`, + `Search followed for ${getMinimalLocationText(searchData?.value?.params)}.`, ); - await reloadFollowed(); } catch (error) { toast.error( - `Error following search for ${getMinimalLocationText(searchData.value.params)}. Please try again.`, + `Error following search for ${getMinimalLocationText(searchData?.value?.params)}. Please try again.`, ); } } diff --git a/client/src/pages/sign-in.vue b/client/src/pages/sign-in.vue index f99e0083..ea0ebe1d 100644 --- a/client/src/pages/sign-in.vue +++ b/client/src/pages/sign-in.vue @@ -117,9 +117,7 @@ export const useGithubAuth = defineBasicLoader('/sign-in', async (route) => { const tokens = await signInWithGithub(githubAccessToken); if (tokens) - return new NavigationResult( - auth.redirectTo ?? { path: '/profile', query: { linked: true } }, - ); + return new NavigationResult(auth.redirectTo ?? { path: '/profile' }); } } catch (error) { if (error.response.data.message.includes('already exists')) { diff --git a/client/src/pages/sign-up/index.vue b/client/src/pages/sign-up/index.vue index 34dd45e9..08ffba34 100644 --- a/client/src/pages/sign-up/index.vue +++ b/client/src/pages/sign-up/index.vue @@ -125,9 +125,7 @@ export const useGithubAuth = defineBasicLoader('/sign-up', async (route) => { const tokens = await signInWithGithub(githubAccessToken); if (tokens) - return new NavigationResult( - auth.redirectTo ?? { path: '/profile', query: { linked: true } }, - ); + return new NavigationResult(auth.redirectTo ?? { path: '/profile' }); } } catch (error) { if (error.response.data.message.includes('already exists')) { diff --git a/client/src/router.js b/client/src/router.js index f5865cc0..1cb0d403 100644 --- a/client/src/router.js +++ b/client/src/router.js @@ -24,15 +24,15 @@ router.beforeEach(async (to, from, next) => { // redirect to login page if not logged in and trying to access a restricted page const auth = useAuthStore(); - if (to.path === '/sign-in' && from.meta.auth) { + if (to.path === '/sign-in' && from.meta?.auth) { auth.$patch({ redirectTo: from }); next(); } - if (to.meta.auth && !auth.isAuthenticated()) { + if (to.meta?.auth && !auth.isAuthenticated()) { auth.$patch({ redirectTo: to }); - next({ path: '/sign-in', replace: true }); + next({ path: '/sign-in' }); } else { next(); } diff --git a/client/src/util/DOM.js b/client/src/util/DOM.js index a68251a8..2f743cb0 100644 --- a/client/src/util/DOM.js +++ b/client/src/util/DOM.js @@ -1,5 +1,4 @@ export function isDescendantOf(child, parent) { - console.debug('iDO', { child, parent }); if (!child || !parent) return false; if (child === parent) return true; return isDescendantOf(child.parentNode, parent); diff --git a/client/src/util/constants.js b/client/src/util/constants.js index 2b8f0a02..7c6a21b5 100644 --- a/client/src/util/constants.js +++ b/client/src/util/constants.js @@ -130,3 +130,66 @@ export const STATES_TO_ABBREVIATIONS = new Map([ ['Wisconsin', 'WI'], ['Wyoming', 'WY'], ]); + +export const ABBREVIATIONS_TO_STATES = new Map([ + ['AL', 'Alabama'], + ['AK', 'Alaska'], + ['AS', 'American Samoa'], + ['AZ', 'Arizona'], + ['AR', 'Arkansas'], + ['AA', 'Armed Forces Americas'], + ['AE', 'Armed Forces Europe'], + ['AP', 'Armed Forces Pacific'], + ['CA', 'California'], + ['CO', 'Colorado'], + ['CT', 'Connecticut'], + ['DE', 'Delaware'], + ['DC', 'District Of Columbia'], + ['FL', 'Florida'], + ['GA', 'Georgia'], + ['GU', 'Guam'], + ['HI', 'Hawaii'], + ['ID', 'Idaho'], + ['IL', 'Illinois'], + ['IN', 'Indiana'], + ['IA', 'Iowa'], + ['KS', 'Kansas'], + ['KY', 'Kentucky'], + ['LA', 'Louisiana'], + ['ME', 'Maine'], + ['MH', 'Marshall Islands'], + ['MD', 'Maryland'], + ['MA', 'Massachusetts'], + ['MI', 'Michigan'], + ['MN', 'Minnesota'], + ['MS', 'Mississippi'], + ['MO', 'Missouri'], + ['MT', 'Montana'], + ['NE', 'Nebraska'], + ['NV', 'Nevada'], + ['NH', 'New Hampshire'], + ['NJ', 'New Jersey'], + ['NM', 'New Mexico'], + ['NY', 'New York'], + ['NC', 'North Carolina'], + ['ND', 'North Dakota'], + ['NP', 'Northern Mariana Islands'], + ['OH', 'Ohio'], + ['OK', 'Oklahoma'], + ['OR', 'Oregon'], + ['PA', 'Pennsylvania'], + ['PR', 'Puerto Rico'], + ['RI', 'Rhode Island'], + ['SC', 'South Carolina'], + ['SD', 'South Dakota'], + ['TN', 'Tennessee'], + ['TX', 'Texas'], + ['VI', 'US Virgin Islands'], + ['UT', 'Utah'], + ['VT', 'Vermont'], + ['VA', 'Virginia'], + ['WA', 'Washington'], + ['WV', 'West Virginia'], + ['WI', 'Wisconsin'], + ['WY', 'Wyoming'], +]); diff --git a/client/src/util/featureFlagV2.js b/client/src/util/featureFlagV2.js new file mode 100644 index 00000000..2f6056e6 --- /dev/null +++ b/client/src/util/featureFlagV2.js @@ -0,0 +1,14 @@ +/** + * Gets enabled / disabled status for features. + * + * @param {'ENHANCED_SEARCH' | 'AUTHENTICATE' | 'CREATE_RECORDS'} featureName Name of V2 feature to check + * @returns {boolean} Whether the feature is enabled or not + */ +export function getIsV2FeatureEnabled(featureName) { + console.debug({ + featureName, + value: import.meta.env[`VITE_V2_FEATURE_${featureName}`], + bool: import.meta.env[`VITE_V2_FEATURE_${featureName}`] === 'enabled', + }); + return import.meta.env[`VITE_V2_FEATURE_${featureName}`] === 'enabled'; +} diff --git a/client/src/util/locationFormatters.js b/client/src/util/locationFormatters.js index a5d8e3a5..ed4c870e 100644 --- a/client/src/util/locationFormatters.js +++ b/client/src/util/locationFormatters.js @@ -4,20 +4,31 @@ export function getFullLocationText(location) { const searched = getMostNarrowSearchLocationWithResults(location); switch (searched) { case 'locality': - return `${location.locality_name}, ${STATES_TO_ABBREVIATIONS.get(location.state_name)}`; + return `${location.locality_name}, ${location.county_name}, ${STATES_TO_ABBREVIATIONS.get(location.state_name)}`; case 'county': return `${location.county_name} ${STATES_TO_ABBREVIATIONS.get(location.state_name) === 'LA' ? 'Parish' : 'County'}, ${location.state_name}`; case 'state': return location.state_name; default: - return location.display_name; + return location.display_name ?? ''; } } export function getLocationCityState(location) { - const locality = location.locality_name ?? ''; - const state = STATES_TO_ABBREVIATIONS.get(location.state_name) ?? ''; - return `${locality}${locality && state ? ', ' : ''}${state}`; + const searched = getMostNarrowSearchLocationWithResults(location); + + const locality = location.locality_name; + const stateAbbr = STATES_TO_ABBREVIATIONS.get(location.state_name); + const state = location.state_name; + const displayName = location.display_name; + + if (searched === 'locality') { + if (locality && !state && !stateAbbr) return locality; + else if (locality && stateAbbr) return `${locality}, ${stateAbbr}`; + else if (locality && state) return `${locality}, ${state}`; + } else if (searched === 'county' || searched === 'state') { + return state; + } else return displayName; } export function getMinimalLocationText(location) { @@ -29,4 +40,48 @@ export function getMostNarrowSearchLocationWithResults(location) { if (location?.locality_name) return 'locality'; if (location?.county_name) return 'county'; if (location?.state_name) return 'state'; + return location?.type?.toLowerCase(); } + +// TODO: cache getLocationById function and fetch to get locations by id rather than all of this parsing. +export const mapSearchParamsToLocation = (obj) => { + const { state, county, locality, location_id, id } = obj; + const mapped = {}; + + if (state) { + mapped.state_name = state; + mapped.state_iso = state; + } + if (county) { + mapped.county_name = county; + } + if (locality) { + mapped.locality_name = locality; + } + if (location_id || id) { + mapped.id = location_id ?? id; + } + + return mapped; +}; + +export const mapLocationToSearchParams = (obj) => { + const { state_name, state_iso, county_name, locality_name, id, location_id } = + obj; + const mapped = {}; + + if (state_name || state_iso) { + mapped.state = state_name ?? state_iso; + } + if (county_name) { + mapped.county = county_name; + } + if (locality_name) { + mapped.locality = locality_name; + } + if (id || location_id) { + mapped.location_id = id ?? location_id; + } + + return mapped; +}; diff --git a/client/vite.config.js b/client/vite.config.js index f955be7d..e9aceedb 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -5,7 +5,9 @@ import VueRouter from 'unplugin-vue-router/vite'; import path from 'path'; export default defineConfig(({ mode }) => { - loadEnv(mode, process.cwd(), ''); + const env = loadEnv(mode, process.cwd(), ''); + + console.debug({ auth: env.VITE_V2_FEATURE_AUTHENTICATE }); return { plugins: [ @@ -18,6 +20,21 @@ export default defineConfig(({ mode }) => { route.meta = { ...route.meta, ...ROUTES_TO_META.get(route.name) }; } + // Hide authentication routes if flag set to disabled + if ( + env.VITE_V2_FEATURE_AUTHENTICATE === 'disabled' && + [ + 'change-password', + 'reset-password', + 'sign-in', + 'sign-out', + 'sign-up', + 'profile', + ].some((pathFrag) => route.fullPath.includes(pathFrag)) + ) { + route.delete(); + } + if (route.fullPath.startsWith('/test/') && mode === 'production') { route.delete(); }