diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index 23704556ad..5181e756c6 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -1,6 +1,5 @@ import { getFrontendStore } from "./store/frontend" import { getAutomationStore } from "./store/automation" -import { getHostingStore } from "./store/hosting" import { getThemeStore } from "./store/theme" import { derived, writable } from "svelte/store" import { FrontendTypes, LAYOUT_NAMES } from "../constants" @@ -9,7 +8,6 @@ import { findComponent } from "./componentUtils" export const store = getFrontendStore() export const automationStore = getAutomationStore() export const themeStore = getThemeStore() -export const hostingStore = getHostingStore() export const currentAsset = derived(store, $store => { const type = $store.currentFrontEndType diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index fdfe450edf..0d740e08e0 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -2,7 +2,6 @@ import { get, writable } from "svelte/store" import { cloneDeep } from "lodash/fp" import { allScreens, - hostingStore, currentAsset, mainLayout, selectedComponent, @@ -100,7 +99,6 @@ export const getFrontendStore = () => { version: application.version, revertableVersion: application.revertableVersion, })) - await hostingStore.actions.fetch() // Initialise backend stores const [_integrations] = await Promise.all([ diff --git a/packages/builder/src/builderStore/store/hosting.js b/packages/builder/src/builderStore/store/hosting.js deleted file mode 100644 index fb174c2663..0000000000 --- a/packages/builder/src/builderStore/store/hosting.js +++ /dev/null @@ -1,34 +0,0 @@ -import { writable } from "svelte/store" -import api, { get } from "../api" - -const INITIAL_HOSTING_UI_STATE = { - appUrl: "", - deployedApps: {}, - deployedAppNames: [], - deployedAppUrls: [], -} - -export const getHostingStore = () => { - const store = writable({ ...INITIAL_HOSTING_UI_STATE }) - store.actions = { - fetch: async () => { - const response = await api.get("/api/hosting/urls") - const urls = await response.json() - store.update(state => { - state.appUrl = urls.app - return state - }) - }, - fetchDeployedApps: async () => { - let deployments = await (await get("/api/hosting/apps")).json() - store.update(state => { - state.deployedApps = deployments - state.deployedAppNames = Object.values(deployments).map(app => app.name) - state.deployedAppUrls = Object.values(deployments).map(app => app.url) - return state - }) - return deployments - }, - } - return store -} diff --git a/packages/builder/src/components/deploy/DeploymentHistory.svelte b/packages/builder/src/components/deploy/DeploymentHistory.svelte index f6bbcef4d4..36c2433c27 100644 --- a/packages/builder/src/components/deploy/DeploymentHistory.svelte +++ b/packages/builder/src/components/deploy/DeploymentHistory.svelte @@ -6,7 +6,7 @@ import api from "builderStore/api" import { notifications } from "@budibase/bbui" import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte" - import { store, hostingStore } from "builderStore" + import { store } from "builderStore" const DeploymentStatus = { SUCCESS: "SUCCESS", @@ -37,7 +37,7 @@ let poll let deployments = [] let urlComponent = $store.url || `/${appId}` - let deploymentUrl = `${$hostingStore.appUrl}${urlComponent}` + let deploymentUrl = `${urlComponent}` const formatDate = (date, format) => Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date) diff --git a/packages/builder/src/components/start/CreateAppModal.svelte b/packages/builder/src/components/start/CreateAppModal.svelte index 60065b6eef..3efd0231aa 100644 --- a/packages/builder/src/components/start/CreateAppModal.svelte +++ b/packages/builder/src/components/start/CreateAppModal.svelte @@ -1,100 +1,46 @@ {#if template?.fromFile} { $values.file = e.detail?.[0] - $touched.file = true + $validation.touched.file = true }} /> {/if} ($touched.name = true)} + error={$validation.touched.name && $validation.errors.name} + on:blur={() => ($validation.touched.name = true)} label="Name" placeholder={$auth.user.firstName - ? `${$auth.user.firstName}'s app` + ? `${$auth.user.firstName}s app` : "My app"} /> + ($validation.touched.url = true)} + label="URL" + placeholder={$values.name + ? "/" + encodeURIComponent($values.name).toLowerCase() + : "/"} + /> diff --git a/packages/builder/src/components/start/UpdateAppModal.svelte b/packages/builder/src/components/start/UpdateAppModal.svelte index 432b13c7c3..7549876fc0 100644 --- a/packages/builder/src/components/start/UpdateAppModal.svelte +++ b/packages/builder/src/components/start/UpdateAppModal.svelte @@ -1,120 +1,75 @@ - - - Update the name of your app. - ($touched.name = true)} - on:change={() => (dirty = true)} - label="Name" - /> - - + + Update the name of your app. + ($validation.touched.name = true)} + label="Name" + /> + ($validation.touched.url = true)} + label="URL" + placeholder={$values.name + ? "/" + encodeURIComponent($values.name).toLowerCase() + : "/"} + /> + diff --git a/packages/builder/src/constants/index.js b/packages/builder/src/constants/index.js index 04f12672e8..6b7aafdfa9 100644 --- a/packages/builder/src/constants/index.js +++ b/packages/builder/src/constants/index.js @@ -36,4 +36,7 @@ export const LAYOUT_NAMES = { export const BUDIBASE_INTERNAL_DB = "bb_internal" +// one or more word characters and whitespace export const APP_NAME_REGEX = /^[\w\s]+$/ +// zero or more non-whitespace characters +export const APP_URL_REGEX = /^\S*$/ diff --git a/packages/builder/src/helpers/validation/validation.js b/packages/builder/src/helpers/validation/validation.js index 8d80d720a1..db5dfe4430 100644 --- a/packages/builder/src/helpers/validation/validation.js +++ b/packages/builder/src/helpers/validation/validation.js @@ -1,5 +1,7 @@ import { writable, derived } from "svelte/store" +// DEPRECATED - Use the yup based validators for future validation + export function createValidationStore(initialValue, ...validators) { let touched = false diff --git a/packages/builder/src/helpers/validation/validators.js b/packages/builder/src/helpers/validation/validators.js index 036487fd50..f842f11313 100644 --- a/packages/builder/src/helpers/validation/validators.js +++ b/packages/builder/src/helpers/validation/validators.js @@ -1,3 +1,5 @@ +// TODO: Convert to yup based validators + export function emailValidator(value) { return ( (value && diff --git a/packages/builder/src/helpers/validation/yup/app.js b/packages/builder/src/helpers/validation/yup/app.js new file mode 100644 index 0000000000..de0f86446c --- /dev/null +++ b/packages/builder/src/helpers/validation/yup/app.js @@ -0,0 +1,83 @@ +import { string, mixed } from "yup" +import { APP_NAME_REGEX, APP_URL_REGEX } from "constants" + +export const name = (validation, { apps, currentApp } = { apps: [] }) => { + validation.addValidator( + "name", + string() + .trim() + .required("Your application must have a name") + .matches( + APP_NAME_REGEX, + "App name must be letters, numbers and spaces only" + ) + .test( + "non-existing-app-name", + "Another app with the same name already exists", + value => { + if (!value) { + // exit early, above validator will fail + return true + } + if (currentApp) { + // filter out the current app if present + apps = apps.filter(app => app.appId !== currentApp.appId) + } + return !apps + .map(app => app.name) + .some(appName => appName.toLowerCase() === value.toLowerCase()) + } + ) + ) +} + +export const url = (validation, { apps, currentApp } = { apps: [] }) => { + validation.addValidator( + "url", + string() + .nullable() + .matches(APP_URL_REGEX, "App URL must not contain spaces") + .test( + "non-existing-app-url", + "Another app with the same URL already exists", + value => { + // url is nullable + if (!value) { + return true + } + if (currentApp) { + // filter out the current app if present + apps = apps.filter(app => app.appId !== currentApp.appId) + } + return !apps + .map(app => app.url) + .some(appUrl => appUrl?.toLowerCase() === value.toLowerCase()) + } + ) + .test("valid-url", "Not a valid URL", value => { + // url is nullable + if (!value) { + return true + } + // make it clear that this is a url path and cannot be a full url + return ( + value.startsWith("/") && + !value.includes("http") && + !value.includes("www") && + !value.includes(".") && + value.length > 1 // just '/' is not valid + ) + }) + ) +} + +export const file = (validation, { template } = {}) => { + const templateToUse = + template && Object.keys(template).length === 0 ? null : template + validation.addValidator( + "file", + templateToUse?.fromFile + ? mixed().required("Please choose a file to import") + : null + ) +} diff --git a/packages/builder/src/helpers/validation/yup/index.js b/packages/builder/src/helpers/validation/yup/index.js new file mode 100644 index 0000000000..6783ad7e58 --- /dev/null +++ b/packages/builder/src/helpers/validation/yup/index.js @@ -0,0 +1,66 @@ +import { capitalise } from "helpers" +import { object } from "yup" +import { writable, get } from "svelte/store" +import { notifications } from "@budibase/bbui" + +export const createValidationStore = () => { + const DEFAULT = { + errors: {}, + touched: {}, + valid: false, + } + + const validator = {} + const validation = writable(DEFAULT) + + const addValidator = (propertyName, propertyValidator) => { + if (!propertyValidator || !propertyName) { + return + } + validator[propertyName] = propertyValidator + } + + const check = async values => { + const obj = object().shape(validator) + // clear the previous errors + const properties = Object.keys(validator) + properties.forEach(property => (get(validation).errors[property] = null)) + + let validationError = false + try { + await obj.validate(values, { abortEarly: false }) + } catch (error) { + if (!error.inner) { + notifications.error("Unexpected validation error", error) + validationError = true + } else { + error.inner.forEach(err => { + validation.update(store => { + store.errors[err.path] = capitalise(err.message) + return store + }) + }) + } + } + + let valid + if (properties.length && !validationError) { + valid = await obj.isValid(values) + } else { + // don't say valid until validators have been loaded + valid = false + } + + validation.update(store => { + store.valid = valid + return store + }) + } + + return { + subscribe: validation.subscribe, + set: validation.set, + check, + addValidator, + } +} diff --git a/packages/builder/src/pages/builder/apps/index.svelte b/packages/builder/src/pages/builder/apps/index.svelte index aafc28cd92..c98e749e45 100644 --- a/packages/builder/src/pages/builder/apps/index.svelte +++ b/packages/builder/src/pages/builder/apps/index.svelte @@ -12,7 +12,7 @@ Modal, } from "@budibase/bbui" import { onMount } from "svelte" - import { apps, organisation, auth, admin } from "stores/portal" + import { apps, organisation, auth } from "stores/portal" import { goto } from "@roxi/routify" import { AppStatus } from "constants" import { gradient } from "actions" @@ -34,7 +34,6 @@ const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED $: publishedApps = $apps.filter(publishedAppsOnly) - $: isCloud = $admin.cloud $: userApps = $auth.user?.builder?.global ? publishedApps : publishedApps.filter(app => @@ -42,7 +41,11 @@ ) function getUrl(app) { - return !isCloud ? `/app/${encodeURIComponent(app.name)}` : `/${app.prodId}` + if (app.url) { + return `/app${app.url}` + } else { + return `/${app.prodId}` + } } diff --git a/packages/builder/src/pages/builder/portal/apps/index.svelte b/packages/builder/src/pages/builder/portal/apps/index.svelte index 047c60e979..faa57e5df3 100644 --- a/packages/builder/src/pages/builder/portal/apps/index.svelte +++ b/packages/builder/src/pages/builder/portal/apps/index.svelte @@ -49,7 +49,6 @@ $: filteredApps = enrichedApps.filter(app => app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) ) - $: isCloud = $admin.cloud const enrichApps = (apps, user, sortBy) => { const enrichedApps = apps.map(app => ({ @@ -80,7 +79,7 @@ } const initiateAppCreation = () => { - template = {} + template = null creationModal.show() creatingApp = true } @@ -162,12 +161,10 @@ } const viewApp = app => { - if (!isCloud && app.deployed) { - // special case to use the short form name if self hosted - window.open(`/app/${encodeURIComponent(app.name)}`) + if (app.url) { + window.open(`/app${app.url}`) } else { - const id = app.deployed ? app.prodId : app.devId - window.open(`/${id}`, "_blank") + window.open(`/${app.prodId}`) } } @@ -442,6 +439,11 @@ > + + + + + {selectedApp?.name}? -