Skip to content

Commit 96a3a9d

Browse files
michaelolo24claude
andcommitted
[Cases] Add task settings page, board view, and task status improvements
- Add standalone /task-settings page with task statuses and task templates - Add kanban board view for tasks with drag-and-drop between status columns - Add task reordering within and across columns - Fix task status toggling not persisting (ConfigurationPartialAttributesRt missing taskStatuses) - Fix missing built-in statuses in task form after any custom status is saved - Fix task create/update API rejecting custom status keys (was validating literal union) - Handle orphaned task statuses: tasks with deleted statuses show as "Unassigned" / "No status" - Make column visibility toggles in board view use EuiSwitch with improved spacing - Wire up task card checkboxes in board view to toggle done/open Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6163df2 commit 96a3a9d

29 files changed

Lines changed: 1817 additions & 319 deletions

File tree

x-pack/platform/plugins/shared/cases/common/constants/application.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const CASE_VIEW_TAB_PATH = `${CASE_VIEW_PATH}/?tabId=:tabId` as const;
2929
export const CASES_TEMPLATES_PATH = '/templates' as const;
3030
export const CASES_CREATE_TEMPLATE_PATH = `${CASES_TEMPLATES_PATH}/create` as const;
3131
export const CASES_EDIT_TEMPLATE_PATH = `${CASES_TEMPLATES_PATH}/:templateId/edit` as const;
32+
export const CASES_TASK_SETTINGS_PATH = '/task-settings' as const;
3233
/**
3334
* The main Cases application is in the stack management under the
3435
* Alerts and Insights section. To do that, Cases registers to the management

x-pack/platform/plugins/shared/cases/common/types/api/configure/v1.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ import {
2727
CustomFieldNumberTypeRt,
2828
} from '../../domain';
2929
import type { Configurations, Configuration } from '../../domain/configure/v1';
30-
import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1';
30+
import {
31+
ConfigurationBasicWithoutOwnerRt,
32+
ClosureTypeRt,
33+
TaskStatusesConfigurationRt,
34+
} from '../../domain/configure/v1';
3135
import { CaseConnectorRt } from '../../domain/connector/v1';
3236
import { CaseBaseOptionalFieldsRequestRt } from '../case/v1';
3337
import {
@@ -189,6 +193,7 @@ export const ConfigurationRequestRt = rt.intersection([
189193
customFields: CustomFieldsConfigurationRt,
190194
templates: TemplatesConfigurationRt,
191195
observableTypes: ObservableTypesConfigurationRt,
196+
taskStatuses: TaskStatusesConfigurationRt,
192197
})
193198
),
194199
]);
@@ -210,11 +215,12 @@ export const CaseConfigureRequestParamsRt = rt.strict({
210215
export const ConfigurationPatchRequestRt = rt.intersection([
211216
rt.exact(
212217
rt.partial({
213-
closure_type: ConfigurationBasicWithoutOwnerRt.type.props.closure_type,
214-
connector: ConfigurationBasicWithoutOwnerRt.type.props.connector,
218+
closure_type: ConfigurationBasicWithoutOwnerRt.types[0].type.props.closure_type,
219+
connector: ConfigurationBasicWithoutOwnerRt.types[0].type.props.connector,
215220
customFields: CustomFieldsConfigurationRt,
216221
templates: TemplatesConfigurationRt,
217222
observableTypes: ObservableTypesConfigurationRt,
223+
taskStatuses: TaskStatusesConfigurationRt,
218224
})
219225
),
220226
rt.strict({ version: rt.string }),

x-pack/platform/plugins/shared/cases/common/types/domain/configure/v1.ts

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '../custom_field/v1';
1616
import { CaseBaseOptionalFieldsRt } from '../case/v1';
1717
import { CaseObservableTypeRt } from '../observable/v1';
18+
import { TaskStatusDefinitionRt } from '../task/v1';
1819

1920
export const ClosureTypeRt = rt.union([
2021
rt.literal('close-by-user'),
@@ -107,28 +108,40 @@ export const TemplateConfigurationRt = rt.intersection([
107108

108109
export const TemplatesConfigurationRt = rt.array(TemplateConfigurationRt);
109110

110-
export const ConfigurationBasicWithoutOwnerRt = rt.strict({
111-
/**
112-
* The external connector
113-
*/
114-
connector: CaseConnectorRt,
115-
/**
116-
* Whether to close the case after it has been synced with the external system
117-
*/
118-
closure_type: ClosureTypeRt,
119-
/**
120-
* The custom fields configured for the case
121-
*/
122-
customFields: CustomFieldsConfigurationRt,
123-
/**
124-
* Templates configured for the case
125-
*/
126-
templates: TemplatesConfigurationRt,
127-
/**
128-
* Observable types configured for the case
129-
*/
130-
observableTypes: ObservableTypesConfigurationRt,
131-
});
111+
export const TaskStatusesConfigurationRt = rt.array(TaskStatusDefinitionRt);
112+
113+
export const ConfigurationBasicWithoutOwnerRt = rt.intersection([
114+
rt.strict({
115+
/**
116+
* The external connector
117+
*/
118+
connector: CaseConnectorRt,
119+
/**
120+
* Whether to close the case after it has been synced with the external system
121+
*/
122+
closure_type: ClosureTypeRt,
123+
/**
124+
* The custom fields configured for the case
125+
*/
126+
customFields: CustomFieldsConfigurationRt,
127+
/**
128+
* Templates configured for the case
129+
*/
130+
templates: TemplatesConfigurationRt,
131+
/**
132+
* Observable types configured for the case
133+
*/
134+
observableTypes: ObservableTypesConfigurationRt,
135+
}),
136+
rt.exact(
137+
rt.partial({
138+
/**
139+
* Custom task status definitions for this configuration
140+
*/
141+
taskStatuses: TaskStatusesConfigurationRt,
142+
})
143+
),
144+
]);
132145

