diff --git a/deploy/auth/hbp-oidc-v2.js b/deploy/auth/hbp-oidc-v2.js index 102aa25c236b6a61801fcae04541622af23ea676..d5427480817ab9ad0a58e9a2c2a103eafa6d5f3b 100644 --- a/deploy/auth/hbp-oidc-v2.js +++ b/deploy/auth/hbp-oidc-v2.js @@ -78,8 +78,4 @@ module.exports = { console.error('oidcv2 auth error', e) } }, - getClient: async () => { - await memoizedInit() - return client - } } diff --git a/deploy/auth/index.spec.js b/deploy/auth/index.spec.js index 84691ddd6ccae9169f321858453c2eacf0dcb28c..8e33c30bd02054d71c31b862b22928d12f359928 100644 --- a/deploy/auth/index.spec.js +++ b/deploy/auth/index.spec.js @@ -8,7 +8,7 @@ const appGetStub = sinon.stub() describe('auth/index.js', () => { before(() => { require.cache[require.resolve('./util')] = { - exports: { initPassportJs: initPassportJsStub } + exports: { initPassportJs: initPassportJsStub, objStoreDb: new Map() } } require.cache[require.resolve('./hbp-oidc-v2')] = { exports: { diff --git a/deploy/package-lock.json b/deploy/package-lock.json index 3a8058b86a578efd909ca7b5d28a32edd93e5492..b8399147d5ab12a520ea0f2068cba33945db6f9b 100644 --- a/deploy/package-lock.json +++ b/deploy/package-lock.json @@ -503,6 +503,11 @@ "wrap-ansi": "^2.0.0" } }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" + }, "clone-response": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", @@ -695,6 +700,14 @@ "type-detect": "^4.0.0" } }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "requires": { + "clone": "^1.0.2" + } + }, "defer-to-connect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.0.tgz", @@ -936,6 +949,11 @@ } } }, + "express-rate-limit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.5.1.tgz", + "integrity": "sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg==" + }, "express-session": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.15.6.tgz", @@ -2118,6 +2136,15 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" }, + "rate-limit-redis": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-2.1.0.tgz", + "integrity": "sha512-6SAsTCzY0v6UCIKLOLLYqR2XzFmgdtF7jWXlSPq2FrNIZk8tZ7xwBvyGW7GFMCe5I4S9lYNdrSJ9E84rz3/CpA==", + "requires": { + "defaults": "^1.0.3", + "redis": "^3.0.2" + } + }, "raw-body": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", diff --git a/deploy/package.json b/deploy/package.json index a8b5d1488439346a9c7b6adc2322a57a482f218e..04cdc908c505ae7a5d8663fbfae313f561b0be80 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -17,6 +17,7 @@ "connect-redis": "^5.0.0", "cookie-parser": "^1.4.5", "express": "^4.16.4", + "express-rate-limit": "^5.5.1", "express-session": "^1.15.6", "got": "^10.5.5", "hbp-seafile": "^0.2.0", @@ -26,6 +27,7 @@ "nomiseco": "0.0.2", "openid-client": "^4.4.0", "passport": "^0.4.0", + "rate-limit-redis": "^2.1.0", "redis": "^3.1.2", "request": "^2.88.0", "showdown": "^1.9.1", diff --git a/deploy/saneUrl/depcObjStore.js b/deploy/saneUrl/depcObjStore.js index a46d6c845936fef10b77b4ac9bb9ed278c8a00e9..c67876686470650d08217cc72141a6936f703511 100644 --- a/deploy/saneUrl/depcObjStore.js +++ b/deploy/saneUrl/depcObjStore.js @@ -21,6 +21,10 @@ class Store { }) } + async del(id){ + // noop + } + async set(id, val){ throw new Error(`Object store is deprecated. Please use seafile storage instead`) } diff --git a/deploy/saneUrl/index.js b/deploy/saneUrl/index.js index 09fe10b25a57e336b25118d57f4b5849c1f8234d..2e9ab0b76528b999720781f7ff9cc913891f478d 100644 --- a/deploy/saneUrl/index.js +++ b/deploy/saneUrl/index.js @@ -1,46 +1,33 @@ const express = require('express') const router = express.Router() -const { FallbackStore: Store, NotFoundError } = require('./store') +const { GitlabSnippetStore: Store, NotFoundError } = require('./store') const { Store: DepcStore } = require('./depcObjStore') +const RateLimit = require('express-rate-limit') +const RedisStore = require('rate-limit-redis') +const { redisURL } = require('../lruStore') +const { ProxyStore, NotExactlyPromiseAny } = require('./util') const store = new Store() const depStore = new DepcStore() -const { HOSTNAME, HOST_PATHNAME } = process.env +const proxyStore = new ProxyStore(store) -const acceptHtmlProg = /text\/html/i - -const getFileFromStore = async (name, store) => { - try { - const value = await store.get(name) - const json = JSON.parse(value) - const { expiry } = json - if ( expiry && ((Date.now() - expiry) > 0) ) { - return null - } - - return value - } catch (e) { - if (e instanceof NotFoundError) { - return null - } - throw e - } -} +const { + HOSTNAME, + HOST_PATHNAME, + DISABLE_LIMITER, +} = process.env -const getFile = async name => { - const value = await getFileFromStore(name, depStore) - || await getFileFromStore(name, store) +const limiter = new RateLimit({ + windowMs: 1e3 * 5, + max: 5, + ...( redisURL ? { store: new RedisStore({ redisURL }) } : {} ) +}) +const passthrough = (_, __, next) => next() - return value -} +const acceptHtmlProg = /text\/html/i -const hardCodedMap = new Map([ - ['bigbrainGreyWhite', '#/a:juelich:iav:atlas:v1.0.0:1/t:minds:core:referencespace:v1.0.0:a1655b99-82f1-420f-a3c2-fe80fd4c8588/p:juelich:iav:atlas:v1.0.0:4/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..7LIx..gIW~.10AwC.B1KK~..1LSm'], - ['whs4', '#/a:minds:core:parcellationatlas:v1.0.0:522b368e-49a3-49fa-88d3-0870a307974a/t:minds:core:referencespace:v1.0.0:d5717c4a-0fa1-46e6-918c-b8003069ade8/p:minds:core:parcellationatlas:v1.0.0:ebb923ba-b4d5-4b82-8088-fa9215c2e1fe-v4/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..kxV..0.0.0..8Yu'], - ['allen2017', '#/a:juelich:iav:atlas:v1.0.0:2/t:minds:core:referencespace:v1.0.0:265d32a0-3d84-40a5-926f-bf89f68212b9/p:minds:core:parcellationatlas:v1.0.0:05655b58-3b6f-49db-b285-64b5a0276f83/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..kxV..0.0.0..8Yu'], - ['mebrains', '#/a:juelich:iav:atlas:v1.0.0:monkey/t:minds:core:referencespace:v1.0.0:MEBRAINS_T1.masked/p:minds:core:parcellationatlas:v1.0.0:mebrains-tmp-id/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..7LIx..0.0.0..1LSm'] -]) +const REAL_HOSTNAME = `${HOSTNAME}${HOST_PATHNAME || ''}/` router.get('/:name', async (req, res) => { const { name } = req.params @@ -49,31 +36,29 @@ router.get('/:name', async (req, res) => { const redirectFlag = acceptHtmlProg.test(headers['accept']) try { - const REAL_HOSTNAME = `${HOSTNAME}${HOST_PATHNAME || ''}/` - const hardcodedRedir = hardCodedMap.get(name) - if (hardcodedRedir) { - if (redirectFlag) res.redirect(`${REAL_HOSTNAME}${hardcodedRedir}`) - else res.status(200).send(hardcodedRedir) - return - } - - const value = await getFile(name) - if (!value) throw new NotFoundError() - const json = JSON.parse(value) - const { queryString } = json + const json = await NotExactlyPromiseAny([ + ProxyStore.StaticGet(depStore, req, name), + proxyStore.get(req, name) + ]) - if (redirectFlag) res.redirect(`${REAL_HOSTNAME}?${queryString}`) - else res.status(200).send(value) + const { queryString, hashPath } = json + if (redirectFlag) { + if (queryString) return res.redirect(`${REAL_HOSTNAME}?${queryString}`) + if (hashPath) return res.redirect(`${REAL_HOSTNAME}#${hashPath}`) + } else { + return res.status(200).send(json) + } } catch (e) { + const notFoundFlag = e instanceof NotFoundError if (redirectFlag) { const REAL_HOSTNAME = `${HOSTNAME}${HOST_PATHNAME || ''}/` res.cookie( 'iav-error', - e instanceof NotFoundError ? `${name} + notFoundFlag ? `${name} not found` : `error while fetching ${name}.`, { @@ -84,13 +69,25 @@ router.get('/:name', async (req, res) => { ) return res.redirect(REAL_HOSTNAME) } - if (e instanceof NotFoundError) return res.status(404).end() + if (notFoundFlag) return res.status(404).end() else return res.status(500).send(e.toString()) } }) router.post('/:name', - (_req, res) => res.status(410).end() + DISABLE_LIMITER ? passthrough : limiter, + express.json(), + async (req, res) => { + if (req.headers['x-noop']) return res.status(200).end() + const { name } = req.params + try { + await proxyStore.set(req, name, req.body) + res.status(201).end() + } catch (e) { + console.log(e.body) + res.status(500).send(e.toString()) + } + } ) router.use((_, res) => { diff --git a/deploy/saneUrl/store.js b/deploy/saneUrl/store.js index 656a070f4fcd5805d816c54390449546fa2885a1..9ef35e8408fd089bb7778ed02d68bd3e18fede58 100644 --- a/deploy/saneUrl/store.js +++ b/deploy/saneUrl/store.js @@ -1,420 +1,112 @@ -const redis = require('redis') -const { promisify } = require('util') -const { getClient } = require('../auth/hbp-oidc-v2') -const { jwtDecode } = require('../auth/oidc') const request = require('request') -const HBP_OIDC_V2_REFRESH_TOKEN_KEY = `HBP_OIDC_V2_REFRESH_TOKEN_KEY` // only insert valid refresh token. needs to be monitored to ensure always get new refresh token(s) -const HBP_OIDC_V2_ACCESS_TOKEN_KEY = `HBP_OIDC_V2_ACCESS_TOKEN_KEY` // only insert valid access token. if expired, get new one via refresh token, then pop & push -const HBP_OIDC_V2_UPDATE_CHAN = `HBP_OIDC_V2_UPDATE_CHAN` // stringified JSON key val of above three, with time stamp -const HBP_OIDC_V2_UPDATE_KEY = `HBP_OIDC_V2_UPDATE_KEY` -const HBP_DATAPROXY_READURL = `HBP_DATAPROXY_READURL` -const HBP_DATAPROXY_WRITEURL = `HBP_DATAPROXY_WRITEURL` +const apiPath = '/api/v4' +const saneUrlVer = `0.0.1` +const titlePrefix = `[saneUrl]` const { __DEBUG__, - - HBP_V2_REFRESH_TOKEN, - HBP_V2_ACCESS_TOKEN, - - DATA_PROXY_URL, - DATA_PROXY_BUCKETNAME, - + GITLAB_ENDPOINT, + GITLAB_PROJECT_ID, + GITLAB_TOKEN } = process.env class NotFoundError extends Error{} -// not part of class, since class is exported, and prototype of class can be easily changed -// using stderr instead of console.error, as by default, logs are collected via fluentd -// debug messages should not be kept as logs -function log(message){ - if (__DEBUG__) { - process.stderr.write(`__DEBUG__`) - process.stdout.write(`\n`) - if (typeof message === 'object') { - process.stderr.write(JSON.stringify(message, null, 2)) - } - if (typeof message === 'number' || typeof message === 'string') { - process.stdout.write(message) - } - process.stdout.write(`\n`) - } -} - - -function _checkValid(urlString){ - log({ - breakpoint: '_checkValid', - payload: urlString - }) - if (!urlString) return false - const url = new URL(urlString) - const expiry = url.searchParams.get('temp_url_expires') - return (new Date() - new Date(expiry)) < 1e3 * 10 -} - -class FallbackStore { - async get(){ - throw new Error(`saneurl is currently offline for maintainence.`) - } - async set(){ - throw new Error(`saneurl is currently offline for maintainence.`) - } - async healthCheck() { - return false - } -} +class NotImplemented extends Error{} -class Store { +class GitlabSnippetStore { constructor(){ - throw new Error(`Not implemented`) - - this.healthFlag = false - this.seafileHandle = null - this.seafileRepoId = null - - /** - * setup redis(or mock) client - */ - this.redisClient = redisURL - ? redis.createClient({ - url: redisURL - }) - : ({ - onCbs: [], - keys: {}, - get: async (key, cb) => { - await Promise.resolve() - cb(null, this.keys[key]) - }, - set: async (key, value, cb) => { - await Promise.resolve() - this.keys[key] = value - cb(null) - }, - on(eventName, cb) { - if (eventName === 'message') { - this.onCbs.push(cb) - } - }, - publish(channel, message){ - for (const cb of this.onCbs){ - cb(channel, message) - } - }, - quit(){} - }) - - this.redisUtil = { - asyncGet: promisify(this.redisClient.get).bind(this.redisClient), - asyncSet: promisify(this.redisClient.set).bind(this.redisClient), - } - - this.pending = {} - this.keys = {} - - this.redisClient.on('message', async (chan, mess) => { - /** - * only liten to HBP_OIDC_V2_UPDATE_CHAN update - */ - if (chan === HBP_OIDC_V2_UPDATE_CHAN) { - try { - const { pending, update } = JSON.parse(mess) - this.pending = pending - for (const key in update) { - try { - this.keys[key] = await this.redisUtil.asyncGet(key) - console.log('on message get key', key, this.keys[key]) - } catch (e) { - console.error(`[saneUrl][store.js] get key ${key} error`) - } - } - } catch (e) { - console.error(`[saneUrl][store.js] parse message HBP_OIDC_V2_UPDATE_CHAN error`) - } - } - }) - this.init() - - /** - * check expiry - */ - this.intervalRef = setInterval(() => { - this.checkExpiry() - }, 1000 * 60) - } - - async init() { - this.keys = { - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: (await this.redisUtil.asyncGet(HBP_OIDC_V2_REFRESH_TOKEN_KEY)) || HBP_V2_REFRESH_TOKEN, - [HBP_OIDC_V2_ACCESS_TOKEN_KEY]: (await this.redisUtil.asyncGet(HBP_OIDC_V2_ACCESS_TOKEN_KEY)) || HBP_V2_ACCESS_TOKEN, - [HBP_DATAPROXY_READURL]: await this.redisUtil.asyncGet(HBP_DATAPROXY_READURL), - [HBP_DATAPROXY_WRITEURL]: await this.redisUtil.asyncGet(HBP_DATAPROXY_WRITEURL), - } - - this.healthFlag = true - } - - async checkExpiry(){ - - const { - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: refreshToken, - [HBP_OIDC_V2_ACCESS_TOKEN_KEY]: accessToken - } = this.keys - - log({ - breakpoint: 'async checkExpiry', - }) - - /** - * if access token is absent - * try to refresh token, without needing to check exp - */ - if (!accessToken) { - await this.doRefreshTokens() - return true - } - const { exp: refreshExp } = jwtDecode(refreshToken) - const { exp: accessExp } = jwtDecode(accessToken) - - const now = new Date().getTime() / 1000 - - if (now > refreshExp) { - console.warn(`[saneUrl] refresh token expired... Need a new refresh token`) - return false - } - if (now > accessExp) { - console.log(`[saneUrl] access token expired. Refreshing access token...`) - await this.doRefreshTokens() - console.log(`[saneUrl] access token successfully refreshed...`) - return true + if ( + GITLAB_ENDPOINT + && GITLAB_PROJECT_ID + && GITLAB_TOKEN + ) { + this.url = `${GITLAB_ENDPOINT}${apiPath}/projects/${GITLAB_PROJECT_ID}/snippets` + this.token = GITLAB_TOKEN + return } - - return true + throw new NotImplemented('Gitlab snippet key value store cannot be configured') } - async doRefreshTokens(){ - - log({ - breakpoint: 'doing refresh tokens' + _promiseRequest(...arg) { + return new Promise((rs, rj) => { + request(...arg, (err, resp, body) => { + if (err) return rj(err) + if (resp.statusCode >= 400) return rj(resp) + rs(body) + }) }) - - /** - * first, check if another process/pod is currently updating - * if they are, give them 1 minute to complete the process - * (usually takes takes less than 5 seconds) - */ - const pendingStart = this.pending[HBP_OIDC_V2_REFRESH_TOKEN_KEY] && - this.pending[HBP_OIDC_V2_REFRESH_TOKEN_KEY].start + 1000 * 60 - const now = new Date() - if (pendingStart && pendingStart > now) { - return - } - - /** - * When start refreshing the tokens, set pending attribute, and start timestamp - */ - const payload = { - pending: { - ...this.pending, - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: { - start: Date.now() - } - } - } - await this.redisUtil.asyncSet(HBP_OIDC_V2_UPDATE_KEY, JSON.stringify(payload)) - this.redisClient.publish(HBP_OIDC_V2_UPDATE_CHAN, JSON.stringify(payload)) - - const client = await getClient() - const tokenset = await client.refresh(this.keys[HBP_OIDC_V2_REFRESH_TOKEN_KEY]) - const { access_token: accessToken, refresh_token: refreshToken } = tokenset - - const { exp: accessTokenExp } = jwtDecode(accessToken) - const { exp: refreshTokenExp } = jwtDecode(refreshToken) - - if (refreshTokenExp - accessTokenExp < 60 * 60 ) { - console.warn(`[saneUrl] refreshToken expires within 1 hour of access token! ${accessTokenExp} ${refreshTokenExp}`) - } - - /** - * once tokens have been refreshed, set them in the redis store first - * Then publish the update message - */ - await this.redisUtil.asyncSet(HBP_OIDC_V2_REFRESH_TOKEN_KEY, refreshToken) - await this.redisUtil.asyncSet(HBP_OIDC_V2_ACCESS_TOKEN_KEY, accessToken) - this.redisClient.publish(HBP_OIDC_V2_UPDATE_CHAN, JSON.stringify({ - keys: { - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: refreshToken, - [HBP_OIDC_V2_ACCESS_TOKEN_KEY]: accessToken, - }, - pending: { - ...this.pending, - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: null - } - })) - this.keys = { - [HBP_OIDC_V2_REFRESH_TOKEN_KEY]: refreshToken, - [HBP_OIDC_V2_ACCESS_TOKEN_KEY]: accessToken, - } } - async _getTmpUrl(permission){ - if (permission !== 'read' && permission !== 'write') throw new Error(`permission need to be either read or write!`) - const method = permission === 'read' - ? 'GET' - : 'PUT' - log({ - breakpoint: 'access key', - access: this.keys[HBP_OIDC_V2_ACCESS_TOKEN_KEY] - }) - const { url } = await new Promise((rs, rj) => { - const payload = { + _request({ addPath = '', method = 'GET', headers = {}, opt = {} } = {}) { + return new Promise((rs, rj) => { + request(`${this.url}${addPath}`, { method, - uri: `${DATA_PROXY_URL}/tempurl/${DATA_PROXY_BUCKETNAME}?lifetime=very_long`, headers: { - 'Authorization': `Bearer ${this.keys[HBP_OIDC_V2_ACCESS_TOKEN_KEY]}` - } - } - log({ - breakpoint: '_getTmpUrl', - payload - }) - request(payload, (err, resp, body) => { + 'PRIVATE-TOKEN': this.token, + ...headers + }, + ...opt + }, (err, resp, body) => { if (err) return rj(err) - if (resp.statusCode >= 400) return rj(new Error(`${resp.statusCode}: ${resp.statusMessage}`)) - if (resp.statusCode === 200) { - rs(JSON.parse(body)) - return - } - return rj(new Error(`[saneurl] [get] unknown error`)) + if (resp.statusCode >= 400) return rj(resp) + return rs(body) }) }) - - return url - } - - _getTmplTag(key, _strings, _proxyUrl, bucketName, id){ - const defaultReturn = `${_strings[0]}${_proxyUrl}${_strings[1]}${bucketName}${_strings[2]}${id}` - if (!key) return defaultReturn - const { - [key]: urlOfInterest - } = this.keys - if (!urlOfInterest) return defaultReturn - const url = new URL(urlOfInterest) - url.pathname += id - return url - } - - _getWriteTmplTag(...args) { - return this._getTmplTag(HBP_DATAPROXY_WRITEURL, ...args) - } - - _getReadTmplTag(...args){ - return this._getTmplTag(HBP_DATAPROXY_READURL, ...args) } async get(id) { - log({ - breakpoint: 'async get', - id - }) - const { - [HBP_DATAPROXY_READURL]: readUrl - } = this.keys - - if (!_checkValid(readUrl)) { - log({ - breakpoint: 'read url not valid, getting new url' - }) - const url = await this._getTmpUrl('read') - const payload = { - keys: { - [HBP_DATAPROXY_READURL]: url - } - } - this.redisClient.publish(HBP_OIDC_V2_UPDATE_CHAN, JSON.stringify(payload)) - this.redisUtil.asyncSet(HBP_DATAPROXY_READURL, url) - this.keys[HBP_DATAPROXY_READURL] = url + const list = JSON.parse(await this._request()) + const found = list.find(item => item.title === `${titlePrefix}${id}`) + if (!found) throw new NotFoundError() + const payloadObj = found.files.find(f => f.path === 'payload') + if (!payloadObj) { + console.error(`id found, but payload not found... this is strange. Check id: ${id}`) + throw new NotFoundError() } - - return await new Promise((rs, rj) => { - request({ - method: 'GET', - uri: this._getReadTmplTag`${DATA_PROXY_URL}/buckets/${DATA_PROXY_BUCKETNAME}/${encodeURIComponent(id)}`, - }, (err, resp, body) => { - if (err) return rj(err) - if (resp.statusCode === 404) return rj(new NotFoundError()) - if (resp.statusCode >= 400) return rj(new Error(`${resp.statusCode}: ${resp.statusMessage}`)) - if (resp.statusCode === 200) return rs(body) - return rj(new Error(`[saneurl] [get] unknown error`)) - }) - }) + if (!payloadObj.raw_url) { + throw new Error(`payloadObj.raw_url not found!`) + } + return await this._promiseRequest(payloadObj.raw_url) } - async _set(id, value) { - const { - [HBP_DATAPROXY_WRITEURL]: writeUrl - } = this.keys - - if (!_checkValid(writeUrl)) { - log({ - breakpoint: '_set', - message: 'write url not valid, getting new one' - }) - const url = await this._getTmpUrl('write') - const payload = { - keys: { - [HBP_DATAPROXY_WRITEURL]: url + async set(id, value) { + return await this._request({ + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + opt: { + json: { + title: `${titlePrefix}${id}`, + description: `Created programmatically. v${saneUrlVer}`, + visibility: 'public', + files: [{ + file_path: 'payload', + content: value + }] } } - this.redisClient.publish(HBP_OIDC_V2_UPDATE_CHAN, JSON.stringify(payload)) - this.redisUtil.asyncSet(HBP_DATAPROXY_WRITEURL, url) - this.keys[HBP_DATAPROXY_WRITEURL] = url - log({ - breakpoint: '_set', - message: 'got new write url' - }) - } - - await new Promise((rs, rj) => { - const payload = { - method: 'PUT', - uri: this._getWriteTmplTag`${DATA_PROXY_URL}/buckets/${DATA_PROXY_BUCKETNAME}/${encodeURIComponent(id)}`, - headers: { - 'content-type': 'text/plain; charset=utf-8' - }, - body: value - } - log({ - breakpoint: 'pre set', - payload - }) - request(payload, (err, resp, body) => { - if (err) return rj(err) - if (resp.statusCode === 404) return rj(new NotFoundError()) - if (resp.statusCode >= 400) return rj(new Error(`${resp.statusCode}: ${resp.statusMessage}`)) - if (resp.statusCode >= 200 && resp.statusCode < 300) return rs(body) - return rj(new Error(`[saneurl] [get] unknown error`)) - }) }) } - async set(id, value) { - const result = await this._set(id, value) - return result + async del(id) { + return await this._request({ + addPath: `/${id}`, + method: 'DELETE', + }) } - dispose(){ - clearInterval(this.intervalRef) - this.redisClient && this.redisClient.quit() + async dispose(){ + } async healthCheck(){ - return this.healthFlag + return true } } -exports.FallbackStore = FallbackStore -exports.Store = Store +exports.GitlabSnippetStore = GitlabSnippetStore exports.NotFoundError = NotFoundError diff --git a/deploy/saneUrl/util.js b/deploy/saneUrl/util.js new file mode 100644 index 0000000000000000000000000000000000000000..949025cdc76bbf6b8794222bc67e2995ae1da482 --- /dev/null +++ b/deploy/saneUrl/util.js @@ -0,0 +1,68 @@ +const { NotFoundError } = require('./store') + +class ProxyStore { + static async StaticGet(store, req, name) { + const payload = JSON.parse(await store.get(name)) + const { expiry, value, ...rest } = payload + if (expiry && (Date.now() > expiry)) { + await store.del(name) + throw new NotFoundError('Expired') + } + // backwards compatibility for depcObjStore . + // when depcObjStore is fully removed, || rest can also be removed + return value || rest + } + + constructor(store){ + this.store = store + } + async get(req, name) { + return await ProxyStore.StaticGet(this.store, req, name) + } + + async set(req, name, value) { + const supplementary = req.user + ? { + userId: req.user.id, + expiry: null + } + : { + userId: null, + expiry: Date.now() + 1000 * 60 * 60 * 72 + } + + const fullPayload = { + value, + ...supplementary + } + return await this.store.set(name, JSON.stringify(fullPayload)) + } +} + +const NotExactlyPromiseAny = async arg => { + const errs = [] + let resolvedFlag = false + return await new Promise((rs, rj) => { + let totalCounter = 0 + for (const pr of arg) { + totalCounter ++ + pr.then(val => { + if (!resolvedFlag) { + resolvedFlag = true + rs(val) + } + }).catch(e => { + errs.push(e) + totalCounter -- + if (totalCounter <= 0) { + rj(new NotFoundError(errs)) + } + }) + } + }) +} + +module.exports = { + ProxyStore, + NotExactlyPromiseAny +} diff --git a/deploy/saneUrl/util.spec.js b/deploy/saneUrl/util.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f19d8cc92e23f79d42384389a6232c6e0a1d6443 --- /dev/null +++ b/deploy/saneUrl/util.spec.js @@ -0,0 +1,206 @@ +const { ProxyStore, NotExactlyPromiseAny } = require('./util') +const { expect, assert } = require('chai') +const sinon = require('sinon') +const { NotFoundError } = require('./store') + +const _name = 'foo-bar' +const _stringValue = 'hello world' +const _objValue = { + 'foo': 'bar' +} +const _req = {} + +describe('> saneUrl/util.js', () => { + describe('> ProxyStore', () => { + let store = { + set: sinon.stub(), + get: sinon.stub(), + del: sinon.stub(), + } + beforeEach(() => { + store.set.returns(Promise.resolve()) + store.get.returns(Promise.resolve('{}')) + store.del.returns(Promise.resolve()) + }) + afterEach(() => { + store.set.resetHistory() + store.get.resetHistory() + store.del.resetHistory() + }) + describe('> StaticGet', () => { + it('> should call store.get', () => { + ProxyStore.StaticGet(store, _req, _name) + assert(store.get.called, 'called') + assert(store.get.calledWith(_name), 'called with right param') + }) + describe('> if not hit', () => { + const err = new NotFoundError('not found') + beforeEach(() => { + store.get.rejects(err) + }) + it('should throw same error', async () => { + try{ + await ProxyStore.StaticGet(store, _req, _name) + assert(false, 'Should throw') + } catch (e) { + assert(e instanceof NotFoundError) + } + }) + }) + describe('> if hit', () => { + describe('> if expired', () => { + beforeEach(() => { + store.get.returns( + Promise.resolve( + JSON.stringify({ + expiry: Date.now() - 1000 + }) + ) + ) + }) + it('> should call store.del', async () => { + try { + await ProxyStore.StaticGet(store, _req, _name) + } catch (e) { + + } + assert(store.del.called, 'store.del should be called') + }) + it('> should throw NotFoundError', async () => { + try { + await ProxyStore.StaticGet(store, _req, _name) + assert(false, 'expect throw') + } catch (e) { + assert(e instanceof NotFoundError, 'throws NotFoundError') + } + }) + }) + describe('> if not expired', () => { + it('> should return .value, if exists', async () => { + store.get.returns( + Promise.resolve( + JSON.stringify({ + value: _objValue, + others: _stringValue + }) + ) + ) + const returnObj = await ProxyStore.StaticGet(store, _req, _name) + expect(returnObj).to.deep.equal(_objValue) + }) + it('> should return ...rest if .value does not exist', async () => { + store.get.returns( + Promise.resolve( + JSON.stringify({ + others: _stringValue + }) + ) + ) + const returnObj = await ProxyStore.StaticGet(store, _req, _name) + expect(returnObj).to.deep.equal({ + others: _stringValue + }) + }) + }) + }) + + }) + + describe('> get', () => { + let staticGetStub + beforeEach(() => { + staticGetStub = sinon.stub(ProxyStore, 'StaticGet') + staticGetStub.returns(Promise.resolve()) + }) + afterEach(() => { + staticGetStub.restore() + }) + it('> proxies calls to StaticGet', async () => { + const store = {} + const proxyStore = new ProxyStore(store) + await proxyStore.get(_req, _name) + assert(staticGetStub.called) + assert(staticGetStub.calledWith(store, _req, _name)) + }) + }) + + describe('> set', () => { + let proxyStore + beforeEach(() => { + proxyStore = new ProxyStore(store) + store.set.returns(Promise.resolve()) + }) + + describe('> no user', () => { + it('> sets expiry some time in the future', async () => { + await proxyStore.set(_req, _name, _objValue) + assert(store.set.called) + + const [ name, stringifiedJson ] = store.set.args[0] + assert(name === _name, 'name is correct') + const payload = JSON.parse(stringifiedJson) + expect(payload.value).to.deep.equal(_objValue, 'payload is correct') + assert(!!payload.expiry, 'expiry exists') + assert((payload.expiry - Date.now()) > 1000 * 60 * 60 * 24, 'expiry is at least 24 hrs in the future') + + assert(!payload.userId, 'userId does not exist') + }) + }) + describe('> yes user', () => { + let __req = { + ..._req, + user: { + id: 'foo-bar-2' + } + } + it('> does not set expiry, but sets userId', async () => { + await proxyStore.set(__req, _name, _objValue) + assert(store.set.called) + + const [ name, stringifiedJson ] = store.set.args[0] + assert(name === _name, 'name is correct') + const payload = JSON.parse(stringifiedJson) + expect(payload.value).to.deep.equal(_objValue, 'payload is correct') + assert(!payload.expiry, 'expiry does not exist') + + assert(payload.userId, 'userId exists') + assert(payload.userId === 'foo-bar-2', 'user-id matches') + }) + }) + }) + }) + + describe('> NotExactlyPromiseAny', () => { + describe('> nothing resolves', () => { + it('> throws not found error', async () => { + try { + await NotExactlyPromiseAny([ + (async () => { + throw new Error(`not here`) + })(), + new Promise((rs, rj) => setTimeout(rj, 100)), + new Promise((rs, rj) => rj('uhoh')) + ]) + assert(false, 'expected to throw') + } catch (e) { + assert(e instanceof NotFoundError, 'expect to throw not found error') + } + }) + }) + describe('> something resolves', () => { + it('> returns the first to resolve', async () => { + try { + + const result = await NotExactlyPromiseAny([ + new Promise((rs, rj) => rj('uhoh')), + new Promise(rs => setTimeout(() => rs('hello world'), 100)), + Promise.resolve('foo-bar') + ]) + assert(result == 'foo-bar', 'expecting first to resolve') + } catch (e) { + assert(false, 'not expecting to throw') + } + }) + }) + }) +}) diff --git a/docs/releases/v2.5.7.md b/docs/releases/v2.5.7.md index 4a8698429cde82cf60e500e7cdb290a3c90a85de..f043d78016f86f7dc85fe95aea053beb348320e2 100644 --- a/docs/releases/v2.5.7.md +++ b/docs/releases/v2.5.7.md @@ -10,6 +10,7 @@ ## Feature - Add menu to change perspective orientation by coronal/sagittal/axial views. +- re-introduced saneUrl ## Under the hood diff --git a/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts index 07eae088ce2154228ecc4df9f2f3779ed3390d5a..bad568f3822e640174da67c0162142b3a2188894 100644 --- a/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts +++ b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts @@ -1,16 +1,18 @@ -import { Component, Optional, ViewChild } from "@angular/core"; +import { Component, Optional, TemplateRef, ViewChild } from "@angular/core"; import { ARIA_LABELS, CONST } from "common/constants"; import { ModularUserAnnotationToolService } from "../tools/service"; import { IAnnotationGeometry, TExportFormats } from "../tools/type"; import { ComponentStore } from "src/viewerModule/componentStore"; import { map, shareReplay, startWith } from "rxjs/operators"; -import { Observable, Subscription } from "rxjs"; +import { combineLatest, Observable, Subscription } from "rxjs"; import { TZipFileConfig } from "src/zipFilesOutput/type"; import { TFileInputEvent } from "src/getFileInput/type"; import { FileInputDirective } from "src/getFileInput/getFileInput.directive"; import { MatSnackBar } from "@angular/material/snack-bar"; import { unzip } from "src/zipFilesOutput/zipFilesOutput.directive"; import { DialogService } from "src/services/dialogService.service"; +import { MatDialog } from "@angular/material/dialog"; +import { userAnnotationRouteKey } from "../constants"; const README = `{id}.sands.json file contains the data of annotations. {id}.desc.json contains the metadata of annotations.` @@ -25,6 +27,8 @@ const README = `{id}.sands.json file contains the data of annotations. {id}.desc }) export class AnnotationList { + public userAnnRoute = {} + public ARIA_LABELS = ARIA_LABELS @ViewChild(FileInputDirective) @@ -66,6 +70,7 @@ export class AnnotationList { constructor( private annotSvc: ModularUserAnnotationToolService, private snackbar: MatSnackBar, + private dialog: MatDialog, cStore: ComponentStore<{ useFormat: TExportFormats }>, @Optional() private dialogSvc: DialogService, ) { @@ -74,7 +79,22 @@ export class AnnotationList { }) this.subs.push( - this.managedAnnotations$.subscribe(anns => this.managedAnnotations = anns) + this.managedAnnotations$.subscribe(anns => this.managedAnnotations = anns), + combineLatest([ + this.managedAnnotations$.pipe( + startWith([]) + ), + this.annotationInOtherSpaces$.pipe( + startWith([]) + ) + ]).subscribe(([ann, annOther]) => { + this.userAnnRoute = { + [userAnnotationRouteKey]: [ + ...ann.map(a => a.toJSON()), + ...annOther.map(a => a.toJSON()), + ] + } + }) ) } @@ -175,4 +195,8 @@ export class AnnotationList { } } } + + public openDialog(template: TemplateRef<any>) { + this.dialog.open(template) + } } diff --git a/src/atlasComponents/userAnnotations/annotationList/annotationList.template.html b/src/atlasComponents/userAnnotations/annotationList/annotationList.template.html index 7543d5d598ce9d77956aa6036636a1ba7b8408c5..f0832efe03d55a7295ad5699d058d35d029304de 100644 --- a/src/atlasComponents/userAnnotations/annotationList/annotationList.template.html +++ b/src/atlasComponents/userAnnotations/annotationList/annotationList.template.html @@ -34,6 +34,15 @@ <i class="fas fa-download"></i> </button> + <!-- share --> + <button mat-icon-button + [matTooltip]="ARIA_LABELS.SHARE_CUSTOM_URL" + (click)="openDialog(saneUrlTmpl)" + iav-state-aggregator + #stateAggregator="iavStateAggregator"> + <i class="fas fa-share-square"></i> + </button> + <!-- delete all annotations --> <button mat-icon-button color="warn" @@ -102,3 +111,23 @@ </ng-template> </mat-card-content> </mat-card> + + +<!-- sane url template --> +<ng-template #saneUrlTmpl> + <h2 mat-dialog-title> + Create custom URL + </h2> + <div mat-dialog-content> + <iav-sane-url [stateTobeSaved]="stateAggregator.jsonifiedState$ | async | mergeObj : userAnnRoute"> + </iav-sane-url> + </div> + + <div mat-dialog-actions + class="d-flex justify-content-center"> + <button mat-button + mat-dialog-close> + close + </button> + </div> +</ng-template> diff --git a/src/atlasComponents/userAnnotations/annotationMode/annotationMode.component.ts b/src/atlasComponents/userAnnotations/annotationMode/annotationMode.component.ts index b316ac2632dd0c6b31cf43bb30345434d7f37941..5eacf72e95c4baadee42cbb70d28a09ddb84dc62 100644 --- a/src/atlasComponents/userAnnotations/annotationMode/annotationMode.component.ts +++ b/src/atlasComponents/userAnnotations/annotationMode/annotationMode.component.ts @@ -1,7 +1,5 @@ import { Component, Inject, OnDestroy, Optional } from "@angular/core"; -import { Store } from "@ngrx/store"; import { ModularUserAnnotationToolService } from "../tools/service"; -import { viewerStateSetViewerMode } from "src/services/state/viewerState.store.helper"; import { ARIA_LABELS } from 'common/constants' import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR, CONTEXT_MENU_ITEM_INJECTOR, TContextMenu } from "src/util"; import { TContextArg } from "src/viewerModule/viewer.interface"; @@ -31,7 +29,6 @@ export class AnnotationMode implements OnDestroy{ private onDestroyCb: (() => void)[] = [] constructor( - private store$: Store<any>, private modularToolSvc: ModularUserAnnotationToolService, snackbar: MatSnackBar, @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, @@ -59,13 +56,6 @@ export class AnnotationMode implements OnDestroy{ }) } - exitAnnotationMode(){ - this.store$.dispatch( - viewerStateSetViewerMode({ - payload: null - }) - ) - } deselectTools(){ this.modularToolSvc.deselectTools() } diff --git a/src/atlasComponents/userAnnotations/annotationMode/annotationMode.template.html b/src/atlasComponents/userAnnotations/annotationMode/annotationMode.template.html index a21e682b08bc6b9e7adcab3737e1261fe63d9e5a..7542cdba29fd4102f5e03c801468cbc69fb77b05 100644 --- a/src/atlasComponents/userAnnotations/annotationMode/annotationMode.template.html +++ b/src/atlasComponents/userAnnotations/annotationMode/annotationMode.template.html @@ -16,7 +16,8 @@ <button mat-button - (click)="exitAnnotationMode()" + annotation-switch + annotation-switch-mode="off" class="tab-toggle" [matTooltip]="ARIA_LABELS.EXIT_ANNOTATION_MODE" color="warn"> diff --git a/src/atlasComponents/userAnnotations/constants.ts b/src/atlasComponents/userAnnotations/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..ddb14d680ab6240706d014ac1e71ebcf117d65cc --- /dev/null +++ b/src/atlasComponents/userAnnotations/constants.ts @@ -0,0 +1 @@ +export const userAnnotationRouteKey = 'x-user-anntn' diff --git a/src/atlasComponents/userAnnotations/directives/annotationSwitch.directive.ts b/src/atlasComponents/userAnnotations/directives/annotationSwitch.directive.ts index 1109fbdbc342124d842fe82b59ee02c2a51d0de4..068f6313632fe3e79f8bbb47b2cf58be31666bac 100644 --- a/src/atlasComponents/userAnnotations/directives/annotationSwitch.directive.ts +++ b/src/atlasComponents/userAnnotations/directives/annotationSwitch.directive.ts @@ -1,13 +1,5 @@ -import { Directive, HostListener, Inject, Input, Optional } from "@angular/core"; -import { viewerStateSetViewerMode } from "src/services/state/viewerState/actions"; -import { ARIA_LABELS } from "common/constants"; -import { select, Store } from "@ngrx/store"; -import { TContextArg } from "src/viewerModule/viewer.interface"; -import { TContextMenuReg } from "src/contextMenuModule"; -import { CONTEXT_MENU_ITEM_INJECTOR, TContextMenu } from "src/util"; +import { Directive, HostListener, Input } from "@angular/core"; import { ModularUserAnnotationToolService } from "../tools/service"; -import { Subscription } from "rxjs"; -import { viewerStateViewerModeSelector } from "src/services/state/viewerState/selectors"; @Directive({ selector: '[annotation-switch]' @@ -17,37 +9,13 @@ export class AnnotationSwitch { @Input('annotation-switch-mode') mode: 'toggle' | 'off' | 'on' = 'on' - private currMode = null - private subs: Subscription[] = [] constructor( - private store$: Store<any>, private svc: ModularUserAnnotationToolService, - @Optional() @Inject(CONTEXT_MENU_ITEM_INJECTOR) ctxMenuInterceptor: TContextMenu<TContextMenuReg<TContextArg<'nehuba' | 'threeSurfer'>>> ) { - this.subs.push( - this.store$.pipe( - select(viewerStateViewerModeSelector) - ).subscribe(mode => { - this.currMode = mode - }) - ) } @HostListener('click') onClick() { - let payload = null - if (this.mode === 'on') payload = ARIA_LABELS.VIEWER_MODE_ANNOTATING - if (this.mode === 'off') { - if (this.currMode === ARIA_LABELS.VIEWER_MODE_ANNOTATING) payload = null - else return - } - if (this.mode === 'toggle') { - payload = this.currMode === ARIA_LABELS.VIEWER_MODE_ANNOTATING - ? null - : ARIA_LABELS.VIEWER_MODE_ANNOTATING - } - this.store$.dispatch( - viewerStateSetViewerMode({ payload }) - ) + this.svc.switchAnnotationMode(this.mode) } } diff --git a/src/atlasComponents/userAnnotations/module.ts b/src/atlasComponents/userAnnotations/module.ts index 39e7f420136db395d43f95380450eb23be2cf759..ec2586e5741ddc2d25ff4c45657154d882386946 100644 --- a/src/atlasComponents/userAnnotations/module.ts +++ b/src/atlasComponents/userAnnotations/module.ts @@ -1,4 +1,4 @@ -import { NgModule } from "@angular/core"; +import { APP_INITIALIZER, NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; import { AngularMaterialModule } from "src/sharedModules"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; @@ -14,6 +14,9 @@ import { FileInputModule } from "src/getFileInput/module"; import { ZipFilesOutputModule } from "src/zipFilesOutput/module"; import { FilterAnnotationsBySpace } from "./filterAnnotationBySpace.pipe"; import { AnnotationEventDirective } from "./directives/annotationEv.directive"; +import { ShareModule } from "src/share"; +import { StateModule } from "src/state"; +import { RoutedAnnotationService } from "./routedAnnotation.service"; @NgModule({ imports: [ @@ -26,6 +29,8 @@ import { AnnotationEventDirective } from "./directives/annotationEv.directive"; UtilModule, FileInputModule, ZipFilesOutputModule, + ShareModule, + StateModule, ], declarations: [ AnnotationMode, @@ -43,6 +48,18 @@ import { AnnotationEventDirective } from "./directives/annotationEv.directive"; AnnotationList, AnnotationSwitch, AnnotationEventDirective + ], + providers: [ + // initialize routerannotation service, so it will parse route and load annotations ... + // ... in url correctly + { + provide: APP_INITIALIZER, + useFactory:(svc: RoutedAnnotationService) => { + return () => Promise.resolve() + }, + deps: [ RoutedAnnotationService ], + multi: true + } ] }) diff --git a/src/atlasComponents/userAnnotations/routedAnnotation.service.spec.ts b/src/atlasComponents/userAnnotations/routedAnnotation.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7aecdd3d4a043e42699d2a8d2279c568bf48191c --- /dev/null +++ b/src/atlasComponents/userAnnotations/routedAnnotation.service.spec.ts @@ -0,0 +1,178 @@ +import { fakeAsync, TestBed, tick } from "@angular/core/testing" +import { BehaviorSubject, of } from "rxjs" +import { RouterService } from "src/routerModule/router.service" +import { SaneUrlSvc } from "src/share/saneUrl/saneUrl.service" +import { ModularUserAnnotationToolService } from "./tools/service" +import { RoutedAnnotationService } from './routedAnnotation.service' +import { userAnnotationRouteKey } from "./constants" + +describe('> routedannotation.service.ts', () => { + describe('> RoutedAnnotationService', () => { + const customRouteSub = new BehaviorSubject(null) + const spyRService = { + customRoute$: customRouteSub.asObservable() + } + + const spyAnnSvc = { + switchAnnotationMode: jasmine.createSpy('switchAnnotationMode'), + parseAnnotationObject: jasmine.createSpy('parseAnnotationObject'), + importAnnotation: jasmine.createSpy('importAnnotation') + } + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + RoutedAnnotationService, + { + provide: RouterService, + useValue: spyRService + }, { + provide: SaneUrlSvc, + useFactory: () => { + return { + getKeyVal: jasmine.createSpy('getKeyVal').and.returnValue( + of({}) + ) + } + } + }, { + provide: ModularUserAnnotationToolService, + useValue: spyAnnSvc + } + ], + }) + }) + afterEach(() => { + spyAnnSvc.switchAnnotationMode.calls.reset() + spyAnnSvc.parseAnnotationObject.calls.reset() + spyAnnSvc.importAnnotation.calls.reset() + }) + + it('> can be init', () => { + const svc = TestBed.inject(RoutedAnnotationService) + expect(svc instanceof RoutedAnnotationService).toBeTrue() + }) + + describe('> normal operation', () => { + const mockVal = 'foo-bar' + const getKeyValReturn = { + [userAnnotationRouteKey]: [{ + foo: 'bar' + }, { + foo: 'hello world' + }] + } + const parseAnnObjReturn = [{ + bar: 'baz' + }, { + hello: 'world' + }] + + beforeEach(() => { + const spySaneUrlSvc = TestBed.inject(SaneUrlSvc) as any + spySaneUrlSvc.getKeyVal.and.returnValue( + of(getKeyValReturn) + ) + spyAnnSvc.parseAnnotationObject.and.returnValues(...parseAnnObjReturn) + }) + + it('> getKeyVal called after at least 160ms', fakeAsync(() => { + customRouteSub.next({ + [userAnnotationRouteKey]: mockVal + }) + const svc = TestBed.inject(RoutedAnnotationService) + tick(200) + const spySaneUrlSvc = TestBed.inject(SaneUrlSvc) as any + expect(spySaneUrlSvc.getKeyVal).toHaveBeenCalled() + expect(spySaneUrlSvc.getKeyVal).toHaveBeenCalledWith(mockVal) + })) + + it('> switchannotation mode is called with "on"', fakeAsync(() => { + customRouteSub.next({ + [userAnnotationRouteKey]: mockVal + }) + const svc = TestBed.inject(RoutedAnnotationService) + tick(200) + expect(spyAnnSvc.switchAnnotationMode).toHaveBeenCalledOnceWith('on') + })) + + it('> parseAnnotationObject is called expected number of times', fakeAsync(() => { + customRouteSub.next({ + [userAnnotationRouteKey]: mockVal + }) + const svc = TestBed.inject(RoutedAnnotationService) + tick(200) + + const userAnn = getKeyValReturn[userAnnotationRouteKey] + expect(spyAnnSvc.parseAnnotationObject).toHaveBeenCalledTimes(userAnn.length) + for (const ann of userAnn) { + expect(spyAnnSvc.parseAnnotationObject).toHaveBeenCalledWith(ann) + } + })) + + it('> importAnnotation is called expected number of times', fakeAsync(() => { + customRouteSub.next({ + [userAnnotationRouteKey]: mockVal + }) + const svc = TestBed.inject(RoutedAnnotationService) + tick(200) + expect(spyAnnSvc.importAnnotation).toHaveBeenCalledTimes(parseAnnObjReturn.length) + for (const obj of parseAnnObjReturn) { + expect(spyAnnSvc.importAnnotation).toHaveBeenCalledWith(obj) + } + })) + }) + + describe('> abnormal operation', () => { + describe('> routerSvc.customRoute$ emits after 160 ms', () => { + const mockVal = 'foo-bar' + + it('> getKeyVal should only be called once', fakeAsync(() => { + customRouteSub.next({ + [userAnnotationRouteKey]: mockVal + }) + const svc = TestBed.inject(RoutedAnnotationService) + tick(200) + customRouteSub.next({ + [userAnnotationRouteKey]: 'hello world' + }) + tick(200) + const spySaneUrlSvc = TestBed.inject(SaneUrlSvc) as any + expect(spySaneUrlSvc.getKeyVal).toHaveBeenCalledOnceWith(mockVal) + })) + }) + + describe('> routerSvc.customRoute$ does not emit valid key', () => { + it('> does not call getKeyVal', fakeAsync(() => { + customRouteSub.next({ + 'hello-world': 'foo-bar' + }) + const svc = TestBed.inject(RoutedAnnotationService) + tick(200) + const spySaneUrlSvc = TestBed.inject(SaneUrlSvc) as any + expect(spySaneUrlSvc.getKeyVal).not.toHaveBeenCalled() + })) + }) + + describe('> getKeyVal returns invalid key', () => { + it('> does not call switchAnnotationMode', fakeAsync(() => { + const spySaneUrlSvc = TestBed.inject(SaneUrlSvc) as any + spySaneUrlSvc.getKeyVal.and.returnValue( + of({ + 'hello-world': [{ + foo: 'bar', + fuzz: 'bizz' + }] + }) + ) + customRouteSub.next({ + [userAnnotationRouteKey]: 'foo-bar' + }) + const svc = TestBed.inject(RoutedAnnotationService) + + tick(320) + expect(spyAnnSvc.switchAnnotationMode).not.toHaveBeenCalled() + })) + }) + }) + }) +}) diff --git a/src/atlasComponents/userAnnotations/routedAnnotation.service.ts b/src/atlasComponents/userAnnotations/routedAnnotation.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..11a28a8fe6548ede886ab3b0c0ca1f09125aa918 --- /dev/null +++ b/src/atlasComponents/userAnnotations/routedAnnotation.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from "@angular/core"; +import { NEVER } from "rxjs"; +import { debounceTime, map, switchMap, take } from "rxjs/operators"; +import { RouterService } from "src/routerModule/router.service"; +import { SaneUrlSvc } from "src/share/saneUrl/saneUrl.service"; +import { userAnnotationRouteKey } from "./constants"; +import { ModularUserAnnotationToolService } from "./tools/service"; + +@Injectable({ + providedIn: 'root' +}) + +export class RoutedAnnotationService{ + constructor( + routerSvc: RouterService, + saneUrlSvc: SaneUrlSvc, + annSvc: ModularUserAnnotationToolService, + ){ + + routerSvc.customRoute$.pipe( + debounceTime(160), + take(1), + map(obj => obj[userAnnotationRouteKey]), + switchMap( + saneUrlKey => { + return saneUrlKey + ? saneUrlSvc.getKeyVal(saneUrlKey) + : NEVER + } + ) + ).subscribe(val => { + if (val[userAnnotationRouteKey]) { + annSvc.switchAnnotationMode('on') + for (const ann of val[userAnnotationRouteKey]){ + const geom = annSvc.parseAnnotationObject(ann) + annSvc.importAnnotation(geom) + } + } + }) + } +} diff --git a/src/atlasComponents/userAnnotations/singleAnnotationUnit/singleAnnotationUnit.component.ts b/src/atlasComponents/userAnnotations/singleAnnotationUnit/singleAnnotationUnit.component.ts index 956f23c080c1929b07fc62159a42024c68334490..8c9a11566b9f7f27350a684cc9ffc3976525c32e 100644 --- a/src/atlasComponents/userAnnotations/singleAnnotationUnit/singleAnnotationUnit.component.ts +++ b/src/atlasComponents/userAnnotations/singleAnnotationUnit/singleAnnotationUnit.component.ts @@ -48,8 +48,8 @@ export class SingleAnnotationUnit implements OnDestroy, AfterViewInit{ this.chSubs.push( this.formGrp.valueChanges.subscribe(value => { const { name, desc, spaceId } = value - this.managedAnnotation.setName(name) - this.managedAnnotation.setDesc(desc) + this.managedAnnotation.name = name + this.managedAnnotation.desc = desc }) ) diff --git a/src/atlasComponents/userAnnotations/tools/line.ts b/src/atlasComponents/userAnnotations/tools/line.ts index 606c155f3e995bbf3bba85d6b82cb4040ae7a76e..bea53cdf42b49e494e9f3419813f4b9178dc3b99 100644 --- a/src/atlasComponents/userAnnotations/tools/line.ts +++ b/src/atlasComponents/userAnnotations/tools/line.ts @@ -45,7 +45,7 @@ export class Line extends IAnnotationGeometry{ }) if (!this.points[0]) this.points[0] = point else this.points[1] = point - this.sendUpdateSignal() + this.changed() return point } @@ -172,11 +172,7 @@ export class Line extends IAnnotationGeometry{ for (const p of this.points){ p.translate(x, y, z) } - this.sendUpdateSignal() - } - - private sendUpdateSignal(){ - this.updateSignal$.next(this.toString()) + this.changed() } } @@ -244,7 +240,7 @@ export class ToolLine extends AbsToolClass<Line> implements IAnnotationTools, On * it only has a single point, and should be removed */ if (this.selectedLine) { - this.removeAnnotation(this.selectedLine.id) + this.removeAnnotation(this.selectedLine) } this.selectedLine = null }), @@ -311,14 +307,4 @@ export class ToolLine extends AbsToolClass<Line> implements IAnnotationTools, On ngOnDestroy(){ this.subs.forEach(s => s.unsubscribe()) } - - removeAnnotation(id: string){ - const idx = this.managedAnnotations.findIndex(ann => ann.id === id) - if (idx < 0) { - return - } - this.managedAnnotations.splice(idx, 1) - this.managedAnnotations$.next(this.managedAnnotations) - } - } diff --git a/src/atlasComponents/userAnnotations/tools/point.ts b/src/atlasComponents/userAnnotations/tools/point.ts index af1543fed9b100ecabda6373bf4055b685c580f7..1db473a6713ed78b82c0d12c4a34acf65f13bb84 100644 --- a/src/atlasComponents/userAnnotations/tools/point.ts +++ b/src/atlasComponents/userAnnotations/tools/point.ts @@ -95,7 +95,7 @@ export class Point extends IAnnotationGeometry { this.x += x this.y += y this.z += z - this.updateSignal$.next(this.toString()) + this.changed() } } @@ -177,20 +177,6 @@ export class ToolPoint extends AbsToolClass<Point> implements IAnnotationTools, ) } - /** - * @description remove managed annotation via id - * @param id id of annotation - */ - removeAnnotation(id: string) { - const idx = this.managedAnnotations.findIndex(ann => id === ann.id) - if (idx < 0){ - throw new Error(`cannot find point idx ${idx}`) - return - } - this.managedAnnotations.splice(idx, 1) - this.managedAnnotations$.next(this.managedAnnotations) - } - onMouseMoveRenderPreview(pos: [number, number, number]) { return [{ id: `${ToolPoint.PREVIEW_ID}_0`, diff --git a/src/atlasComponents/userAnnotations/tools/poly.ts b/src/atlasComponents/userAnnotations/tools/poly.ts index 2cd9285b256119f8ec112825b96fc615a6eea407..b7929b5dfe77e378ae6a52c2f5c67d5333547025 100644 --- a/src/atlasComponents/userAnnotations/tools/poly.ts +++ b/src/atlasComponents/userAnnotations/tools/poly.ts @@ -37,7 +37,7 @@ export class Polygon extends IAnnotationGeometry{ this.edges = this.edges.filter(([ idx1, idx2 ]) => idx1 !== ptIdx && idx2 !== ptIdx) this.points.splice(ptIdx, 1) - this.sendUpdateSignal() + this.changed() } public addPoint(p: Point | {x: number, y: number, z: number}, linkTo?: Point): Point { @@ -56,7 +56,7 @@ export class Polygon extends IAnnotationGeometry{ if (!this.hasPoint(pointToBeAdded)) { this.points.push(pointToBeAdded) const sub = pointToBeAdded.updateSignal$.subscribe( - () => this.sendUpdateSignal() + () => this.changed() ) this.ptWkMp.set(pointToBeAdded, { onremove: () => { @@ -71,7 +71,7 @@ export class Polygon extends IAnnotationGeometry{ ] as [number, number] this.edges.push(newEdge) } - this.sendUpdateSignal() + this.changed() return pointToBeAdded } @@ -209,15 +209,11 @@ export class Polygon extends IAnnotationGeometry{ this.edges = edges } - private sendUpdateSignal(){ - this.updateSignal$.next(this.toString()) - } - public translate(x: number, y: number, z: number) { for (const p of this.points){ p.translate(x, y, z) } - this.sendUpdateSignal() + this.changed() } } @@ -307,7 +303,7 @@ export class ToolPolygon extends AbsToolClass<Polygon> implements IAnnotationToo * if edges < 3, discard poly */ if (edges.length < 3) { - this.removeAnnotation(this.selectedPoly.id) + this.removeAnnotation(this.selectedPoly) } } @@ -404,15 +400,6 @@ export class ToolPolygon extends AbsToolClass<Polygon> implements IAnnotationToo ) } - removeAnnotation(id: string) { - const idx = this.managedAnnotations.findIndex(ann => ann.id === id) - if (idx < 0) { - return - } - this.managedAnnotations.splice(idx, 1) - this.managedAnnotations$.next(this.managedAnnotations) - } - ngOnDestroy(){ if (this.subs.length > 0) this.subs.pop().unsubscribe() } diff --git a/src/atlasComponents/userAnnotations/tools/service.ts b/src/atlasComponents/userAnnotations/tools/service.ts index 8185ed4b6177cb3688ca381c87a4c7a05961ada4..196d77ec0563fa57183f10fb2c7d865e462f6d00 100644 --- a/src/atlasComponents/userAnnotations/tools/service.ts +++ b/src/atlasComponents/userAnnotations/tools/service.ts @@ -15,6 +15,7 @@ import { Point } from "./point"; import { FilterAnnotationsBySpace } from "../filterAnnotationBySpace.pipe"; import { retry } from 'common/util' import { MatSnackBar } from "@angular/material/snack-bar"; +import { viewerStateSetViewerMode } from "src/services/state/viewerState.store.helper"; const LOCAL_STORAGE_KEY = 'userAnnotationKey' @@ -490,6 +491,7 @@ export class ModularUserAnnotationToolService implements OnDestroy{ store.pipe( select(viewerStateViewerModeSelector) ).subscribe(viewerMode => { + this.currMode = viewerMode if (viewerMode === ModularUserAnnotationToolService.VIEWER_MODE) { if (this.ngAnnotationLayer) this.ngAnnotationLayer.setVisible(true) else { @@ -697,8 +699,8 @@ export class ModularUserAnnotationToolService implements OnDestroy{ // potentially overwriting existing name and desc... // maybe should show warning? - existingAnn.setName(json.name) - existingAnn.setDesc(json.desc) + existingAnn.name = json.name + existingAnn.desc = json.desc return existingAnn } else { const { id, name, desc } = json @@ -708,8 +710,8 @@ export class ModularUserAnnotationToolService implements OnDestroy{ } else { const metadata = this.metadataMap.get(returnObj.id) if (returnObj && metadata) { - returnObj.setName(metadata?.name || null) - returnObj.setDesc(metadata?.desc || null) + returnObj.name = metadata?.name || null + returnObj.desc = metadata?.desc || null this.metadataMap.delete(returnObj.id) } } @@ -730,6 +732,25 @@ export class ModularUserAnnotationToolService implements OnDestroy{ ngOnDestroy(){ while(this.subscription.length > 0) this.subscription.pop().unsubscribe() } + + private currMode: string + switchAnnotationMode(mode: 'on' | 'off' | 'toggle' = 'toggle') { + + let payload = null + if (mode === 'on') payload = ARIA_LABELS.VIEWER_MODE_ANNOTATING + if (mode === 'off') { + if (this.currMode === ARIA_LABELS.VIEWER_MODE_ANNOTATING) payload = null + else return + } + if (mode === 'toggle') { + payload = this.currMode === ARIA_LABELS.VIEWER_MODE_ANNOTATING + ? null + : ARIA_LABELS.VIEWER_MODE_ANNOTATING + } + this.store.dispatch( + viewerStateSetViewerMode({ payload }) + ) + } } export function parseNgAnnotation(ann: INgAnnotationTypes[keyof INgAnnotationTypes]){ diff --git a/src/atlasComponents/userAnnotations/tools/type.spec.ts b/src/atlasComponents/userAnnotations/tools/type.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4b28738913465c7eda8ee898f28fdc724dbf016 --- /dev/null +++ b/src/atlasComponents/userAnnotations/tools/type.spec.ts @@ -0,0 +1,166 @@ +import { Subject, Subscription } from "rxjs" +import { AbsToolClass, IAnnotationEvents, IAnnotationGeometry, TAnnotationEvent } from "./type" + +class TmpCls extends IAnnotationGeometry{ + getNgAnnotationIds(){ + return [] + } + toNgAnnotation() { + return [] + } + toJSON() { + return {} + } + toString() { + return '' + } + toSands(){ + return {} as any + } +} + +class TmpToolCls extends AbsToolClass<TmpCls> { + iconClass = '' + name: 'tmplClsTool' + subs = [] + onMouseMoveRenderPreview() { + return [] + } + + protected managedAnnotations: TmpCls[] = [] + public managedAnnotations$ = new Subject<TmpCls[]>() +} + +describe('> types.ts', () => { + describe('> AbsToolClass', () => { + let tool: TmpToolCls + let ann: TmpCls + let managedAnn: jasmine.Spy + const subs: Subscription[] = [] + + beforeEach(() => { + const ev$ = new Subject<TAnnotationEvent<keyof IAnnotationEvents>>() + tool = new TmpToolCls(ev$, () => {}) + ann = new TmpCls() + managedAnn = jasmine.createSpy('managedannspy') + subs.push( + tool.managedAnnotations$.subscribe(managedAnn) + ) + }) + + afterEach(() => { + managedAnn.calls.reset() + while(subs.length) subs.pop().unsubscribe() + }) + + it('> shuld init just fine', () => { + expect(true).toEqual(true) + }) + describe('> managedAnnotations$', () => { + describe('> on point add', () => { + it('> should emit new managedannotations', () => { + tool.addAnnotation(ann) + expect(managedAnn).toHaveBeenCalled() + expect(managedAnn).toHaveBeenCalledTimes(1) + const firstCallArgs = managedAnn.calls.allArgs()[0] + const firstArg = firstCallArgs[0] + expect(firstArg).toEqual([ann]) + }) + }) + describe('> on point update', () => { + it('> should emit new managedannotations', () => { + tool.addAnnotation(ann) + ann.name = 'blabla' + expect(managedAnn).toHaveBeenCalledTimes(2) + const firstCallArgs = managedAnn.calls.allArgs()[0] + const secondCallArgs = managedAnn.calls.allArgs()[1] + expect(firstCallArgs).toEqual(secondCallArgs) + }) + }) + describe('> on point rm', () => { + it('> managed annotation === 0', () => { + tool.addAnnotation(ann) + + const firstCallArgs = managedAnn.calls.allArgs()[0] + const subManagedAnn0 = firstCallArgs[0] + expect(subManagedAnn0.length).toEqual(1) + + ann.remove() + expect(managedAnn).toHaveBeenCalledTimes(2) + + const secondCallArgs = managedAnn.calls.allArgs()[1] + const subManagedAnn1 = secondCallArgs[0] + expect(subManagedAnn1.length).toEqual(0) + }) + it('> does not trigger after rm', () => { + tool.addAnnotation(ann) + ann.remove() + ann.name = 'blabla' + expect(managedAnn).toHaveBeenCalledTimes(2) + }) + }) + }) + }) + + describe('> IAnnotationGeometry', () => { + it('> can be init fine', () => { + new TmpCls() + expect(true).toBe(true) + }) + + describe('> updateSignal$', () => { + class TmpCls extends IAnnotationGeometry{ + getNgAnnotationIds(){ + return [] + } + toNgAnnotation() { + return [] + } + toJSON() { + return {} + } + toString() { + return `${this.name || ''}:${this.desc || ''}` + } + toSands(){ + return {} as any + } + } + + let tmp: TmpCls + let subs: Subscription[] = [] + let updateStub: jasmine.Spy + beforeEach(() => { + tmp = new TmpCls() + updateStub = jasmine.createSpy('updateSpy') + subs.push( + tmp.updateSignal$.subscribe( + val => updateStub(val) + ) + ) + }) + afterEach(() => { + while(subs.length) subs.pop().unsubscribe() + updateStub.calls.reset() + }) + it('> is fired on setting name', () => { + tmp.name = 'test' + expect(updateStub).toHaveBeenCalled() + expect(updateStub).toHaveBeenCalledTimes(1) + }) + + it('> is fired on setting desc', () => { + tmp.desc = 'testdesc' + expect(updateStub).toHaveBeenCalled() + expect(updateStub).toHaveBeenCalledTimes(1) + }) + + it('> should be fired even if same string', () => { + + tmp.name = null + tmp.name = undefined + expect(updateStub).toHaveBeenCalledTimes(2) + }) + }) + }) +}) diff --git a/src/atlasComponents/userAnnotations/tools/type.ts b/src/atlasComponents/userAnnotations/tools/type.ts index e74f0ca8ee389775b93f49836a4f504ab88ccfec..85a4c2e0f6783a024017a34fc0b50588a89ccfcd 100644 --- a/src/atlasComponents/userAnnotations/tools/type.ts +++ b/src/atlasComponents/userAnnotations/tools/type.ts @@ -18,9 +18,8 @@ export abstract class AbsToolClass<T extends IAnnotationGeometry> { public abstract name: string public abstract iconClass: string - public abstract removeAnnotation(id: string): void public abstract managedAnnotations$: Subject<T[]> - protected abstract managedAnnotations: T[] = [] + protected managedAnnotations: T[] = [] abstract subs: Subscription[] protected space: TBaseAnnotationGeomtrySpec['space'] @@ -158,10 +157,26 @@ export abstract class AbsToolClass<T extends IAnnotationGeometry> { public addAnnotation(geom: T) { const found = this.managedAnnotations.find(ann => ann.id === geom.id) if (found) found.remove() - geom.remove = () => this.removeAnnotation(geom.id) + const sub = geom.updateSignal$.subscribe(() => { + this.managedAnnotations$.next(this.managedAnnotations) + }) + geom.remove = () => { + this.removeAnnotation(geom) + sub.unsubscribe() + } this.managedAnnotations.push(geom) this.managedAnnotations$.next(this.managedAnnotations) } + + public removeAnnotation(geom: T) { + const idx = this.managedAnnotations.findIndex(ann => ann.id === geom.id) + if (idx < 0) { + console.warn(`removeAnnotation error: cannot annotation with id: ${geom.id}`) + return + } + this.managedAnnotations.splice(idx, 1) + this.managedAnnotations$.next(this.managedAnnotations) + } } export type TToolType = 'selecting' | 'drawing' | 'deletion' @@ -276,8 +291,25 @@ export abstract class Highlightable { export abstract class IAnnotationGeometry extends Highlightable { public id: string - public name: string - public desc: string + private _name: string + set name(val: string) { + if (val === this._name) return + this._name = val + this.changed() + } + get name(): string { + return this._name + } + + private _desc: string + set desc(val: string) { + if (val === this._desc) return + this._desc = val + this.changed() + } + get desc(): string { + return this._desc + } public space: TBaseAnnotationGeomtrySpec['space'] @@ -290,7 +322,13 @@ export abstract class IAnnotationGeometry extends Highlightable { public remove() { throw new Error(`The remove method needs to be overwritten by the tool manager`) } - public updateSignal$ = new Subject() + + private _updateSignal$ = new Subject() + + public updateSignal$ = this._updateSignal$.asObservable() + protected changed(){ + this._updateSignal$.next(Date.now()) + } constructor(spec?: TBaseAnnotationGeomtrySpec){ super() @@ -299,15 +337,6 @@ export abstract class IAnnotationGeometry extends Highlightable { this.name = spec?.name this.desc = spec?.desc } - - setName(name: string) { - this.name = name - this.updateSignal$.next(this.toString()) - } - setDesc(desc: string) { - this.desc = desc - this.updateSignal$.next(this.toString()) - } } export interface IAnnotationTools { diff --git a/src/routerModule/router.service.spec.ts b/src/routerModule/router.service.spec.ts index 5507f4a5ba5d4bb05c86853bf91768b4d7eaf213..2b933c6f1a5711b8503776d082203e684299f563 100644 --- a/src/routerModule/router.service.spec.ts +++ b/src/routerModule/router.service.spec.ts @@ -3,6 +3,7 @@ import { discardPeriodicTasks, fakeAsync, TestBed, tick } from "@angular/core/te import { Router } from "@angular/router" import { RouterTestingModule } from '@angular/router/testing' import { MockStore, provideMockStore } from "@ngrx/store/testing" +import { cold } from "jasmine-marbles" import { of } from "rxjs" import { PureContantService } from "src/util" import { RouterService } from "./router.service" @@ -290,5 +291,114 @@ describe('> router.service.ts', () => { }) }) }) + + describe('> customRoute$', () => { + let decodeCustomStateSpy: jasmine.Spy + beforeEach(() => { + decodeCustomStateSpy = jasmine.createSpy('decodeCustomState') + spyOnProperty(util, 'decodeCustomState').and.returnValue(decodeCustomStateSpy) + + router = TestBed.inject(Router) + }) + + afterEach(() => { + decodeCustomStateSpy.calls.reset() + }) + + it('> emits return record from decodeCustomState', fakeAsync(() => { + const value = { + 'x-foo': 'bar' + } + decodeCustomStateSpy.and.returnValue(value) + const rService = TestBed.inject(RouterService) + router.navigate(['foo']) + tick(320) + + expect(rService.customRoute$).toBeObservable( + cold('a', { + a: { + 'x-foo': 'bar' + } + }) + ) + discardPeriodicTasks() + })) + it('> merges observable from _customRoutes$', fakeAsync(() => { + decodeCustomStateSpy.and.returnValue({}) + const rService = TestBed.inject(RouterService) + rService.setCustomRoute('x-fizz', 'buzz') + tick(320) + + expect(rService.customRoute$).toBeObservable( + cold('(ba)', { + a: { + 'x-fizz': 'buzz' + }, + b: {} + }) + ) + discardPeriodicTasks() + })) + + it('> merges from both sources', fakeAsync(() => { + const value = { + 'x-foo': 'bar' + } + decodeCustomStateSpy.and.returnValue(value) + const rService = TestBed.inject(RouterService) + rService.setCustomRoute('x-fizz', 'buzz') + tick(320) + + expect(rService.customRoute$).toBeObservable( + cold('(ba)', { + a: { + 'x-fizz': 'buzz', + 'x-foo': 'bar' + }, + b: { + 'x-foo': 'bar' + } + }) + ) + discardPeriodicTasks() + })) + + it('> subsequent emits overwrites', fakeAsync(() => { + decodeCustomStateSpy.and.returnValue({}) + const rService = TestBed.inject(RouterService) + spyOn(router, 'navigateByUrl').and.callFake((() => { + console.log('navigate by url') + }) as any) + + const customRouteSpy = jasmine.createSpy('customRouteSpy') + rService.customRoute$.subscribe(customRouteSpy) + + rService.setCustomRoute('x-fizz', 'buzz') + tick(20) + rService.setCustomRoute('x-foo', 'bar') + tick(20) + rService.setCustomRoute('x-foo', null) + + tick(320) + + const expectedCalls = { + z: { + 'x-fizz': 'buzz', + 'x-foo': null + }, + a: { + 'x-fizz': 'buzz', + 'x-foo': 'bar' + }, + b: { + 'x-fizz': 'buzz' + } + } + for (const c in expectedCalls) { + expect(customRouteSpy).toHaveBeenCalledWith(expectedCalls[c]) + } + discardPeriodicTasks() + })) + }) }) }) diff --git a/src/routerModule/router.service.ts b/src/routerModule/router.service.ts index 0a9ce582a948aae79538f17c67401d8c7b079257..ee0246f79302d60fae4c9caa8477c65120929d52 100644 --- a/src/routerModule/router.service.ts +++ b/src/routerModule/router.service.ts @@ -3,10 +3,12 @@ import { APP_BASE_HREF } from "@angular/common"; import { Inject } from "@angular/core"; import { NavigationEnd, Router } from '@angular/router' import { Store } from "@ngrx/store"; -import { debounceTime, filter, map, shareReplay, switchMapTo, take, withLatestFrom } from "rxjs/operators"; +import { debounceTime, distinctUntilChanged, filter, map, shareReplay, startWith, switchMapTo, take, tap, withLatestFrom } from "rxjs/operators"; import { generalApplyState } from "src/services/stateStore.helper"; import { PureContantService } from "src/util"; -import { cvtStateToHashedRoutes, cvtFullRouteToState } from "./util"; +import { cvtStateToHashedRoutes, cvtFullRouteToState, encodeCustomState, decodeCustomState, verifyCustomState } from "./util"; +import { BehaviorSubject, combineLatest, merge, Observable } from 'rxjs' +import { scan } from 'rxjs/operators' @Injectable({ providedIn: 'root' @@ -18,6 +20,21 @@ export class RouterService { console.log(...e) } + private _customRoute$ = new BehaviorSubject<{ + [key: string]: string + }>({}) + + public customRoute$: Observable<Record<string, any>> + + setCustomRoute(key: string, state: string){ + if (!verifyCustomState(key)) { + throw new Error(`custom state key must start with x- `) + } + this._customRoute$.next({ + [key]: state + }) + } + constructor( router: Router, pureConstantService: PureContantService, @@ -40,6 +57,40 @@ export class RouterService { shareReplay(1), ) + this.customRoute$ = ready$.pipe( + switchMapTo( + merge( + navEnd$.pipe( + map((ev: NavigationEnd) => { + const fullPath = ev.urlAfterRedirects + const customState = decodeCustomState( + router.parseUrl(fullPath) + ) + return customState || {} + }), + ), + this._customRoute$ + ).pipe( + scan<Record<string, string>>((acc, curr) => { + return { + ...acc, + ...curr + } + }, {}), + // TODO add obj eql distinctuntilchanged check + distinctUntilChanged((o, n) => { + if (Object.keys(o).length !== Object.keys(n).length) { + return false + } + for (const key in o) { + if (o[key] !== n[key]) return false + } + return true + }), + ) + ), + ) + ready$.pipe( switchMapTo( navEnd$.pipe( @@ -70,15 +121,28 @@ export class RouterService { // which may or many not be ready$.pipe( switchMapTo( - store$.pipe( - debounceTime(160), - map(state => { - try { - return cvtStateToHashedRoutes(state) - } catch (e) { - this.logError(e) - return `` + combineLatest([ + store$.pipe( + debounceTime(160), + map(state => { + try { + return cvtStateToHashedRoutes(state) + } catch (e) { + this.logError(e) + return `` + } + }) + ), + this.customRoute$, + ]).pipe( + map(([ routePath, customPath ]) => { + let returnPath = routePath + for (const key in customPath) { + const customStatePath = encodeCustomState(key, customPath[key]) + if (!customStatePath) continue + returnPath += `/${customStatePath}` } + return returnPath }) ) ) diff --git a/src/routerModule/util.spec.ts b/src/routerModule/util.spec.ts index b2ed55ea82db7f09f581114c2157a40ad2731bb6..35e0baefc23aa62bd6531b3ea32a2659744c63e7 100644 --- a/src/routerModule/util.spec.ts +++ b/src/routerModule/util.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing' import { MockStore, provideMockStore } from '@ngrx/store/testing' import { uiStatePreviewingDatasetFilesSelector } from 'src/services/state/uiState/selectors' import { viewerStateGetSelectedAtlas, viewerStateSelectedParcellationSelector, viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector, viewerStateSelectorNavigation, viewerStateSelectorStandaloneVolumes } from 'src/services/state/viewerState/selectors' -import { cvtFullRouteToState, cvtStateToHashedRoutes, DummyCmp, routes } from './util' +import { cvtFullRouteToState, cvtStateToHashedRoutes, DummyCmp, encodeCustomState, routes, verifyCustomState } from './util' import { encodeNumber } from './cipher' import { Router } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' @@ -158,4 +158,35 @@ describe('> util.ts', () => { }) }) }) + + describe('> verifyCustomState', () => { + it('> should return false on bad custom state', () => { + expect(verifyCustomState('hello')).toBeFalse() + }) + it('> should return true on valid custom state', () => { + expect(verifyCustomState('x-test')).toBeTrue() + }) + }) + + describe('> encodeCustomState', () => { + describe('> malformed values', () => { + describe('> bad key', () => { + it('> throws', () => { + expect(() => { + encodeCustomState('hello', 'world') + }).toThrow() + }) + }) + describe('> falsy value', () => { + it('> returns falsy value', () => { + expect(encodeCustomState('x-test', null)).toBeFalsy() + }) + }) + }) + describe('> correct values', () => { + it('> encodes correctly', () => { + expect(encodeCustomState('x-test', 'foo-bar')).toEqual('x-test:foo-bar') + }) + }) + }) }) diff --git a/src/routerModule/util.ts b/src/routerModule/util.ts index bbf8791d3e53851ffb05a9e0acff9646003ff356..39ec477aebc60ba1bd201971923bc5c3a6f539c5 100644 --- a/src/routerModule/util.ts +++ b/src/routerModule/util.ts @@ -306,6 +306,34 @@ export const cvtStateToHashedRoutes = (state): string => { : `${routesArr.join('/')}?${searchParam.toString()}` } +export const verifyCustomState = (key: string) => { + return /^x-/.test(key) +} + +export const decodeCustomState = (fullPath: UrlTree) => { + const returnObj: Record<string, string> = {} + + const pathFragments: UrlSegment[] = fullPath.root.hasChildren() + ? fullPath.root.children['primary'].segments + : [] + + for (const f of pathFragments) { + if (!verifyCustomState(f.path)) continue + const { key, val } = decodePath(f.path) || {} + if (!key || !val) continue + returnObj[key] = val[0] + } + return returnObj +} + +export const encodeCustomState = (key: string, value: string) => { + if (!verifyCustomState(key)) { + throw new Error(`custom state must start with x-`) + } + if (!value) return null + return endcodePath(key, value) +} + @Component({ template: '' }) diff --git a/src/share/saneUrl/saneUrl.component.spec.ts b/src/share/saneUrl/saneUrl.component.spec.ts index 4b830bb1aac5339f3a27cf9963328be5d9cd45c1..5ab173950c92148144b7b3025f63cddf95205a30 100644 --- a/src/share/saneUrl/saneUrl.component.spec.ts +++ b/src/share/saneUrl/saneUrl.component.spec.ts @@ -1,26 +1,49 @@ -import { TestBed, async, fakeAsync, tick, flush } from '@angular/core/testing' +import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing' import { ShareModule } from '../share.module' import { SaneUrl } from './saneUrl.component' import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing' import { By } from '@angular/platform-browser' import { BACKENDURL } from 'src/util/constants' import { NoopAnimationsModule } from '@angular/platform-browser/animations' +import { SaneUrlSvc } from './saneUrl.service' +import { AngularMaterialModule } from 'src/sharedModules' +import { CUSTOM_ELEMENTS_SCHEMA, Directive } from '@angular/core' +import { of } from 'rxjs' const inputCss = `input[aria-label="Custom link"]` const submitCss = `button[aria-label="Create custom link"]` const copyBtnCss = `button[aria-label="Copy created custom URL to clipboard"]` +@Directive({ + selector: '[iav-auth-auth-state]', + exportAs: 'iavAuthAuthState' +}) + +class AuthStateDummy { + user$ = of(null) +} + describe('> saneUrl.component.ts', () => { describe('> SaneUrl', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [ - ShareModule, HttpClientTestingModule, NoopAnimationsModule, + AngularMaterialModule, + ], + providers: [ + SaneUrlSvc, + ], + declarations: [ + SaneUrl, + AuthStateDummy + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA ] }).compileComponents() - })) + }) afterEach(() => { const ctrl = TestBed.inject(HttpTestingController) @@ -65,8 +88,6 @@ describe('> saneUrl.component.ts', () => { fixture.detectChanges() const input = fixture.debugElement.query( By.css( inputCss ) ) - const invalid = input.attributes['aria-invalid'] - expect(invalid.toString()).toEqual('true') })) @@ -89,8 +110,6 @@ describe('> saneUrl.component.ts', () => { fixture.detectChanges() const input = fixture.debugElement.query( By.css( inputCss ) ) - const invalid = input.attributes['aria-invalid'] - expect(invalid.toString()).toEqual('false') }) it('> on entering string in input, makes debounced GET request', fakeAsync(() => { @@ -139,8 +158,6 @@ describe('> saneUrl.component.ts', () => { fixture.detectChanges() const input = fixture.debugElement.query( By.css( inputCss ) ) - const invalid = input.attributes['aria-invalid'] - expect(invalid.toString()).toEqual('true') const submit = fixture.debugElement.query( By.css( submitCss ) ) const disabled = !!submit.attributes['disabled'] @@ -173,8 +190,6 @@ describe('> saneUrl.component.ts', () => { fixture.detectChanges() const input = fixture.debugElement.query( By.css( inputCss ) ) - const invalid = input.attributes['aria-invalid'] - expect(invalid.toString()).toEqual('false') const submit = fixture.debugElement.query( By.css( submitCss ) ) const disabled = !!submit.attributes['disabled'] @@ -207,8 +222,6 @@ describe('> saneUrl.component.ts', () => { fixture.detectChanges() const input = fixture.debugElement.query( By.css( inputCss ) ) - const invalid = input.attributes['aria-invalid'] - expect(invalid.toString()).toEqual('true') const submit = fixture.debugElement.query( By.css( submitCss ) ) const disabled = !!submit.attributes['disabled'] @@ -307,8 +320,6 @@ describe('> saneUrl.component.ts', () => { fixture.detectChanges() const input = fixture.debugElement.query( By.css( inputCss ) ) - const invalid = input.attributes['aria-invalid'] - expect(invalid.toString()).toEqual('true') })) }) diff --git a/src/share/saneUrl/saneUrl.component.ts b/src/share/saneUrl/saneUrl.component.ts index c397478f5b62b0a7574d3b4b00a5d95fc8e7253a..dbcbb722efd73c5d9f8a15dc4e6c282901ff7cfa 100644 --- a/src/share/saneUrl/saneUrl.component.ts +++ b/src/share/saneUrl/saneUrl.component.ts @@ -1,12 +1,12 @@ import { Component, OnDestroy, Input } from "@angular/core"; -import { HttpClient } from '@angular/common/http' -import { BACKENDURL } from 'src/util/constants' import { Observable, merge, of, Subscription, BehaviorSubject, combineLatest } from "rxjs"; import { startWith, mapTo, map, debounceTime, switchMap, catchError, shareReplay, filter, tap, takeUntil, distinctUntilChanged } from "rxjs/operators"; import { FormControl } from "@angular/forms"; import { ErrorStateMatcher } from "@angular/material/core"; import { Clipboard } from "@angular/cdk/clipboard"; import { MatSnackBar } from "@angular/material/snack-bar"; +import { SaneUrlSvc } from "./saneUrl.service"; +import { NotFoundError } from "../type"; export class SaneUrlErrorStateMatcher implements ErrorStateMatcher{ isErrorState(ctrl: FormControl | null){ @@ -43,6 +43,8 @@ enum ESavingStatus { export class SaneUrl implements OnDestroy{ + public saneUrlRoot = this.svc.saneUrlRoot + @Input() stateTobeSaved: any private subscriptions: Subscription[] = [] @@ -62,9 +64,9 @@ export class SaneUrl implements OnDestroy{ public saved$: Observable<boolean> constructor( - private http: HttpClient, private clipboard: Clipboard, private snackbar: MatSnackBar, + private svc: SaneUrlSvc, ){ const validatedValueInput$ = this.customUrl.valueChanges.pipe( @@ -84,11 +86,10 @@ export class SaneUrl implements OnDestroy{ debounceTime(500), switchMap(val => val === '' ? of(false) - : this.http.get(`${this.saneUrlRoot}${val}`).pipe( + : this.svc.getKeyVal(val).pipe( mapTo(false), catchError((err, obs) => { - const { status } = err - if (status === 404) return of(true) + if (err instanceof NotFoundError) return of(true) return of(false) }) ) @@ -105,10 +106,10 @@ export class SaneUrl implements OnDestroy{ ) ) - this.btnHintTxt$ = combineLatest( + this.btnHintTxt$ = combineLatest([ this.savingStatus$, this.savingProgress$, - ).pipe( + ]).pipe( map(([savingStatus, savingProgress]) => { if (savingProgress === ESavingProgress.DONE) return EBtnTxt.CREATED if (savingProgress === ESavingProgress.INPROGRESS) return EBtnTxt.CREATING @@ -123,10 +124,10 @@ export class SaneUrl implements OnDestroy{ startWith(true) ) - this.iconClass$ = combineLatest( + this.iconClass$ = combineLatest([ this.savingStatus$, this.savingProgress$, - ).pipe( + ]).pipe( map(([savingStatus, savingProgress]) => { if (savingProgress === ESavingProgress.DONE) return `fas fa-check` if (savingProgress === ESavingProgress.INPROGRESS) return `fas fa-spinner fa-spin` @@ -159,8 +160,8 @@ export class SaneUrl implements OnDestroy{ saveLink(){ this.savingProgress$.next(ESavingProgress.INPROGRESS) this.customUrl.disable() - this.http.post( - `${this.saneUrlRoot}${this.customUrl.value}`, + this.svc.setKeyVal( + this.customUrl.value, this.stateTobeSaved ).subscribe( resp => { @@ -184,6 +185,4 @@ export class SaneUrl implements OnDestroy{ { duration: 1000 } ) } - - public saneUrlRoot = `${BACKENDURL}saneUrl/` } diff --git a/src/share/saneUrl/saneUrl.service.ts b/src/share/saneUrl/saneUrl.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f3af1f3d55fcb9baec57d44ddbbda76ac24c2df --- /dev/null +++ b/src/share/saneUrl/saneUrl.service.ts @@ -0,0 +1,43 @@ +import { HttpClient } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { throwError } from "rxjs"; +import { catchError, mapTo } from "rxjs/operators"; +import { BACKENDURL } from 'src/util/constants' +import { IKeyValStore, NotFoundError } from '../type' + +@Injectable({ + providedIn: 'root' +}) + +export class SaneUrlSvc implements IKeyValStore{ + public saneUrlRoot = `${BACKENDURL}saneUrl/` + constructor( + private http: HttpClient + ){ + + } + + getKeyVal(key: string) { + return this.http.get<Record<string, any>>( + `${this.saneUrlRoot}${key}`, + { responseType: 'json' } + ).pipe( + catchError((err, obs) => { + const { status } = err + if (status === 404) { + return throwError(new NotFoundError('Not found')) + } + return throwError(err) + }) + ) + } + + setKeyVal(key: string, value: any) { + return this.http.post( + `${this.saneUrlRoot}${key}`, + value + ).pipe( + mapTo(`${this.saneUrlRoot}${key}`) + ) + } +} diff --git a/src/share/saneUrl/saneUrl.template.html b/src/share/saneUrl/saneUrl.template.html index a673dcef78ef91587d47cf9406af51af7de8b151..cd191f957b3e6e59a687bd37593d5058238cfed0 100644 --- a/src/share/saneUrl/saneUrl.template.html +++ b/src/share/saneUrl/saneUrl.template.html @@ -1,3 +1,36 @@ +<div iav-auth-auth-state + #authState="iavAuthAuthState"> + +<!-- Logged in. Explain that links will not expire, offer to logout --> +<ng-container *ngIf="authState.user$ | async as user; else otherTmpl"> + <span> + Logged in as {{ user.name }} + </span> + <button mat-button + color="warn" + tabindex="-1"> + <i class="fas fa-sign-in-alt"></i> + <span> + Logout + </span> + </button> +</ng-container> + +<!-- Not logged in. Offer to login --> +<ng-template #otherTmpl> + <span> + Not logged in + </span> + <signin-modal></signin-modal> +</ng-template> +</div> + +<!-- explain links expiration --> +<div class="text-muted mat-small"> +{{ (authState.user$ | async) ? 'Links you generate will not expire' : 'Links you generate will expire after 72 hours' }} +</div> + + <mat-form-field class="mr-2"> <span matPrefix class="text-muted"> {{ saneUrlRoot }} diff --git a/src/share/share.module.ts b/src/share/share.module.ts index 28e16704f18648e8a4a554d1ad63d0f50e14a1c6..9634e12279dd09f165c6a6d4fd34653cbf52550f 100644 --- a/src/share/share.module.ts +++ b/src/share/share.module.ts @@ -5,6 +5,7 @@ import { HttpClientModule } from "@angular/common/http"; import { SaneUrl } from "./saneUrl/saneUrl.component"; import { CommonModule } from "@angular/common"; import { ReactiveFormsModule, FormsModule } from "@angular/forms"; +import { AuthModule } from "src/auth"; @NgModule({ imports: [ @@ -13,6 +14,7 @@ import { ReactiveFormsModule, FormsModule } from "@angular/forms"; CommonModule, FormsModule, ReactiveFormsModule, + AuthModule, ], declarations: [ ClipboardCopy, diff --git a/src/share/type.ts b/src/share/type.ts new file mode 100644 index 0000000000000000000000000000000000000000..ead6dfdbdb8c409f6051532bfec218cda0625d46 --- /dev/null +++ b/src/share/type.ts @@ -0,0 +1,8 @@ +import { Observable } from "rxjs"; + +export class NotFoundError extends Error{} + +export interface IKeyValStore { + getKeyVal(key: string): Observable<unknown> + setKeyVal(key: string, val: unknown): Observable<void|string> +} diff --git a/src/state/stateAggregator.directive.ts b/src/state/stateAggregator.directive.ts index 62d66666227e91a18bd1aac9ba2309d7a5c43a8d..28df9bf19c5000333fbe228743b1495932f117fd 100644 --- a/src/state/stateAggregator.directive.ts +++ b/src/state/stateAggregator.directive.ts @@ -1,14 +1,14 @@ -import { Directive } from "@angular/core"; +import { Directive, OnDestroy } from "@angular/core"; import { NavigationEnd, Router } from "@angular/router"; -import { Observable } from "rxjs"; -import { filter, map } from "rxjs/operators"; +import { Observable, Subscription } from "rxjs"; +import { filter, map, startWith } from "rxjs/operators"; const jsonVersion = '1.0.0' // ver 0.0.1 === query param interface IJsonifiedState { ver: string - queryString: any + hashPath: string } @Directive({ @@ -16,20 +16,30 @@ interface IJsonifiedState { exportAs: 'iavStateAggregator' }) -export class StateAggregator{ +export class StateAggregator implements OnDestroy{ - public jsonifiedSstate$: Observable<IJsonifiedState> + public jsonifiedState: IJsonifiedState + public jsonifiedState$: Observable<IJsonifiedState> = this.router.events.pipe( + filter(ev => ev instanceof NavigationEnd), + map((ev: NavigationEnd) => ev.urlAfterRedirects), + startWith(this.router.url), + map((path: string) => { + return { + ver: jsonVersion, + hashPath: path + } + }), + ) constructor( - router: Router + private router: Router ){ - this.jsonifiedSstate$ = router.events.pipe( - filter(ev => ev instanceof NavigationEnd), - map((ev: NavigationEnd) => { - return { - ver: jsonVersion, - queryString: ev.urlAfterRedirects - } - }) + this.subscriptions.push( + this.jsonifiedState$.subscribe(val => this.jsonifiedState = val) ) } + + private subscriptions: Subscription[] = [] + ngOnDestroy(){ + while (this.subscriptions.length) this.subscriptions.pop().unsubscribe() + } } diff --git a/src/util/mergeObj.pipe.ts b/src/util/mergeObj.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..104592d6732bcadff54c8a5a92a32405e40f6025 --- /dev/null +++ b/src/util/mergeObj.pipe.ts @@ -0,0 +1,17 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +type TObj = Record<string, any> + +@Pipe({ + name: 'mergeObj', + pure: true +}) + +export class MergeObjPipe implements PipeTransform{ + public transform(o1: TObj, o2: TObj){ + return { + ...o1, + ...o2 + } + } +} diff --git a/src/util/util.module.ts b/src/util/util.module.ts index 4d817736e98a231bfde294dd19b751fd44f3db63..bde4b907f1d01f911d5d9886ed967ba5b4698f3f 100644 --- a/src/util/util.module.ts +++ b/src/util/util.module.ts @@ -20,6 +20,7 @@ import { GetPropertyPipe } from "./pipes/getProperty.pipe"; import { FilterArrayPipe } from "./pipes/filterArray.pipe"; import { DoiParserPipe } from "./pipes/doiPipe.pipe"; import { GetFilenamePipe } from "./pipes/getFilename.pipe"; +import { MergeObjPipe } from "./mergeObj.pipe"; @NgModule({ imports:[ @@ -45,6 +46,7 @@ import { GetFilenamePipe } from "./pipes/getFilename.pipe"; FilterArrayPipe, DoiParserPipe, GetFilenamePipe, + MergeObjPipe, ], exports: [ FilterRowsByVisbilityPipe, @@ -66,6 +68,7 @@ import { GetFilenamePipe } from "./pipes/getFilename.pipe"; FilterArrayPipe, DoiParserPipe, GetFilenamePipe, + MergeObjPipe, ] }) diff --git a/src/viewerModule/nehuba/statusCard/statusCard.component.ts b/src/viewerModule/nehuba/statusCard/statusCard.component.ts index 76dfa6bbbdb0288b6755984fa651d71b3e50c5df..a6daa233d6acdc5197d19d4a9dbc284c3e26f329 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.component.ts +++ b/src/viewerModule/nehuba/statusCard/statusCard.component.ts @@ -57,8 +57,6 @@ export class StatusCardComponent implements OnInit, OnChanges{ order: 6, } - public saneUrlDeprecated = `Custom URL is going away. New custom URLs can no longer be created. Custom URLs you generated in the past will continue to work.` - public SHARE_BTN_ARIA_LABEL = ARIA_LABELS.SHARE_BTN public COPY_URL_TO_CLIPBOARD_ARIA_LABEL = ARIA_LABELS.SHARE_COPY_URL_CLIPBOARD public SHARE_CUSTOM_URL_ARIA_LABEL = ARIA_LABELS.SHARE_CUSTOM_URL diff --git a/src/viewerModule/nehuba/statusCard/statusCard.template.html b/src/viewerModule/nehuba/statusCard/statusCard.template.html index 5f57d9719055a265dbfb005c5c4d7d3cc46ab1ea..69d2a44547d2761e2c20c96f5a7d1bb155dcf535 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.template.html +++ b/src/viewerModule/nehuba/statusCard/statusCard.template.html @@ -157,8 +157,9 @@ <mat-list-item [attr.aria-label]="SHARE_CUSTOM_URL_ARIA_LABEL" [attr.tab-index]="10" - [matTooltip]="saneUrlDeprecated" - class="text-muted"> + (click)="openDialog(shareSaneUrl, { ariaLabel: SHARE_CUSTOM_URL_ARIA_LABEL })" + [matTooltip]="SHARE_CUSTOM_URL_ARIA_LABEL" + > <mat-icon class="mr-4" fontSet="fas" @@ -179,40 +180,8 @@ </h2> <div mat-dialog-content> - <div iav-auth-auth-state - #authState="iavAuthAuthState"> - - <!-- Logged in. Explain that links will not expire, offer to logout --> - <ng-container *ngIf="authState.user$ | async as user; else otherTmpl"> - <span> - Logged in as {{ user.name }} - </span> - <button mat-button - color="warn" - tabindex="-1"> - <i class="fas fa-sign-in-alt"></i> - <span> - Logout - </span> - </button> - </ng-container> - - <!-- Not logged in. Offer to login --> - <ng-template #otherTmpl> - <span> - Not logged in - </span> - <signin-modal></signin-modal> - </ng-template> - </div> - - <!-- explain links expiration --> - <div class="text-muted mat-small"> - {{ (authState.user$ | async) ? 'Links you generate will not expire' : 'Links you generate will expire after 72 hours' }} - </div> - <iav-sane-url iav-state-aggregator - [stateTobeSaved]="stateAggregator.jsonifiedSstate$ | async" + [stateTobeSaved]="stateAggregator.jsonifiedState$ | async" #stateAggregator="iavStateAggregator"> </iav-sane-url> </div>