Skip to content

Commit

Permalink
Embed: IPC (ready signaling, navigation), overrides, docs (#2314)
Browse files Browse the repository at this point in the history
* Embed: IPC (ready signaling, navigation), overrides

* embed docs

* TSify utils/parseSearch
  • Loading branch information
nl0 authored Sep 2, 2021
1 parent a1fef5b commit a3bd50b
Show file tree
Hide file tree
Showing 15 changed files with 723 additions and 63 deletions.
107 changes: 81 additions & 26 deletions catalog/app/embed/Embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import WithGlobalStyles from '../global-styles'

import AppBar from './AppBar'
import * as EmbedConfig from './EmbedConfig'
import * as Overrides from './Overrides'
import * as ipc from './ipc'

const mkLazy = (load) =>
RT.loadable(load, { fallback: () => <Placeholder color="text.secondary" /> })
Expand Down Expand Up @@ -136,36 +138,40 @@ function BucketLayout({ bucket, children }) {
}

function useInit() {
const messageParent = ipc.useMessageParent()
const [state, setState] = React.useState(null)

const handleMessage = React.useCallback(
({ data }) => {
if (!data || data.type !== 'init') return
const { type, ...init } = data
try {
if (!init.bucket) throw new Error('missing .bucket')
if (init.scope) {
if (typeof init.scope !== 'string') throw new Error('.scope must be a string')
init.scope = s3paths.ensureSlash(init.scope)
ipc.useMessageHandler(
React.useCallback(
({ type, ...init }) => {
if (type !== 'init') return
try {
if (!init.bucket && !init.route)
throw new Error('missing either .bucket or .route')
if (init.scope) {
if (typeof init.scope !== 'string') throw new Error('.scope must be a string')
// eslint-disable-next-line no-param-reassign
init.scope = s3paths.ensureSlash(init.scope)
}
Overrides.validate(init.overrides)
setState(init)
} catch (e) {
const message = `Configuration error: ${e.message}`
// eslint-disable-next-line no-console
console.error(message)
// eslint-disable-next-line no-console
console.log('init object:', init)
messageParent({ type: 'error', message, init })
setState(e)
}
setState(init)
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Configuration error: ${e.message}`)
// eslint-disable-next-line no-console
console.log('init object:', init)
setState(e)
}
},
[setState],
},
[setState, messageParent],
),
)

React.useEffect(() => {
window.addEventListener('message', handleMessage)
return () => {
window.removeEventListener('message', handleMessage)
}
}, [handleMessage])
messageParent({ type: 'ready' })
}, [messageParent])

return state
}
Expand All @@ -191,6 +197,7 @@ function Init() {

function usePostInit(init) {
const dispatch = redux.useDispatch()
const messageParent = ipc.useMessageParent()
const [state, setState] = React.useState(null)

React.useEffect(() => {
Expand All @@ -199,15 +206,21 @@ function usePostInit(init) {
result.promise
.then(() => {
setState(true)
messageParent({ type: 'init', init })
})
.catch((e) => {
// eslint-disable-next-line no-console
console.warn('Authentication failure:')
// eslint-disable-next-line no-console
console.error(e)
setState(new ErrorDisplay('Authentication Failure'))
messageParent({
type: 'error',
message: `Authentication failure: ${e.message}`,
credentials: init.credentials,
})
})
}, [init, dispatch])
}, [init, dispatch, messageParent])

return state
}
Expand Down Expand Up @@ -258,12 +271,53 @@ function useCssFiles(files = []) {
}, [files])
}

function useSyncHistory(history) {
const messageParent = ipc.useMessageParent()

ipc.useMessageHandler(
React.useCallback(
({ type, ...data }) => {
if (type !== 'navigate') return
try {
if (!data.route) throw new Error('missing .route')
if (typeof data.route !== 'string') throw new Error('.route must be a string')
history.push(data.route)
} catch (e) {
const message = `Navigate: error: ${e.message}`
// eslint-disable-next-line no-console
console.error(message)
// eslint-disable-next-line no-console
console.log('params:', data)
messageParent({ type: 'error', message, data })
}
},
[history, messageParent],
),
)

React.useEffect(
() =>
history.listen((l, action) => {
messageParent({
type: 'navigate',
route: `${l.pathname}${l.search}${l.hash}`,
action,
})
}),
[history, messageParent],
)
}

function App({ init }) {
const { urls } = NamedRoutes.use()
const history = useConstant(() =>
createHistory({ initialEntries: [urls.bucketDir(init.bucket, init.path)] }),
createHistory({
initialEntries: [init.route || urls.bucketDir(init.bucket, init.path)],
}),
)

useSyncHistory(history)

const storage = useConstant(() => ({
load: () => ({}),
set: () => {},
Expand All @@ -273,6 +327,7 @@ function App({ init }) {
useCssFiles(init.css)

return RT.nest(
[Overrides.Provider, { value: init.overrides }],
[EmbedConfig.Provider, { config: init }],
[CustomThemeProvider, { theme: init.theme }],
[Store.Provider, { history }],
Expand Down
48 changes: 40 additions & 8 deletions catalog/app/embed/File.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,17 @@ import renderPreview from 'containers/Bucket/renderPreview'
import * as requests from 'containers/Bucket/requests'

import * as EmbedConfig from './EmbedConfig'
import * as Overrides from './Overrides'
import getCrumbs from './getCrumbs'
import * as ipc from './ipc'

const defaults = {
s3ObjectLink: {
title: "Copy object version's canonical HTTPS URI to the clipboard",
href: (ctx) => ctx.s3HttpsUri,
notification: 'HTTPS URI copied to clipboard',
},
}

const useVersionInfoStyles = M.makeStyles(({ typography }) => ({
version: {
Expand All @@ -54,23 +64,45 @@ function VersionInfo({ bucket, path, version }) {
const { urls } = NamedRoutes.use()
const cfg = Config.use()
const { push } = Notifications.use()
const messageParent = ipc.useMessageParent()

const containerRef = React.useRef()
const [anchor, setAnchor] = React.useState()
const [opened, setOpened] = React.useState(false)
const open = React.useCallback(() => setOpened(true), [])
const close = React.useCallback(() => setOpened(false), [])

const overrides = Overrides.use(defaults)

const classes = useVersionInfoStyles()

const getHttpsUri = (v) =>
s3paths.handleToHttpsUri({ bucket, key: path, version: v.id })
const getLink = (v) =>
overrides.s3ObjectLink.href({
url: urls.bucketFile(bucket, path, v.id),
s3HttpsUri: s3paths.handleToHttpsUri({ bucket, key: path, version: v.id }),
bucket,
key: path,
version: v.id,
})

const getCliArgs = (v) => `--bucket ${bucket} --key "${path}" --version-id ${v.id}`

const copyHttpsUri = (v) => (e) => {
const copyLink = (v) => (e) => {
e.preventDefault()
copyToClipboard(getHttpsUri(v), { container: containerRef.current })
push('HTTPS URI copied to clipboard')
if (overrides.s3ObjectLink.emit !== 'override') {
copyToClipboard(getLink(v), { container: containerRef.current })
push(overrides.s3ObjectLink.notification)
}
if (overrides.s3ObjectLink.emit) {
messageParent({
type: 's3ObjectLink',
url: urls.bucketFile(bucket, path, v.id),
s3HttpsUri: s3paths.handleToHttpsUri({ bucket, key: path, version: v.id }),
bucket,
key: path,
version: v.id,
})
}
}

const copyCliArgs = (v) => (e) => {
Expand Down Expand Up @@ -145,9 +177,9 @@ function VersionInfo({ bucket, path, version }) {
)}
<M.Hidden xsDown>
<M.IconButton
title="Copy object version's canonical HTTPS URI to the clipboard"
href={getHttpsUri(v)}
onClick={copyHttpsUri(v)}
title={overrides.s3ObjectLink.title}
href={getLink(v)}
onClick={copyLink(v)}
>
<M.Icon>link</M.Icon>
</M.IconButton>
Expand Down
85 changes: 85 additions & 0 deletions catalog/app/embed/Overrides.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import template from 'lodash/template'
import * as R from 'ramda'
import * as React from 'react'

// XXX: consider using io-ts
interface S3ObjectLinkOverride {
title?: React.ReactNode
href?: (ctx: {
url: string
s3HttpsUri: string
bucket: string
// TODO: add encoded key?
key: string
version: string
}) => string
notification?: React.ReactNode
emit?: 'notify' | 'override'
}

interface Overrides {
s3ObjectLink?: S3ObjectLinkOverride
}

export const Context = React.createContext<Overrides>({})
Context.displayName = 'Overrides'

export const { Provider } = Context

function merge<L extends any, R extends any>(l: L, r: R) {
return l ?? r
}

export function useOverrides(defaults?: Overrides): Overrides {
const overrides = React.useContext(Context)
return React.useMemo(
() => compile(R.mergeDeepWith(merge, overrides, defaults || {})),
[defaults, overrides],
)
}

export { useOverrides as use }

function assertIsObject(scope: string, obj: unknown): asserts obj is object {
if (obj != null && typeof obj !== 'object') {
throw new Error(`${scope} must be an object if present`)
}
}

const compileTemplate = (scope?: string) => (str?: unknown) => {
try {
return typeof str === 'string'
? template(str)
: (str as NonNullable<Overrides['s3ObjectLink']>['href'])
} catch (e) {
if (scope) {
throw new Error(`${scope} must be a valid template string: ${e.message}`)
}
throw e
}
}

function validateEmit(v: unknown) {
if (!v) return undefined
if (v !== 'notify' && v !== 'override') {
throw new Error(
'.overrides.s3ObjectLink.emit must be either "notify" or "override" or some falsy value',
)
}
return v
}

const compile = R.evolve({
s3ObjectLink: { href: compileTemplate(), emit: validateEmit },
})

export function validate(input: unknown) {
assertIsObject('.overrides', input)
assertIsObject('.overrides.s3ObjectLink', (input as any)?.s3ObjectLink)
R.evolve({
s3ObjectLink: {
href: compileTemplate('.overrides.s3ObjectLink.href'),
emit: validateEmit,
},
})(input)
}
11 changes: 11 additions & 0 deletions catalog/app/embed/debug-harness.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
<meta charset="utf-8" />
<!-- Make the page mobile compatible -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Roboto for Material Design -->
<link
href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,700|Roboto:300,400,500"
rel="stylesheet"
/>
<!-- Material Design Icons -->
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<script src="https://polyfill.io/v3/polyfill.min.js?features=Intl%2Cdefault%2CResizeObserver%2CBlob%2Cfetch%2Ces2016%2Ces2017%2Ces2018%2Ces2019%2Ces2015%2CIntl.RelativeTimeFormat"></script>
</head>
<body>
<!--[if lte IE 10]>
Expand Down
Loading

0 comments on commit a3bd50b

Please sign in to comment.