+
+
-
-
+ {showBanner && (
+
+
+ No projects.
+ Add one via
+
+ ldcli dev-server add-project --help
+
+
+
+ )}
+ {!showBanner && (
+
+
+
+
+ )}
+ {selectedProject && (
+
+
+
+ )}
-
- >
+
);
}
diff --git a/internal/dev_server/ui/src/Flags.tsx b/internal/dev_server/ui/src/Flags.tsx
new file mode 100644
index 00000000..ac6a2711
--- /dev/null
+++ b/internal/dev_server/ui/src/Flags.tsx
@@ -0,0 +1,347 @@
+import { LDFlagSet, LDFlagValue } from 'launchdarkly-js-client-sdk';
+import {
+ Button,
+ Checkbox,
+ IconButton,
+ Label,
+ Switch,
+ Modal,
+ ModalOverlay,
+ DialogTrigger,
+ Dialog,
+ TextArea,
+} from '@launchpad-ui/components';
+import {
+ Box,
+ CopyToClipboard,
+ InlineEdit,
+ TextField,
+} from '@launchpad-ui/core';
+import Theme from '@launchpad-ui/tokens';
+import { useEffect, useRef, useState } from 'react';
+import { Icon } from '@launchpad-ui/icons';
+import { apiRoute, sortFlags } from './util.ts';
+
+type FlagProps = {
+ selectedProject: string;
+ flags: LDFlagSet | null;
+ setFlags: (flags: LDFlagSet) => void;
+};
+
+function Flags({ selectedProject, flags, setFlags }: FlagProps) {
+ const [overrides, setOverrides] = useState
| null>(null);
+ const [onlyShowOverrides, setOnlyShowOverrides] = useState(false);
+ const overridesPresent = overrides && Object.keys(overrides).length > 0;
+ const textAreaRef = useRef(null);
+
+ const updateOverride = (flagKey: string, overrideValue: LDFlagValue) => {
+ fetch(apiRoute(`/dev/projects/${selectedProject}/overrides/${flagKey}`), {
+ method: 'PUT',
+ body: JSON.stringify(overrideValue),
+ })
+ .then(async (res) => {
+ if (!res.ok) {
+ return; // todo
+ }
+
+ const updatedOverrides = {
+ ...overrides,
+ ...{
+ [flagKey]: {
+ value: overrideValue,
+ },
+ },
+ };
+
+ setOverrides(updatedOverrides);
+ })
+ .catch((_e) => {
+ // todo
+ });
+ };
+
+ const removeOverride = (flagKey: string, updateState: boolean = true) => {
+ return fetch(
+ apiRoute(`/dev/projects/${selectedProject}/overrides/${flagKey}`),
+ {
+ method: 'DELETE',
+ },
+ )
+ .then((res) => {
+ // todo: clean this up.
+ //
+ // In the remove-all-override case, we need to fan out and make the
+ // request for every override, so we don't want to be interleaving
+ // local state updates. Expect the consumer to update the local state
+ // when all requests are done
+ if (res.ok && updateState) {
+ const updatedOverrides = { ...overrides };
+ delete updatedOverrides[flagKey];
+
+ setOverrides(updatedOverrides);
+
+ if (Object.keys(updatedOverrides).length === 0)
+ setOnlyShowOverrides(false);
+ }
+ })
+ .catch((_e) => {
+ // todo
+ });
+ };
+
+ const fetchFlags = async () => {
+ const res = await fetch(
+ apiRoute(`/dev/projects/${selectedProject}?expand=overrides`),
+ );
+ const json = await res.json();
+ if (!res.ok) {
+ throw new Error(`Got ${res.status}, ${res.statusText} from flag fetch`);
+ }
+
+ const { flagsState: flags, overrides } = json;
+
+ setFlags(sortFlags(flags));
+ setOverrides(overrides);
+ };
+
+ // Fetch flags / overrides on mount
+ useEffect(() => {
+ fetchFlags().catch(
+ console.error.bind(console, 'error when fetching flags'),
+ );
+ }, [selectedProject]);
+
+ if (!flags) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {Object.entries(flags).map(
+ ([flagKey, { value: flagValue }], index) => {
+ const overrideValue = overrides?.[flagKey]?.value;
+ const hasOverride = overrideValue !== undefined;
+ let valueNode;
+
+ if (onlyShowOverrides && !hasOverride) {
+ return null;
+ }
+
+ switch (typeof flagValue) {
+ case 'boolean':
+ valueNode = (
+ {
+ updateOverride(flagKey, newValue);
+ }}
+ />
+ );
+ break;
+ case 'number':
+ valueNode = (
+ {
+ updateOverride(flagKey, Number(e.target.value));
+ }}
+ />
+ );
+ break;
+ case 'string':
+ valueNode = (
+ {
+ updateOverride(flagKey, newValue);
+ }}
+ renderInput={
+
+ }
+ >
+ {hasOverride ? overrideValue : flagValue}
+
+ );
+ break;
+ default:
+ valueNode = (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+ -
+
+
+
+ {flagKey}
+
+
+
+ {valueNode}
+
+ {hasOverride && (
+ {
+ removeOverride(flagKey);
+ }}
+ variant="destructive"
+ />
+ )}
+
+
+ );
+ },
+ )}
+
+
+ >
+ );
+}
+
+export default Flags;
diff --git a/internal/dev_server/ui/src/ProjectSelector.tsx b/internal/dev_server/ui/src/ProjectSelector.tsx
new file mode 100644
index 00000000..139093fa
--- /dev/null
+++ b/internal/dev_server/ui/src/ProjectSelector.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useState } from 'react';
+import { apiRoute } from './util.ts';
+import {
+ Button,
+ Heading,
+ Menu,
+ MenuItem,
+ MenuTrigger,
+ Popover,
+ ProgressBar,
+ Text,
+ Tooltip,
+ TooltipTrigger,
+} from '@launchpad-ui/components';
+import { Alert, CopyToClipboard, Inline } from '@launchpad-ui/core';
+
+const fetchProjects = async () => {
+ const res = await fetch(apiRoute(`/dev/projects`));
+ const json = await res.json();
+ if (!res.ok) {
+ throw new Error(`Got ${res.status}, ${res.statusText} from projects fetch`);
+ }
+ return json;
+};
+
+type Props = {
+ selectedProject: string | null;
+ setSelectedProject: (selectedProject: string) => void;
+ setShowBanner: (showBanner: boolean) => void;
+};
+
+function ProjectSelector({
+ selectedProject,
+ setSelectedProject,
+ setShowBanner,
+}: Props) {
+ const [projects, setProjects] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const setProjectsAndUpdateSelectedProject = (projects: string[]) => {
+ setProjects(projects);
+ setShowBanner(projects.length == 0);
+ if (projects.length == 1) {
+ setSelectedProject(projects[0]);
+ }
+ setIsLoading(false);
+ };
+
+ useEffect(() => {
+ fetchProjects()
+ .then(setProjectsAndUpdateSelectedProject)
+ .catch((error) => {
+ console.error(error);
+ setIsLoading(false); //bad
+ });
+ setProjects([]);
+ }, []);
+
+ if (isLoading) {
+ return (
+
+
+
Projects are loading
+
+ );
+ }
+
+ return projects.length > 0 ? (
+
+
+
+
+
+ {selectedProject == null
+ ? 'Please select a project'
+ : 'This is the selected project'}
+
+
+
+
+
+
+
+
+ ) : (
+
+ No projects.
+ Add one via
+
+ ldcli dev-server add-project --help
+
+
+ );
+}
+
+export default ProjectSelector;
diff --git a/internal/dev_server/ui/src/Sync.tsx b/internal/dev_server/ui/src/Sync.tsx
new file mode 100644
index 00000000..3daf160d
--- /dev/null
+++ b/internal/dev_server/ui/src/Sync.tsx
@@ -0,0 +1,73 @@
+import {
+ Button,
+ Tooltip,
+ TooltipTrigger,
+ ProgressBar,
+} from '@launchpad-ui/components';
+import { apiRoute, sortFlags } from './util.ts';
+import { LDFlagSet } from 'launchdarkly-js-client-sdk';
+import { useState } from 'react';
+import { Icon } from '@launchpad-ui/icons';
+import { Inline } from '@launchpad-ui/core';
+
+const syncProject = async (selectedProject: string) => {
+ const res = await fetch(apiRoute(`/dev/projects/${selectedProject}/sync`), {
+ method: 'PATCH',
+ });
+
+ const json = await res.json();
+ if (!res.ok) {
+ throw new Error(`Got ${res.status}, ${res.statusText} from projects fetch`);
+ }
+ return json;
+};
+
+const SyncButton = ({
+ selectedProject,
+ setFlags,
+}: {
+ selectedProject: string | null;
+ setFlags: (flags: LDFlagSet) => void;
+}) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleClick = async () => {
+ setIsLoading(true);
+ try {
+ const result = await syncProject(selectedProject!);
+ setFlags(sortFlags(result.flagsState));
+ } catch (error) {
+ console.error('Sync failed:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (!selectedProject) {
+ return null;
+ }
+
+ return (
+
+
+ Sync the selected project from the source environment
+
+ );
+};
+
+export default SyncButton;
diff --git a/internal/dev_server/ui/src/util.ts b/internal/dev_server/ui/src/util.ts
new file mode 100644
index 00000000..464b430a
--- /dev/null
+++ b/internal/dev_server/ui/src/util.ts
@@ -0,0 +1,12 @@
+import { LDFlagValue } from "launchdarkly-js-client-sdk";
+
+const API_BASE = import.meta.env.PROD ? '' : '/api';
+export const apiRoute = (pathname: string) => `${API_BASE}${pathname}`;
+
+export const sortFlags = (flags: Record) => Object.keys(flags)
+ .sort((a, b) => a.localeCompare(b))
+ .reduce>((accum, flagKey) => {
+ accum[flagKey] = flags[flagKey];
+
+ return accum;
+ }, {});