Merge pull request #4821 from Budibase/copy-paste-improvements

Copy paste improvements + extras
This commit is contained in:
Martin McKeaveney 2022-03-21 08:25:01 +00:00 committed by GitHub
commit cf9105c9dc
25 changed files with 410 additions and 211 deletions

View File

@ -56,6 +56,7 @@
$: if (!loading) loaded = true $: if (!loading) loaded = true
$: fields = getFields(schema, showAutoColumns, autoSortColumns) $: fields = getFields(schema, showAutoColumns, autoSortColumns)
$: rows = fields?.length ? data || [] : [] $: rows = fields?.length ? data || [] : []
$: totalRowCount = rows?.length || 0
$: visibleRowCount = getVisibleRowCount( $: visibleRowCount = getVisibleRowCount(
loaded, loaded,
height, height,
@ -63,7 +64,12 @@
rowCount, rowCount,
rowHeight rowHeight
) )
$: contentStyle = getContentStyle(visibleRowCount, rowCount, rowHeight) $: heightStyle = getHeightStyle(
visibleRowCount,
rowCount,
totalRowCount,
rowHeight
)
$: sortedRows = sortRows(rows, sortColumn, sortOrder) $: sortedRows = sortRows(rows, sortColumn, sortOrder)
$: gridStyle = getGridStyle(fields, schema, showEditColumn) $: gridStyle = getGridStyle(fields, schema, showEditColumn)
$: showEditColumn = allowEditRows || allowSelectRows $: showEditColumn = allowEditRows || allowSelectRows
@ -107,11 +113,16 @@
return Math.min(allRows, Math.ceil(height / rowHeight)) return Math.min(allRows, Math.ceil(height / rowHeight))
} }
const getContentStyle = (visibleRows, rowCount, rowHeight) => { const getHeightStyle = (
if (!rowCount || !visibleRows) { visibleRowCount,
rowCount,
totalRowCount,
rowHeight
) => {
if (!rowCount || !visibleRowCount || totalRowCount <= rowCount) {
return "" return ""
} }
return `height: ${headerHeight + visibleRows * rowHeight}px;` return `height: ${headerHeight + visibleRowCount * rowHeight}px;`
} }
const getGridStyle = (fields, schema, showEditColumn) => { const getGridStyle = (fields, schema, showEditColumn) => {
@ -264,11 +275,11 @@
style={`--row-height: ${rowHeight}px; --header-height: ${headerHeight}px;`} style={`--row-height: ${rowHeight}px; --header-height: ${headerHeight}px;`}
> >
{#if !loaded} {#if !loaded}
<div class="loading" style={contentStyle}> <div class="loading" style={heightStyle}>
<ProgressCircle /> <ProgressCircle />
</div> </div>
{:else} {:else}
<div class="spectrum-Table" style={`${contentStyle}${gridStyle}`}> <div class="spectrum-Table" style={`${heightStyle}${gridStyle}`}>
{#if fields.length} {#if fields.length}
<div class="spectrum-Table-head"> <div class="spectrum-Table-head">
{#if showEditColumn} {#if showEditColumn}

View File

@ -247,7 +247,7 @@ Cypress.Commands.add("createScreen", (screenName, route) => {
cy.get("[aria-label=AddCircle]").click() cy.get("[aria-label=AddCircle]").click()
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
cy.get(".item").contains("Blank").click() cy.get(".item").contains("Blank").click()
cy.get(".spectrum-Button").contains("Add Screens").click({ force: true }) cy.get(".spectrum-Button").contains("Add screens").click({ force: true })
cy.wait(500) cy.wait(500)
}) })
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
@ -265,7 +265,7 @@ Cypress.Commands.add("createAutogeneratedScreens", screenNames => {
for (let i = 0; i < screenNames.length; i++) { for (let i = 0; i < screenNames.length; i++) {
cy.get(".item").contains(screenNames[i]).click() cy.get(".item").contains(screenNames[i]).click()
} }
cy.get(".spectrum-Button").contains("Add Screens").click({ force: true }) cy.get(".spectrum-Button").contains("Add screens").click({ force: true })
cy.wait(4000) cy.wait(4000)
}) })

View File

@ -1,4 +1,10 @@
import { store } from "./index" import { store } from "./index"
import { Helpers } from "@budibase/bbui"
import {
decodeJSBinding,
encodeJSBinding,
findHBSBlocks,
} from "@budibase/string-templates"
/** /**
* Recursively searches for a specific component ID * Recursively searches for a specific component ID
@ -161,3 +167,58 @@ export const getComponentSettings = componentType => {
return settings return settings
} }
/**
* Randomises a components ID's, including all child component IDs, and also
* updates all data bindings to still be valid.
* This mutates the object in place.
* @param component the component to randomise
*/
export const makeComponentUnique = component => {
if (!component) {
return
}
// Replace component ID
const oldId = component._id
const newId = Helpers.uuid()
component._id = newId
if (component._children?.length) {
let children = JSON.stringify(component._children)
// Replace all instances of this ID in child HBS bindings
children = children.replace(new RegExp(oldId, "g"), newId)
// Replace all instances of this ID in child JS bindings
const bindings = findHBSBlocks(children)
bindings.forEach(binding => {
// JSON.stringify will have escaped double quotes, so we need
// to account for that
let sanitizedBinding = binding.replace(/\\"/g, '"')
// Check if this is a valid JS binding
let js = decodeJSBinding(sanitizedBinding)
if (js != null) {
// Replace ID inside JS binding
js = js.replace(new RegExp(oldId, "g"), newId)
// Create new valid JS binding
let newBinding = encodeJSBinding(js)
// Replace escaped double quotes
newBinding = newBinding.replace(/"/g, '\\"')
// Insert new JS back into binding.
// A single string replace here is better than a regex as
// the binding contains special characters, and we only need
// to replace a single instance.
children = children.replace(binding, newBinding)
}
})
// Recurse on all children
component._children = JSON.parse(children)
component._children.forEach(makeComponentUnique)
}
}

View File

@ -126,7 +126,7 @@ export const getDatasourceForProvider = (asset, component) => {
if (dataProviderSetting) { if (dataProviderSetting) {
const settingValue = component[dataProviderSetting.key] const settingValue = component[dataProviderSetting.key]
const providerId = extractLiteralHandlebarsID(settingValue) const providerId = extractLiteralHandlebarsID(settingValue)
const provider = findComponent(asset.props, providerId) const provider = findComponent(asset?.props, providerId)
return getDatasourceForProvider(asset, provider) return getDatasourceForProvider(asset, provider)
} }
@ -458,7 +458,7 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// Determine the entity which backs this datasource. // Determine the entity which backs this datasource.
// "provider" datasources are those targeting another data provider // "provider" datasources are those targeting another data provider
if (type === "provider") { if (type === "provider") {
const component = findComponent(asset.props, datasource.providerId) const component = findComponent(asset?.props, datasource.providerId)
const source = getDatasourceForProvider(asset, component) const source = getDatasourceForProvider(asset, component)
return getSchemaForDatasource(asset, source, options) return getSchemaForDatasource(asset, source, options)
} }

View File

@ -25,7 +25,7 @@ export const selectedComponent = derived(
if (!$currentAsset || !$store.selectedComponentId) { if (!$currentAsset || !$store.selectedComponentId) {
return null return null
} }
return findComponent($currentAsset.props, $store.selectedComponentId) return findComponent($currentAsset?.props, $store.selectedComponentId)
} }
) )

View File

@ -24,9 +24,9 @@ import {
findAllMatchingComponents, findAllMatchingComponents,
findComponent, findComponent,
getComponentSettings, getComponentSettings,
makeComponentUnique,
} from "../componentUtils" } from "../componentUtils"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { removeBindings } from "../dataBinding"
const INITIAL_FRONTEND_STATE = { const INITIAL_FRONTEND_STATE = {
apps: [], apps: [],
@ -400,11 +400,11 @@ export const getFrontendStore = () => {
parentComponent = selected parentComponent = selected
} else { } else {
// Otherwise we need to use the parent of this component // Otherwise we need to use the parent of this component
parentComponent = findComponentParent(asset.props, selected._id) parentComponent = findComponentParent(asset?.props, selected._id)
} }
} else { } else {
// Use screen or layout if no component is selected // Use screen or layout if no component is selected
parentComponent = asset.props parentComponent = asset?.props
} }
// Attach component // Attach component
@ -490,37 +490,22 @@ export const getFrontendStore = () => {
} }
} }
}, },
paste: async (targetComponent, mode, preserveBindings = false) => { paste: async (targetComponent, mode) => {
let promises = [] let promises = []
store.update(state => { store.update(state => {
// Stop if we have nothing to paste // Stop if we have nothing to paste
if (!state.componentToPaste) { if (!state.componentToPaste) {
return state return state
} }
// defines if this is a copy or a cut
const cut = state.componentToPaste.isCut const cut = state.componentToPaste.isCut
// immediately need to remove bindings, currently these aren't valid when pasted // Clone the component to paste and make unique if copying
if (!cut && !preserveBindings) {
state.componentToPaste = removeBindings(state.componentToPaste, "")
}
// Clone the component to paste
// Retain the same ID if cutting as things may be referencing this component
delete state.componentToPaste.isCut delete state.componentToPaste.isCut
let componentToPaste = cloneDeep(state.componentToPaste) let componentToPaste = cloneDeep(state.componentToPaste)
if (cut) { if (cut) {
state.componentToPaste = null state.componentToPaste = null
} else { } else {
const randomizeIds = component => { makeComponentUnique(componentToPaste)
if (!component) {
return
}
component._id = Helpers.uuid()
component._children?.forEach(randomizeIds)
}
randomizeIds(componentToPaste)
} }
if (mode === "inside") { if (mode === "inside") {

View File

@ -10,17 +10,18 @@ const allTemplates = tables => [
] ]
// Allows us to apply common behaviour to all create() functions // Allows us to apply common behaviour to all create() functions
const createTemplateOverride = (frontendState, create) => () => { const createTemplateOverride = (frontendState, template) => () => {
const screen = create() const screen = template.create()
screen.name = screen.props._id screen.name = screen.props._id
screen.routing.route = screen.routing.route.toLowerCase() screen.routing.route = screen.routing.route.toLowerCase()
screen.template = template.id
return screen return screen
} }
export default (frontendState, tables) => { export default (frontendState, tables) => {
const enrichTemplate = template => ({ const enrichTemplate = template => ({
...template, ...template,
create: createTemplateOverride(frontendState, template.create), create: createTemplateOverride(frontendState, template),
}) })
const fromScratch = enrichTemplate(createFromScratchScreen) const fromScratch = enrichTemplate(createFromScratchScreen)

View File

@ -160,6 +160,11 @@
await store.actions.components.updateProp(data.prop, data.value) await store.actions.components.updateProp(data.prop, data.value)
} else if (type === "delete-component" && data.id) { } else if (type === "delete-component" && data.id) {
confirmDeleteComponent(data.id) confirmDeleteComponent(data.id)
} else if (type === "duplicate-component" && data.id) {
const rootComponent = get(currentAsset).props
const component = findComponent(rootComponent, data.id)
store.actions.components.copy(component)
await store.actions.components.paste(component)
} else if (type === "preview-loaded") { } else if (type === "preview-loaded") {
// Wait for this event to show the client library if intelligent // Wait for this event to show the client library if intelligent
// loading is supported // loading is supported

View File

@ -21,7 +21,7 @@
const moveUpComponent = () => { const moveUpComponent = () => {
const asset = get(currentAsset) const asset = get(currentAsset)
const parent = findComponentParent(asset.props, component._id) const parent = findComponentParent(asset?.props, component._id)
if (!parent) { if (!parent) {
return return
} }
@ -41,7 +41,7 @@
const moveDownComponent = () => { const moveDownComponent = () => {
const asset = get(currentAsset) const asset = get(currentAsset)
const parent = findComponentParent(asset.props, component._id) const parent = findComponentParent(asset?.props, component._id)
if (!parent) { if (!parent) {
return return
} }
@ -61,7 +61,7 @@
const duplicateComponent = () => { const duplicateComponent = () => {
storeComponentForCopy(false) storeComponentForCopy(false)
pasteComponent("below", true) pasteComponent("below")
} }
const deleteComponent = async () => { const deleteComponent = async () => {
@ -73,14 +73,12 @@
} }
const storeComponentForCopy = (cut = false) => { const storeComponentForCopy = (cut = false) => {
// lives in store - also used by drag drop
store.actions.components.copy(component, cut) store.actions.components.copy(component, cut)
} }
const pasteComponent = (mode, preserveBindings = false) => { const pasteComponent = mode => {
try { try {
// lives in store - also used by drag drop store.actions.components.paste(component, mode)
store.actions.components.paste(component, mode, preserveBindings)
} catch (error) { } catch (error) {
notifications.error("Error saving component") notifications.error("Error saving component")
} }

View File

@ -1,7 +1,7 @@
<script> <script>
import { store } from "builderStore" import { store } from "builderStore"
import { DropEffect, DropPosition } from "./dragDropStore" import { DropEffect, DropPosition } from "./dragDropStore"
import ComponentDropdownMenu from "../ComponentDropdownMenu.svelte" import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"

View File

@ -51,7 +51,7 @@
bind:this={confirmDeleteDialog} bind:this={confirmDeleteDialog}
title="Confirm Deletion" title="Confirm Deletion"
body={"Are you sure you wish to delete this layout?"} body={"Are you sure you wish to delete this layout?"}
okText="Delete Layout" okText="Delete layout"
onOk={deleteLayout} onOk={deleteLayout}
/> />

View File

@ -0,0 +1,78 @@
<script>
import { goto } from "@roxi/routify"
import { store } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import {
ActionMenu,
MenuItem,
Icon,
Layout,
notifications,
} from "@budibase/bbui"
import { get } from "svelte/store"
export let path
export let screens
let confirmDeleteDialog
const deleteScreens = async () => {
if (!screens?.length) {
return
}
try {
for (let { id } of screens) {
// We have to fetch the screen to be deleted immediately before deleting
// as otherwise we're very likely to 409
const screen = get(store).screens.find(screen => screen._id === id)
if (!screen) {
continue
}
await store.actions.screens.delete(screen)
}
notifications.success("Screens deleted successfully")
$goto("../")
} catch (error) {
notifications.error("Error deleting screens")
}
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>
Delete all screens
</MenuItem>
</ActionMenu>
<ConfirmDialog
bind:this={confirmDeleteDialog}
title="Confirm Deletion"
okText="Delete screens"
onOk={deleteScreens}
>
<Layout noPadding gap="S">
<div>
Are you sure you want to delete all screens under the <b>{path}</b> route?
</div>
<div>The following screens will be deleted:</div>
<div class="to-delete">
{#each screens as screen}
<div>{screen.route}</div>
{/each}
</div>
</Layout>
</ConfirmDialog>
<style>
.to-delete {
font-weight: bold;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
padding-left: var(--spacing-xl);
}
</style>

View File

@ -8,6 +8,7 @@
import instantiateStore from "./dragDropStore" import instantiateStore from "./dragDropStore"
import ComponentTree from "./ComponentTree.svelte" import ComponentTree from "./ComponentTree.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import PathDropdownMenu from "./PathDropdownMenu.svelte"
import ScreenDropdownMenu from "./ScreenDropdownMenu.svelte" import ScreenDropdownMenu from "./ScreenDropdownMenu.svelte"
import { get } from "svelte/store" import { get } from "svelte/store"
@ -73,7 +74,9 @@
opened={routeOpened} opened={routeOpened}
{border} {border}
withArrow={route.subpaths} withArrow={route.subpaths}
/> >
<PathDropdownMenu screens={allScreens} {path} />
</NavItem>
{#if routeOpened} {#if routeOpened}
{#each filteredScreens as screen (screen.id)} {#each filteredScreens as screen (screen.id)}

View File

@ -2,14 +2,57 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { store, allScreens } from "builderStore" import { store, allScreens } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui" import {
ActionMenu,
MenuItem,
Icon,
Modal,
Helpers,
notifications,
} from "@budibase/bbui"
import ScreenDetailsModal from "../ScreenDetailsModal.svelte"
import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl"
import analytics, { Events } from "analytics"
import { makeComponentUnique } from "builderStore/componentUtils"
export let screenId export let screenId
let confirmDeleteDialog let confirmDeleteDialog
let screenDetailsModal
$: screen = $allScreens.find(screen => screen._id === screenId) $: screen = $allScreens.find(screen => screen._id === screenId)
const duplicateScreen = () => {
screenDetailsModal.show()
}
const createDuplicateScreen = async ({ screenName, screenUrl }) => {
// Create a dupe and ensure it is unique
let duplicateScreen = Helpers.cloneDeep(screen)
delete duplicateScreen._id
delete duplicateScreen._rev
makeComponentUnique(duplicateScreen.props)
// Attach the new name and URL
duplicateScreen.routing.route = sanitizeUrl(screenUrl)
duplicateScreen.props._instanceName = screenName
try {
// Create the screen
await store.actions.screens.save(duplicateScreen)
// Analytics
if (screen.template) {
analytics.captureEvent(Events.SCREEN.CREATED, {
template: "createFromScratch",
})
}
} catch (error) {
notifications.error("Error duplicating screen")
console.log(error)
}
}
const deleteScreen = async () => { const deleteScreen = async () => {
try { try {
await store.actions.screens.delete(screen) await store.actions.screens.delete(screen)
@ -19,12 +62,28 @@
notifications.error("Error deleting screen") notifications.error("Error deleting screen")
} }
} }
const pasteComponent = mode => {
try {
store.actions.components.paste(screen?.props, mode)
} catch (error) {
notifications.error("Error saving component")
}
}
</script> </script>
<ActionMenu> <ActionMenu>
<div slot="control" class="icon"> <div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" /> <Icon size="S" hoverable name="MoreSmallList" />
</div> </div>
<MenuItem icon="Duplicate" on:click={duplicateScreen}>Duplicate</MenuItem>
<MenuItem
icon="ShowOneLayer"
on:click={() => pasteComponent("inside")}
disabled={!$store.componentToPaste}
>
Paste inside
</MenuItem>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem> <MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu> </ActionMenu>
@ -32,6 +91,15 @@
bind:this={confirmDeleteDialog} bind:this={confirmDeleteDialog}
title="Confirm Deletion" title="Confirm Deletion"
body={"Are you sure you wish to delete this screen?"} body={"Are you sure you wish to delete this screen?"}
okText="Delete Screen" okText="Delete screen"
onOk={deleteScreen} onOk={deleteScreen}
/> />
<Modal bind:this={screenDetailsModal}>
<ScreenDetailsModal
onConfirm={createDuplicateScreen}
screenName={screen?.props._instanceName}
screenUrl={screen?.routing.route}
confirmText="Duplicate"
/>
</Modal>

View File

@ -10,39 +10,19 @@
ProgressCircle, ProgressCircle,
} from "@budibase/bbui" } from "@budibase/bbui"
import getTemplates from "builderStore/store/screenTemplates" import getTemplates from "builderStore/store/screenTemplates"
import { onDestroy } from "svelte"
import { createEventDispatcher } from "svelte" export let onConfirm
export let onCancel
export let chooseModal
export let save
export let showProgressCircle = false export let showProgressCircle = false
let selectedScreens = []
const blankScreen = "createFromScratch" const blankScreen = "createFromScratch"
const dispatch = createEventDispatcher()
function setScreens() { let selectedScreens = []
dispatch("save", { let templates = getTemplates($store, $tables.list)
screens: selectedScreens,
})
}
$: blankSelected = selectedScreens?.length === 1 $: blankSelected = selectedScreens?.length === 1
$: autoSelected = selectedScreens?.length > 0 && !blankSelected $: autoSelected = selectedScreens?.length > 0 && !blankSelected
let templates = getTemplates($store, $tables.list)
const confirm = async () => {
if (autoSelected) {
setScreens()
await save()
} else {
setScreens()
chooseModal(1)
}
}
const toggleScreenSelection = table => { const toggleScreenSelection = table => {
if (selectedScreens.find(s => s.table === table.name)) { if (selectedScreens.find(s => s.table === table.name)) {
selectedScreens = selectedScreens.filter( selectedScreens = selectedScreens.filter(
@ -56,25 +36,25 @@
} }
} }
onDestroy(() => { const confirmScreenSelection = async () => {
selectedScreens = [] await onConfirm(selectedScreens)
}) }
</script> </script>
<div> <div>
<ModalContent <ModalContent
title="Add screens" title="Add screens"
confirmText="Add Screens" confirmText="Add screens"
cancelText="Cancel" cancelText="Cancel"
onConfirm={() => confirm()} onConfirm={confirmScreenSelection}
{onCancel}
disabled={!selectedScreens.length} disabled={!selectedScreens.length}
size="L" size="L"
> >
<Body size="S" <Body size="S">
>Please select the screens you would like to add to your application. Please select the screens you would like to add to your application.
Autogenerated screens come with CRUD functionality.</Body Autogenerated screens come with CRUD functionality.
> </Body>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<Detail size="S">Blank screen</Detail> <Detail size="S">Blank screen</Detail>
<div <div

View File

@ -2,58 +2,62 @@
import { ModalContent, Input, ProgressCircle } from "@budibase/bbui" import { ModalContent, Input, ProgressCircle } from "@budibase/bbui"
import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl" import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl"
import { selectedAccessRole, allScreens } from "builderStore" import { selectedAccessRole, allScreens } from "builderStore"
import { onDestroy } from "svelte" import { get } from "svelte/store"
export let screenName export let onConfirm
export let url export let onCancel
export let chooseModal
export let save
export let showProgressCircle = false export let showProgressCircle = false
export let screenName
export let screenUrl
export let confirmText = "Continue"
let routeError let routeError
let roleId = $selectedAccessRole || "BASIC" let touched = false
const routeChanged = event => { const routeChanged = event => {
if (!event.detail.startsWith("/")) { if (!event.detail.startsWith("/")) {
url = "/" + event.detail screenUrl = "/" + event.detail
} }
url = sanitizeUrl(url) touched = true
screenUrl = sanitizeUrl(screenUrl)
if (routeExists(url, roleId)) { if (routeExists(screenUrl)) {
routeError = "This URL is already taken for this access role" routeError = "This URL is already taken for this access role"
} else { } else {
routeError = "" routeError = null
} }
} }
const routeExists = (url, roleId) => { const routeExists = url => {
return $allScreens.some( const roleId = get(selectedAccessRole) || "BASIC"
return get(allScreens).some(
screen => screen =>
screen.routing.route.toLowerCase() === url.toLowerCase() && screen.routing.route.toLowerCase() === url.toLowerCase() &&
screen.routing.roleId === roleId screen.routing.roleId === roleId
) )
} }
onDestroy(() => { const confirmScreenDetails = async () => {
screenName = "" await onConfirm({
url = "" screenName,
screenUrl,
}) })
}
</script> </script>
<ModalContent <ModalContent
size="M" size="M"
title={"Enter details"} title={"Enter details"}
confirmText={"Continue"} {confirmText}
onCancel={() => chooseModal(0)} onConfirm={confirmScreenDetails}
onConfirm={() => save()} {onCancel}
cancelText={"Back"} cancelText={"Back"}
disabled={!screenName || !url || routeError} disabled={!screenName || !screenUrl || routeError || !touched}
> >
<Input label="Name" bind:value={screenName} /> <Input label="Name" bind:value={screenName} />
<Input <Input
label="URL" label="URL"
error={routeError} error={routeError}
bind:value={url} bind:value={screenUrl}
on:change={routeChanged} on:change={routeChanged}
/> />
<div slot="footer"> <div slot="footer">

View File

@ -3,141 +3,133 @@
import NewScreenModal from "components/design/NavigationPanel/NewScreenModal.svelte" import NewScreenModal from "components/design/NavigationPanel/NewScreenModal.svelte"
import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl" import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl"
import { Modal, notifications } from "@budibase/bbui" import { Modal, notifications } from "@budibase/bbui"
import { store, selectedAccessRole, allScreens } from "builderStore" import { store, selectedAccessRole } from "builderStore"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
import { get } from "svelte/store"
let newScreenModal let pendingScreen
let navigationSelectionModal
let screenDetailsModal
let screenName = ""
let url = ""
let selectedScreens = []
let showProgressCircle = false let showProgressCircle = false
let routeError
let createdScreens = []
$: roleId = $selectedAccessRole || "BASIC" // Modal refs
let newScreenModal
let screenDetailsModal
const createScreens = async () => { // External handler to show the screen wizard
for (let screen of selectedScreens) { export const showModal = () => {
let test = screen.create() newScreenModal.show()
createdScreens.push(test)
analytics.captureEvent(Events.SCREEN.CREATED, {
template: screen.id || screen.name,
})
}
}
const save = async () => { // Reset state when showing modal again
showProgressCircle = true pendingScreen = null
try {
await createScreens()
for (let screen of createdScreens) {
await saveScreens(screen)
}
await store.actions.routing.fetch()
selectedScreens = []
createdScreens = []
screenName = ""
url = ""
} catch (error) {
notifications.error("Error creating screens")
}
showProgressCircle = false showProgressCircle = false
} }
const saveScreens = async draftScreen => { // Creates an array of screens, checking and sanitising their URLs
let existingScreenCount = $store.screens.filter( const createScreens = async screens => {
s => s.props._instanceName == draftScreen.props._instanceName if (!screens?.length) {
).length return
if (existingScreenCount > 0) {
let oldUrlArr = draftScreen.routing.route.split("/")
oldUrlArr[1] = `${oldUrlArr[1]}-${existingScreenCount + 1}`
draftScreen.routing.route = oldUrlArr.join("/")
} }
showProgressCircle = true
let route = url ? sanitizeUrl(`${url}`) : draftScreen.routing.route
if (draftScreen) {
if (!route) {
routeError = "URL is required"
} else {
if (routeExists(route, roleId)) {
routeError = "This URL is already taken for this access role"
} else {
routeError = ""
}
}
if (routeError) return false
if (screenName) {
draftScreen.props._instanceName = screenName
}
draftScreen.routing.route = route
draftScreen.routing.roleId = roleId
await store.actions.screens.save(draftScreen)
if (draftScreen.props._instanceName.endsWith("List")) {
try { try {
for (let screen of screens) {
// Check we aren't clashing with an existing URL
if (hasExistingUrl(screen.routing.route)) {
let suffix = 2
let candidateUrl = makeCandidateUrl(screen, suffix)
while (hasExistingUrl(candidateUrl)) {
candidateUrl = makeCandidateUrl(screen, ++suffix)
}
screen.routing.route = candidateUrl
}
// Sanitise URL
screen.routing.route = sanitizeUrl(screen.routing.route)
// Use the currently selected role
screen.routing.roleId = get(selectedAccessRole) || "BASIC"
// Create the screen
await store.actions.screens.save(screen)
// Analytics
if (screen.template) {
analytics.captureEvent(Events.SCREEN.CREATED, {
template: screen.template,
})
}
// Add link in layout for list screens
if (screen.props._instanceName.endsWith("List")) {
await store.actions.components.links.save( await store.actions.components.links.save(
draftScreen.routing.route, screen.routing.route,
draftScreen.routing.route.split("/")[1] screen.routing.route.split("/")[1]
) )
}
}
} catch (error) { } catch (error) {
notifications.error("Error creating link to screen") notifications.error("Error creating screens")
} }
showProgressCircle = false
} }
// Checks if any screens exist in the store with the given route and
// currently selected role
const hasExistingUrl = url => {
const roleId = get(selectedAccessRole) || "BASIC"
const screens = get(store).screens.filter(s => s.routing.roleId === roleId)
return !!screens.find(s => s.routing?.route === url)
}
// Constructs a candidate URL for a new screen, suffixing the base of the
// screen's URL with a given suffix.
// e.g. "/sales/:id" => "/sales-1/:id"
const makeCandidateUrl = (screen, suffix) => {
let url = screen.routing?.route || ""
if (url.startsWith("/")) {
url = url.slice(1)
}
if (!url.includes("/")) {
return `/${url}-${suffix}`
} else {
const split = url.split("/")
return `/${split[0]}-${suffix}/${split.slice(1).join("/")}`
} }
} }
const routeExists = (route, roleId) => { // Handler for NewScreenModal
return $allScreens.some( const confirmScreenSelection = async templates => {
screen => // Handle template selection
screen.routing.route.toLowerCase() === route.toLowerCase() && if (templates?.length > 1) {
screen.routing.roleId === roleId // Autoscreens, so create immediately
) const screens = templates.map(template => template.create())
} await createScreens(screens)
} else {
export const showModal = () => { // Empty screen, so proceed to the next modal
newScreenModal.show() pendingScreen = templates[0].create()
}
const setScreens = evt => {
selectedScreens = evt.detail.screens
}
const chooseModal = index => {
/*
0 = newScreenModal
1 = screenDetailsModal
2 = navigationSelectionModal
*/
if (index === 0) {
newScreenModal.show()
} else if (index === 1) {
screenDetailsModal.show() screenDetailsModal.show()
} else if (index === 2) {
navigationSelectionModal.show()
} }
} }
// Handler for ScreenDetailsModal
const confirmScreenDetails = async ({ screenName, screenUrl }) => {
if (!pendingScreen) {
return
}
pendingScreen.props._instanceName = screenName
pendingScreen.routing.route = screenUrl
await createScreens([pendingScreen])
}
</script> </script>
<Modal bind:this={newScreenModal}> <Modal bind:this={newScreenModal}>
<NewScreenModal <NewScreenModal onConfirm={confirmScreenSelection} {showProgressCircle} />
on:save={setScreens}
{showProgressCircle}
{save}
{chooseModal}
/>
</Modal> </Modal>
<Modal bind:this={screenDetailsModal}> <Modal bind:this={screenDetailsModal}>
<ScreenDetailsModal <ScreenDetailsModal
bind:screenName
bind:url
{showProgressCircle} {showProgressCircle}
{save} onConfirm={confirmScreenDetails}
{chooseModal} onCancel={() => newScreenModal.show()}
/> />
</Modal> </Modal>

View File

@ -33,7 +33,7 @@
const customSections = settings.filter(setting => setting.section) const customSections = settings.filter(setting => setting.section)
return [ return [
{ {
name: "General", name: componentDefinition?.name || "General",
info: componentDefinition?.info, info: componentDefinition?.info,
settings: generalSettings, settings: generalSettings,
}, },

View File

@ -5,7 +5,7 @@
export let parameters export let parameters
$: components = findAllMatchingComponents($currentAsset.props, component => $: components = findAllMatchingComponents($currentAsset?.props, component =>
component._component.endsWith("s3upload") component._component.endsWith("s3upload")
) )
</script> </script>

View File

@ -10,7 +10,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const getValue = component => `{{ literal ${makePropSafe(component._id)} }}` const getValue = component => `{{ literal ${makePropSafe(component._id)} }}`
$: path = findComponentPath($currentAsset.props, $store.selectedComponentId) $: path = findComponentPath($currentAsset?.props, $store.selectedComponentId)
$: providers = path.filter(c => c._component?.endsWith("/dataprovider")) $: providers = path.filter(c => c._component?.endsWith("/dataprovider"))
// Set initial value to closest data provider // Set initial value to closest data provider

View File

@ -12,7 +12,7 @@
export let type export let type
$: form = findClosestMatchingComponent( $: form = findClosestMatchingComponent(
$currentAsset.props, $currentAsset?.props,
componentInstance._id, componentInstance._id,
component => component._component === "@budibase/standard-components/form" component => component._component === "@budibase/standard-components/form"
) )

View File

@ -11,7 +11,7 @@
const resetFormFields = async () => { const resetFormFields = async () => {
const form = findClosestMatchingComponent( const form = findClosestMatchingComponent(
$currentAsset.props, $currentAsset?.props,
componentInstance._id, componentInstance._id,
component => component._component.endsWith("/form") component => component._component.endsWith("/form")
) )

View File

@ -135,7 +135,7 @@
if (asset?._id) { if (asset?._id) {
url += `/${asset._id}` url += `/${asset._id}`
if (componentId) { if (componentId) {
const componentPath = findComponentPath(asset.props, componentId) const componentPath = findComponentPath(asset?.props, componentId)
const componentURL = componentPath const componentURL = componentPath
.slice(1) .slice(1)
.map(comp => comp._id) .map(comp => comp._id)

View File

@ -146,6 +146,15 @@
<div class="divider" /> <div class="divider" />
{/if} {/if}
{/each} {/each}
<SettingsButton
icon="Duplicate"
on:click={() => {
builderStore.actions.duplicateComponent(
$builderStore.selectedComponent._id
)
}}
title="Duplicate component"
/>
<SettingsButton <SettingsButton
icon="Delete" icon="Delete"
on:click={() => { on:click={() => {
@ -153,6 +162,7 @@
$builderStore.selectedComponent._id $builderStore.selectedComponent._id
) )
}} }}
title="Delete component"
/> />
</div> </div>
{/if} {/if}

View File

@ -62,6 +62,9 @@ const createBuilderStore = () => {
deleteComponent: id => { deleteComponent: id => {
dispatchEvent("delete-component", { id }) dispatchEvent("delete-component", { id })
}, },
duplicateComponent: id => {
dispatchEvent("duplicate-component", { id })
},
notifyLoaded: () => { notifyLoaded: () => {
dispatchEvent("preview-loaded") dispatchEvent("preview-loaded")
}, },