Skip to content

Commit 5656e4f

Browse files
committed
feature: Added improvements to multi-select and completed the init flow
1 parent 0165234 commit 5656e4f

File tree

9 files changed

+178
-44
lines changed

9 files changed

+178
-44
lines changed

src/orchestrators/initialize.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { ProcessName, ctx } from '../events/context.js';
1+
import chalk from 'chalk';
2+
import path from 'node:path';
3+
4+
import { ResourceConfig } from '../entities/resource-config.js';
5+
import { ProcessName, SubProcessName, ctx } from '../events/context.js';
26
import { Reporter } from '../ui/reporters/reporter.js';
7+
import { FileUtils } from '../utils/file.js';
8+
import { resolvePathWithVariables, untildify } from '../utils/index.js';
39
import { PluginInitOrchestrator } from './initialize-plugins.js';
410

511
export const InitializeOrchestrator = {
@@ -13,6 +19,7 @@ export const InitializeOrchestrator = {
1319

1420
const { pluginManager, typeIdsToDependenciesMap } = await PluginInitOrchestrator.run({}, reporter);
1521

22+
ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE)
1623
const importResults = await Promise.all([...typeIdsToDependenciesMap.keys()].map(async (typeId) => {
1724
try {
1825
return await pluginManager.importResource({
@@ -23,15 +30,62 @@ export const InitializeOrchestrator = {
2330
return null;
2431
}
2532
}))
33+
ctx.subprocessFinished(SubProcessName.IMPORT_RESOURCE)
2634

2735
const flattenedResults = importResults.filter(Boolean).flatMap(p => p?.result).filter(Boolean)
2836

2937
const userSelectedTypes = await reporter.promptInitResultSelection([...new Set(flattenedResults.map((r) => r!.core.type))])
38+
ctx.log('Resource types were chosen to be imported.')
3039

31-
ctx.processFinished(ProcessName.INIT);
40+
const locationToSave = await this.promptSaveLocation(reporter);
41+
ctx.log(`Save results to ${locationToSave}`)
42+
await reporter.hide();
43+
44+
const resourcesRaw = flattenedResults.filter((r) => userSelectedTypes.includes(r.core.type))
45+
.map((r) => ResourceConfig.fromJson(r!))
46+
.map((r) => r.raw);
47+
48+
await FileUtils.writeFile(locationToSave, JSON.stringify(resourcesRaw, null, 2));
49+
ctx.log('File successfully saved');
3250

33-
console.log(JSON.stringify(flattenedResults, null, 2));
51+
await reporter.displayMessage(`
52+
🎉🎉 Codify successfully initialized. 🎉🎉
53+
The imported configs were written to: ${locationToSave}
54+
55+
Use ${chalk.bgMagenta.bold(' codify plan ')} to compute changes and ${chalk.bgMagenta.bold(' codify apply ')} to apply them.
56+
For more information visit: https://docs.codifycli.com.
57+
58+
Enjoy!
59+
`)
60+
61+
ctx.processFinished(ProcessName.INIT);
3462
},
3563

64+
async promptSaveLocation(reporter: Reporter): Promise<string> {
65+
let locationToSave = '';
66+
let input = '';
67+
let isValidSaveLocation = false;
68+
let error = false;
69+
70+
while (!isValidSaveLocation) {
71+
input = (await reporter.promptInput(
72+
`Where to save the new Codify configs? ${chalk.grey.dim('(leave blank for ~/codify.json)')}`,
73+
error ? `Invalid location: ${input} already exists` : undefined)
74+
)
75+
input = input ? input : '~/codify.json';
76+
77+
locationToSave = path.resolve(untildify(resolvePathWithVariables(input)));
78+
79+
try {
80+
isValidSaveLocation = !(await FileUtils.fileExists(locationToSave));
81+
error = !isValidSaveLocation;
82+
} catch {
83+
isValidSaveLocation = false;
84+
error = true;
85+
}
86+
}
87+
88+
return locationToSave;
89+
}
3690

3791
};

src/ui/components/default-component.test.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
1+
import chalk from 'chalk';
12
import { cleanup, render } from 'ink-testing-library';
23
import { EventEmitter } from 'node:events';
34
import React from 'react';
45
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
56

7+
import { DefaultReporter } from '../reporters/default-reporter.js';
68
import { RenderStatus, store } from '../store/index.js';
79
import { DefaultComponent } from './default-component.js';
810

911
// Mock dependent components
10-
vi.mock('./progress/progress-display', () => ({
11-
ProgressDisplay: () => <div>Mock Progress Display</div>
12-
}));
13-
vi.mock('./import/index', () => ({
14-
ImportParametersForm: () => <div>Mock Import Parameters Form</div>
15-
}));
16-
vi.mock('./plan/plan', () => ({
17-
PlanComponent: () => <div>Mock Plan Component</div>
18-
}));
19-
vi.mock('./import/import-result', () => ({
20-
ImportResultComponent: () => <div>Mock Import Result Component</div>
21-
}));
12+
// vi.mock('./progress/progress-display', () => ({
13+
// ProgressDisplay: () => <div>Mock Progress Display</div>
14+
// }));
15+
// vi.mock('./import/index', () => ({
16+
// ImportParametersForm: () => <div>Mock Import Parameters Form</div>
17+
// }));
18+
// vi.mock('./plan/plan', () => ({
19+
// PlanComponent: () => <div>Mock Plan Component</div>
20+
// }));
21+
// vi.mock('./import/import-result', () => ({
22+
// ImportResultComponent: () => <div>Mock Import Result Component</div>
23+
// }));
2224

2325
describe('DefaultComponent', () => {
2426
let emitter: EventEmitter;
@@ -33,6 +35,19 @@ describe('DefaultComponent', () => {
3335
emitter.removeAllListeners();
3436
});
3537

38+
it('Renders the init completed message', () => {
39+
const reporter = new DefaultReporter();
40+
const locationToSave = '~/codify.json'
41+
42+
reporter.displayMessage(`
43+
🎉🎉 Codify successfully initialized. 🎉🎉
44+
The imported configs were written to: ${locationToSave}
45+
46+
Use ${chalk.bgHex('#F0EAD6').bold(' codify plan ')} to futures compute changes and ${chalk.bgHex('#F0EAD6').bold(' codify apply ')} to apply them.
47+
Visit the documentation for more info: https://docs.codifycli.com.
48+
`)
49+
})
50+
3651
it('renders progress display when renderStatus is PROGRESS', () => {
3752
// TODO: Doesn't work on github actions for some reason. Will investigate later 02-13-2025
3853
// store.set(store.renderState, { status: RenderStatus.PROGRESS });

src/ui/components/default-component.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Form, FormProps } from '@codifycli/ink-form';
2-
import { PasswordInput } from '@inkjs/ui';
2+
import { PasswordInput, TextInput } from '@inkjs/ui';
33
import chalk from 'chalk';
44
import { Box, Static, Text } from 'ink';
55
import SelectInput from 'ink-select-input';
@@ -142,15 +142,24 @@ export function DefaultComponent(props: {
142142
<Box flexDirection='column'>
143143
<Text>Codify found the following supported resorces on your system.</Text>
144144
<Text> </Text>
145-
<Text bold> Select which ones to import:</Text>
145+
<Text bold> Select the resources to import:</Text>
146146
<MultiSelect
147-
limit={9}
148-
items={(renderData as string[]).map((o) => ({ label: o, value: o })).sort((a, b) => a.label.localeCompare(b.label))}
149-
onSubmit={(result: unknown[]) => emitter.emit(RenderEvent.PROMPT_RESULT, result)}
150147
defaultSelected={(renderData as string[]).map((o) => ({ label: o, value: o }))}
148+
items={(renderData as string[]).map((o) => ({ label: o, value: o })).sort((a, b) => a.label.localeCompare(b.label))}
149+
limit={9}
150+
onSubmit={(result: unknown[]) => emitter.emit(RenderEvent.PROMPT_RESULT, result.map((r: any) => r?.label))}
151151
/>
152152
</Box>
153153
)
154154
}
155+
{
156+
renderStatus === RenderStatus.PROMPT_INPUT && (
157+
<Box flexDirection='column'>
158+
<Text bold>{renderData.prompt}</Text>
159+
{ renderData.error && (<Text color='red'>{renderData.error}</Text>) }
160+
<TextInput placeholder='~/codify.json' onSubmit={(result) => emitter.emit(RenderEvent.PROMPT_RESULT, result)} />
161+
</Box>
162+
)
163+
}
155164
</Box>
156165
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { render } from 'ink';
2+
import React from 'react';
3+
import { describe } from 'vitest';
4+
5+
import { MultiSelect } from './MultiSelect.js';
6+
7+
render(<MultiSelect defaultSelected={['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', 'Item 6']} items={[
8+
{ label: 'Item 1', value: 'Item 1' },
9+
{ label: 'Item 2', value: 'Item 2' },
10+
{ label: 'Item 3', value: 'Item 3' },
11+
{ label: 'Item 4', value: 'Item 4' },
12+
{ label: 'Item 5', value: 'Item 5' },
13+
{ label: 'Item 6', value: 'Item 6' },
14+
{ label: 'Item 7', value: 'Item 7' },
15+
{ label: 'Item 8', value: 'Item 8' },
16+
{ label: 'Item 9', value: 'Item 9' },
17+
{ label: 'Item 10', value: 'Item 10' },
18+
{ label: 'Item 11', value: 'Item 11' },
19+
{ label: 'Item 12', value: 'Item 12' },
20+
{ label: 'Item 13', value: 'Item 13' },
21+
]} /> )

src/ui/components/multi-select/MultiSelect.tsx

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, useInput, useStdin } from 'ink';
1+
import { Box, Text, useInput, useStdin } from 'ink';
22
import React, { useLayoutEffect, useState } from 'react';
33

44
import Checkbox from './Checkbox.js';
@@ -14,12 +14,12 @@ interface Item {
1414

1515
interface Props {
1616
items: Array<Item>;
17-
selected: Array<Item>;
18-
defaultSelected: Array<string>;
19-
defaultHighlightedIndex: number;
20-
focus: boolean;
21-
initialIndex: number;
22-
limit: number
17+
selected?: Array<Item>;
18+
defaultSelected?: Array<Item>;
19+
defaultHighlightedIndex?: number;
20+
focus?: boolean;
21+
initialIndex?: number;
22+
limit?: number
2323
onSelect?: (item: Item) => void;
2424
onUnselect?: (item: Item) => void;
2525
onSubmit?: (result: Item[]) => void;
@@ -88,18 +88,26 @@ export function MultiSelect(props: Props) {
8888
if (key.return) {
8989
onSubmit?.(newlySelected);
9090
}
91+
92+
if (input === 'a') {
93+
setSelected(items);
94+
}
95+
96+
if (input === 'd') {
97+
setSelected([]);
98+
}
9199
})
92100

93101
const hasLimit = () => {
94102
const { limit, items } = props;
95-
return items.length > limit;
103+
return items.length > (limit ?? items.length);
96104
}
97105

98106
const limit = () => {
99107
const { limit, items } = props;
100108

101109
if (hasLimit()) {
102-
return Math.min(limit, items.length);
110+
return Math.min(limit ?? items.length, items.length);
103111
}
104112

105113
return items.length;
@@ -122,19 +130,24 @@ export function MultiSelect(props: Props) {
122130
const slicedItems = hasLimit() ? arrRotate(props.items, rotateIndex).slice(0, limit()) : props.items;
123131

124132
return (
125-
<Box flexDirection="column">
126-
{slicedItems.map((item, index) => {
127-
const key = item.key ?? item.value;
128-
const isHighlighted = index === highlightedIndex;
129-
130-
return (
131-
<Box key={key}>
132-
<Indicator isHighlighted={isHighlighted} />
133-
<Checkbox isSelected={isSelected(item.value)} />
134-
<Item {...item} isHighlighted={isHighlighted} />
135-
</Box>
136-
);
137-
})}
133+
<Box flexDirection='column'>
134+
<Box flexDirection="column">
135+
{slicedItems.map((item, index) => {
136+
const key = item.key ?? item.value;
137+
const isHighlighted = index === highlightedIndex;
138+
139+
return (
140+
<Box key={key}>
141+
<Indicator isHighlighted={isHighlighted} />
142+
<Checkbox isSelected={isSelected(item.value)} />
143+
<Item {...item} isHighlighted={isHighlighted} />
144+
</Box>
145+
);
146+
})}
147+
</Box>
148+
<Text color='gray' dimColor>{'Use <space> to select and <return> to submit.'}</Text>
149+
<Text color='gray' dimColor>{'Use <a> to select all items and <d> to de-select all items.'}</Text>
138150
</Box>
151+
139152
);
140153
}

src/ui/reporters/default-reporter.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,18 @@ export class DefaultReporter implements Reporter {
5656
)
5757
}
5858

59+
async promptInput(prompt: string, error?: string, validation?: () => Promise<boolean>, autoComplete?: (input: string) => string[]): Promise<string> {
60+
return this.updateStateAndAwaitEvent<string>(
61+
() => this.updateRenderState(RenderStatus.PROMPT_INPUT, { prompt, error }),
62+
RenderEvent.PROMPT_RESULT,
63+
)
64+
}
65+
5966
async displayProgress(): Promise<void> {
6067
this.updateRenderState(RenderStatus.PROGRESS);
6168
}
6269

63-
async hideProgress(): Promise<void> {
70+
async hide(): Promise<void> {
6471
this.updateRenderState(RenderStatus.NOTHING);
6572
}
6673

src/ui/reporters/reporter.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ export interface Reporter {
4848

4949
displayProgress(): Promise<void>;
5050

51-
hideProgress(): Promise<void>;
51+
hide(): Promise<void>;
5252

5353
promptInitResultSelection(availableTypes: string[]): Promise<string[]>;
5454

55+
promptInput(prompt: string, error?: string, validation?: () => Promise<boolean>, autoComplete?: (input: string) => string[]): Promise<string>;
56+
5557
promptConfirmation(message: string): Promise<boolean>
5658

5759
promptOptions(message: string, options: string[]): Promise<number>;

src/ui/store/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export enum RenderStatus {
1919
IMPORT_PROMPT_WARNING,
2020
PROMPT_CONFIRMATION,
2121
PROMPT_OPTIONS,
22+
PROMPT_INPUT,
2223
SUDO_PROMPT,
2324
DISPLAY_MESSAGE,
2425
}

src/utils/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os from 'node:os';
2+
13
export function groupBy<T>(arr: T[], grouper: (item: T) => string): Record<string, T[]> {
24
// eslint-disable-next-line unicorn/no-array-reduce
35
return arr.reduce((result, curr) => {
@@ -59,3 +61,13 @@ export function deepEqual(obj1: unknown, obj2: unknown): boolean {
5961
// If all checks pass, the objects are deep equal.
6062
return true;
6163
}
64+
65+
export function resolvePathWithVariables(pathWithVariables: string) {
66+
// @ts-expect-error Ignore this for now
67+
return pathWithVariables.replace(/\$([A-Z_]+[A-Z0-9_]*)|\${([A-Z0-9_]*)}/ig, (_, a, b) => process.env[a || b])
68+
}
69+
70+
export function untildify(pathWithTilde: string) {
71+
const homeDirectory = os.homedir();
72+
return homeDirectory ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) : pathWithTilde;
73+
}

0 commit comments

Comments
 (0)