diff --git a/packages/backend-core/src/constants/misc.ts b/packages/backend-core/src/constants/misc.ts index e2fd975e409..1fa8d6e9269 100644 --- a/packages/backend-core/src/constants/misc.ts +++ b/packages/backend-core/src/constants/misc.ts @@ -9,6 +9,7 @@ export enum Cookie { ACCOUNT_RETURN_URL = "budibase:account:returnurl", DatasourceAuth = "budibase:datasourceauth", OIDC_CONFIG = "budibase:oidc:config", + FeatureFlags = "budibase:featureflags", } export { Header } from "@budibase/shared-core" diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index 22b937db88f..9bea94de29f 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -460,6 +460,17 @@ export function setFeatureFlags(key: string, value: Record) { context.featureFlagCache[key] = value } +export function getFeatureFlagOverrides(): Record { + return getCurrentContext()?.featureFlagOverrides || {} +} + +export async function doInFeatureFlagOverrideContext( + value: Record, + callback: () => Promise +) { + return await newContext({ featureFlagOverrides: value }, callback) +} + export function getTableForView(viewId: string): Table | undefined { const context = getCurrentContext() if (!context) { diff --git a/packages/backend-core/src/context/types.ts b/packages/backend-core/src/context/types.ts index c2cb966731c..b34ef8c1b76 100644 --- a/packages/backend-core/src/context/types.ts +++ b/packages/backend-core/src/context/types.ts @@ -24,5 +24,6 @@ export type ContextMap = { featureFlagCache?: { [key: string]: Record } + featureFlagOverrides?: Record viewToTableCache?: Record } diff --git a/packages/backend-core/src/features/features.ts b/packages/backend-core/src/features/features.ts index 71067770848..a76ffe8a7ba 100644 --- a/packages/backend-core/src/features/features.ts +++ b/packages/backend-core/src/features/features.ts @@ -175,6 +175,21 @@ export class FlagSet { } } + const overrides = context.getFeatureFlagOverrides() + for (const [key, value] of Object.entries(overrides)) { + if (!this.isFlagName(key)) { + continue + } + + if (typeof value !== "boolean") { + continue + } + + // @ts-expect-error - TS does not like you writing into a generic type. + flagValues[key] = value + tags[`flags.${key}.source`] = "override" + } + context.setFeatureFlags(this.setId, flagValues) for (const [key, value] of Object.entries(flagValues)) { tags[`flags.${key}.value`] = value diff --git a/packages/backend-core/src/middleware/featureFlagCookie.ts b/packages/backend-core/src/middleware/featureFlagCookie.ts new file mode 100644 index 00000000000..070e2161af2 --- /dev/null +++ b/packages/backend-core/src/middleware/featureFlagCookie.ts @@ -0,0 +1,13 @@ +import { Ctx, FeatureFlagCookie } from "@budibase/types" +import { Middleware, Next } from "koa" +import { getCookie } from "../utils" +import { Cookie } from "../constants" +import { doInFeatureFlagOverrideContext } from "../context" + +export default (async (ctx: Ctx, next: Next) => { + const cookie = getCookie(ctx, Cookie.FeatureFlags) + const flags = cookie?.flags || {} + await doInFeatureFlagOverrideContext(flags, async () => { + await next() + }) +}) as Middleware diff --git a/packages/backend-core/src/middleware/index.ts b/packages/backend-core/src/middleware/index.ts index 9ee51db45bc..0d16ae52e3a 100644 --- a/packages/backend-core/src/middleware/index.ts +++ b/packages/backend-core/src/middleware/index.ts @@ -20,5 +20,6 @@ export { default as correlation } from "../logging/correlation/middleware" export { default as errorHandling } from "./errorHandling" export { default as querystringToBody } from "./querystringToBody" export { default as csp } from "./contentSecurityPolicy" +export { default as featureFlagCookie } from "./featureFlagCookie" export * as joiValidator from "./joi-validator" export { default as ip } from "./ip" diff --git a/packages/builder/src/components/commandPalette/CommandPalette.svelte b/packages/builder/src/components/commandPalette/CommandPalette.svelte index 4598b44873d..427b1e960f4 100644 --- a/packages/builder/src/components/commandPalette/CommandPalette.svelte +++ b/packages/builder/src/components/commandPalette/CommandPalette.svelte @@ -8,7 +8,7 @@ notifications, } from "@budibase/bbui" import { API } from "@/api" - import { goto } from "@roxi/routify" + import { goto, params, isActive } from "@roxi/routify" import { automationStore, previewStore, @@ -19,54 +19,36 @@ queries, tables, views, + viewsV2, } from "@/stores/builder" - import { themeStore } from "@/stores/portal" + import { themeStore, featureFlags } from "@/stores/portal" import { getContext } from "svelte" import { ThemeOptions } from "@budibase/shared-core" + import { FeatureFlag } from "@budibase/types" const modalContext = getContext(Context.Modal) - const commands = [ + + let search + let selected = null + + $: inApp = $isActive("/builder/app/:application") + $: commands = [ { type: "Access", name: "Invite users and manage app access", description: "", icon: "User", action: () => builderStore.showBuilderSidePanel(), + requiresApp: true, }, - { - type: "Navigate", - name: "Portal", - description: "", - icon: "Compass", - action: () => $goto("../../portal"), - }, - { - type: "Navigate", - name: "Data", - description: "", - icon: "Compass", - action: () => $goto("./data"), - }, - { - type: "Navigate", - name: "Design", - description: "", - icon: "Compass", - action: () => $goto("./design"), - }, - { - type: "Navigate", - name: "Automations", - description: "", - icon: "Compass", - action: () => $goto("./automation"), - }, + ...navigationCommands(), { type: "Publish", name: "App", description: "Deploy your application", icon: "Box", action: deployApp, + requiresApp: true, }, { type: "Preview", @@ -74,12 +56,14 @@ description: "", icon: "Play", action: () => previewStore.showPreview(true), + requiresApp: true, }, { type: "Preview", name: "Published App", icon: "Play", action: () => window.open(`/app${$appStore.url}`), + requiresApp: true, }, { type: "Support", @@ -87,6 +71,7 @@ icon: "Help", action: () => window.open(`https://github.com/Budibase/budibase/discussions/new`), + requiresApp: true, }, { type: "Support", @@ -96,52 +81,166 @@ window.open( `https://github.com/Budibase/budibase/issues/new?assignees=&labels=bug&template=bug_report.md&title=` ), + requiresApp: true, }, - ...($datasources?.list?.map(datasource => ({ + ...datasourceCommands($datasources?.list || []), + ...tableCommands($tables?.list || []), + ...viewCommands($views?.list || []), + ...viewV2Commands($viewsV2?.list || []), + ...queryCommands($queries?.list || []), + ...screenCommands($sortedScreens), + ...automationCommands($automationStore?.automations || []), + ...themeCommands(), + ...featureFlagCommands($featureFlags), + ] + $: enrichedCommands = commands.map(cmd => ({ + ...cmd, + searchValue: `${cmd.type} ${cmd.name}`.toLowerCase().replace(/_/g, " "), + })) + $: results = filterResults(enrichedCommands, search, inApp) + $: categories = groupResults(results) + + const navigationCommands = () => { + const routes = [ + { + name: "Portal", + url: "/builder/portal", + }, + { + name: "Data", + url: "/builder/app/:application/data", + }, + { + name: "Design", + url: "/builder/app/:application/design", + }, + { + name: "Automations", + url: "/builder/app/:application/automation", + }, + { + name: "Settings", + url: "/builder/app/:application/settings", + }, + ] + return routes.map(route => ({ + type: "Navigate", + name: route.name, + icon: "Compass", + action: () => { + const gotoParams = route.url.includes(":application") + ? { application: $params.application } + : {} + $goto(route.url, gotoParams) + }, + requiresApp: true, + })) + } + + const datasourceCommands = datasources => { + return datasources.map(datasource => ({ type: "Datasource", - name: `${datasource.name}`, + name: datasource.name, icon: "Data", - action: () => $goto(`./data/datasource/${datasource._id}`), - })) ?? []), - ...($tables?.list?.map(table => ({ + action: () => + $goto(`/builder/app/:application/data/datasource/:id`, { + application: $params.application, + id: datasource._id, + }), + requiresApp: true, + })) + } + + const tableCommands = tables => { + return tables.map(table => ({ type: "Table", name: table.name, icon: "Table", - action: () => $goto(`./data/table/${table._id}`), - })) ?? []), - ...($views?.list?.map(view => ({ + action: () => + $goto(`/builder/app/:application/data/table/:id`, { + application: $params.application, + id: table._id, + }), + requiresApp: true, + })) + } + + const viewCommands = views => { + return views.map(view => ({ type: "View", name: view.name, icon: "Remove", action: () => { - if (view.version === 2) { - $goto(`./data/view/v2/${view.id}`) - } else { - $goto(`./data/view/${view.name}`) - } + $goto(`/builder/app/:application/data/view/:name`, { + application: $params.application, + name: view.name, + }) + }, + requiresApp: true, + })) + } + + const viewV2Commands = views => { + return views.map(view => ({ + type: "View", + name: view.name, + icon: "Remove", + action: () => { + $goto(`/builder/app/:application/data/table/:tableId/:viewId`, { + application: $params.application, + x: view.tableId, + viewId: view.id, + }) }, - })) ?? []), - ...($queries?.list?.map(query => ({ + requiresApp: true, + })) + } + + const queryCommands = queries => { + return queries.map(query => ({ type: "Query", name: query.name, icon: "SQLQuery", - action: () => $goto(`./data/query/${query._id}`), - })) ?? []), - ...$sortedScreens.map(screen => ({ + action: () => + $goto(`/builder/app/:application/data/query/:id`, { + application: $params.application, + id: query._id, + }), + requiresApp: true, + })) + } + + const screenCommands = screens => { + return screens.map(screen => ({ type: "Screen", name: screen.routing.route, icon: "WebPage", - action: () => { - $goto(`./design/${screen._id}/${screen._id}-screen`) - }, - })), - ...($automationStore?.automations?.map(automation => ({ + action: () => + $goto(`/builder/app/:application/design/:screenId/:componentId`, { + application: $params.application, + screenId: screen._id, + componentId: `${screen._id}-screen`, + }), + requiresApp: true, + })) + } + + const automationCommands = automations => { + return automations.map(automation => ({ type: "Automation", name: automation.name, icon: "ShareAndroid", - action: () => $goto(`./automation/${automation._id}`), - })) ?? []), - ...ThemeOptions.map(themeMeta => ({ + action: () => + $goto(`/builder/app/:application/automation/:id`, { + application: $params.application, + id: automation._id, + }), + requiresApp: true, + })) + } + + const themeCommands = () => { + return ThemeOptions.map(themeMeta => ({ type: "Change Builder Theme", name: themeMeta.name, icon: "ColorPalette", @@ -150,28 +249,41 @@ state.theme = themeMeta.id return state }), - })), - ] - - let search - let selected = null + })) + } - $: enrichedCommands = commands.map(cmd => ({ - ...cmd, - searchValue: `${cmd.type} ${cmd.name}`.toLowerCase(), - })) - $: results = filterResults(enrichedCommands, search) - $: categories = groupResults(results) + const featureFlagCommands = flags => { + if (!flags.DEBUG_UI) { + return [] + } + return Object.entries(flags) + .filter(([flag]) => flag !== FeatureFlag.DEBUG_UI) + .map(([flag, value]) => ({ + type: "Feature Flag", + name: `${value ? "Disable" : "Enable"} ${flag}`, + icon: "Flag", + action: () => { + featureFlags.setFlag(flag, !value) + }, + })) + } - const filterResults = (commands, search) => { - if (!search) { + const filterResults = (commands, search, inApp) => { + if (search) { + selected = 0 + search = search.toLowerCase().replace(/_/g, " ") + } else { selected = null - return commands } - selected = 0 - search = search.toLowerCase() return commands - .filter(cmd => cmd.searchValue.includes(search)) + .filter(cmd => { + // Handle searching + if (search && !cmd.searchValue.includes(search)) { + return false + } + // Handle commands that require an app + return inApp || !cmd.requiresApp + }) .map((cmd, idx) => ({ ...cmd, idx, @@ -264,7 +376,8 @@ {command.type}: 
- {command.name} + + {@html command.name}
{/each} @@ -339,4 +452,10 @@ text-overflow: ellipsis; white-space: nowrap; } + .name :global(code) { + font-size: 12px; + background: var(--background-alt); + padding: 4px; + border-radius: 4px; + } diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index 9efbef2c8db..bcf377c0692 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -9,7 +9,6 @@ import { Label } from "@budibase/bbui" import { onMount, createEventDispatcher, onDestroy } from "svelte" import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates" - import { autocompletion, closeBrackets, @@ -52,17 +51,12 @@ import type { Extension } from "@codemirror/state" import { javascript } from "@codemirror/lang-javascript" import { EditorModes } from "./" - import { themeStore } from "@/stores/portal" - import { - type EnrichedBinding, - FeatureFlag, - type EditorMode, - } from "@budibase/types" + import { featureFlags, themeStore } from "@/stores/portal" + import { type EnrichedBinding, type EditorMode } from "@budibase/types" import { tooltips } from "@codemirror/view" import type { BindingCompletion, CodeValidator } from "@/types" import { validateHbsTemplate } from "./validator/hbs" import { validateJsTemplate } from "./validator/js" - import { featureFlag } from "@/helpers" import AIGen from "./AIGen.svelte" export let label: string | undefined = undefined @@ -102,9 +96,7 @@ } $: aiGenEnabled = - featureFlag.isEnabled(FeatureFlag.AI_JS_GENERATION) && - mode.name === "javascript" && - !readonly + $featureFlags.AI_JS_GENERATION && mode.name === "javascript" && !readonly $: { if (autofocus && isEditorInitialised) { diff --git a/packages/builder/src/pages/builder/_layout.svelte b/packages/builder/src/pages/builder/_layout.svelte index 7e765d7366f..7bb97be71dc 100644 --- a/packages/builder/src/pages/builder/_layout.svelte +++ b/packages/builder/src/pages/builder/_layout.svelte @@ -6,8 +6,11 @@ import { API } from "@/api" import Branding from "./Branding.svelte" import ContextMenu from "@/components/ContextMenu.svelte" + import CommandPalette from "@/components/commandPalette/CommandPalette.svelte" + import { Modal } from "@budibase/bbui" let loaded = false + let commandPaletteModal $: multiTenancyEnabled = $admin.multiTenancy $: hasAdminUser = $admin?.checklist?.adminUser?.checked @@ -157,12 +160,25 @@ } } } + + // Event handler for the command palette + const handleKeyDown = e => { + if (e.key === "k" && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + commandPaletteModal.toggle() + } + } + + + + + {#if loaded} {/if} diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte index a799553aca1..668aca657ee 100644 --- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte @@ -14,7 +14,6 @@ Tabs, Tab, Heading, - Modal, notifications, TooltipPosition, } from "@budibase/bbui" @@ -24,7 +23,6 @@ import { capitalise } from "@/helpers" import { onMount, onDestroy } from "svelte" import VerificationPromptBanner from "@/components/common/VerificationPromptBanner.svelte" - import CommandPalette from "@/components/commandPalette/CommandPalette.svelte" import TourWrap from "@/components/portal/onboarding/TourWrap.svelte" import TourPopover from "@/components/portal/onboarding/TourPopover.svelte" import BuilderSidePanel from "./_components/BuilderSidePanel.svelte" @@ -38,7 +36,6 @@ let promise = getPackage() let hasSynced = false - let commandPaletteModal let loaded = false $: loaded && initTour() @@ -79,14 +76,6 @@ $goto($builderStore.previousTopNavPath[path] || path) } - // Event handler for the command palette - const handleKeyDown = e => { - if (e.key === "k" && (e.ctrlKey || e.metaKey)) { - e.preventDefault() - commandPaletteModal.toggle() - } - } - const initTour = async () => { // Check if onboarding is enabled. if (!$auth.user?.onboardedAt) { @@ -184,11 +173,6 @@ {/if} - - - - -