Modal component (#13848)
* wip * wip * wip * wip * wip * add note for illegalChildren reset behavior * on close working * wip * lint * wip * Fix potential remounting loop caused by spreading props and unnecessary component keying * theme * user prompt * dotted border for empty * PR Feedback * lint * fix modal background color * use bbui modal * lint * fix indicator and prevent closing modal in builder * pr feedback * pr feedback * fix fullscreen --------- Co-authored-by: deanhannigan <deanhannigan@gmail.com> Co-authored-by: Andrew Kingston <andrew@kingston.dev>
This commit is contained in:
parent
c5935e9e56
commit
e88ffea1a4
|
@ -162,6 +162,7 @@
|
|||
max-height: 100%;
|
||||
}
|
||||
.modal-inner-wrapper {
|
||||
padding: 40px;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -176,7 +177,6 @@
|
|||
border: 2px solid var(--spectrum-global-color-gray-200);
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
margin: 40px 0;
|
||||
transform: none;
|
||||
--spectrum-dialog-confirm-border-radius: var(
|
||||
--spectrum-global-dimension-size-100
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<div class="root">This action doesn't require any settings.</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,36 @@
|
|||
<script>
|
||||
import { Select, Label } from "@budibase/bbui"
|
||||
import { selectedScreen } from "stores/builder"
|
||||
import { findAllMatchingComponents } from "helpers/components"
|
||||
|
||||
export let parameters
|
||||
|
||||
$: modalOptions = getModalOptions($selectedScreen)
|
||||
|
||||
const getModalOptions = screen => {
|
||||
const modalComponents = findAllMatchingComponents(screen.props, component =>
|
||||
component._component.endsWith("/modal")
|
||||
)
|
||||
return modalComponents.map(modal => ({
|
||||
label: modal._instanceName,
|
||||
value: modal._id,
|
||||
}))
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Label small>Modal</Label>
|
||||
<Select bind:value={parameters.id} options={modalOptions} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-l);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: 60px 1fr;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
|
@ -21,5 +21,7 @@ export { default as ShowNotification } from "./ShowNotification.svelte"
|
|||
export { default as PromptUser } from "./PromptUser.svelte"
|
||||
export { default as OpenSidePanel } from "./OpenSidePanel.svelte"
|
||||
export { default as CloseSidePanel } from "./CloseSidePanel.svelte"
|
||||
export { default as OpenModal } from "./OpenModal.svelte"
|
||||
export { default as CloseModal } from "./CloseModal.svelte"
|
||||
export { default as ClearRowSelection } from "./ClearRowSelection.svelte"
|
||||
export { default as DownloadFile } from "./DownloadFile.svelte"
|
||||
|
|
|
@ -157,6 +157,18 @@
|
|||
"component": "CloseSidePanel",
|
||||
"dependsOnFeature": "sidePanel"
|
||||
},
|
||||
{
|
||||
"name": "Open Modal",
|
||||
"type": "application",
|
||||
"component": "OpenModal",
|
||||
"dependsOnFeature": "modal"
|
||||
},
|
||||
{
|
||||
"name": "Close Modal",
|
||||
"type": "application",
|
||||
"component": "CloseModal",
|
||||
"dependsOnFeature": "modal"
|
||||
},
|
||||
{
|
||||
"name": "Clear Row Selection",
|
||||
"type": "data",
|
||||
|
|
|
@ -59,7 +59,14 @@
|
|||
// Build up list of illegal children from ancestors
|
||||
let illegalChildren = definition.illegalChildren || []
|
||||
path.forEach(ancestor => {
|
||||
if (ancestor._component === `@budibase/standard-components/sidepanel`) {
|
||||
// Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level.
|
||||
// Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here.
|
||||
if (
|
||||
[
|
||||
"@budibase/standard-components/sidepanel",
|
||||
"@budibase/standard-components/modal",
|
||||
].includes(ancestor._component)
|
||||
) {
|
||||
illegalChildren = []
|
||||
}
|
||||
const def = componentStore.getDefinition(ancestor._component)
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
{
|
||||
"name": "Layout",
|
||||
"icon": "ClassicGridView",
|
||||
"children": ["container", "section", "sidepanel"]
|
||||
"children": ["container", "section", "sidepanel", "modal"]
|
||||
},
|
||||
{
|
||||
"name": "Data",
|
||||
|
|
|
@ -125,7 +125,14 @@ export class ScreenStore extends BudiStore {
|
|||
return
|
||||
}
|
||||
|
||||
if (type === "@budibase/standard-components/sidepanel") {
|
||||
// Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level.
|
||||
// Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here.
|
||||
if (
|
||||
[
|
||||
"@budibase/standard-components/sidepanel",
|
||||
"@budibase/standard-components/modal",
|
||||
].includes(type)
|
||||
) {
|
||||
illegalChildren = []
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"continueIfAction": true,
|
||||
"showNotificationAction": true,
|
||||
"sidePanel": true,
|
||||
"modal": true,
|
||||
"skeletonLoader": true
|
||||
},
|
||||
"typeSupportPresets": {
|
||||
|
@ -6975,7 +6976,7 @@
|
|||
"name": "Side Panel",
|
||||
"icon": "RailRight",
|
||||
"hasChildren": true,
|
||||
"illegalChildren": ["section", "sidepanel"],
|
||||
"illegalChildren": ["section", "sidepanel", "modal"],
|
||||
"showEmptyState": false,
|
||||
"draggable": false,
|
||||
"info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action.",
|
||||
|
@ -6993,6 +6994,52 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"modal": {
|
||||
"name": "Modal",
|
||||
"icon": "MBox",
|
||||
"hasChildren": true,
|
||||
"illegalChildren": ["section", "modal", "sidepanel"],
|
||||
"showEmptyState": false,
|
||||
"draggable": false,
|
||||
"info": "Modals are hidden by default. They will only be revealed when triggered by the 'Open Modal' action.",
|
||||
"settings": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"key": "ignoreClicksOutside",
|
||||
"label": "Ignore clicks outside",
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"key": "onClose",
|
||||
"label": "On close"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"label": "Size",
|
||||
"key": "size",
|
||||
"defaultValue": "small",
|
||||
"options": [
|
||||
{
|
||||
"label": "Small",
|
||||
"value": "small"
|
||||
},
|
||||
{
|
||||
"label": "Medium",
|
||||
"value": "medium"
|
||||
},
|
||||
{
|
||||
"label": "Large",
|
||||
"value": "large"
|
||||
},
|
||||
{
|
||||
"label": "Fullscreen",
|
||||
"value": "fullscreen"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"rowexplorer": {
|
||||
"block": true,
|
||||
"name": "Row Explorer Block",
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
devToolsEnabled,
|
||||
environmentStore,
|
||||
sidePanelStore,
|
||||
modalStore,
|
||||
} from "stores"
|
||||
import NotificationDisplay from "components/overlay/NotificationDisplay.svelte"
|
||||
import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte"
|
||||
|
@ -104,10 +105,15 @@
|
|||
})
|
||||
}
|
||||
const handleHashChange = () => {
|
||||
const { open } = $sidePanelStore
|
||||
if (open) {
|
||||
const { open: sidePanelOpen } = $sidePanelStore
|
||||
if (sidePanelOpen) {
|
||||
sidePanelStore.actions.close()
|
||||
}
|
||||
|
||||
const { open: modalOpen } = $modalStore
|
||||
if (modalOpen) {
|
||||
modalStore.actions.close()
|
||||
}
|
||||
}
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
return () => {
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
linkable,
|
||||
builderStore,
|
||||
sidePanelStore,
|
||||
modalStore,
|
||||
appStore,
|
||||
} = sdk
|
||||
const context = getContext("context")
|
||||
|
@ -77,6 +78,7 @@
|
|||
!$builderStore.inBuilder &&
|
||||
$sidePanelStore.open &&
|
||||
!$sidePanelStore.ignoreClicksOutside
|
||||
|
||||
$: screenId = $builderStore.inBuilder
|
||||
? `${$builderStore.screen?._id}-screen`
|
||||
: "screen"
|
||||
|
@ -198,6 +200,7 @@
|
|||
const handleClickLink = () => {
|
||||
mobileOpen = false
|
||||
sidePanelStore.actions.close()
|
||||
modalStore.actions.close()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const { linkable, styleable, builderStore, sidePanelStore } =
|
||||
const { linkable, styleable, builderStore, sidePanelStore, modalStore } =
|
||||
getContext("sdk")
|
||||
const component = getContext("component")
|
||||
|
||||
|
@ -29,6 +29,11 @@
|
|||
// overrides the color when it's passed as inline style.
|
||||
$: styles = enrichStyles($component.styles, color)
|
||||
|
||||
const handleUrlChange = () => {
|
||||
sidePanelStore.actions.close()
|
||||
modalStore.actions.close()
|
||||
}
|
||||
|
||||
const getSanitizedUrl = (url, externalLink, newTab) => {
|
||||
if (!url) {
|
||||
return externalLink || newTab ? "#/" : "/"
|
||||
|
@ -109,7 +114,7 @@
|
|||
class:italic
|
||||
class:underline
|
||||
class="align--{align || 'left'} size--{size || 'M'}"
|
||||
on:click={sidePanelStore.actions.close}
|
||||
on:click={handleUrlChange}
|
||||
>
|
||||
{componentText}
|
||||
</a>
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { Modal, Icon } from "@budibase/bbui"
|
||||
|
||||
const component = getContext("component")
|
||||
const { styleable, modalStore, builderStore, dndIsDragging } =
|
||||
getContext("sdk")
|
||||
|
||||
export let onClose
|
||||
export let ignoreClicksOutside
|
||||
export let size
|
||||
let modal
|
||||
|
||||
// Open modal automatically in builder
|
||||
$: {
|
||||
if ($builderStore.inBuilder) {
|
||||
if (
|
||||
$component.inSelectedPath &&
|
||||
$modalStore.contentId !== $component.id
|
||||
) {
|
||||
modalStore.actions.open($component.id)
|
||||
} else if (
|
||||
!$component.inSelectedPath &&
|
||||
$modalStore.contentId === $component.id &&
|
||||
!$dndIsDragging
|
||||
) {
|
||||
modalStore.actions.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: open = $modalStore.contentId === $component.id
|
||||
|
||||
const handleModalClose = async () => {
|
||||
if (onClose) {
|
||||
await onClose()
|
||||
}
|
||||
modalStore.actions.close()
|
||||
}
|
||||
|
||||
const handleOpen = (open, modal) => {
|
||||
if (!modal) return
|
||||
|
||||
if (open) {
|
||||
modal.show()
|
||||
} else {
|
||||
modal.hide()
|
||||
}
|
||||
}
|
||||
|
||||
$: handleOpen(open, modal)
|
||||
</script>
|
||||
|
||||
<!-- Conditional displaying in the builder is necessary otherwise previews don't update properly upon component deletion -->
|
||||
{#if !$builderStore.inBuilder || open}
|
||||
<Modal
|
||||
on:cancel={handleModalClose}
|
||||
bind:this={modal}
|
||||
disableCancel={$builderStore.inBuilder}
|
||||
zIndex={2}
|
||||
>
|
||||
<div use:styleable={$component.styles} class={`modal-content ${size}`}>
|
||||
<div class="modal-header">
|
||||
<Icon
|
||||
color="var(--spectrum-global-color-gray-800)"
|
||||
name="Close"
|
||||
hoverable
|
||||
on:click={handleModalClose}
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-main">
|
||||
<div class="modal-main-inner">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 0px 40px;
|
||||
}
|
||||
|
||||
.small {
|
||||
width: 400px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.medium {
|
||||
width: 600px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.large {
|
||||
width: 800px;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.fullscreen {
|
||||
width: calc(100vw - 80px);
|
||||
min-height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
padding: 0 12px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-main {
|
||||
padding: 0 40px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-main :global(.component > *) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.modal-main-inner {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-main-inner:empty {
|
||||
border-radius: 3px;
|
||||
border: 2px dashed var(--spectrum-global-color-gray-400);
|
||||
}
|
||||
</style>
|
|
@ -31,41 +31,23 @@
|
|||
|
||||
let schema
|
||||
|
||||
$: formattedFields = convertOldFieldFormat(fields)
|
||||
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
|
||||
$: fetchSchema(dataSource)
|
||||
$: id = $component.id
|
||||
// We could simply spread $$props into the inner form and append our
|
||||
// additions, but that would create svelte warnings about unused props and
|
||||
// make maintenance in future more confusing as we typically always have a
|
||||
// proper mapping of schema settings to component exports, without having to
|
||||
// search multiple files
|
||||
$: innerProps = {
|
||||
dataSource,
|
||||
actionUrl,
|
||||
actionType,
|
||||
size,
|
||||
disabled,
|
||||
fields: fieldsOrDefault,
|
||||
title,
|
||||
description,
|
||||
schema,
|
||||
notificationOverride,
|
||||
buttons:
|
||||
buttons ||
|
||||
Utils.buildFormBlockButtonConfig({
|
||||
_id: id,
|
||||
showDeleteButton,
|
||||
showSaveButton,
|
||||
saveButtonLabel,
|
||||
deleteButtonLabel,
|
||||
notificationOverride,
|
||||
actionType,
|
||||
actionUrl,
|
||||
dataSource,
|
||||
}),
|
||||
buttonPosition: buttons ? buttonPosition : "top",
|
||||
}
|
||||
$: formattedFields = convertOldFieldFormat(fields)
|
||||
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
|
||||
$: buttonsOrDefault =
|
||||
buttons ||
|
||||
Utils.buildFormBlockButtonConfig({
|
||||
_id: id,
|
||||
showDeleteButton,
|
||||
showSaveButton,
|
||||
saveButtonLabel,
|
||||
deleteButtonLabel,
|
||||
notificationOverride,
|
||||
actionType,
|
||||
actionUrl,
|
||||
dataSource,
|
||||
})
|
||||
|
||||
// Provide additional data context for live binding eval
|
||||
export const getAdditionalDataContext = () => {
|
||||
|
@ -123,5 +105,18 @@
|
|||
</script>
|
||||
|
||||
<FormBlockWrapper {actionType} {dataSource} {rowId} {noRowsMessage}>
|
||||
<InnerFormBlock {...innerProps} />
|
||||
<InnerFormBlock
|
||||
{dataSource}
|
||||
{actionUrl}
|
||||
{actionType}
|
||||
{size}
|
||||
{disabled}
|
||||
fields={fieldsOrDefault}
|
||||
{title}
|
||||
{description}
|
||||
{schema}
|
||||
{notificationOverride}
|
||||
buttons={buttonsOrDefault}
|
||||
buttonPosition={buttons ? buttonPosition : "top"}
|
||||
/>
|
||||
</FormBlockWrapper>
|
||||
|
|
|
@ -91,15 +91,13 @@
|
|||
{#if description}
|
||||
<BlockComponent type="text" props={{ text: description }} order={1} />
|
||||
{/if}
|
||||
{#key fields}
|
||||
<BlockComponent type="container">
|
||||
<div class="form-block fields" class:mobile={$context.device.mobile}>
|
||||
{#each fields as field, idx}
|
||||
<FormBlockComponent {field} {schema} order={idx} />
|
||||
{/each}
|
||||
</div>
|
||||
</BlockComponent>
|
||||
{/key}
|
||||
<BlockComponent type="container">
|
||||
<div class="form-block fields" class:mobile={$context.device.mobile}>
|
||||
{#each fields as field, idx}
|
||||
<FormBlockComponent {field} {schema} order={idx} />
|
||||
{/each}
|
||||
</div>
|
||||
</BlockComponent>
|
||||
</BlockComponent>
|
||||
{#if buttonPosition === "bottom"}
|
||||
<BlockComponent
|
||||
|
|
|
@ -37,6 +37,7 @@ export { default as markdownviewer } from "./MarkdownViewer.svelte"
|
|||
export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte"
|
||||
export { default as grid } from "./Grid.svelte"
|
||||
export { default as sidepanel } from "./SidePanel.svelte"
|
||||
export { default as modal } from "./Modal.svelte"
|
||||
export { default as gridblock } from "./GridBlock.svelte"
|
||||
export * from "./charts"
|
||||
export * from "./forms"
|
||||
|
|
|
@ -57,7 +57,9 @@
|
|||
return
|
||||
}
|
||||
nextState.indicators[idx].visible =
|
||||
nextState.indicators[idx].insideSidePanel || entries[0].isIntersecting
|
||||
nextState.indicators[idx].insideModal ||
|
||||
nextState.indicators[idx].insideSidePanel ||
|
||||
entries[0].isIntersecting
|
||||
if (++callbackCount === observers.length) {
|
||||
state = nextState
|
||||
updating = false
|
||||
|
@ -139,6 +141,7 @@
|
|||
height: elBounds.height + 4,
|
||||
visible: false,
|
||||
insideSidePanel: !!child.closest(".side-panel"),
|
||||
insideModal: !!child.closest(".modal-content"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
currentRole,
|
||||
environmentStore,
|
||||
sidePanelStore,
|
||||
modalStore,
|
||||
dndIsDragging,
|
||||
confirmationStore,
|
||||
roleStore,
|
||||
|
@ -53,6 +54,7 @@ export default {
|
|||
componentStore,
|
||||
environmentStore,
|
||||
sidePanelStore,
|
||||
modalStore,
|
||||
dndIsDragging,
|
||||
currentRole,
|
||||
confirmationStore,
|
||||
|
|
|
@ -27,6 +27,7 @@ export {
|
|||
dndIsDragging,
|
||||
} from "./dnd"
|
||||
export { sidePanelStore } from "./sidePanel"
|
||||
export { modalStore } from "./modal"
|
||||
export { hoverStore } from "./hover"
|
||||
|
||||
// Context stores are layered and duplicated, so it is not a singleton
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { writable } from "svelte/store"
|
||||
|
||||
export const createModalStore = () => {
|
||||
const initialState = {
|
||||
contentId: null,
|
||||
}
|
||||
const store = writable(initialState)
|
||||
|
||||
const open = id => {
|
||||
store.update(state => {
|
||||
state.contentId = id
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
store.update(state => {
|
||||
state.contentId = null
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
actions: {
|
||||
open,
|
||||
close,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const modalStore = createModalStore()
|
|
@ -12,6 +12,7 @@ import {
|
|||
uploadStore,
|
||||
rowSelectionStore,
|
||||
sidePanelStore,
|
||||
modalStore,
|
||||
} from "stores"
|
||||
import { API } from "api"
|
||||
import { ActionTypes } from "constants"
|
||||
|
@ -436,6 +437,17 @@ const closeSidePanelHandler = () => {
|
|||
sidePanelStore.actions.close()
|
||||
}
|
||||
|
||||
const openModalHandler = action => {
|
||||
const { id } = action.parameters
|
||||
if (id) {
|
||||
modalStore.actions.open(id)
|
||||
}
|
||||
}
|
||||
|
||||
const closeModalHandler = () => {
|
||||
modalStore.actions.close()
|
||||
}
|
||||
|
||||
const downloadFileHandler = async action => {
|
||||
const { url, fileName } = action.parameters
|
||||
try {
|
||||
|
@ -499,6 +511,8 @@ const handlerMap = {
|
|||
["Prompt User"]: promptUserHandler,
|
||||
["Open Side Panel"]: openSidePanelHandler,
|
||||
["Close Side Panel"]: closeSidePanelHandler,
|
||||
["Open Modal"]: openModalHandler,
|
||||
["Close Modal"]: closeModalHandler,
|
||||
["Download File"]: downloadFileHandler,
|
||||
}
|
||||
|
||||
|
|
|
@ -161,6 +161,9 @@ export const buildFormBlockButtonConfig = props => {
|
|||
{
|
||||
"##eventHandlerType": "Close Side Panel",
|
||||
},
|
||||
{
|
||||
"##eventHandlerType": "Close Modal",
|
||||
},
|
||||
// Clear a create form once submitted
|
||||
...(actionType !== "Create"
|
||||
? []
|
||||
|
|
Loading…
Reference in New Issue