feat(web-organizations): add organization detail editing and isolate detail view state from global navigation

This commit is contained in:
2026-03-20 16:48:04 +00:00
parent ffe7ffbde9
commit 087b8c0bb3
9 changed files with 90 additions and 32 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-03-20 - 1.6.0 - feat(web-organizations)
add organization detail editing and isolate detail view state from global navigation
- adds an update organization action to persist organization detail edits from the detail view
- updates organization and package views to track selected detail entities locally instead of mutating global ui state
- preserves resolved app shell tabs for role-based filtering after async tab loading
- includes type-cast fixes for admin auth provider responses and bundled file Response bodies
## 2026-03-20 - 1.5.1 - fix(web-app) ## 2026-03-20 - 1.5.1 - fix(web-app)
update dashboard navigation to use the router directly and refresh admin tabs on login changes update dashboard navigation to use the router directly and refresh admin tabs on login changes

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@stack.gallery/registry', name: '@stack.gallery/registry',
version: '1.5.1', version: '1.6.0',
description: 'Enterprise-grade multi-protocol package registry' description: 'Enterprise-grade multi-protocol package registry'
} }

View File

@@ -26,7 +26,7 @@ export class AdminHandler {
try { try {
const providers = await AuthProvider.getAllProviders(); const providers = await AuthProvider.getAllProviders();
return { return {
providers: providers.map((p) => p.toAdminInfo()), providers: providers.map((p) => p.toAdminInfo() as unknown as interfaces.data.IAuthProvider),
}; };
} catch (error) { } catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error; if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
@@ -124,7 +124,7 @@ export class AdminHandler {
}, },
}); });
return { provider: provider.toAdminInfo() }; return { provider: provider.toAdminInfo() as unknown as interfaces.data.IAuthProvider };
} catch (error) { } catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error; if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to create provider'); throw new plugins.typedrequest.TypedResponseError('Failed to create provider');
@@ -146,7 +146,7 @@ export class AdminHandler {
throw new plugins.typedrequest.TypedResponseError('Provider not found'); throw new plugins.typedrequest.TypedResponseError('Provider not found');
} }
return { provider: provider.toAdminInfo() }; return { provider: provider.toAdminInfo() as unknown as interfaces.data.IAuthProvider };
} catch (error) { } catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error; if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get provider'); throw new plugins.typedrequest.TypedResponseError('Failed to get provider');
@@ -235,7 +235,7 @@ export class AdminHandler {
metadata: { providerName: provider.name }, metadata: { providerName: provider.name },
}); });
return { provider: provider.toAdminInfo() }; return { provider: provider.toAdminInfo() as unknown as interfaces.data.IAuthProvider };
} catch (error) { } catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error; if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update provider'); throw new plugins.typedrequest.TypedResponseError('Failed to update provider');

View File

