first commit
This commit is contained in:
78
services/web/frontend/js/utils/EventEmitter.ts
Normal file
78
services/web/frontend/js/utils/EventEmitter.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// Simple event emitter implementation, but has a slightly unusual API for
|
||||
// removing specific listeners. If a specific listener needs to be removed
|
||||
// (instead of all listeners), then it needs to use a "namespace":
|
||||
// Create a listener on the foo event with bar namespace: .on 'foo.bar'
|
||||
// Trigger all events for the foo event (including namespaces): .trigger 'foo'
|
||||
// Remove all listeners for the foo event (including namespaces): .off 'foo'
|
||||
// Remove a listener for the foo event with the bar namespace: .off 'foo.bar'
|
||||
|
||||
export default class EventEmitter {
|
||||
events: Record<
|
||||
string,
|
||||
{
|
||||
callback: (...args: any[]) => void
|
||||
namespace: string
|
||||
}[]
|
||||
>
|
||||
|
||||
constructor() {
|
||||
this.events = {}
|
||||
}
|
||||
|
||||
on(event: string, callback: (...args: any[]) => void) {
|
||||
if (!this.events) {
|
||||
this.events = {}
|
||||
}
|
||||
let namespace
|
||||
;[event, namespace] = Array.from(event.split('.'))
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
this.events[event].push({
|
||||
callback,
|
||||
namespace,
|
||||
})
|
||||
}
|
||||
|
||||
off(event?: string, callback?: (...args: any[]) => void) {
|
||||
if (!this.events) {
|
||||
this.events = {}
|
||||
}
|
||||
if (event) {
|
||||
let namespace
|
||||
;[event, namespace] = event.split('.')
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
if (callback) {
|
||||
this.events[event] = this.events[event].filter(
|
||||
e => e.callback !== callback
|
||||
)
|
||||
} else if (!namespace) {
|
||||
// Clear all listeners for event
|
||||
delete this.events[event]
|
||||
} else {
|
||||
// Clear only namespaced listeners
|
||||
this.events[event] = this.events[event].filter(
|
||||
e => e.namespace !== namespace
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Remove all listeners
|
||||
this.events = {}
|
||||
}
|
||||
}
|
||||
|
||||
trigger(event: string, ...args: any[]) {
|
||||
if (!this.events) {
|
||||
this.events = {}
|
||||
}
|
||||
if (this.events[event]) {
|
||||
this.events[event].forEach(e => e.callback(...args))
|
||||
}
|
||||
}
|
||||
|
||||
emit(event: string, ...args: any[]) {
|
||||
this.trigger(event, ...args)
|
||||
}
|
||||
}
|
25
services/web/frontend/js/utils/abort-signal.ts
Normal file
25
services/web/frontend/js/utils/abort-signal.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const supportsModernAbortSignal =
|
||||
typeof AbortSignal.any === 'function' &&
|
||||
typeof AbortSignal.timeout === 'function'
|
||||
|
||||
export const signalWithTimeout = (signal: AbortSignal, timeout: number) => {
|
||||
if (supportsModernAbortSignal) {
|
||||
return AbortSignal.any([signal, AbortSignal.timeout(timeout)])
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
const abort = () => {
|
||||
window.clearTimeout(timer)
|
||||
signal.removeEventListener('abort', abort)
|
||||
abortController.abort()
|
||||
}
|
||||
|
||||
// abort after timeout has expired
|
||||
const timer = window.setTimeout(abort, timeout)
|
||||
|
||||
// abort when the original signal is aborted
|
||||
signal.addEventListener('abort', abort)
|
||||
|
||||
return abortController.signal
|
||||
}
|
13
services/web/frontend/js/utils/dates.ts
Normal file
13
services/web/frontend/js/utils/dates.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import moment from 'moment'
|
||||
|
||||
export function formatDate(date: moment.MomentInput, format?: string) {
|
||||
if (!date) return 'N/A'
|
||||
if (format == null) {
|
||||
format = 'Do MMM YYYY, h:mm a'
|
||||
}
|
||||
return moment(date).format(format)
|
||||
}
|
||||
|
||||
export function fromNowDate(date: moment.MomentInput | string) {
|
||||
return moment(date).fromNow()
|
||||
}
|
13
services/web/frontend/js/utils/debugging.ts
Normal file
13
services/web/frontend/js/utils/debugging.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable no-console */
|
||||
type DebugConsole = {
|
||||
debug(...data: any[]): void
|
||||
log(...data: any[]): void
|
||||
warn(...data: any[]): void
|
||||
error(...data: any[]): void
|
||||
}
|
||||
|
||||
export const debugging =
|
||||
new URLSearchParams(window.location.search).get('debug') === 'true'
|
||||
export const debugConsole: DebugConsole = debugging
|
||||
? console
|
||||
: { debug() {}, log() {}, warn: console.warn, error: console.error }
|
5
services/web/frontend/js/utils/decode-utf8.ts
Normal file
5
services/web/frontend/js/utils/decode-utf8.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// TODO: MIGRATION: Can we use TextDecoder now? https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder
|
||||
// See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
|
||||
export function decodeUtf8(text: string) {
|
||||
return decodeURIComponent(escape(text))
|
||||
}
|
6
services/web/frontend/js/utils/functions.ts
Normal file
6
services/web/frontend/js/utils/functions.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function callFnsInSequence<
|
||||
Args extends Array<any>,
|
||||
Fn extends ((...args: Args) => void) | void,
|
||||
>(...fns: Fn[]) {
|
||||
return (...args: Args) => fns.forEach(fn => fn?.(...args))
|
||||
}
|
8
services/web/frontend/js/utils/is-ieee-branded.ts
Normal file
8
services/web/frontend/js/utils/is-ieee-branded.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export function isIEEEBranded() {
|
||||
const brandVariation = getMeta('ol-brandVariation')
|
||||
const { ieeeBrandId } = getMeta('ol-ExposedSettings')
|
||||
|
||||
return brandVariation?.brand_id === ieeeBrandId
|
||||
}
|
73
services/web/frontend/js/utils/is-network-error.ts
Normal file
73
services/web/frontend/js/utils/is-network-error.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
const NETWORK_ERRORS = [
|
||||
// fetch
|
||||
'Failed to fetch',
|
||||
// fetch
|
||||
'NetworkError when attempting to fetch resource.',
|
||||
// download body
|
||||
'Load failed',
|
||||
// dns
|
||||
'A server with the specified hostname could not be found.',
|
||||
'Es wurde kein Server mit dem angegebenen Hostnamen gefunden.',
|
||||
'Impossibile trovare un server con il nome host specificato.',
|
||||
'未能找到使用指定主机名的服务器。',
|
||||
// offline
|
||||
'The Internet connection appears to be offline.',
|
||||
'Internetanslutningen verkar vara nedkopplad.',
|
||||
// connection error
|
||||
'Could not connect to the server.',
|
||||
'Impossible de se connecter au serveur.',
|
||||
'Verbindung zum Server konnte nicht hergestellt werden.',
|
||||
'Не удалось подключиться к серверу.',
|
||||
'يبدو أنه لا يوجد اتصال بالإنترنت.',
|
||||
'无法连接服务器。',
|
||||
// disconnected
|
||||
'The network connection was lost.',
|
||||
'A conexão de rede foi perdida.',
|
||||
'A hálózati kapcsolat megszakadt.',
|
||||
'A ligação de rede foi cortada.',
|
||||
'Ağ bağlantısı kesildi.',
|
||||
'Conexiunea de rețea a fost pierdută.',
|
||||
'De netwerkverbinding is verbroken.',
|
||||
'Die Netzwerkverbindung wurde unterbrochen.',
|
||||
'La conexión de red se ha perdido.',
|
||||
'La conexión de red se perdió.',
|
||||
'La connessione è stata persa.',
|
||||
'La connexion réseau a été perdue.',
|
||||
'La connexió de xarxa s’ha perdut.',
|
||||
'Mistet nettverksforbindelsen.',
|
||||
'Netværksforbindelsen gik tabt.',
|
||||
'Nätverksanslutningen förlorades.',
|
||||
'Połączenie sieciowe zostało przerwane.',
|
||||
'Veza s mrežom je prekinuta.',
|
||||
'la connessione è stata persa.',
|
||||
'Đã mất kết nối mạng.',
|
||||
'Сетевое соединение потеряно.',
|
||||
'החיבור לרשת אבד.',
|
||||
'تم فقدان اتصال الشبكة.',
|
||||
'キャンセルしました',
|
||||
'ネットワーク接続が切れました。',
|
||||
'已取消',
|
||||
'網絡連線中斷。',
|
||||
'網路連線中斷。',
|
||||
// slow network
|
||||
'The request timed out.',
|
||||
'Begäran nådde en maxtidsgräns.',
|
||||
'Esgotou-se o tempo limite da solicitação.',
|
||||
'Il tempo di attesa della richiesta è scaduto.',
|
||||
'La requête a expiré.',
|
||||
'Przekroczenie limitu czasu żądania.',
|
||||
'Se agotó el tiempo de espera.',
|
||||
'Se ha agotado el tiempo de espera.',
|
||||
'Tempo di richiesta scaduto.',
|
||||
'Temps esgotat per a la sol·licitud.',
|
||||
'Zeitüberschreitung bei der Anforderung.',
|
||||
'Превышен лимит времени на запрос.',
|
||||
'انتهت مهلة الطلب.',
|
||||
'データベースの要求が時間切れになりました。',
|
||||
'要求逾時。',
|
||||
'요청한 시간이 초과되었습니다.',
|
||||
]
|
||||
|
||||
export function isNetworkError(err?: Error) {
|
||||
return err && NETWORK_ERRORS.includes(err.message)
|
||||
}
|
10
services/web/frontend/js/utils/labs-utils.ts
Normal file
10
services/web/frontend/js/utils/labs-utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import getMeta from './meta'
|
||||
|
||||
// Should be `never` when no experiments are active. Otherwise it should be a
|
||||
// union of active experiment names e.g. `'experiment1' | 'experiment2'`
|
||||
export type ActiveExperiment = 'editor-redesign'
|
||||
|
||||
export const isInExperiment = (experiment: ActiveExperiment): boolean => {
|
||||
const experiments = getMeta('ol-labsExperiments')
|
||||
return Boolean(experiments?.includes(experiment))
|
||||
}
|
303
services/web/frontend/js/utils/meta.ts
Normal file
303
services/web/frontend/js/utils/meta.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { User, Features } from '../../../types/user'
|
||||
import { User as MinimalUser } from '../../../types/admin/user'
|
||||
import { User as ManagedUser } from '../../../types/group-management/user'
|
||||
import { UserSettings } from '../../../types/user-settings'
|
||||
import { OAuthProviders } from '../../../types/oauth-providers'
|
||||
import { ExposedSettings } from '../../../types/exposed-settings'
|
||||
import {
|
||||
type AllowedImageName,
|
||||
OverallThemeMeta,
|
||||
type SpellCheckLanguage,
|
||||
} from '../../../types/project-settings'
|
||||
import { CurrencyCode } from '../../../types/subscription/currency'
|
||||
import { PricingFormState } from '../../../types/subscription/payment-context-value'
|
||||
import { Plan } from '../../../types/subscription/plan'
|
||||
import { Affiliation } from '../../../types/affiliation'
|
||||
import type { PortalTemplate } from '../../../types/portal-template'
|
||||
import { UserEmailData } from '../../../types/user-email'
|
||||
import {
|
||||
GroupsAndEnterpriseBannerVariant,
|
||||
Institution as InstitutionType,
|
||||
Notification as NotificationType,
|
||||
PendingGroupSubscriptionEnrollment,
|
||||
USGovBannerVariant,
|
||||
} from '../../../types/project/dashboard/notification'
|
||||
import { Survey } from '../../../types/project/dashboard/survey'
|
||||
import { GetProjectsResponseBody } from '../../../types/project/dashboard/api'
|
||||
import { Tag } from '../../../app/src/Features/Tags/types'
|
||||
import { Institution } from '../../../types/institution'
|
||||
import {
|
||||
GroupPolicy,
|
||||
ManagedGroupSubscription,
|
||||
MemberGroupSubscription,
|
||||
} from '../../../types/subscription/dashboard/subscription'
|
||||
import { SplitTestInfo } from '../../../types/split-test'
|
||||
import { ValidationStatus } from '../../../types/group-management/validation'
|
||||
import { ManagedInstitution } from '../../../types/subscription/dashboard/managed-institution'
|
||||
import { GroupSSOTestResult } from '../../../modules/group-settings/frontend/js/utils/types'
|
||||
import {
|
||||
AccessToken,
|
||||
InstitutionLink,
|
||||
SAMLError,
|
||||
} from '../../../types/settings-page'
|
||||
import { SuggestedLanguage } from '../../../types/system-message'
|
||||
import type { TeamInvite } from '../../../types/team-invite'
|
||||
import { GroupPlans } from '../../../types/subscription/dashboard/group-plans'
|
||||
import { GroupSSOLinkingStatus } from '../../../types/subscription/sso'
|
||||
import { PasswordStrengthOptions } from '../../../types/password-strength-options'
|
||||
import { Subscription as ProjectDashboardSubscription } from '../../../types/project/dashboard/subscription'
|
||||
import { ThirdPartyIds } from '../../../types/third-party-ids'
|
||||
import { Publisher } from '../../../types/subscription/dashboard/publisher'
|
||||
import { SubscriptionChangePreview } from '../../../types/subscription/subscription-change-preview'
|
||||
import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
|
||||
import { FooterMetadata } from '@/features/ui/components/types/footer-metadata'
|
||||
import type { ScriptLogType } from '../../../modules/admin-panel/frontend/js/features/script-logs/script-log'
|
||||
import { ActiveExperiment } from './labs-utils'
|
||||
export interface Meta {
|
||||
'ol-ExposedSettings': ExposedSettings
|
||||
'ol-addonPrices': Record<string, { annual: string; monthly: string }>
|
||||
'ol-allInReconfirmNotificationPeriods': UserEmailData[]
|
||||
'ol-allowedExperiments': string[]
|
||||
'ol-allowedImageNames': AllowedImageName[]
|
||||
'ol-anonymous': boolean
|
||||
'ol-baseAssetPath': string
|
||||
'ol-bootstrapVersion': 3 | 5
|
||||
'ol-brandVariation': Record<string, any>
|
||||
|
||||
// dynamic keys based on permissions
|
||||
'ol-canUseAddSeatsFeature': boolean
|
||||
'ol-canUseFlexibleLicensing': boolean
|
||||
'ol-canUseFlexibleLicensingForConsolidatedPlans': boolean
|
||||
'ol-cannot-add-secondary-email': boolean
|
||||
'ol-cannot-change-password': boolean
|
||||
'ol-cannot-delete-own-account': boolean
|
||||
'ol-cannot-join-subscription': boolean
|
||||
'ol-cannot-leave-group-subscription': boolean
|
||||
'ol-cannot-link-google-sso': boolean
|
||||
'ol-cannot-link-other-third-party-sso': boolean
|
||||
'ol-cannot-reactivate-subscription': boolean
|
||||
'ol-cannot-use-ai': boolean
|
||||
'ol-chatEnabled': boolean
|
||||
'ol-compilesUserContentDomain': string
|
||||
'ol-countryCode': PricingFormState['country']
|
||||
'ol-couponCode': PricingFormState['coupon']
|
||||
'ol-createdAt': Date
|
||||
'ol-csrfToken': string
|
||||
'ol-currentInstitutionsWithLicence': Institution[]
|
||||
'ol-currentManagedUserAdminEmail': string
|
||||
'ol-currentUrl': string
|
||||
'ol-customerIoEnabled': boolean
|
||||
'ol-debugPdfDetach': boolean
|
||||
'ol-detachRole': 'detached' | 'detacher' | ''
|
||||
'ol-dictionariesRoot': 'string'
|
||||
'ol-dropbox': { error: boolean; registered: boolean }
|
||||
'ol-editorThemes': string[]
|
||||
'ol-email': string
|
||||
'ol-emailAddressLimit': number
|
||||
'ol-error': { name: string } | undefined
|
||||
'ol-expired': boolean
|
||||
'ol-features': Features
|
||||
'ol-footer': FooterMetadata
|
||||
'ol-fromPlansPage': boolean
|
||||
'ol-galleryTagName': string
|
||||
'ol-gitBridgeEnabled': boolean
|
||||
'ol-gitBridgePublicBaseUrl': string
|
||||
'ol-github': { enabled: boolean; error: boolean }
|
||||
'ol-groupId': string
|
||||
'ol-groupName': string
|
||||
'ol-groupPlans': GroupPlans
|
||||
'ol-groupPolicy': GroupPolicy
|
||||
'ol-groupSSOActive': boolean
|
||||
'ol-groupSSOTestResult': GroupSSOTestResult
|
||||
'ol-groupSettingsAdvertisedFor': string[]
|
||||
'ol-groupSettingsEnabledFor': string[]
|
||||
'ol-groupSize': number
|
||||
'ol-groupSsoSetupSuccess': boolean
|
||||
'ol-groupSubscriptionsPendingEnrollment': PendingGroupSubscriptionEnrollment[]
|
||||
'ol-groupsAndEnterpriseBannerVariant': GroupsAndEnterpriseBannerVariant
|
||||
'ol-hasAiAssistViaWritefull': boolean
|
||||
'ol-hasGroupSSOFeature': boolean
|
||||
'ol-hasIndividualRecurlySubscription': boolean
|
||||
'ol-hasManagedUsersFeature': boolean
|
||||
'ol-hasPassword': boolean
|
||||
'ol-hasSubscription': boolean
|
||||
'ol-hasTrackChangesFeature': boolean
|
||||
'ol-hideLinkingWidgets': boolean // CI only
|
||||
'ol-i18n': { currentLangCode: string }
|
||||
'ol-inactiveTutorials': string[]
|
||||
'ol-institutionEmailNonCanonical': string | undefined
|
||||
'ol-institutionLinked': InstitutionLink | undefined
|
||||
'ol-inviteToken': string
|
||||
'ol-inviterName': string
|
||||
'ol-isExternalAuthenticationSystemUsed': boolean
|
||||
'ol-isManagedAccount': boolean
|
||||
'ol-isPaywallChangeCompileTimeoutEnabled': boolean
|
||||
'ol-isProfessional': boolean
|
||||
'ol-isRegisteredViaGoogle': boolean
|
||||
'ol-isRestrictedTokenMember': boolean
|
||||
'ol-isReviewerRoleEnabled': boolean
|
||||
'ol-isSaas': boolean
|
||||
'ol-itm_campaign': string
|
||||
'ol-itm_content': string
|
||||
'ol-itm_referrer': string
|
||||
'ol-labs': boolean
|
||||
'ol-labsExperiments': ActiveExperiment[] | undefined
|
||||
'ol-languages': SpellCheckLanguage[]
|
||||
'ol-learnedWords': string[]
|
||||
'ol-legacyEditorThemes': string[]
|
||||
'ol-licenseQuantity': number | undefined
|
||||
'ol-loadingText': string
|
||||
'ol-managedGroupSubscriptions': ManagedGroupSubscription[]
|
||||
'ol-managedInstitutions': ManagedInstitution[]
|
||||
'ol-managedPublishers': Publisher[]
|
||||
'ol-managedUsersActive': boolean
|
||||
'ol-managedUsersEnabled': boolean
|
||||
'ol-managers': MinimalUser[]
|
||||
'ol-mathJaxPath': string
|
||||
'ol-maxDocLength': number
|
||||
'ol-maxReconnectGracefullyIntervalMs': number
|
||||
'ol-memberGroupSubscriptions': MemberGroupSubscription[]
|
||||
'ol-memberOfSSOEnabledGroups': GroupSSOLinkingStatus[]
|
||||
'ol-members': MinimalUser[]
|
||||
'ol-navbar': DefaultNavbarMetadata
|
||||
'ol-no-single-dollar': boolean
|
||||
'ol-notifications': NotificationType[]
|
||||
'ol-notificationsInstitution': InstitutionType[]
|
||||
'ol-oauthProviders': OAuthProviders
|
||||
'ol-odcRole': string
|
||||
'ol-overallThemes': OverallThemeMeta[]
|
||||
'ol-pages': number
|
||||
'ol-passwordStrengthOptions': PasswordStrengthOptions
|
||||
'ol-paywallPlans': { [key: string]: string }
|
||||
'ol-personalAccessTokens': AccessToken[] | undefined
|
||||
'ol-plan': Plan
|
||||
'ol-planCode': string
|
||||
'ol-planCodesChangingAtTermEnd': string[] | undefined
|
||||
'ol-plans': Plan[]
|
||||
'ol-portalTemplates': PortalTemplate[]
|
||||
'ol-postCheckoutRedirect': string
|
||||
'ol-postUrl': string
|
||||
'ol-prefetchedProjectsBlob': GetProjectsResponseBody | undefined
|
||||
'ol-preventCompileOnLoad'?: boolean
|
||||
'ol-primaryEmail': { email: string; confirmed: boolean }
|
||||
'ol-project': any // TODO
|
||||
'ol-projectHistoryBlobsEnabled': boolean
|
||||
'ol-projectName': string
|
||||
'ol-projectSyncSuccessMessage': string
|
||||
'ol-projectTags': Tag[]
|
||||
'ol-project_id': string
|
||||
'ol-recommendedCurrency': CurrencyCode
|
||||
'ol-reconfirmationRemoveEmail': string
|
||||
'ol-reconfirmedViaSAML': string
|
||||
'ol-recurlyApiKey': string
|
||||
'ol-recurlySubdomain': string
|
||||
'ol-ro-mirror-on-client-no-local-storage': boolean
|
||||
'ol-samlError': SAMLError | undefined
|
||||
'ol-script-log': ScriptLogType
|
||||
'ol-script-logs': ScriptLogType[]
|
||||
'ol-settingsGroupSSO': { enabled: boolean } | undefined
|
||||
'ol-settingsPlans': Plan[]
|
||||
'ol-shouldAllowEditingDetails': boolean
|
||||
'ol-shouldLoadHotjar': boolean
|
||||
'ol-showAiErrorAssistant': boolean
|
||||
'ol-showBrlGeoBanner': boolean
|
||||
'ol-showCouponField': boolean
|
||||
'ol-showGroupDiscount': boolean
|
||||
'ol-showGroupsAndEnterpriseBanner': boolean
|
||||
'ol-showInrGeoBanner': boolean
|
||||
'ol-showLATAMBanner': boolean
|
||||
'ol-showSupport': boolean
|
||||
'ol-showSymbolPalette': boolean
|
||||
'ol-showTemplatesServerPro': boolean
|
||||
'ol-showUSGovBanner': boolean
|
||||
'ol-showUpgradePrompt': boolean
|
||||
'ol-skipUrl': string
|
||||
'ol-splitTestInfo': { [name: string]: SplitTestInfo }
|
||||
'ol-splitTestVariants': { [name: string]: string }
|
||||
'ol-ssoDisabled': boolean
|
||||
'ol-ssoErrorMessage': string
|
||||
'ol-subscription': any // TODO: mixed types, split into two fields
|
||||
'ol-subscriptionChangePreview': SubscriptionChangePreview
|
||||
'ol-subscriptionId': string
|
||||
'ol-suggestedLanguage': SuggestedLanguage | undefined
|
||||
'ol-survey': Survey | undefined
|
||||
'ol-symbolPaletteAvailable': boolean
|
||||
'ol-tags': Tag[]
|
||||
'ol-teamInvites': TeamInvite[]
|
||||
'ol-thirdPartyIds': ThirdPartyIds
|
||||
'ol-totalLicenses': number
|
||||
'ol-translationIoNotLoaded': string
|
||||
'ol-translationLoadErrorMessage': string
|
||||
'ol-translationMaintenance': string
|
||||
'ol-translationUnableToJoin': string
|
||||
'ol-usGovBannerVariant': USGovBannerVariant
|
||||
'ol-useShareJsHash': boolean
|
||||
'ol-user': User
|
||||
'ol-userAffiliations': Affiliation[]
|
||||
'ol-userCanExtendTrial': boolean
|
||||
'ol-userCanNotStartRequestedTrial': boolean
|
||||
'ol-userEmails': UserEmailData[]
|
||||
'ol-userSettings': UserSettings
|
||||
'ol-user_id': string | undefined
|
||||
'ol-users': ManagedUser[]
|
||||
'ol-usersBestSubscription': ProjectDashboardSubscription | undefined
|
||||
'ol-usersEmail': string | undefined
|
||||
'ol-validationStatus': ValidationStatus
|
||||
'ol-wikiEnabled': boolean
|
||||
'ol-writefullCssUrl': string
|
||||
'ol-writefullEnabled': boolean
|
||||
'ol-writefullJsUrl': string
|
||||
'ol-wsUrl': string
|
||||
}
|
||||
|
||||
type DeepPartial<T> =
|
||||
T extends Record<string, any> ? { [P in keyof T]?: DeepPartial<T[P]> } : T
|
||||
|
||||
export type PartialMeta = DeepPartial<Meta>
|
||||
|
||||
export type MetaAttributesCache<
|
||||
K extends keyof PartialMeta = keyof PartialMeta,
|
||||
> = Map<K, PartialMeta[K]>
|
||||
|
||||
export type MetaTag = {
|
||||
[K in keyof Meta]: {
|
||||
name: K
|
||||
value: Meta[K]
|
||||
}
|
||||
}[keyof Meta]
|
||||
|
||||
// cache for parsed values
|
||||
window.metaAttributesCache = window.metaAttributesCache || new Map()
|
||||
|
||||
export default function getMeta<T extends keyof Meta>(name: T): Meta[T] {
|
||||
if (window.metaAttributesCache.has(name)) {
|
||||
return window.metaAttributesCache.get(name)
|
||||
}
|
||||
const element = document.head.querySelector(
|
||||
`meta[name="${name}"]`
|
||||
) as HTMLMetaElement
|
||||
if (!element) {
|
||||
return undefined!
|
||||
}
|
||||
const plainTextValue = element.content
|
||||
let value
|
||||
switch (element.dataset.type) {
|
||||
case 'boolean':
|
||||
// in pug: content=false -> no content field
|
||||
// in pug: content=true -> empty content field
|
||||
value = element.hasAttribute('content')
|
||||
break
|
||||
case 'json':
|
||||
if (!plainTextValue) {
|
||||
// JSON.parse('') throws
|
||||
value = undefined
|
||||
} else {
|
||||
value = JSON.parse(plainTextValue)
|
||||
}
|
||||
break
|
||||
default:
|
||||
value = plainTextValue
|
||||
}
|
||||
window.metaAttributesCache.set(name, value)
|
||||
return value
|
||||
}
|
22
services/web/frontend/js/utils/normalize.ts
Normal file
22
services/web/frontend/js/utils/normalize.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import mapKeys from 'lodash/mapKeys'
|
||||
|
||||
export interface NormalizedObject<T> {
|
||||
[p: string]: T
|
||||
}
|
||||
|
||||
type Data<T> = T[]
|
||||
type Config = Partial<{
|
||||
idAttribute: string
|
||||
}>
|
||||
|
||||
export function normalize<T>(
|
||||
data: Data<T>,
|
||||
config: Config = {}
|
||||
): NormalizedObject<T> | undefined {
|
||||
const { idAttribute = 'id' } = config
|
||||
const mapped = mapKeys(data, idAttribute)
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(mapped, 'undefined')
|
||||
? undefined
|
||||
: mapped
|
||||
}
|
43
services/web/frontend/js/utils/operations.ts
Normal file
43
services/web/frontend/js/utils/operations.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
AnyOperation,
|
||||
Change,
|
||||
CommentOperation,
|
||||
DeleteOperation,
|
||||
EditOperation,
|
||||
InsertOperation,
|
||||
Operation,
|
||||
} from '../../../types/change'
|
||||
|
||||
export const isInsertOperation = (op: Operation): op is InsertOperation =>
|
||||
'i' in op
|
||||
export const isCommentOperation = (op: Operation): op is CommentOperation =>
|
||||
'c' in op
|
||||
export const isDeleteOperation = (op: Operation): op is DeleteOperation =>
|
||||
'd' in op
|
||||
|
||||
export const isEditOperation = (op: Operation): op is EditOperation =>
|
||||
isInsertOperation(op) || isDeleteOperation(op)
|
||||
|
||||
export const isInsertChange = (
|
||||
change: Change<EditOperation>
|
||||
): change is Change<InsertOperation> => isInsertOperation(change.op)
|
||||
|
||||
export const isCommentChange = (
|
||||
change: Change<CommentOperation>
|
||||
): change is Change<CommentOperation> => isCommentOperation(change.op)
|
||||
|
||||
export const isDeleteChange = (
|
||||
change: Change<EditOperation>
|
||||
): change is Change<DeleteOperation> => isDeleteOperation(change.op)
|
||||
|
||||
export const visibleTextLength = (op: AnyOperation) => {
|
||||
if (isCommentOperation(op)) {
|
||||
return op.c.length
|
||||
}
|
||||
|
||||
if (isInsertOperation(op)) {
|
||||
return op.i.length
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
21
services/web/frontend/js/utils/react.ts
Normal file
21
services/web/frontend/js/utils/react.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export const fixedForwardRef = <
|
||||
T,
|
||||
P = object,
|
||||
A extends Record<string, React.FunctionComponent> = Record<
|
||||
string,
|
||||
React.FunctionComponent
|
||||
>,
|
||||
>(
|
||||
render: (props: P, ref: React.Ref<T>) => React.ReactElement | null,
|
||||
propsToAttach: A = {} as A
|
||||
): ((props: P & React.RefAttributes<T>) => React.ReactElement | null) & A => {
|
||||
const ForwardReferredComponent = forwardRef(render) as any
|
||||
|
||||
for (const i in propsToAttach) {
|
||||
ForwardReferredComponent[i] = propsToAttach[i]
|
||||
}
|
||||
|
||||
return ForwardReferredComponent
|
||||
}
|
@@ -0,0 +1,44 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* A polyfill for `ReadableStream.protototype[Symbol.asyncIterator]`,
|
||||
* aligning as closely as possible to the specification.
|
||||
*
|
||||
* from https://gist.github.com/MattiasBuelens/496fc1d37adb50a733edd43853f2f60e
|
||||
*
|
||||
* @see https://streams.spec.whatwg.org/#rs-asynciterator
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream#async_iteration
|
||||
*/
|
||||
|
||||
ReadableStream.prototype.values ??= function ({ preventCancel = false } = {}) {
|
||||
const reader = this.getReader()
|
||||
return {
|
||||
async next() {
|
||||
try {
|
||||
const result = await reader.read()
|
||||
if (result.done) {
|
||||
reader.releaseLock()
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
reader.releaseLock()
|
||||
throw e
|
||||
}
|
||||
},
|
||||
async return(value) {
|
||||
if (!preventCancel) {
|
||||
const cancelPromise = reader.cancel(value)
|
||||
reader.releaseLock()
|
||||
await cancelPromise
|
||||
} else {
|
||||
reader.releaseLock()
|
||||
}
|
||||
return { done: true, value }
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
ReadableStream.prototype[Symbol.asyncIterator] ??=
|
||||
ReadableStream.prototype.values
|
34
services/web/frontend/js/utils/screen-breakpoint.ts
Normal file
34
services/web/frontend/js/utils/screen-breakpoint.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
|
||||
|
||||
type Breakpoint = {
|
||||
name: BreakpointName
|
||||
minWidth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps window width to Bootstrap 5 breakpoint names
|
||||
* Breakpoints based on Bootstrap 5 documentation:
|
||||
* xs: 0-575px
|
||||
* sm: 576-767px
|
||||
* md: 768-991px
|
||||
* lg: 992-1199px
|
||||
* xl: 1200-1399px
|
||||
* xxl: ≥1400px
|
||||
* @param {number} width - Window width in pixels
|
||||
* @returns {BreakpointName} Bootstrap breakpoint name
|
||||
*/
|
||||
export function getBootstrap5Breakpoint(
|
||||
width: number
|
||||
): BreakpointName | undefined {
|
||||
const breakpoints: Breakpoint[] = [
|
||||
{ name: 'xxl', minWidth: 1400 },
|
||||
{ name: 'xl', minWidth: 1200 },
|
||||
{ name: 'lg', minWidth: 992 },
|
||||
{ name: 'md', minWidth: 768 },
|
||||
{ name: 'sm', minWidth: 576 },
|
||||
{ name: 'xs', minWidth: 0 },
|
||||
]
|
||||
|
||||
const breakpoint = breakpoints.find(bp => width >= bp.minWidth)
|
||||
return breakpoint?.name
|
||||
}
|
17
services/web/frontend/js/utils/service-worker-cleanup.js
Normal file
17
services/web/frontend/js/utils/service-worker-cleanup.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export function cleanupServiceWorker() {
|
||||
try {
|
||||
navigator.serviceWorker
|
||||
.getRegistrations()
|
||||
.catch(() => {
|
||||
// fail silently if permission not given (e.g. SecurityError)
|
||||
return []
|
||||
})
|
||||
.then(registrations => {
|
||||
registrations.forEach(worker => {
|
||||
worker.unregister()
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
// fail silently if service worker are not available (on the navigator)
|
||||
}
|
||||
}
|
18
services/web/frontend/js/utils/splitTestUtils.ts
Normal file
18
services/web/frontend/js/utils/splitTestUtils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import getMeta from './meta'
|
||||
|
||||
export function isSplitTestEnabled(name: string) {
|
||||
return getMeta('ol-splitTestVariants')?.[name] === 'enabled'
|
||||
}
|
||||
|
||||
export function getSplitTestVariant(name: string, fallback?: string) {
|
||||
return getMeta('ol-splitTestVariants')?.[name] || fallback
|
||||
}
|
||||
|
||||
export function parseIntFromSplitTest(name: string, defaultValue: number) {
|
||||
const v = getMeta('ol-splitTestVariants')?.[name]
|
||||
const n = parseInt(v, 10)
|
||||
if (v === 'default' || Number.isNaN(n)) {
|
||||
return defaultValue
|
||||
}
|
||||
return n
|
||||
}
|
1
services/web/frontend/js/utils/wasm.ts
Normal file
1
services/web/frontend/js/utils/wasm.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const supportsWebAssembly = () => typeof window.WebAssembly === 'object'
|
5
services/web/frontend/js/utils/webpack-public-path.ts
Normal file
5
services/web/frontend/js/utils/webpack-public-path.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import getMeta from './meta'
|
||||
|
||||
// Configure dynamically loaded assets (via webpack) to be downloaded from CDN
|
||||
// See: https://webpack.js.org/guides/public-path/#on-the-fly
|
||||
__webpack_public_path__ = getMeta('ol-baseAssetPath')
|
Reference in New Issue
Block a user