diff --git a/src/components/app.vue b/src/components/app.vue
index 85e91e509..d71310e14 100644
--- a/src/components/app.vue
+++ b/src/components/app.vue
@@ -14,6 +14,7 @@ except according to the terms contained in the LICENSE file.
+
+
+
+
+
+ {
+ "en": {
+ "outdatedVersionHtml": {
+ "heading": "You’re using a significantly outdated version of ODK Central",
+ "sentence1": "Upgrade now to protect your data and take advantage of the latest features.",
+ // {OdkCloudLink} is a hyperlink to the ODK Cloud webpage and the text of it is "ODK Cloud",
+ // which doesn't need to be translated.
+ "sentence2": "If you don’t want to maintain Central, try {OdkCloudLink}."
+ }
+ }
+ }
+
diff --git a/src/components/home/news.vue b/src/components/home/news.vue
index 583e0efe2..a37197234 100644
--- a/src/components/home/news.vue
+++ b/src/components/home/news.vue
@@ -15,17 +15,25 @@ except according to the terms contained in the LICENSE file.
{{ $t('title') }}
-
+
+
+
+ {
+ "en": {
+ // This is a title for the outdated version banner shown for the screenreaders only
+ "title": "Outdated Version",
+ "instructionsToUpgrade": "Instructions to upgrade",
+ "instructionsToUpgradeTooltip": "Click here to see instructions to upgrade Central",
+ "dismiss": "Dismiss for 30 days",
+ "dismissTooltip": "Click here to dismiss this warning for 30 days."
+ }
+ }
+
diff --git a/src/request-data/resources.js b/src/request-data/resources.js
index 2051f8680..37002e340 100644
--- a/src/request-data/resources.js
+++ b/src/request-data/resources.js
@@ -42,7 +42,14 @@ export default (container, createResource) => {
: configDefaults),
loaded: computed(() => config.dataExists && config.loadError == null)
}));
- createResource('centralVersion');
+ createResource('centralVersion', () => ({
+ transformResponse: ({ data, headers }) =>
+ shallowReactive({
+ versionText: data,
+ currentVersion: data.match(/\(v(\d{4}[^-]*)/)[1],
+ currentDate: new Date(headers.get('date'))
+ })
+ }));
createResource('analyticsConfig', noargs(setupOption));
createResource('roles', (roles) => ({
bySystem: computeIfExists(() => {
diff --git a/src/request-data/user-preferences/normalizers.js b/src/request-data/user-preferences/normalizers.js
index ba6384aaf..ac63e32f8 100644
--- a/src/request-data/user-preferences/normalizers.js
+++ b/src/request-data/user-preferences/normalizers.js
@@ -33,6 +33,21 @@ export class SitePreferenceNormalizer extends PreferenceNormalizer {
static projectSortMode(val) {
return ['alphabetical', 'latest', 'newest'].includes(val) ? val : 'latest';
}
+
+ static outdatedVersionWarningDismissDate(val) {
+ // Frontend to Backend
+ if (val instanceof Date) {
+ return val.toISOString();
+ }
+
+ // Backend to Frontend
+ const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z?$/;
+ if (typeof (val) === 'string' && isoDateRegex.test(val)) {
+ return val;
+ }
+
+ return null;
+ }
}
export class ProjectPreferenceNormalizer extends PreferenceNormalizer {
diff --git a/src/util/load-async.js b/src/util/load-async.js
index 33a45280a..5933586b7 100644
--- a/src/util/load-async.js
+++ b/src/util/load-async.js
@@ -155,6 +155,10 @@ const loaders = new Map()
/* webpackChunkName: "component-not-found" */
'../components/not-found.vue'
)))
+ .set('OutdatedVersion', loader(() => import(
+ /* webpackChunkName: "component-outdated-version" */
+ '../components/outdated-version.vue'
+ )))
.set('ProjectFormAccess', loader(() => import(
/* webpackChunkName: "component-project-form-access" */
'../components/project/form-access.vue'
diff --git a/test/components/app.spec.js b/test/components/app.spec.js
index 83c4df045..a26e66ace 100644
--- a/test/components/app.spec.js
+++ b/test/components/app.spec.js
@@ -21,7 +21,7 @@ describe('App', () => {
})
// This isn't actually what a version looks like. However, the value
// itself doesn't really matter, but rather only whether it changes.
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.testRequests([{ url: '/version.txt' }]);
});
@@ -32,12 +32,12 @@ describe('App', () => {
.request(() => {
clock.tick(15000);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.complete()
.request(() => {
clock.tick(60000);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.testRequests([{ url: '/version.txt' }]);
});
@@ -49,21 +49,21 @@ describe('App', () => {
.request(() => {
clock.tick(15000);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.afterResponse(app => {
app.should.not.alert();
})
.request(() => {
clock.tick(60000);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.afterResponse(app => {
app.should.not.alert();
})
.request(() => {
clock.tick(60000);
})
- .respondWithData(() => 'v1.3')
+ .respondWithData(() => '(v2024.1.3-sha)')
.afterResponse(app => {
clock.tick(0);
app.should.alert('info', (message) => {
@@ -79,12 +79,12 @@ describe('App', () => {
.request(() => {
clock.tick(15000);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.complete()
.request(() => {
clock.tick(60000);
})
- .respondWithData(() => 'v1.3')
+ .respondWithData(() => '(v2024.1.3-sha)')
.complete()
.testNoRequest(() => {
clock.tick(60000);
@@ -98,12 +98,12 @@ describe('App', () => {
.request(() => {
clock.tick(15000);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.complete()
.request(() => {
clock.tick(60000);
})
- .respondWithData(() => 'v1.3')
+ .respondWithData(() => '(v2024.1.3-sha)')
.afterResponse(async (app) => {
clock.tick(0);
app.vm.$container.alert.blank();
@@ -142,7 +142,7 @@ describe('App', () => {
.request(() => {
clock.tick(60000);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.testRequests([{ url: '/version.txt' }]);
});
@@ -156,13 +156,13 @@ describe('App', () => {
.beforeEachResponse((app, { url }) => {
if (url === '/version.txt') logOut(app.vm.$container, false);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.respondWithSuccess()
.complete()
.request(() => {
clock.tick(60000);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.testRequests([{ url: '/version.txt' }]);
});
@@ -173,7 +173,7 @@ describe('App', () => {
.request(() => {
clock.tick(15000);
})
- .respondWithData(() => 'v1.2')
+ .respondWithData(() => '(v2024.1.2-sha)')
.complete()
.request(() => {
clock.tick(60000);
@@ -183,7 +183,7 @@ describe('App', () => {
.request(() => {
clock.tick(60000);
})
- .respondWithData(() => 'v1.3')
+ .respondWithData(() => '(v2024.1.3-sha)')
.afterResponse(app => {
clock.tick(0);
app.should.alert('info', (message) => {
diff --git a/test/components/home.spec.js b/test/components/home.spec.js
index ed1c4eb11..66d07025e 100644
--- a/test/components/home.spec.js
+++ b/test/components/home.spec.js
@@ -1,4 +1,5 @@
import HomeConfigSection from '../../src/components/home/config-section.vue';
+import { loadLocale } from '../../src/util/i18n';
import { load } from '../util/http';
import { mockLogin } from '../util/session';
@@ -43,4 +44,19 @@ describe('Home', () => {
app.findComponent(HomeConfigSection).exists().should.be.false;
});
});
+
+ describe('news section', () => {
+ beforeEach(mockLogin);
+
+ it('should have correct iframe src', async () => {
+ const app = await load('/', { root: false });
+ app.find('iframe').attributes().src.should.be.equal('https://getodk.github.io/central/news.html?outdatedVersionWarning=false&lang=en');
+ });
+
+ it('should update language in the iframe src', async () => {
+ const app = await load('/', { root: false });
+ await loadLocale(app.vm.$container, 'zh-Hant');
+ app.find('iframe').attributes().src.should.be.equal('https://getodk.github.io/central/news.html?outdatedVersionWarning=false&lang=zh-Hant');
+ });
+ });
});
diff --git a/test/components/outdated-version.spec.js b/test/components/outdated-version.spec.js
new file mode 100644
index 000000000..eafbddce1
--- /dev/null
+++ b/test/components/outdated-version.spec.js
@@ -0,0 +1,107 @@
+import { mount } from '../util/lifecycle';
+import OutdatedVersion from '../../src/components/outdated-version.vue';
+import { mockLogin } from '../util/session';
+import testData from '../data';
+import { mockHttp } from '../util/http';
+import { loadLocale } from '../../src/util/i18n';
+
+const mountOptions = (options) => ({
+ global: {
+ provide: { visiblyLoggedIn: options.userLoggedIn ?? true }
+ },
+ container: {
+ requestData: {
+ centralVersion: {
+ status: 200,
+ data: options.centralVersion,
+ headers: new Map([['date', options.currentDate ? new Date(options.currentDate) : new Date()]])
+ }
+ }
+ }
+});
+
+const mountComponent = (options) => mount(OutdatedVersion, mountOptions(options));
+
+describe('OutdatedVersionBanner', () => {
+ const cases = [
+ {
+ description: 'user is not logged in',
+ expectedResult: false, userLoggedIn: false, role: 'admin', centralVersion: '(v2022.1.2-sha)', currentDate: '2024-12-11', dismissDate: null
+ },
+ {
+ description: 'user is not system wide admin',
+ expectedResult: false, userLoggedIn: true, role: 'viewer', centralVersion: '(v2022.1.2-sha)', currentDate: '2024-12-11', dismissDate: null
+ },
+ {
+ description: 'version is too old and there is no dismiss date',
+ expectedResult: true, userLoggedIn: true, role: 'admin', centralVersion: '(v2022.1.2-sha)', currentDate: '2024-12-11', dismissDate: null
+ },
+ {
+ description: 'version is too old and dismiss date is 30 days ago',
+ expectedResult: true, userLoggedIn: true, role: 'admin', centralVersion: '(v2022.1.2-sha)', currentDate: '2024-12-11', dismissDate: '2024-11-10T12:00:00.000Z'
+ },
+ {
+ description: 'version is too old but dismiss date is recent',
+ expectedResult: false, userLoggedIn: true, role: 'admin', centralVersion: '(v2022.1.2-sha)', currentDate: '2024-12-11', dismissDate: '2024-12-01T12:00:00.000Z'
+ },
+ {
+ description: 'version is not old',
+ expectedResult: false, userLoggedIn: true, role: 'admin', centralVersion: '(v2024.1.2-sha)', currentDate: '2024-12-11', dismissDate: null
+ }
+ ];
+
+ cases.forEach(c => {
+ it(c.description, () => {
+ if (c.userLoggedIn) {
+ mockLogin({ role: c.role });
+ const { preferences } = testData.extendedUsers.first();
+ preferences.site.outdatedVersionWarningDismissDate = c.dismissDate;
+ testData.extendedUsers.update(-1, { preferences });
+ }
+ const component = mountComponent(c);
+ component.find('.outdated-version-banner').exists().should.equal(c.expectedResult);
+ });
+ });
+
+ describe('dismiss button', () => {
+ beforeEach(mockLogin);
+
+ it('should go away on dismiss button', () => mockHttp()
+ .mount(OutdatedVersion, mountOptions({ centralVersion: '(v2022.1.2-sha)', currentDate: '2024-12-11' }))
+ .request((component) => {
+ component.find('.outdated-version-banner').exists().should.be.true;
+ return component.find('.btn-danger').trigger('click');
+ })
+ .respondWithSuccess()
+ .afterResponses(component => {
+ component.find('.outdated-version-banner').exists().should.be.false;
+ }));
+
+ it('updates the user preference', () => mockHttp()
+ .mount(OutdatedVersion, mountOptions({ centralVersion: '(v2022.1.2-sha)', currentDate: '2024-12-11T00:00:00.000Z' }))
+ .request((component) => component.find('.btn-danger').trigger('click'))
+ .beforeEachResponse((_, { method, url, data }) => {
+ method.should.equal('PUT');
+ url.includes('/v1/user-preferences/site/outdatedVersionWarningDismissDate').should.be.true;
+ data.propertyValue.should.be.equal('2024-12-11T00:00:00.000Z');
+ })
+ .respondWithSuccess());
+ });
+
+ describe('iframe src', () => {
+ beforeEach(mockLogin);
+
+ it('has the correct src', () => {
+ const component = mountComponent({ centralVersion: '(v2022.1.2-sha)' });
+ component.find('.outdated-version-banner').exists().should.equal(true);
+ component.find('iframe').attributes().src.should.be.equal('https://getodk.github.io/central/outdated-version.html?version=2022.1.2&lang=en');
+ });
+
+ it('updates the lang in the iframe src', async () => {
+ const component = mountComponent({ centralVersion: '(v2022.1.2-sha)' });
+ component.find('.outdated-version-banner').exists().should.equal(true);
+ await loadLocale(component.vm.$container, 'es');
+ component.find('iframe').attributes().src.should.be.equal('https://getodk.github.io/central/outdated-version.html?version=2022.1.2&lang=es');
+ });
+ });
+});
diff --git a/test/util/http.js b/test/util/http.js
index 247f2f6a6..15801b3ca 100644
--- a/test/util/http.js
+++ b/test/util/http.js
@@ -703,6 +703,7 @@ class MockHttp {
this._requestResponseLog.push(responseWithoutConfig);
const response = { ...responseWithoutConfig, config };
+ response.headers = new Map([['date', new Date()], ...(response.headers ?? [])]);
if (response.status >= 200 && response.status < 300)
resolve(response);
else
diff --git a/transifex/strings_en.json b/transifex/strings_en.json
index 523a9471d..645e37cf4 100644
--- a/transifex/strings_en.json
+++ b/transifex/strings_en.json
@@ -2282,6 +2282,20 @@
"developer_comment": "This refers to creation of an Entity. {filename} is the name of an uploaded file containing the Entity. {name} is the name of a Web User."
}
},
+ "ExtraTranslations": {
+ "outdatedVersionHtml": {
+ "heading": {
+ "string": "You’re using a significantly outdated version of ODK Central"
+ },
+ "sentence1": {
+ "string": "Upgrade now to protect your data and take advantage of the latest features."
+ },
+ "sentence2": {
+ "string": "If you don’t want to maintain Central, try {OdkCloudLink}.",
+ "developer_comment": "{OdkCloudLink} is a hyperlink to the ODK Cloud webpage and the text of it is \"ODK Cloud\", which doesn't need to be translated."
+ }
+ }
+ },
"FeedbackButton": {
"feedback": {
"string": "Feedback",
@@ -3646,6 +3660,24 @@
}
}
},
+ "OutdatedVersion": {
+ "title": {
+ "string": "Outdated Version",
+ "developer_comment": "This is a title for the outdated version banner shown for the screenreaders only"
+ },
+ "instructionsToUpgrade": {
+ "string": "Instructions to upgrade"
+ },
+ "instructionsToUpgradeTooltip": {
+ "string": "Click here to see instructions to upgrade Central"
+ },
+ "dismiss": {
+ "string": "Dismiss for 30 days"
+ },
+ "dismissTooltip": {
+ "string": "Click here to dismiss this warning for 30 days."
+ }
+ },
"Pagination": {
"action": {
"first": {