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": {