Compare commits
2 Commits
fd00f83585
...
35c1a861f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c1a861f5 | ||
|
|
27101bbdd6 |
@@ -410,6 +410,36 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) ResetDevelopmentState(ctx context.Context) error {
|
||||||
|
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
TRUNCATE TABLE
|
||||||
|
project_memberships,
|
||||||
|
team_memberships,
|
||||||
|
organization_memberships,
|
||||||
|
workspaces,
|
||||||
|
projects,
|
||||||
|
teams,
|
||||||
|
departments,
|
||||||
|
user_homes,
|
||||||
|
users,
|
||||||
|
organizations,
|
||||||
|
installations
|
||||||
|
RESTART IDENTITY;
|
||||||
|
`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
|
func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
|
||||||
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
|
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
|
||||||
SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped
|
SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped
|
||||||
|
|||||||
@@ -166,6 +166,28 @@ func (routes apiRoutes) handleAppShellState(w http.ResponseWriter, r *http.Reque
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (routes apiRoutes) handleDevelopmentBootstrapReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !routes.cfg.Config.IsDevelopment() {
|
||||||
|
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := routes.bootstrapService().ResetDevelopmentState(r.Context()); err != nil {
|
||||||
|
routes.writeBootstrapPersistenceError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"data": map[string]any{
|
||||||
|
"reset": true,
|
||||||
|
},
|
||||||
|
"meta": map[string]any{
|
||||||
|
"resource": "development-bootstrap-reset",
|
||||||
|
"developmentOnly": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (routes apiRoutes) handleBootstrapInstanceStep(w http.ResponseWriter, r *http.Request) {
|
func (routes apiRoutes) handleBootstrapInstanceStep(w http.ResponseWriter, r *http.Request) {
|
||||||
payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r)
|
payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ func (routes apiRoutes) Register(router chi.Router) {
|
|||||||
apiRouter.Get("/app-shell", routes.handleAppShellState)
|
apiRouter.Get("/app-shell", routes.handleAppShellState)
|
||||||
apiRouter.Get("/organizations", routes.handleOrganizations)
|
apiRouter.Get("/organizations", routes.handleOrganizations)
|
||||||
apiRouter.Get("/workspaces", routes.handleWorkspaces)
|
apiRouter.Get("/workspaces", routes.handleWorkspaces)
|
||||||
|
|
||||||
|
if routes.cfg.Config.IsDevelopment() {
|
||||||
|
apiRouter.Post("/dev/bootstrap/reset", routes.handleDevelopmentBootstrapReset)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,20 @@ type AppShellPayload = {
|
|||||||
workspaces: AppShellWorkspace[];
|
workspaces: AppShellWorkspace[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
|
||||||
|
installation: payload?.installation,
|
||||||
|
admin: payload?.admin,
|
||||||
|
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
|
||||||
|
departments: Array.isArray(payload?.departments) ? payload.departments : [],
|
||||||
|
teams: Array.isArray(payload?.teams) ? payload.teams : [],
|
||||||
|
projects: Array.isArray(payload?.projects) ? payload.projects : [],
|
||||||
|
workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
|
||||||
|
});
|
||||||
|
|
||||||
type AppShellContextValue = {
|
type AppShellContextValue = {
|
||||||
status: Accessor<"idle" | "loading" | "success" | "error">;
|
status: Accessor<"idle" | "loading" | "success" | "error">;
|
||||||
error: Accessor<string>;
|
error: Accessor<string>;
|
||||||
|
installation: Accessor<AppShellInstallation | undefined>;
|
||||||
railItems: Accessor<readonly RailItem[]>;
|
railItems: Accessor<readonly RailItem[]>;
|
||||||
activeServer: Accessor<ActiveServer>;
|
activeServer: Accessor<ActiveServer>;
|
||||||
activeProject: Accessor<ActiveProject>;
|
activeProject: Accessor<ActiveProject>;
|
||||||
@@ -277,7 +288,7 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem
|
|||||||
throw new Error(body.error?.message || "Failed to load app shell state.");
|
throw new Error(body.error?.message || "Failed to load app shell state.");
|
||||||
}
|
}
|
||||||
|
|
||||||
setPayload(body.data);
|
setPayload(normalizeAppShellPayload(body.data));
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
@@ -292,6 +303,7 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem
|
|||||||
const value: AppShellContextValue = {
|
const value: AppShellContextValue = {
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
|
installation: createMemo(() => payload()?.installation),
|
||||||
railItems: createMemo(() => buildRailItems(payload())),
|
railItems: createMemo(() => buildRailItems(payload())),
|
||||||
activeServer: createMemo(() => buildActiveServer(payload())),
|
activeServer: createMemo(() => buildActiveServer(payload())),
|
||||||
activeProject: createMemo(() => buildActiveProject(payload())),
|
activeProject: createMemo(() => buildActiveProject(payload())),
|
||||||
|
|||||||
@@ -261,6 +261,14 @@
|
|||||||
background 160ms var(--easing-standard);
|
background 160ms var(--easing-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field input:disabled,
|
||||||
|
.field select:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: color-mix(in srgb, var(--color-surface-secondary) 92%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-border) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.field input:focus-visible,
|
.field input:focus-visible,
|
||||||
.field select:focus-visible {
|
.field select:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
|
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
|
||||||
|
|
||||||
import { For, Show, createMemo, createSignal, onMount, type JSX } from "solid-js";
|
import { For, Show, createEffect, createMemo, createSignal, onMount, type JSX } from "solid-js";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
import { resolveAPIBase } from "../../../lib/api";
|
import { resolveAPIBase } from "../../../lib/api";
|
||||||
@@ -46,6 +46,37 @@ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
|
|||||||
|
|
||||||
const bootstrapCompletionStorageKey = "moku.bootstrap.completed";
|
const bootstrapCompletionStorageKey = "moku.bootstrap.completed";
|
||||||
|
|
||||||
|
const defaultInstanceForm = {
|
||||||
|
protocol: "http",
|
||||||
|
access: "local",
|
||||||
|
host: "localhost",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const defaultModeForm = {
|
||||||
|
mode: "personal",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const defaultAdminForm = {
|
||||||
|
displayName: "Admin",
|
||||||
|
email: "admin@example.com",
|
||||||
|
password: "",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const personalStructureDefaults = {
|
||||||
|
departmentName: "Default",
|
||||||
|
teamName: "Personal",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const organizationalStructureDefaults = {
|
||||||
|
departmentName: "Department",
|
||||||
|
teamName: "Team",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const defaultStructureForm = {
|
||||||
|
...personalStructureDefaults,
|
||||||
|
projectName: "Project",
|
||||||
|
} as const;
|
||||||
|
|
||||||
const initialSubmissionState = (): BootstrapSubmissionState => ({
|
const initialSubmissionState = (): BootstrapSubmissionState => ({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
error: "",
|
error: "",
|
||||||
@@ -93,24 +124,10 @@ type WorkspaceHomeProps = {
|
|||||||
|
|
||||||
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||||
const appShellData = useAppShellData();
|
const appShellData = useAppShellData();
|
||||||
const [instanceForm, setInstanceForm] = createStore({
|
const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm });
|
||||||
protocol: "http",
|
const [modeForm, setModeForm] = createStore({ ...defaultModeForm });
|
||||||
access: "local",
|
const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm });
|
||||||
host: "localhost",
|
const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm });
|
||||||
});
|
|
||||||
const [modeForm, setModeForm] = createStore({
|
|
||||||
mode: "personal",
|
|
||||||
});
|
|
||||||
const [adminForm, setAdminForm] = createStore({
|
|
||||||
displayName: "Ronald",
|
|
||||||
email: "admin@example.com",
|
|
||||||
password: "",
|
|
||||||
});
|
|
||||||
const [structureForm, setStructureForm] = createStore({
|
|
||||||
departmentName: "Platform",
|
|
||||||
teamName: "Core",
|
|
||||||
projectName: "Moku",
|
|
||||||
});
|
|
||||||
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
|
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
|
||||||
instance: initialSubmissionState(),
|
instance: initialSubmissionState(),
|
||||||
mode: initialSubmissionState(),
|
mode: initialSubmissionState(),
|
||||||
@@ -129,10 +146,54 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
|||||||
setIsBootstrapStateResolved(true);
|
setIsBootstrapStateResolved(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (modeForm.mode === "personal") {
|
||||||
|
setStructureForm("departmentName", personalStructureDefaults.departmentName);
|
||||||
|
setStructureForm("teamName", personalStructureDefaults.teamName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (structureForm.departmentName === personalStructureDefaults.departmentName) {
|
||||||
|
setStructureForm("departmentName", organizationalStructureDefaults.departmentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (structureForm.teamName === personalStructureDefaults.teamName) {
|
||||||
|
setStructureForm("teamName", organizationalStructureDefaults.teamName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!isBootstrapStateResolved()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appShellData.status() !== "success") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installation = appShellData.installation();
|
||||||
|
const isPersistedBootstrap = installation?.isBootstrapped ?? false;
|
||||||
|
|
||||||
|
if (isPersistedBootstrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBootstrapComplete() && isWizardOpen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBootstrapCompletion(false);
|
||||||
|
setIsBootstrapComplete(false);
|
||||||
|
setIsWizardOpen(true);
|
||||||
|
resetWizardState();
|
||||||
|
});
|
||||||
|
|
||||||
const sidebarToggleLabel = (): string =>
|
const sidebarToggleLabel = (): string =>
|
||||||
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
|
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
|
||||||
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
|
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
|
||||||
const apiBase = (): string => resolveAPIBase();
|
const apiBase = (): string => resolveAPIBase();
|
||||||
|
const bootstrapTargetLabel = (): string =>
|
||||||
|
modeForm.mode === "personal" ? "Personal server" : "Organization server";
|
||||||
const currentStep = createMemo<BootstrapStepDefinition>(
|
const currentStep = createMemo<BootstrapStepDefinition>(
|
||||||
() => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!,
|
() => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!,
|
||||||
);
|
);
|
||||||
@@ -141,6 +202,20 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
|||||||
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
|
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
|
||||||
const canDismissWizard = (): boolean => isBootstrapComplete();
|
const canDismissWizard = (): boolean => isBootstrapComplete();
|
||||||
|
|
||||||
|
const resetWizardState = (): void => {
|
||||||
|
setInstanceForm({ ...defaultInstanceForm });
|
||||||
|
setModeForm({ ...defaultModeForm });
|
||||||
|
setAdminForm({ ...defaultAdminForm });
|
||||||
|
setStructureForm({ ...defaultStructureForm });
|
||||||
|
setStepState({
|
||||||
|
instance: initialSubmissionState(),
|
||||||
|
mode: initialSubmissionState(),
|
||||||
|
admin: initialSubmissionState(),
|
||||||
|
structure: initialSubmissionState(),
|
||||||
|
});
|
||||||
|
setCurrentStepIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
|
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
|
||||||
setStepState(step, { status: "submitting", error: "" });
|
setStepState(step, { status: "submitting", error: "" });
|
||||||
|
|
||||||
@@ -201,6 +276,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLastStep()) {
|
if (isLastStep()) {
|
||||||
|
await appShellData.reload();
|
||||||
writeBootstrapCompletion(true);
|
writeBootstrapCompletion(true);
|
||||||
setIsBootstrapComplete(true);
|
setIsBootstrapComplete(true);
|
||||||
setIsWizardOpen(false);
|
setIsWizardOpen(false);
|
||||||
@@ -268,7 +344,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
|||||||
|
|
||||||
<section class={styles.hero} data-slot="workspace-home-hero">
|
<section class={styles.hero} data-slot="workspace-home-hero">
|
||||||
<span class={styles.eyebrow}>Bootstrap</span>
|
<span class={styles.eyebrow}>Bootstrap</span>
|
||||||
<h1 class={styles.title}>{appShellData.activeServer().name}</h1>
|
<h1 class={styles.title}>{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1>
|
||||||
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
|
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
|
||||||
<div class={styles.heroActions}>
|
<div class={styles.heroActions}>
|
||||||
<button type="button" class={styles.primaryButton} onClick={(): void => setIsWizardOpen(true)}>
|
<button type="button" class={styles.primaryButton} onClick={(): void => setIsWizardOpen(true)}>
|
||||||
@@ -288,7 +364,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
|||||||
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
|
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
|
||||||
<div class={styles.wizardHeaderCopy}>
|
<div class={styles.wizardHeaderCopy}>
|
||||||
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
|
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
|
||||||
Bootstrap {appShellData.activeServer().name}
|
Bootstrap {bootstrapTargetLabel()}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Show when={canDismissWizard()}>
|
<Show when={canDismissWizard()}>
|
||||||
@@ -379,13 +455,13 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
|||||||
<>
|
<>
|
||||||
<label class={styles.field}>
|
<label class={styles.field}>
|
||||||
<span class={styles.fieldLabel}>Display name</span>
|
<span class={styles.fieldLabel}>Display name</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={adminForm.displayName}
|
value={adminForm.displayName}
|
||||||
onInput={(event): void => setAdminForm("displayName", event.currentTarget.value)}
|
onInput={(event): void => setAdminForm("displayName", event.currentTarget.value)}
|
||||||
placeholder="First admin"
|
placeholder="Admin"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class={styles.field}>
|
<label class={styles.field}>
|
||||||
<span class={styles.fieldLabel}>Email</span>
|
<span class={styles.fieldLabel}>Email</span>
|
||||||
<input
|
<input
|
||||||
@@ -414,8 +490,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={structureForm.departmentName}
|
value={structureForm.departmentName}
|
||||||
|
disabled={modeForm.mode === "personal"}
|
||||||
onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)}
|
onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)}
|
||||||
placeholder="Platform"
|
placeholder={organizationalStructureDefaults.departmentName}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class={styles.field}>
|
<label class={styles.field}>
|
||||||
@@ -423,8 +500,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={structureForm.teamName}
|
value={structureForm.teamName}
|
||||||
|
disabled={modeForm.mode === "personal"}
|
||||||
onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)}
|
onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)}
|
||||||
placeholder="Core"
|
placeholder={organizationalStructureDefaults.teamName}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class={styles.field}>
|
<label class={styles.field}>
|
||||||
|
|||||||
Reference in New Issue
Block a user