diff --git a/packages/builder/cypress/integration/createBinding.spec.js b/packages/builder/cypress/integration/createBinding.spec.js new file mode 100644 index 000000000..971df3ed7 --- /dev/null +++ b/packages/builder/cypress/integration/createBinding.spec.js @@ -0,0 +1,18 @@ +xcontext('Create a Binding', () => { + before(() => { + cy.visit('localhost:4001/_builder') + cy.createApp('Binding App', 'Binding App Description') + cy.navigateToFrontend() + }) + + it('add an input binding', () => { + cy.get(".nav-items-container").contains('Home').click() + cy.contains("Add").click() + cy.get("[data-cy=Input]").click() + cy.get("[data-cy=Textfield]").click() + cy.contains("Heading").click() + cy.get("[data-cy=text-binding-button]").click() + cy.get("[data-cy=binding-dropdown-modal]").contains('Input 1').click() + cy.get("[data-cy=binding-dropdown-modal] textarea").should('have.value', 'Home{{ Input 1 }}') + }) +}) diff --git a/packages/builder/cypress/integration/createUser.spec.js b/packages/builder/cypress/integration/createUser.spec.js index 192cdae31..b1f737068 100644 --- a/packages/builder/cypress/integration/createUser.spec.js +++ b/packages/builder/cypress/integration/createUser.spec.js @@ -9,7 +9,7 @@ context('Create a User', () => { // https://on.cypress.io/interacting-with-elements it('should create a user', () => { - cy.createUser('bbuser', 'test', 'ADMIN') + cy.createUser('bbuser', 'test', 'POWER_USER') // Check to make sure user was created! cy.get("input[disabled]").should('have.value', 'bbuser') diff --git a/packages/builder/cypress/integration/createWorkflow.spec.js b/packages/builder/cypress/integration/createWorkflow.spec.js index f341bf86b..e7fe34b63 100644 --- a/packages/builder/cypress/integration/createWorkflow.spec.js +++ b/packages/builder/cypress/integration/createWorkflow.spec.js @@ -1,4 +1,4 @@ -xcontext('Create a workflow', () => { +context('Create a workflow', () => { before(() => { cy.server() @@ -9,7 +9,7 @@ xcontext('Create a workflow', () => { // https://on.cypress.io/interacting-with-elements it('should create a workflow', () => { - cy.createTable('dog', 'name', 'age') + cy.createTestTableWithData() cy.contains('workflow').click() cy.contains('Create New Workflow').click() @@ -23,21 +23,23 @@ xcontext('Create a workflow', () => { // Create action cy.get('[data-cy=SAVE_RECORD]').click() - cy.get(':nth-child(2) > .budibase__input').type('goodboy') - cy.get(':nth-child(3) > .budibase__input').type('11') + cy.get('.container input').first().type('goodboy') + cy.get('.container input').eq(1).type('11') // Save cy.contains('Save Workflow').click() // Activate Workflow cy.get('[data-cy=activate-workflow]').click() - + cy.contains("Add Record").should("be.visible") + cy.get(".stop-button.highlighted").should("be.visible") }) - xit('should add record when a new record is added', () => { - cy.contains('backend').click() - cy.addRecord('bob', '15') + it('should add record when a new record is added', () => { + cy.contains('backend').click() + cy.addRecord(["Rover", 15]) + cy.reload() cy.contains('goodboy').should('have.text', 'goodboy') }) diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index da85abddb..fc8397f13 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -57,12 +57,17 @@ Cypress.Commands.add("createApp", name => { }) }) +Cypress.Commands.add("createTestTableWithData", () => { + cy.createTable("dog") + cy.addColumn("dog", "name", "Plain Text") + cy.addColumn("dog", "age", "Number") +}) + Cypress.Commands.add("createTable", tableName => { // Enter model name cy.contains("Create New Table").click() cy.get("[placeholder='Table Name']").type(tableName) - // Add 'name' field cy.contains("Save").click() cy.contains(tableName).should("be.visible") }) @@ -84,7 +89,7 @@ Cypress.Commands.add("addRecord", values => { cy.contains("Create New Row").click() for (let i = 0; i < values.length; i++) { - cy.get("input") + cy.get(".actions input") .eq(i) .type(values[i]) } @@ -93,7 +98,7 @@ Cypress.Commands.add("addRecord", values => { cy.contains("Save").click() }) -Cypress.Commands.add("createUser", (username, password) => { +Cypress.Commands.add("createUser", (username, password, accessLevel) => { // Create User cy.get(".toprightnav > .settings").click() cy.contains("Users").click() @@ -104,9 +109,12 @@ Cypress.Commands.add("createUser", (username, password) => { cy.get("[name=Password]") .first() .type(password) + cy.get("select") + .first() + .select(accessLevel) // Save - cy.get(".create-button").click() + cy.get(".create-button > button").click() }) Cypress.Commands.add("addHeadlineComponent", text => { diff --git a/packages/builder/cypress/support/cookies.js b/packages/builder/cypress/support/cookies.js index bfb470c57..1c172f4d4 100644 --- a/packages/builder/cypress/support/cookies.js +++ b/packages/builder/cypress/support/cookies.js @@ -1,3 +1,3 @@ Cypress.Cookies.defaults({ - whitelist: "builder:token", + preserve: "builder:token", }) diff --git a/packages/builder/package.json b/packages/builder/package.json index cfd51249d..b2dc56506 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -49,15 +49,21 @@ ], "setupFilesAfterEnv": [ "@testing-library/jest-dom/extend-expect" + ], + "setupFiles": [ + "./scripts/jestSetup.js" ] }, "eslintConfig": { "extends": [ "plugin:cypress/recommended" - ] + ], + "rules": { + "cypress/no-unnecessary-waiting": "off" + } }, "dependencies": { - "@budibase/bbui": "^1.27.1", + "@budibase/bbui": "^1.29.1", "@budibase/client": "^0.1.19", "@budibase/colorpicker": "^1.0.1", "@sentry/browser": "5.19.1", @@ -88,7 +94,7 @@ "@testing-library/jest-dom": "^5.11.0", "@testing-library/svelte": "^3.0.0", "babel-jest": "^24.8.0", - "cypress": "^4.8.0", + "cypress": "^5.1.0", "cypress-terminal-report": "^1.4.1", "eslint-plugin-cypress": "^2.11.1", "identity-obj-proxy": "^3.0.0", diff --git a/packages/builder/scripts/jestSetup.js b/packages/builder/scripts/jestSetup.js new file mode 100644 index 000000000..52053d409 --- /dev/null +++ b/packages/builder/scripts/jestSetup.js @@ -0,0 +1,25 @@ +if (!Array.prototype.flat) { + Object.defineProperty(Array.prototype, "flat", { + configurable: true, + value: function flat() { + var depth = isNaN(arguments[0]) ? 1 : Number(arguments[0]) + + return depth + ? Array.prototype.reduce.call( + this, + function(acc, cur) { + if (Array.isArray(cur)) { + acc.push.apply(acc, flat.call(cur, depth - 1)) + } else { + acc.push(cur) + } + + return acc + }, + [] + ) + : Array.prototype.slice.call(this) + }, + writable: true, + }) +} diff --git a/packages/builder/src/budibase.css b/packages/builder/src/budibase.css index 8de82530b..0db3e2fec 100644 --- a/packages/builder/src/budibase.css +++ b/packages/builder/src/budibase.css @@ -127,4 +127,30 @@ align-items: center; justify-content: center; font-size: 20px; -} \ No newline at end of file +} + +/* Table styles */ +.bb-table { + border-collapse: collapse; + border-spacing: 0; + width: 100%; + margin-bottom: var(--spacing-xl); +} + +* + .bb-table { + margin-top: var(--spacing-xl) +} + +.bb-table th { + padding: var(--spacing-l) var(--spacing-m); + text-align: left; + vertical-align: bottom; +} + +.bb-table td { + padding: var(--spacing-l) var(--spacing-m); + vertical-align: top; +} + +.bb-table td > :last-child { margin-bottom: 0; } + diff --git a/packages/builder/src/builderStore/fetchBindableProperties.js b/packages/builder/src/builderStore/fetchBindableProperties.js new file mode 100644 index 000000000..f36484fbd --- /dev/null +++ b/packages/builder/src/builderStore/fetchBindableProperties.js @@ -0,0 +1,157 @@ +import { cloneDeep, difference } from "lodash/fp" + +/** + * parameter for fetchBindableProperties function + * @typedef {Object} fetchBindablePropertiesParameter + * @property {string} componentInstanceId - an _id of a component that has been added to a screen, which you want to fetch bindable props for + * @propperty {Object} screen - current screen - where componentInstanceId lives + * @property {Object} components - dictionary of component definitions + * @property {Array} models - array of all models + */ + +/** + * + * @typedef {Object} BindableProperty + * @property {string} type - either "instance" (binding to a component instance) or "context" (binding to data in context e.g. List Item) + * @property {Object} instance - relevant component instance. If "context" type, this instance is the component that provides the context... e.g. the List + * @property {string} runtimeBinding - a binding string that is a) saved against the string, and b) used at runtime to read/write the value + * @property {string} readableBinding - a binding string that is displayed to the user, in the builder + */ + +/** + * Generates all allowed bindings from within any particular component instance + * @param {fetchBindablePropertiesParameter} param + * @returns {Array.} + */ +export default function({ componentInstanceId, screen, components, models }) { + const walkResult = walk({ + // cloning so we are free to mutate props (e.g. by adding _contexts) + instance: cloneDeep(screen.props), + targetId: componentInstanceId, + components, + models, + }) + + return [ + ...walkResult.bindableInstances + .filter(isInstanceInSharedContext(walkResult)) + .map(componentInstanceToBindable(walkResult)), + + ...walkResult.target._contexts.map(contextToBindables(walkResult)).flat(), + ] +} + +const isInstanceInSharedContext = walkResult => i => + // should cover + // - neither are in any context + // - both in same context + // - instance is in ancestor context of target + i.instance._contexts.length <= walkResult.target._contexts.length && + difference(i.instance._contexts, walkResult.target._contexts).length === 0 + +// turns a component instance prop into binding expressions +// used by the UI +const componentInstanceToBindable = walkResult => i => { + const lastContext = + i.instance._contexts.length && + i.instance._contexts[i.instance._contexts.length - 1] + const contextParentPath = lastContext + ? getParentPath(walkResult, lastContext) + : "" + + return { + type: "instance", + instance: i.instance, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: `${contextParentPath}${i.instance._id}.${i.prop}`, + // how the binding exressions looks to the user of the builder + readableBinding: `${i.instance._instanceName}`, + } +} + +const contextToBindables = walkResult => context => { + const contextParentPath = getParentPath(walkResult, context) + + return Object.keys(context.model.schema).map(k => ({ + type: "context", + instance: context.instance, + // how the binding expression persists, and is used in the app at runtime + runtimeBinding: `${contextParentPath}data.${k}`, + // how the binding exressions looks to the user of the builder + readableBinding: `${context.instance._instanceName}.${context.model.name}.${k}`, + })) +} + +const getParentPath = (walkResult, context) => { + // describes the number of "parent" in the path + // clone array first so original array is not mtated + const contextParentNumber = [...walkResult.target._contexts] + .reverse() + .indexOf(context) + + return ( + new Array(contextParentNumber).fill("parent").join(".") + + // trailing . if has parents + (contextParentNumber ? "." : "") + ) +} + +const walk = ({ instance, targetId, components, models, result }) => { + if (!result) { + result = { + target: null, + bindableInstances: [], + allContexts: [], + currentContexts: [], + } + } + + if (!instance._contexts) instance._contexts = [] + + // "component" is the component definition (object in component.json) + const component = components[instance._component] + + if (instance._id === targetId) { + // found it + result.target = instance + } else { + if (component && component.bindable) { + // pushing all components in here initially + // but this will not be correct, as some of + // these components will be in another context + // but we dont know this until the end of the walk + // so we will filter in another method + result.bindableInstances.push({ + instance, + prop: component.bindable, + }) + } + } + + // a component that provides context to it's children + const contextualInstance = + component && component.context && instance[component.context] + + if (contextualInstance) { + // add to currentContexts (ancestory of context) + // before walking children + const model = models.find(m => m._id === instance[component.context]) + result.currentContexts.push({ instance, model }) + } + + const currentContexts = [...result.currentContexts] + for (let child of instance._children || []) { + // attaching _contexts of components, for eas comparison later + // these have been deep cloned above, so shouln't modify the + // original component instances + child._contexts = currentContexts + walk({ instance: child, targetId, components, models, result }) + } + + if (contextualInstance) { + // child walk done, remove from currentContexts + result.currentContexts.pop() + } + + return result +} diff --git a/packages/builder/src/builderStore/getNewComponentName.js b/packages/builder/src/builderStore/getNewComponentName.js new file mode 100644 index 000000000..b3ddc4e95 --- /dev/null +++ b/packages/builder/src/builderStore/getNewComponentName.js @@ -0,0 +1,39 @@ +import { walkProps } from "./storeUtils" +import { get_capitalised_name } from "../helpers" + +export default function(component, state) { + const capitalised = get_capitalised_name(component) + + const matchingComponents = [] + + const findMatches = props => { + walkProps(props, c => { + if ((c._instanceName || "").startsWith(capitalised)) { + matchingComponents.push(c._instanceName) + } + }) + } + + // check page first + findMatches(state.pages[state.currentPageName].props) + + // if viewing screen, check current screen for duplicate + if (state.currentFrontEndType === "screen") { + findMatches(state.currentPreviewItem.props) + } else { + // viewing master page - need to find against all screens + for (let screen of state.screens) { + findMatches(screen.props) + } + } + + let index = 1 + let name + while (!name) { + const tryName = `${capitalised} ${index}` + if (!matchingComponents.includes(tryName)) name = tryName + index++ + } + + return name +} diff --git a/packages/builder/src/builderStore/store/index.js b/packages/builder/src/builderStore/store/index.js index c97eb48f2..ac13c90db 100644 --- a/packages/builder/src/builderStore/store/index.js +++ b/packages/builder/src/builderStore/store/index.js @@ -1,5 +1,5 @@ import { values, cloneDeep } from "lodash/fp" -import { get_capitalised_name } from "../../helpers" +import getNewComponentName from "../getNewComponentName" import { backendUiStore } from "builderStore" import { writable, get } from "svelte/store" import api from "../api" @@ -24,6 +24,7 @@ import { saveScreenApi as _saveScreenApi, regenerateCssForCurrentScreen, generateNewIdsForComponent, + getComponentDefinition, } from "../storeUtils" export const getStore = () => { const initial = { @@ -74,9 +75,6 @@ export const getStore = () => { export default getStore -export const getComponentDefinition = (state, name) => - name.startsWith("##") ? getBuiltin(name) : state.components[name] - const setPackage = (store, initial) => async pkg => { const [main_screens, unauth_screens] = await Promise.all([ api @@ -278,7 +276,7 @@ const addChildComponent = store => (componentToAdd, presetProps = {}) => { const component = getComponentDefinition(state, componentToAdd) const instanceId = get(backendUiStore).selectedDatabase._id - const instanceName = get_capitalised_name(componentToAdd) + const instanceName = getNewComponentName(componentToAdd, state) const newComponent = createProps( component, @@ -484,7 +482,7 @@ const pasteComponent = store => (targetComponent, mode) => { // in case we paste a second time s.componentToPaste.isCut = false } else { - generateNewIdsForComponent(componentToPaste) + generateNewIdsForComponent(componentToPaste, s) } delete componentToPaste.isCut diff --git a/packages/builder/src/builderStore/storeUtils.js b/packages/builder/src/builderStore/storeUtils.js index 1630d118e..2efffc9d4 100644 --- a/packages/builder/src/builderStore/storeUtils.js +++ b/packages/builder/src/builderStore/storeUtils.js @@ -1,7 +1,11 @@ -import { makePropsSafe } from "components/userInterface/pagesParsing/createProps" +import { + makePropsSafe, + getBuiltin, +} from "components/userInterface/pagesParsing/createProps" import api from "./api" import { generate_screen_css } from "./generate_css" import { uuid } from "./uuid" +import getNewComponentName from "./getNewComponentName" export const selectComponent = (state, component) => { const componentDef = component._component.startsWith("##") @@ -81,7 +85,11 @@ export const regenerateCssForCurrentScreen = state => { return state } -export const generateNewIdsForComponent = c => +export const generateNewIdsForComponent = (c, state) => walkProps(c, p => { p._id = uuid() + p._instanceName = getNewComponentName(p._component, state) }) + +export const getComponentDefinition = (state, name) => + name.startsWith("##") ? getBuiltin(name) : state.components[name] diff --git a/packages/builder/src/builderStore/uuid.js b/packages/builder/src/builderStore/uuid.js index 5a1893b56..5dbd9ccdb 100644 --- a/packages/builder/src/builderStore/uuid.js +++ b/packages/builder/src/builderStore/uuid.js @@ -1,5 +1,7 @@ export function uuid() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { + // always want to make this start with a letter, as this makes it + // easier to use with mustache bindings in the client + return "cxxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8 return v.toString(16) diff --git a/packages/builder/src/components/common/DatePicker.svelte b/packages/builder/src/components/common/DatePicker.svelte index 853338959..6d5b03d93 100644 --- a/packages/builder/src/components/common/DatePicker.svelte +++ b/packages/builder/src/components/common/DatePicker.svelte @@ -26,9 +26,7 @@
-
- -
+
diff --git a/packages/builder/src/components/common/ValuesList.svelte b/packages/builder/src/components/common/ValuesList.svelte index 4f161247a..19cd6c533 100644 --- a/packages/builder/src/components/common/ValuesList.svelte +++ b/packages/builder/src/components/common/ValuesList.svelte @@ -17,9 +17,9 @@
-
-