133146
export const CasesConfigureBasicRt = rt.intersection([
134147
ConfigurationBasicWithoutOwnerRt,
@@ -175,3 +188,4 @@ export type Configuration = rt.TypeOf<typeof ConfigurationRt>;
175188
export type Configurations = rt.TypeOf<typeof ConfigurationsRt>;
176189
export type ObservableTypesConfiguration = rt.TypeOf<typeof ObservableTypesConfigurationRt>;
177190
export type ObservableTypeConfiguration = rt.TypeOf<typeof CaseObservableTypeRt>;
191+
export type TaskStatusesConfiguration = rt.TypeOf<typeof TaskStatusesConfigurationRt>;

x-pack/platform/plugins/shared/cases/common/types/domain/task/v1.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,47 @@
88
import * as rt from 'io-ts';
99
import { UserRt } from '../user/v1';
1010

11-
export const CaseTaskStatusRt = rt.keyof({
12-
open: null,
13-
in_progress: null,
14-
completed: null,
15-
cancelled: null,
16-
});
11+
export const CaseTaskStatusRt = rt.string;
12+
13+
export const TaskStatusDefinitionRt = rt.intersection([
14+
rt.strict({
15+
key: rt.string,
16+
label: rt.string,
17+
color: rt.string,
18+
}),
19+
rt.exact(rt.partial({ disabled: rt.boolean })),
20+
]);
21+
22+
export const TaskStatusesConfigurationRt = rt.array(TaskStatusDefinitionRt);
23+
24+
/** Keys of the four built-in statuses that can be disabled but never deleted. */
25+
export const BUILTIN_STATUS_KEYS: ReadonlySet<string> = new Set([
26+
'open',
27+
'in_progress',
28+
'done',
29+
'cancelled',
30+
]);
31+
32+
export const DEFAULT_TASK_STATUSES: Array<rt.TypeOf<typeof TaskStatusDefinitionRt>> = [
33+
{ key: 'open', label: 'Open', color: 'default' },
34+
{ key: 'in_progress', label: 'In progress', color: 'primary' },
35+
{ key: 'done', label: 'Done', color: 'success' },
36+
{ key: 'cancelled', label: 'Cancelled', color: 'default' },
37+
];
38+
39+
/**
40+
* Merge stored task statuses with the 4 built-in defaults.
41+
* Built-ins always appear first, preserving any stored overrides (label/color/disabled).
42+
* Custom statuses (not in BUILTIN_STATUS_KEYS) are appended after.
43+
*/
44+
export function mergeTaskStatusesWithDefaults(
45+
stored: TaskStatusDefinition[]
46+
): TaskStatusDefinition[] {
47+
const storedByKey = new Map(stored.map((s) => [s.key, s]));
48+
const builtins = DEFAULT_TASK_STATUSES.map((def) => storedByKey.get(def.key) ?? def);
49+
const custom = stored.filter((s) => !BUILTIN_STATUS_KEYS.has(s.key));
50+
return [...builtins, ...custom];
51+
}
1752

1853
export const CaseTaskPriorityRt = rt.keyof({
1954
low: null,
@@ -80,6 +115,8 @@ export const CaseTaskSummaryRt = rt.strict({
80115
});
81116

82117
export type CaseTaskStatus = rt.TypeOf<typeof CaseTaskStatusRt>;
118+
export type TaskStatusDefinition = rt.TypeOf<typeof TaskStatusDefinitionRt>;
119+
export type TaskStatusesConfiguration = rt.TypeOf<typeof TaskStatusesConfigurationRt>;
83120
export type CaseTaskPriority = rt.TypeOf<typeof CaseTaskPriorityRt>;
84121
export type CaseTaskAssignee = rt.TypeOf<typeof CaseTaskAssigneeRt>;
85122
export type CaseTaskCustomField = rt.TypeOf<typeof CaseTaskCustomFieldRt>;

x-pack/platform/plugins/shared/cases/common/ui/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@ export type CasesConfigurationUI = Pick<
162162
| 'version'
163163
| 'owner'
164164
| 'observableTypes'
165-
>;
165+
> & {
166+
taskStatuses?: Array<{ key: string; label: string; color: string; disabled?: boolean }>;
167+
};
166168

167169
export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number];
168170
export type CasesConfigurationUITemplate = CasesConfigurationUI['templates'][number];

x-pack/platform/plugins/shared/cases/public/common/navigation/deep_links.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ import {
1212
getCreateCasePath,
1313
getCasesConfigurePath,
1414
getCasesTemplatesPath,
15+
getCasesTaskSettingsPath,
1516
} from './paths';
1617

1718
export const CasesDeepLinkId = {
1819
cases: 'cases',
1920
casesCreate: 'cases_create',
2021
casesConfigure: 'cases_configure',
2122
casesTemplates: 'cases_templates',
23+
casesTaskSettings: 'cases_task_settings',
2224
} as const;
2325

2426
export type ICasesDeepLinkId = (typeof CasesDeepLinkId)[keyof typeof CasesDeepLinkId];
@@ -62,6 +64,15 @@ export const getCasesDeepLinks = <T extends AppDeepLink = AppDeepLink>({
6264
} as T & { id: ICasesDeepLinkId });
6365
}
6466

67+
deepLinks.push({
68+
title: i18n.translate('xpack.cases.navigation.taskSettings', {
69+
defaultMessage: 'Task settings',
70+
}),
71+
...(extend[CasesDeepLinkId.casesTaskSettings] ?? {}),
72+
id: CasesDeepLinkId.casesTaskSettings,
73+
path: getCasesTaskSettingsPath(basePath),
74+
} as T & { id: ICasesDeepLinkId });
75+
6576
return {
6677
title: i18n.translate('xpack.cases.navigation.cases', {
6778
defaultMessage: 'Cases',

x-pack/platform/plugins/shared/cases/public/common/navigation/hooks.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
CASES_CREATE_PATH,
1515
CASES_CREATE_TEMPLATE_PATH,
1616
CASES_TEMPLATES_PATH,
17+
CASES_TASK_SETTINGS_PATH,
1718
} from '../../../common/constants';
1819
import { useNavigation } from '../lib/kibana';
1920
import type { ICasesDeepLinkId } from './deep_links';
@@ -77,6 +78,7 @@ const navigationMapping = {
7778
configure: { path: CASES_CONFIGURE_PATH },
7879
templates: { path: CASES_TEMPLATES_PATH },
7980
createTemplate: { path: CASES_CREATE_TEMPLATE_PATH },
81+
taskSettings: { path: CASES_TASK_SETTINGS_PATH },
8082
};
8183

8284
export const useAllCasesNavigation = () => {
@@ -120,6 +122,14 @@ export const useCasesCreateTemplateNavigation = () => {
120122
return { getCasesCreateTemplateUrl, navigateToCasesCreateTemplate };
121123
};
122124

125+
export const useCasesTaskSettingsNavigation = () => {
126+
const [getCasesTaskSettingsUrl, navigateToCasesTaskSettings] = useCasesNavigation({
127+
path: navigationMapping.taskSettings.path,
128+
deepLinkId: APP_ID,
129+
});
130+
return { getCasesTaskSettingsUrl, navigateToCasesTaskSettings };
131+
};
132+
123133
export const useTemplateViewParams = () => useParams<TemplateViewPathParams>();
124134

125135
type GetEditTemplateUrl = (pathParams: TemplateViewPathParams, absolute?: boolean) => string;

x-pack/platform/plugins/shared/cases/public/common/navigation/paths.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
CASES_TEMPLATES_PATH,
1818
CASES_CREATE_TEMPLATE_PATH,
1919
CASES_EDIT_TEMPLATE_PATH,
20+
CASES_TASK_SETTINGS_PATH,
2021
} from '../../../common/constants';
2122
import type { CASE_VIEW_PAGE_TABS } from '../../../common/types';
2223

@@ -43,6 +44,8 @@ export const getCaseViewWithCommentPath = (casesBasePath: string) =>
4344
normalizePath(`${casesBasePath}${CASE_VIEW_COMMENT_PATH}`);
4445
export const getCasesTemplatesPath = (casesBasePath: string) =>
4546
normalizePath(`${casesBasePath}${CASES_TEMPLATES_PATH}`);
47+
export const getCasesTaskSettingsPath = (casesBasePath: string) =>
48+
normalizePath(`${casesBasePath}${CASES_TASK_SETTINGS_PATH}`);
4649
export const getCasesCreateTemplatePath = (casesBasePath: string) =>
4750
normalizePath(`${casesBasePath}${CASES_CREATE_TEMPLATE_PATH}`);
4851
export const getCasesEditTemplatePath = (casesBasePath: string) =>

x-pack/platform/plugins/shared/cases/public/components/app/routes.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
getCasesTemplatesPath,
2828
getCasesCreateTemplatePath,
2929
getCasesEditTemplatePath,
30+
getCasesTaskSettingsPath,
3031
} from '../../common/navigation';
3132
import { NoPrivilegesPage } from '../no_privileges';
3233
import * as i18n from './translations';
@@ -51,6 +52,8 @@ const AllCasesTemplatesLazy: React.FC = lazy(
5152
() => import('../templates_v2/pages/all_templates_page')
5253
);
5354

55+
const TaskSettingsPageLazy: React.FC = lazy(() => import('../task_settings'));
56+
5457
const CasesRoutesComponent: React.FC<CasesRoutesProps> = ({
5558
actionsNavigation,
5659
ruleDetailsNavigation,
@@ -127,6 +130,16 @@ const CasesRoutesComponent: React.FC<CasesRoutesProps> = ({
127130
</Route>
128131
)}
129132

133+
<Route exact path={getCasesTaskSettingsPath(basePath)}>
134+
{permissions.settings ? (
135+
<Suspense fallback={<EuiLoadingSpinner />}>
136+
<TaskSettingsPageLazy />
137+
</Suspense>
138+
) : (
139+
<NoPrivilegesPage pageName={i18n.CONFIGURE_CASES_PAGE_NAME} />
140+
)}
141+
</Route>
142+
130143
{/* NOTE: current case view implementation retains some local state between renders, eg. when going from one case directly to another one. as a short term fix, we are forcing the component remount. */}
131144
<Route exact path={[getCaseViewWithCommentPath(basePath), getCaseViewPath(basePath)]}>
132145
<Suspense fallback={<EuiLoadingSpinner />}>

x-pack/platform/plugins/shared/cases/public/components/configure_cases/index.tsx

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,6 @@ import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder';
6060
import { ObservableTypes } from '../observable_types';
6161
import { ObservableTypesForm } from '../observable_types/form';
6262
import { useCasesFeatures } from '../../common/use_cases_features';
63-
import { TaskTemplates } from '../task_templates/task_templates';
64-
import { TaskTemplateFlyout } from '../task_templates/task_template_flyout';
65-
import type { CaseTaskTemplate } from '../../../common/types/domain/task_template/v1';
6663

6764
const sectionWrapperCss = css`
6865
box-sizing: content-box;
@@ -135,8 +132,6 @@ export const ConfigureCases: React.FC = React.memo(() => {
135132
const [templateToEdit, setTemplateToEdit] = useState<TemplateConfiguration | null>(null);
136133
const [observableTypeToEdit, setObservableTypeToEdit] =
137134
useState<ObservableTypeConfiguration | null>(null);
138-
const [taskTemplateFlyoutOpen, setTaskTemplateFlyoutOpen] = useState(false);
139-
const [taskTemplateToEdit, setTaskTemplateToEdit] = useState<CaseTaskTemplate | null>(null);
140135
const { euiTheme } = useEuiTheme();
141136

142137
const {
@@ -571,21 +566,6 @@ export const ConfigureCases: React.FC = React.memo(() => {
571566
]
572567
);
573568

574-
const onAddTaskTemplate = useCallback(() => {
575-
setTaskTemplateToEdit(null);
576-
setTaskTemplateFlyoutOpen(true);
577-
}, []);
578-
579-
const onEditTaskTemplate = useCallback((template: CaseTaskTemplate) => {
580-
setTaskTemplateToEdit(template);
581-
setTaskTemplateFlyoutOpen(true);
582-
}, []);
583-
584-
const onCloseTaskTemplateFlyout = useCallback(() => {
585-
setTaskTemplateFlyoutOpen(false);
586-
setTaskTemplateToEdit(null);
587-
}, []);
588-
589569
const AddOrEditCustomFieldFlyout =
590570
flyOutVisibility?.type === 'customField' && flyOutVisibility?.visible ? (
591571
<CommonFlyout<CustomFieldConfiguration>
@@ -758,30 +738,11 @@ export const ConfigureCases: React.FC = React.memo(() => {
758738

759739
<EuiSpacer size="xl" />
760740

761-
<div css={sectionWrapperCss}>
762-
<EuiFlexItem grow={false}>
763-
<TaskTemplates
764-
disabled={isLoadingCaseConfiguration}
765-
isLoading={isLoadingCaseConfiguration}
766-
onAddTemplate={onAddTaskTemplate}
767-
onEditTemplate={onEditTaskTemplate}
768-
/>
769-
</EuiFlexItem>
770-
</div>
771-
772-
<EuiSpacer size="xl" />
773-
774741
{ConnectorAddFlyout}
775742
{ConnectorEditFlyout}
776743
{AddOrEditCustomFieldFlyout}
777744
{AddOrEditTemplateFlyout}
778745
{AddOrEditObservableTypeFlyout}
779-
{taskTemplateFlyoutOpen && (
780-
<TaskTemplateFlyout
781-
templateToEdit={taskTemplateToEdit}
782-
onClose={onCloseTaskTemplateFlyout}
783-
/>
784-
)}
785746
</div>
786747
</EuiPageBody>
787748
</EuiPageSection>

0 commit comments

Comments
 (0)