[NEW] UiKit - Interactive UI elements for Rocket.Chat Apps (#16048)
parent
e6a8821a8d
commit
1932254338
@ -0,0 +1,13 @@ |
||||
import { Notifications } from '../../../notifications/server'; |
||||
|
||||
export class UiInteractionBridge { |
||||
constructor(orch) { |
||||
this.orch = orch; |
||||
} |
||||
|
||||
async notifyUser(user, interaction, appId) { |
||||
this.orch.debugLog(`The App ${ appId } is sending an interaction to user.`); |
||||
|
||||
Notifications.notifyUser(user.id, 'uiInteraction', interaction); |
||||
} |
||||
} |
@ -1,11 +1,6 @@ |
||||
import { AppMethods } from './methods'; |
||||
import { AppsRestApi } from './rest'; |
||||
import { AppEvents, AppServerNotifier, AppServerListener } from './websockets'; |
||||
import { AppUIKitInteractionApi } from './uikit'; |
||||
import { AppEvents, AppServerListener, AppServerNotifier } from './websockets'; |
||||
|
||||
export { |
||||
AppMethods, |
||||
AppsRestApi, |
||||
AppEvents, |
||||
AppServerNotifier, |
||||
AppServerListener, |
||||
}; |
||||
export { AppUIKitInteractionApi, AppMethods, AppsRestApi, AppEvents, AppServerNotifier, AppServerListener }; |
||||
|
@ -0,0 +1,159 @@ |
||||
import express from 'express'; |
||||
import { WebApp } from 'meteor/webapp'; |
||||
import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; |
||||
|
||||
import { Users } from '../../../models/server'; |
||||
|
||||
const apiServer = express(); |
||||
|
||||
apiServer.disable('x-powered-by'); |
||||
|
||||
WebApp.connectHandlers.use(apiServer); |
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const router = express.Router(); |
||||
|
||||
const unauthorized = (res) => |
||||
res.status(401).send({ |
||||
status: 'error', |
||||
message: 'You must be logged in to do this.', |
||||
}); |
||||
|
||||
router.use((req, res, next) => { |
||||
const { |
||||
'x-user-id': userId, |
||||
'x-auth-token': authToken, |
||||
} = req.headers; |
||||
|
||||
if (!userId || !authToken) { |
||||
return unauthorized(res); |
||||
} |
||||
|
||||
const user = Users.findOneByIdAndLoginToken(userId, authToken); |
||||
if (!user) { |
||||
return unauthorized(res); |
||||
} |
||||
|
||||
req.user = user; |
||||
req.userId = user._id; |
||||
|
||||
next(); |
||||
}); |
||||
|
||||
apiServer.use('/api/apps/ui.interaction/', router); |
||||
|
||||
export class AppUIKitInteractionApi { |
||||
constructor(orch) { |
||||
this.orch = orch; |
||||
|
||||
router.post('/:appId', (req, res) => { |
||||
const { |
||||
appId, |
||||
} = req.params; |
||||
|
||||
const { |
||||
type, |
||||
} = req.body; |
||||
|
||||
switch (type) { |
||||
case UIKitIncomingInteractionType.BLOCK: { |
||||
const { |
||||
type, |
||||
actionId, |
||||
triggerId, |
||||
mid, |
||||
rid, |
||||
payload, |
||||
} = req.body; |
||||
|
||||
const room = this.orch.getConverters().get('rooms').convertById(rid); |
||||
const user = this.orch.getConverters().get('users').convertToApp(req.user); |
||||
const message = mid && this.orch.getConverters().get('messages').convertById(mid); |
||||
|
||||
const action = { |
||||
type, |
||||
appId, |
||||
actionId, |
||||
message, |
||||
triggerId, |
||||
payload, |
||||
user, |
||||
room, |
||||
}; |
||||
|
||||
try { |
||||
const result = Promise.await(this.orch.getBridges().getListenerBridge().uiKitInteractionEvent('IUIKitInteractionHandler', action)); |
||||
|
||||
res.send(result); |
||||
} catch (e) { |
||||
res.status(500).send(e.message); |
||||
} |
||||
break; |
||||
} |
||||
|
||||
case UIKitIncomingInteractionType.VIEW_CLOSED: { |
||||
const { |
||||
type, |
||||
actionId, |
||||
view, |
||||
isCleared, |
||||
} = req.body; |
||||
|
||||
const user = this.orch.getConverters().get('users').convertToApp(req.user); |
||||
|
||||
const action = { |
||||
type, |
||||
appId, |
||||
actionId, |
||||
user, |
||||
payload: { |
||||
view, |
||||
isCleared, |
||||
}, |
||||
}; |
||||
|
||||
try { |
||||
Promise.await(this.orch.getBridges().getListenerBridge().uiKitInteractionEvent('IUIKitInteractionHandler', action)); |
||||
|
||||
res.send(200); |
||||
} catch (e) { |
||||
console.log(e); |
||||
res.status(500).send(e.message); |
||||
} |
||||
break; |
||||
} |
||||
|
||||
case UIKitIncomingInteractionType.VIEW_SUBMIT: { |
||||
const { |
||||
type, |
||||
actionId, |
||||
triggerId, |
||||
payload, |
||||
} = req.body; |
||||
|
||||
const user = this.orch.getConverters().get('users').convertToApp(req.user); |
||||
|
||||
const action = { |
||||
type, |
||||
appId, |
||||
actionId, |
||||
triggerId, |
||||
payload, |
||||
user, |
||||
}; |
||||
|
||||
try { |
||||
const result = Promise.await(this.orch.getBridges().getListenerBridge().uiKitInteractionEvent('IUIKitInteractionHandler', action)); |
||||
|
||||
res.send(result); |
||||
} catch (e) { |
||||
res.status(500).send(e.message); |
||||
} |
||||
break; |
||||
} |
||||
} |
||||
|
||||
// TODO: validate payloads per type
|
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,154 @@ |
||||
import { UIKitInteractionType, UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { Random } from 'meteor/random'; |
||||
import EventEmitter from 'wolfy87-eventemitter'; |
||||
|
||||
import Notifications from '../../notifications/client/lib/Notifications'; |
||||
import { CachedCollectionManager } from '../../ui-cached-collection'; |
||||
import { modal } from '../../ui-utils/client/lib/modal'; |
||||
import { APIClient } from '../../utils'; |
||||
|
||||
const events = new EventEmitter(); |
||||
|
||||
export const on = (...args) => { |
||||
events.on(...args); |
||||
}; |
||||
|
||||
export const off = (...args) => { |
||||
events.off(...args); |
||||
}; |
||||
|
||||
const TRIGGER_TIMEOUT = 5000; |
||||
|
||||
const triggersId = new Map(); |
||||
|
||||
const instances = new Map(); |
||||
|
||||
const invalidateTriggerId = (id) => { |
||||
const appId = triggersId.get(id); |
||||
triggersId.delete(id); |
||||
return appId; |
||||
}; |
||||
|
||||
export const generateTriggerId = (appId) => { |
||||
const triggerId = Random.id(); |
||||
triggersId.set(triggerId, appId); |
||||
setTimeout(invalidateTriggerId, TRIGGER_TIMEOUT, triggerId); |
||||
return triggerId; |
||||
}; |
||||
|
||||
const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data }) => { |
||||
if (!triggersId.has(triggerId)) { |
||||
return; |
||||
} |
||||
const appId = invalidateTriggerId(triggerId); |
||||
if (!appId) { |
||||
return; |
||||
} |
||||
|
||||
const { view } = data; |
||||
let { viewId } = data; |
||||
|
||||
if (view && view.id) { |
||||
viewId = view.id; |
||||
} |
||||
|
||||
if (!viewId) { |
||||
return; |
||||
} |
||||
|
||||
if ([UIKitInteractionType.ERRORS].includes(type)) { |
||||
events.emit(viewId, { |
||||
type, |
||||
triggerId, |
||||
viewId, |
||||
appId, |
||||
...data, |
||||
}); |
||||
return UIKitInteractionType.ERRORS; |
||||
} |
||||
|
||||
if ([UIKitInteractionType.MODAL_UPDATE].includes(type)) { |
||||
events.emit(viewId, { |
||||
type, |
||||
triggerId, |
||||
viewId, |
||||
appId, |
||||
...data, |
||||
}); |
||||
return UIKitInteractionType.MODAL_UPDATE; |
||||
} |
||||
|
||||
if ([UIKitInteractionType.MODAL_OPEN].includes(type)) { |
||||
const instance = modal.push({ |
||||
template: 'ModalBlock', |
||||
modifier: 'uikit', |
||||
closeOnEscape: false, |
||||
data: { |
||||
triggerId, |
||||
viewId, |
||||
appId, |
||||
...data, |
||||
}, |
||||
}); |
||||
instances.set(viewId, instance); |
||||
return UIKitInteractionType.MODAL_OPEN; |
||||
} |
||||
|
||||
return UIKitInteractionType.MODAL_ClOSE; |
||||
}; |
||||
|
||||
export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, ...rest }) => new Promise(async (resolve, reject) => { |
||||
const triggerId = generateTriggerId(appId); |
||||
|
||||
const payload = rest.payload || rest; |
||||
|
||||
setTimeout(reject, TRIGGER_TIMEOUT, triggerId); |
||||
|
||||
const { type: interactionType, ...data } = await APIClient.post( |
||||
`apps/ui.interaction/${ appId }`, |
||||
{ type, actionId, payload, mid, rid, triggerId, viewId }, |
||||
); |
||||
|
||||
return resolve(handlePayloadUserInteraction(interactionType, data)); |
||||
}); |
||||
|
||||
export const triggerBlockAction = (options) => triggerAction({ type: UIKitIncomingInteractionType.BLOCK, ...options }); |
||||
export const triggerSubmitView = async ({ viewId, ...options }) => { |
||||
const close = () => { |
||||
const instance = instances.get(viewId); |
||||
|
||||
if (instance) { |
||||
instance.close(); |
||||
instances.delete(viewId); |
||||
} |
||||
}; |
||||
|
||||
try { |
||||
const result = await triggerAction({ type: UIKitIncomingInteractionType.VIEW_SUBMIT, viewId, ...options }); |
||||
if (!result || UIKitInteractionType.MODAL_CLOSE === result) { |
||||
close(); |
||||
} |
||||
} catch { |
||||
close(); |
||||
} |
||||
}; |
||||
export const triggerCancel = async ({ view, ...options }) => { |
||||
const instance = instances.get(view.id); |
||||
try { |
||||
await triggerAction({ type: UIKitIncomingInteractionType.VIEW_CLOSED, view, ...options }); |
||||
} finally { |
||||
if (instance) { |
||||
instance.close(); |
||||
instances.delete(view.id); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
Meteor.startup(() => |
||||
CachedCollectionManager.onLogin(() => |
||||
Notifications.onUser('uiInteraction', ({ type, ...data }) => { |
||||
handlePayloadUserInteraction(type, data); |
||||
}), |
||||
), |
||||
); |
@ -0,0 +1,3 @@ |
||||
<template name="Blocks"> |
||||
<div class="js-block-wrapper"></div> |
||||
</template> |
@ -0,0 +1,35 @@ |
||||
import { Template } from 'meteor/templating'; |
||||
import { ReactiveVar } from 'meteor/reactive-var'; |
||||
|
||||
import { messageBlockWithContext } from './MessageBlock'; |
||||
import './Blocks.html'; |
||||
import * as ActionManager from '../ActionManager'; |
||||
|
||||
Template.Blocks.onRendered(async function() { |
||||
const React = await import('react'); |
||||
const ReactDOM = await import('react-dom'); |
||||
const state = new ReactiveVar(); |
||||
this.autorun(() => { |
||||
state.set(Template.currentData()); |
||||
}); |
||||
|
||||
ReactDOM.render( |
||||
React.createElement(messageBlockWithContext({ |
||||
action: (options) => { |
||||
const { actionId, value, blockId, mid = this.data.mid } = options; |
||||
ActionManager.triggerBlockAction({ actionId, appId: this.data.blocks[1].appId, value, blockId, rid: this.data.rid, mid }); |
||||
}, |
||||
// state: alert,
|
||||
appId: this.data.appId, |
||||
rid: this.data.rid, |
||||
}), { data: () => state.get() }), |
||||
this.firstNode, |
||||
); |
||||
const event = new Event('rendered'); |
||||
this.firstNode.dispatchEvent(event); |
||||
}); |
||||
|
||||
Template.Blocks.onDestroyed(async function() { |
||||
const ReactDOM = await import('react-dom'); |
||||
this.firstNode && ReactDOM.unmountComponentAtNode(this.firstNode); |
||||
}); |
@ -0,0 +1,3 @@ |
||||
<template name="ButtonElement"> |
||||
<button form="{{form}}" value="{{value}}" id="{{id}}" type="{{type}}" class="rc-button rc-button--primary"> {{>TextBlock text}} </button> |
||||
</template> |
@ -0,0 +1,164 @@ |
||||
import React, { useRef, useEffect, useCallback, useMemo } from 'react'; |
||||
import { UiKitMessage as uiKitMessage, kitContext, UiKitModal as uiKitModal, messageParser, modalParser, UiKitComponent } from '@rocket.chat/fuselage-ui-kit'; |
||||
import { uiKitText } from '@rocket.chat/ui-kit'; |
||||
import { Modal, AnimatedVisibility, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; |
||||
import { useUniqueId } from '@rocket.chat/fuselage-hooks'; |
||||
|
||||
import { renderMessageBody } from '../../../ui-utils/client'; |
||||
import { useReactiveValue } from '../../../../client/hooks/useReactiveValue'; |
||||
|
||||
|
||||
const focusableElementsString = 'a[href]:not([tabindex="-1"]), area[href]:not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), select:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]):not([tabindex="-1"]), button:not([disabled]):not([tabindex="-1"]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable]'; |
||||
|
||||
messageParser.text = ({ text, type } = {}) => { |
||||
if (type !== 'mrkdwn') { |
||||
return text; |
||||
} |
||||
|
||||
return <span dangerouslySetInnerHTML={{ __html: renderMessageBody({ msg: text }) }} />; |
||||
}; |
||||
|
||||
modalParser.text = messageParser.text; |
||||
|
||||
const contextDefault = { |
||||
action: console.log, |
||||
state: (data) => { |
||||
console.log('state', data); |
||||
}, |
||||
}; |
||||
export const messageBlockWithContext = (context) => (props) => { |
||||
const data = useReactiveValue(props.data); |
||||
return ( |
||||
<kitContext.Provider value={context}> |
||||
{uiKitMessage(data.blocks)} |
||||
</kitContext.Provider> |
||||
); |
||||
}; |
||||
|
||||
const textParser = uiKitText(new class { |
||||
plain_text({ text }) { |
||||
return text; |
||||
} |
||||
|
||||
text({ text }) { |
||||
return text; |
||||
} |
||||
}()); |
||||
const thumb = 'data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='; |
||||
|
||||
// https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html
|
||||
|
||||
export const modalBlockWithContext = ({ |
||||
view: { |
||||
title, |
||||
close, |
||||
submit, |
||||
}, |
||||
onSubmit, |
||||
onClose, |
||||
onCancel, |
||||
...context |
||||
}) => (props) => { |
||||
const id = `modal_id_${ useUniqueId() }`; |
||||
|
||||
const { view, ...data } = useReactiveValue(props.data); |
||||
const ref = useRef(); |
||||
|
||||
// Auto focus
|
||||
useEffect(() => ref.current && ref.current.querySelector(focusableElementsString).focus(), [ref.current]); |
||||
// save fovus to restore after close
|
||||
const previousFocus = useMemo(() => document.activeElement, []); |
||||
// restore the focus after the component unmount
|
||||
useEffect(() => () => previousFocus && previousFocus.focus(), []); |
||||
// Handle Tab, Shift + Tab, Enter and Escape
|
||||
const handleKeyUp = useCallback((event) => { |
||||
if (event.keyCode === 13) { // ENTER
|
||||
return onSubmit(); |
||||
} |
||||
|
||||
if (event.keyCode === 27) { // ESC
|
||||
event.stopPropagation(); |
||||
event.preventDefault(); |
||||
onClose(); |
||||
return false; |
||||
} |
||||
|
||||
if (event.keyCode === 9) { // TAB
|
||||
const elements = Array.from(ref.current.querySelectorAll(focusableElementsString)); |
||||
const [first] = elements; |
||||
const last = elements.pop(); |
||||
|
||||
if (!ref.current.contains(document.activeElement)) { |
||||
return first.focus(); |
||||
} |
||||
|
||||
if (event.shiftKey) { |
||||
if (!first || first === document.activeElement) { |
||||
last.focus(); |
||||
event.stopPropagation(); |
||||
event.preventDefault(); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if (!last || last === document.activeElement) { |
||||
first.focus(); |
||||
event.stopPropagation(); |
||||
event.preventDefault(); |
||||
} |
||||
} |
||||
}, [onSubmit]); |
||||
// Clean the events
|
||||
useEffect(() => { |
||||
const close = (e) => { |
||||
e.preventDefault(); |
||||
e.stopPropagation(); |
||||
onClose(); |
||||
return false; |
||||
}; |
||||
const element = document.querySelector('.rc-modal-wrapper'); |
||||
document.addEventListener('keydown', handleKeyUp); |
||||
element.addEventListener('click', close); |
||||
return () => { |
||||
document.removeEventListener('keydown', handleKeyUp); |
||||
element.removeEventListener('click', close); |
||||
}; |
||||
}, handleKeyUp); |
||||
|
||||
return ( |
||||
<kitContext.Provider value={{ ...context, ...data }}> |
||||
<AnimatedVisibility visibility={AnimatedVisibility.UNHIDING}> |
||||
<Modal open id={id} ref={ref}> |
||||
<Modal.Header> |
||||
{/* <Modal.Thumb url={`api/apps/${ context.appId }/icon`} /> */} |
||||
<Modal.Thumb url={thumb} /> |
||||
<Modal.Title>{textParser([title])}</Modal.Title> |
||||
<Modal.Close tabIndex={-1} onClick={onClose} /> |
||||
</Modal.Header> |
||||
<Modal.Content> |
||||
<Box |
||||
is='form' |
||||
method='post' |
||||
action='#' |
||||
onSubmit={onSubmit} |
||||
> |
||||
<UiKitComponent render={uiKitModal} blocks={view.blocks} /> |
||||
</Box> |
||||
</Modal.Content> |
||||
<Modal.Footer> |
||||
<ButtonGroup align='end'> |
||||
<Button onClick={onCancel}>{textParser([close.text])}</Button> |
||||
<Button primary onClick={onSubmit}>{textParser([submit.text])}</Button> |
||||
</ButtonGroup> |
||||
</Modal.Footer> |
||||
</Modal> |
||||
</AnimatedVisibility> |
||||
</kitContext.Provider> |
||||
); |
||||
}; |
||||
|
||||
export const MessageBlock = ({ blocks }, context = contextDefault) => ( |
||||
<kitContext.Provider value={context}> |
||||
{uiKitMessage(blocks)} |
||||
</kitContext.Provider> |
||||
); |
@ -0,0 +1,3 @@ |
||||
<template name="ModalBlock"> |
||||
<div class="js-modal-block"></div> |
||||
</template> |
@ -0,0 +1,103 @@ |
||||
import { Template } from 'meteor/templating'; |
||||
import { ReactiveDict } from 'meteor/reactive-dict'; |
||||
import { ReactiveVar } from 'meteor/reactive-var'; |
||||
|
||||
import { modalBlockWithContext } from './MessageBlock'; |
||||
import './ModalBlock.html'; |
||||
import * as ActionManager from '../ActionManager'; |
||||
|
||||
Template.ModalBlock.onRendered(async function() { |
||||
const React = await import('react'); |
||||
const ReactDOM = await import('react-dom'); |
||||
const state = new ReactiveVar(); |
||||
|
||||
const { viewId, appId } = this.data; |
||||
|
||||
this.autorun(() => { |
||||
state.set(Template.currentData()); |
||||
}); |
||||
|
||||
const handleUpdate = ({ type, ...data }) => { |
||||
if (type === 'errors') { |
||||
return state.set({ ...state.get(), errors: data.errors }); |
||||
} |
||||
return state.set(data); |
||||
}; |
||||
|
||||
this.cancel = () => { |
||||
ActionManager.off(viewId, handleUpdate); |
||||
}; |
||||
|
||||
this.node = this.find('.js-modal-block').parentElement; |
||||
ActionManager.on(viewId, handleUpdate); |
||||
|
||||
const filterInputFields = ({ type, element }) => type === 'input' && element.initialValue; |
||||
const mapElementToState = ({ element, blockId }) => [element.actionId, { value: element.initialValue, blockId }]; |
||||
const groupStateByBlockIdMap = (obj, [key, { blockId, value }]) => { |
||||
obj[blockId] = obj[blockId] || {}; |
||||
obj[blockId][key] = value; |
||||
return obj; |
||||
}; |
||||
const groupStateByBlockId = (obj) => Object.entries(obj).reduce(groupStateByBlockIdMap, {}); |
||||
|
||||
this.state = new ReactiveDict(Object.fromEntries(this.data.view.blocks.filter(filterInputFields).map(mapElementToState))); |
||||
|
||||
ReactDOM.render( |
||||
React.createElement( |
||||
modalBlockWithContext({ |
||||
onCancel: () => ActionManager.triggerCancel({ |
||||
appId, |
||||
viewId, |
||||
view: { |
||||
...this.data.view, |
||||
id: viewId, |
||||
state: groupStateByBlockId(this.state.all()), |
||||
}, |
||||
}), |
||||
onClose: () => ActionManager.triggerCancel({ |
||||
appId, |
||||
viewId, |
||||
view: { |
||||
...this.data.view, |
||||
id: viewId, |
||||
state: groupStateByBlockId(this.state.all()), |
||||
}, |
||||
isCleared: true, |
||||
}), |
||||
onSubmit: () => ActionManager.triggerSubmitView({ |
||||
viewId, |
||||
appId, |
||||
payload: { |
||||
view: { |
||||
...this.data.view, |
||||
id: viewId, |
||||
state: groupStateByBlockId(this.state.all()), |
||||
}, |
||||
}, |
||||
}), |
||||
action: ({ actionId, appId, value, blockId, mid = this.data.mid }) => { |
||||
ActionManager.triggerBlockAction({ |
||||
actionId, |
||||
appId, |
||||
value, |
||||
blockId, |
||||
mid, |
||||
}); |
||||
}, |
||||
state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { |
||||
this.state.set(actionId, { |
||||
blockId, |
||||
value, |
||||
}); |
||||
}, |
||||
...this.data, |
||||
}), |
||||
{ data: () => state.get() }, |
||||
), |
||||
this.node, |
||||
); |
||||
}); |
||||
Template.ModalBlock.onDestroyed(async function() { |
||||
const ReactDOM = await import('react-dom'); |
||||
this.node && ReactDOM.unmountComponentAtNode(this.node); |
||||
}); |
@ -0,0 +1,12 @@ |
||||
<!-- { |
||||
"type": "button", |
||||
"text": { |
||||
"type": "plain_text", |
||||
"text": "Button", |
||||
"emoji": true |
||||
} |
||||
}, |
||||
--> |
||||
<template name="TextBlock" arguments="type text emoji"> |
||||
{{text.text}} |
||||
</template> |
@ -0,0 +1,5 @@ |
||||
|
||||
|
||||
// import { Template } from 'meteor/templating';
|
||||
|
||||
import './TextBlock.html'; |
@ -0,0 +1,5 @@ |
||||
import './styles.css'; |
||||
import './Blocks.js'; |
||||
import './ModalBlock'; |
||||
import './TextBlock'; |
||||
import './ButtonElement.html'; |
@ -0,0 +1,18 @@ |
||||
.block-kit-debug.debug { |
||||
padding: 1rem; |
||||
|
||||
border: 1px solid; |
||||
} |
||||
|
||||
.block-kit-debug legend { |
||||
display: none; |
||||
} |
||||
|
||||
.block-kit-debug.debug legend { |
||||
display: initial; |
||||
} |
||||
|
||||
.rc-modal--uikit { |
||||
width: 680px; |
||||
max-width: 100%; |
||||
} |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue