Feat: Add development bootstrap reset
This commit is contained in:
@@ -410,6 +410,36 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
|
||||
}, 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) {
|
||||
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
|
||||
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) {
|
||||
payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r)
|
||||
if !ok {
|
||||
|
||||
@@ -33,6 +33,10 @@ func (routes apiRoutes) Register(router chi.Router) {
|
||||
apiRouter.Get("/app-shell", routes.handleAppShellState)
|
||||
apiRouter.Get("/organizations", routes.handleOrganizations)
|
||||
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[];
|
||||
};
|
||||
|
||||
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 = {
|
||||
status: Accessor<"idle" | "loading" | "success" | "error">;
|
||||
error: Accessor<string>;
|
||||
installation: Accessor<AppShellInstallation | undefined>;
|
||||
railItems: Accessor<readonly RailItem[]>;
|
||||
activeServer: Accessor<ActiveServer>;
|
||||
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.");
|
||||
}
|
||||
|
||||
setPayload(body.data);
|
||||
setPayload(normalizeAppShellPayload(body.data));
|
||||
setStatus("success");
|
||||
} catch (loadError) {
|
||||
setStatus("error");
|
||||
@@ -292,6 +303,7 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem
|
||||
const value: AppShellContextValue = {
|
||||
status,
|
||||
error,
|
||||
installation: createMemo(() => payload()?.installation),
|
||||
railItems: createMemo(() => buildRailItems(payload())),
|
||||
activeServer: createMemo(() => buildActiveServer(payload())),
|
||||
activeProject: createMemo(() => buildActiveProject(payload())),
|
||||
|
||||
@@ -261,6 +261,14 @@
|
||||
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 select:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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 { createStore } from "solid-js/store";
|
||||
import { resolveAPIBase } from "../../../lib/api";
|
||||
@@ -46,6 +46,37 @@ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
|
||||
|
||||
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 => ({
|
||||
status: "idle",
|
||||
error: "",
|
||||
@@ -93,24 +124,10 @@ type WorkspaceHomeProps = {
|
||||
|
||||
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||
const appShellData = useAppShellData();
|
||||
const [instanceForm, setInstanceForm] = createStore({
|
||||
protocol: "http",
|
||||
access: "local",
|
||||
host: "localhost",
|
||||
});
|
||||
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 [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm });
|
||||
const [modeForm, setModeForm] = createStore({ ...defaultModeForm });
|
||||
const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm });
|
||||
const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm });
|
||||
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
|
||||
instance: initialSubmissionState(),
|
||||
mode: initialSubmissionState(),
|
||||
@@ -129,10 +146,54 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||
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 =>
|
||||
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
|
||||
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
|
||||
const apiBase = (): string => resolveAPIBase();
|
||||
const bootstrapTargetLabel = (): string =>
|
||||
modeForm.mode === "personal" ? "Personal server" : "Organization server";
|
||||
const currentStep = createMemo<BootstrapStepDefinition>(
|
||||
() => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!,
|
||||
);
|
||||
@@ -141,6 +202,20 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
|
||||
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> => {
|
||||
setStepState(step, { status: "submitting", error: "" });
|
||||
|
||||
@@ -201,6 +276,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||
}
|
||||
|
||||
if (isLastStep()) {
|
||||
await appShellData.reload();
|
||||
writeBootstrapCompletion(true);
|
||||
setIsBootstrapComplete(true);
|
||||
setIsWizardOpen(false);
|
||||
@@ -268,7 +344,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||
|
||||
<section class={styles.hero} data-slot="workspace-home-hero">
|
||||
<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()}>
|
||||
<div class={styles.heroActions}>
|
||||
<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">
|
||||
<div class={styles.wizardHeaderCopy}>
|
||||
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
|
||||
Bootstrap {appShellData.activeServer().name}
|
||||
Bootstrap {bootstrapTargetLabel()}
|
||||
</h2>
|
||||
</div>
|
||||
<Show when={canDismissWizard()}>
|
||||
@@ -383,7 +459,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||
type="text"
|
||||
value={adminForm.displayName}
|
||||
onInput={(event): void => setAdminForm("displayName", event.currentTarget.value)}
|
||||
placeholder="First admin"
|
||||
placeholder="Admin"
|
||||
/>
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
@@ -414,8 +490,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||
<input
|
||||
type="text"
|
||||
value={structureForm.departmentName}
|
||||
disabled={modeForm.mode === "personal"}
|
||||
onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)}
|
||||
placeholder="Platform"
|
||||
placeholder={organizationalStructureDefaults.departmentName}
|
||||
/>
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
@@ -423,8 +500,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
|
||||
<input
|
||||
type="text"
|
||||
value={structureForm.teamName}
|
||||
disabled={modeForm.mode === "personal"}
|
||||
onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)}
|
||||
placeholder="Core"
|
||||
placeholder={organizationalStructureDefaults.teamName}
|
||||
/>
|
||||
</label>
|
||||
<label class={styles.field}>
|
||||
|
||||
Reference in New Issue
Block a user