Hide flow finalization behind a feature flag

Finalizing a flow makes it permanently read-only and cannot be undone, and it is
not part of the current workflow, so the controls that create that state are now
hidden: the Finalized toggle in the title toolbar and the Finalized entry in the
flows list filter. Gated by FLOW_FINALIZATION_ENABLED, matching SWIMLANES_ENABLED,
so nothing is deleted and re-enabling is one line.

Only the controls are gated. The badge on a flow row, the disabled delete and the
read-only editor stay, because rows finalized before the switch still exist and
hiding the explanation of why such a flow cannot be edited would make the app
inexplicable. For the same reason the toggle reappears for a flow that is already
finalized, so its state is never invisible in the place that owns it.

A persisted FINALIZED list filter now falls back to showing everything: a filter
whose control is hidden would otherwise keep narrowing the list with nothing on
screen to clear it.

The backend is untouched - finalized still gates editing and deletion there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 10:58:15 +02:00
parent 4c973b3922
commit 4fa449658e
6 changed files with 57 additions and 3 deletions

View File

@ -10,3 +10,14 @@ export const SWIMLANES_ENABLED = false;
* aspirational. See docs/project-memory.md before changing this flag.
*/
export const PROJECTS_ENABLED = true;
/**
* Finalizing a flow makes it permanently read-only and is irreversible, and it is not part of the
* current workflow, so the controls that create that state are hidden.
*
* Only the *controls* are gated - the Finalized toggle and the Finalized filter. The indicators
* that explain the state stay visible (the badge on a flow row, the disabled delete, the read-only
* editor), because rows finalized earlier still exist and hiding their explanation would make the
* app inexplicable. See docs/project-memory.md before changing this flag.
*/
export const FLOW_FINALIZATION_ENABLED = false;

View File

@ -27,7 +27,9 @@
<mat-button-toggle value="all">All</mat-button-toggle>
<mat-button-toggle value="PUBLIC">Public</mat-button-toggle>
<mat-button-toggle value="PRIVATE">Private</mat-button-toggle>
@if (finalizationEnabled) {
<mat-button-toggle value="FINALIZED">Finalized</mat-button-toggle>
}
</mat-button-toggle-group>
<div class="flows-list-ordering">

View File

@ -11,6 +11,7 @@ import { of, throwError } from 'rxjs';
import { ListState } from '@stores/list-state';
import { vi } from 'vitest';
import { FLOW_FINALIZATION_ENABLED } from '@shared/feature-flags';
import { FlowsList } from './flows-list';
function makeFlow(id: string, name: string, projectId?: string): Flow {
@ -258,4 +259,32 @@ describe('FlowsList', () => {
expect(component.activeFilterCount()).toBe(2);
});
it('offers the Finalized filter only while finalization is enabled', async () => {
const fixture = await build([makeFlow('1', 'Alpha')], []);
fixture.componentInstance.toggleFilters();
fixture.detectChanges();
const values = Array.from(
fixture.nativeElement.querySelectorAll('mat-button-toggle')
).map((toggle: any) => toggle.textContent.trim());
expect(values).toContain('All');
expect(values.includes('Finalized')).toBe(FLOW_FINALIZATION_ENABLED);
});
it('does not keep narrowing the list with a filter whose control is hidden', async () => {
// A persisted Finalized filter would otherwise hide flows with nothing on screen to clear it.
const fixture = await build([makeFlow('1', 'Alpha')], []);
const view = fixture.componentInstance.view;
view.filter = 'FINALIZED';
TestBed.resetTestingModule();
const reopened = await build([makeFlow('1', 'Alpha')], []);
if (!FLOW_FINALIZATION_ENABLED) {
expect(reopened.componentInstance.filter()).toBe('all');
expect(reopened.componentInstance.orderedFlows()).toHaveLength(1);
}
});
});

View File

@ -20,7 +20,7 @@ import { NotificationService } from '@services/notifications/notification';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { ProjectsService } from '@services/projects/projects';
import { EditorStateHolder } from '@stores/flow-editor';
import { PROJECTS_ENABLED } from '@shared/feature-flags';
import { FLOW_FINALIZATION_ENABLED, PROJECTS_ENABLED } from '@shared/feature-flags';
import { OrderEvent, OrderField, Ordering, orderDirType } from "@shared/ordering/ordering";
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
import { FlowsGroup } from './flows-group/flows-group';
@ -63,6 +63,7 @@ export class FlowsList extends ListStateViewHolder<Flow> {
private editorState = inject(EditorStateHolder);
readonly projectsEnabled = PROJECTS_ENABLED;
readonly finalizationEnabled = FLOW_FINALIZATION_ENABLED;
readonly ungroupedKey = UNGROUPED_PROJECT_KEY;
readonly projects = this.projectsService.projects;
@ -98,6 +99,14 @@ export class FlowsList extends ListStateViewHolder<Flow> {
this.filtersOpen.update((open) => !open);
}
/**
* A filter whose control is hidden would keep narrowing the list with no way to see or clear it,
* so a persisted Finalized filter falls back to showing everything.
*/
private usableFilter(filter: FlowFilter): FlowFilter {
return filter === 'FINALIZED' && !FLOW_FINALIZATION_ENABLED ? 'all' : (filter || 'all');
}
constructor() {
super('flowsList', {defaultOrder: { orderBy: 'name', orderDir: 'asc' } as OrderViewState, defaultFilter: 'all'});
effect(() => {
@ -125,7 +134,7 @@ export class FlowsList extends ListStateViewHolder<Flow> {
this.flows = existingState.list;
this.loading.set(false);
if (existingState.filter)
this.filter.set(existingState.filter as FlowFilter || 'all');
this.filter.set(this.usableFilter(existingState.filter as FlowFilter));
return;
}

View File

@ -95,6 +95,7 @@
Published
</mat-slide-toggle>
@if (finalizationEnabled || flow()!.finalized) {
<mat-slide-toggle
[checked]="!!flow()!.finalized"
[disabled]="!!flow()!.finalized || finalizeSaving() || notSaved() || blockSyncInProgress()"
@ -102,6 +103,7 @@
(change)="onFinalizedToggle($event.checked)">
Finalized
</mat-slide-toggle>
}
</div>
}
<button

View File

@ -17,7 +17,7 @@ import { ProjectsService } from '@services/projects/projects';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { take } from 'rxjs';
import { EditorStateHolder } from '@stores/flow-editor';
import { PROJECTS_ENABLED, SWIMLANES_ENABLED } from '@shared/feature-flags';
import { FLOW_FINALIZATION_ENABLED, PROJECTS_ENABLED, SWIMLANES_ENABLED } from '@shared/feature-flags';
@Component({
selector: 'app-title-toolbar',
@ -41,6 +41,7 @@ export class TitleToolbar {
private projectsService = inject(ProjectsService);
readonly projectsEnabled = PROJECTS_ENABLED;
readonly finalizationEnabled = FLOW_FINALIZATION_ENABLED;
private authorization = inject(Authorization);
private taskExecutionsService = inject(TaskExecutionsService);
flow = computed(() => this.editorState.currentFlow());