@@ -325,7 +325,7 @@ export class StackGalleryRegistry {
// Get bundled file // Get bundled file
const file = bundledFileMap.get(filePath); const file = bundledFileMap.get(filePath);
if (file) { if (file) {
return new Response(file.data, { return new Response(file.data as unknown as BodyInit, {
status: 200, status: 200,
headers: { 'Content-Type': file.contentType }, headers: { 'Content-Type': file.contentType },
}); });
@@ -334,7 +334,7 @@ export class StackGalleryRegistry {
// SPA fallback: serve index.html for unknown paths // SPA fallback: serve index.html for unknown paths
const indexFile = bundledFileMap.get('/index.html'); const indexFile = bundledFileMap.get('/index.html');
if (indexFile) { if (indexFile) {
return new Response(indexFile.data, { return new Response(indexFile.data as unknown as BodyInit, {
status: 200, status: 200,
headers: { 'Content-Type': 'text/html' }, headers: { 'Content-Type': 'text/html' },
}); });

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@stack.gallery/registry', name: '@stack.gallery/registry',
version: '1.5.1', version: '1.6.0',
description: 'Enterprise-grade multi-protocol package registry' description: 'Enterprise-grade multi-protocol package registry'
} }

View File

@@ -276,6 +276,30 @@ export const createOrganizationAction = organizationsStatePart.createAction<{
} }
}); });
export const updateOrganizationAction = organizationsStatePart.createAction<{
organizationId: string;
displayName?: string;
description?: string;
website?: string;
isPublic?: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
if (!context.identity) return statePartArg.getState();
try {
const typedRequest = createTypedRequest<interfaces.requests.IReq_UpdateOrganization>(
'updateOrganization',
);
const response = await typedRequest.fire({
identity: context.identity,
...dataArg,
});
// Update the current org in state
return { ...statePartArg.getState(), currentOrg: response.organization };
} catch {
return statePartArg.getState();
}
});
export const deleteOrganizationAction = organizationsStatePart.createAction<{ export const deleteOrganizationAction = organizationsStatePart.createAction<{
organizationId: string; organizationId: string;
}>(async (statePartArg, dataArg) => { }>(async (statePartArg, dataArg) => {

View File

@@ -152,7 +152,7 @@ export class SgAppShell extends DeesElement {
this.fetchAuthProviders(); this.fetchAuthProviders();
// Resolve async view tab imports // Resolve async view tab imports
const allTabs = await Promise.all( this.allResolvedViewTabs = await Promise.all(
this.viewTabs.map(async (tab) => ({ this.viewTabs.map(async (tab) => ({
name: tab.name, name: tab.name,
iconName: tab.iconName, iconName: tab.iconName,
@@ -162,8 +162,8 @@ export class SgAppShell extends DeesElement {
// Filter admin tab based on user role // Filter admin tab based on user role
this.resolvedViewTabs = this.loginState.identity?.isSystemAdmin this.resolvedViewTabs = this.loginState.identity?.isSystemAdmin
? allTabs ? this.allResolvedViewTabs
: allTabs.filter((t) => t.name !== 'Admin'); : this.allResolvedViewTabs.filter((t) => t.name !== 'Admin');
this.requestUpdate(); this.requestUpdate();
await this.updateComplete; await this.updateComplete;

View File

@@ -1,5 +1,6 @@
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import { appRouter } from '../router.js';
import { import {
css, css,
cssManager, cssManager,
@@ -23,6 +24,9 @@ export class SgViewOrganizations extends DeesElement {
@state() @state()
accessor uiState: appstate.IUiState = { activeView: 'organizations' }; accessor uiState: appstate.IUiState = { activeView: 'organizations' };
@state()
accessor detailOrgId: string | null = null;
constructor() { constructor() {
super(); super();
const orgSub = appstate.organizationsStatePart const orgSub = appstate.organizationsStatePart
@@ -48,9 +52,10 @@ export class SgViewOrganizations extends DeesElement {
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
await appstate.organizationsStatePart.dispatchAction(appstate.fetchOrganizationsAction, null); await appstate.organizationsStatePart.dispatchAction(appstate.fetchOrganizationsAction, null);
// If there's an entity ID, load the detail // If there's an entity ID from the URL, copy it to internal state
if (this.uiState.activeEntityId) { if (this.uiState.activeEntityId) {
await this.loadOrgDetail(this.uiState.activeEntityId); this.detailOrgId = this.uiState.activeEntityId;
await this.loadOrgDetail(this.detailOrgId);
} }
} }
@@ -70,7 +75,7 @@ export class SgViewOrganizations extends DeesElement {
} }
public render(): TemplateResult { public render(): TemplateResult {
if (this.uiState.activeEntityId && this.organizationsState.currentOrg) { if (this.detailOrgId && this.organizationsState.currentOrg) {
return html` return html`
<sg-organization-detail-view <sg-organization-detail-view
.organization="${this.organizationsState.currentOrg}" .organization="${this.organizationsState.currentOrg}"
@@ -79,6 +84,8 @@ export class SgViewOrganizations extends DeesElement {
@back="${() => this.goBack()}" @back="${() => this.goBack()}"
@select-repo="${(e: CustomEvent) => this.selectRepo(e.detail.repositoryId)}" @select-repo="${(e: CustomEvent) => this.selectRepo(e.detail.repositoryId)}"
@create-repo="${() => {/* TODO: create repo modal */}}" @create-repo="${() => {/* TODO: create repo modal */}}"
@edit="${(e: CustomEvent) => this.handleEditOrg(e.detail)}"
@delete="${(e: CustomEvent) => this.handleDeleteOrg(e.detail.organizationId)}"
></sg-organization-detail-view> ></sg-organization-detail-view>
`; `;
} }
@@ -93,10 +100,7 @@ export class SgViewOrganizations extends DeesElement {
} }
private selectOrg(orgId: string) { private selectOrg(orgId: string) {
appstate.uiStatePart.setState({ this.detailOrgId = orgId;
...appstate.uiStatePart.getState(),
activeEntityId: orgId,
});
this.loadOrgDetail(orgId); this.loadOrgDetail(orgId);
} }
@@ -106,10 +110,7 @@ export class SgViewOrganizations extends DeesElement {
} }
private goBack() { private goBack() {
appstate.uiStatePart.setState({ this.detailOrgId = null;
...appstate.uiStatePart.getState(),
activeEntityId: undefined,
});
appstate.organizationsStatePart.setState({ appstate.organizationsStatePart.setState({
...appstate.organizationsStatePart.getState(), ...appstate.organizationsStatePart.getState(),
currentOrg: null, currentOrg: null,
@@ -118,6 +119,31 @@ export class SgViewOrganizations extends DeesElement {
}); });
} }
private async handleEditOrg(data: {
organizationId: string;
displayName?: string;
description?: string;
website?: string;
isPublic?: boolean;
}) {
await appstate.organizationsStatePart.dispatchAction(
appstate.updateOrganizationAction,
data,
);
// Re-load detail to reflect changes
if (this.detailOrgId) {
await this.loadOrgDetail(this.detailOrgId);
}
}
private async handleDeleteOrg(organizationId: string) {
await appstate.organizationsStatePart.dispatchAction(
appstate.deleteOrganizationAction,
{ organizationId },
);
this.goBack();
}
private async createOrg(data: { name: string; displayName?: string; description?: string }) { private async createOrg(data: { name: string; displayName?: string; description?: string }) {
await appstate.organizationsStatePart.dispatchAction( await appstate.organizationsStatePart.dispatchAction(
appstate.createOrganizationAction, appstate.createOrganizationAction,

View File

@@ -1,5 +1,6 @@
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import { appRouter } from '../router.js';
import { import {
css, css,
cssManager, cssManager,
@@ -25,6 +26,9 @@ export class SgViewPackages extends DeesElement {
@state() @state()
accessor uiState: appstate.IUiState = { activeView: 'packages' }; accessor uiState: appstate.IUiState = { activeView: 'packages' };
@state()
accessor detailPackageId: string | null = null;
constructor() { constructor() {
super(); super();
const pkgSub = appstate.packagesStatePart const pkgSub = appstate.packagesStatePart
@@ -49,8 +53,10 @@ export class SgViewPackages extends DeesElement {
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
// If there's an entity ID from the URL, copy it to internal state
if (this.uiState.activeEntityId) { if (this.uiState.activeEntityId) {
await this.loadPackageDetail(this.uiState.activeEntityId); this.detailPackageId = this.uiState.activeEntityId;
await this.loadPackageDetail(this.detailPackageId);
} else { } else {
await appstate.packagesStatePart.dispatchAction( await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction, appstate.searchPackagesAction,
@@ -71,7 +77,7 @@ export class SgViewPackages extends DeesElement {
} }
public render(): TemplateResult { public render(): TemplateResult {
if (this.uiState.activeEntityId && this.packagesState.currentPackage) { if (this.detailPackageId && this.packagesState.currentPackage) {
return html` return html`
<sg-package-detail-view <sg-package-detail-view
.package="${this.packagesState.currentPackage}" .package="${this.packagesState.currentPackage}"
@@ -98,18 +104,12 @@ export class SgViewPackages extends DeesElement {
} }
private selectPackage(packageId: string) { private selectPackage(packageId: string) {
appstate.uiStatePart.setState({ this.detailPackageId = packageId;
...appstate.uiStatePart.getState(),
activeEntityId: packageId,
});
this.loadPackageDetail(packageId); this.loadPackageDetail(packageId);
} }
private goBack() { private goBack() {
appstate.uiStatePart.setState({ this.detailPackageId = null;
...appstate.uiStatePart.getState(),
activeEntityId: undefined,
});
appstate.packagesStatePart.setState({ appstate.packagesStatePart.setState({
...appstate.packagesStatePart.getState(), ...appstate.packagesStatePart.getState(),
currentPackage: null, currentPackage: null,