Skip to content

Commit 22b28c9

Browse files
committed
chore: add-on enabling and selected logic
1 parent 5711343 commit 22b28c9

File tree

15 files changed

+389
-18
lines changed

15 files changed

+389
-18
lines changed

examples/react-cra/blog-starter/starter.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"author": "Jane Smith <[email protected]>",
77
"license": "MIT",
88
"link": "https://github.com/jane-smith/blog-example-starter",
9-
"shadcnComponents": ["card"],
9+
"shadcnComponents": [],
1010
"framework": "react-cra",
1111
"mode": "file-router",
1212
"routes": [],

packages/cta-cli/src/cli.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ export function cli({
280280
framework: getFrameworkById(cliOptions.framework || 'react-cra')!,
281281
mode: 'file-router',
282282
chosenAddOns: [],
283-
packageManager: 'npm',
283+
packageManager: 'pnpm',
284284
projectName: projectName || 'my-app',
285285
targetDir: resolve(process.cwd(), projectName || 'my-app'),
286286
typescript: true,

packages/cta-engine/src/environment.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function createDefaultEnvironment(): Environment {
5151
cwd,
5252
})
5353
return { stdout: result.stdout }
54-
} catch {
54+
} catch (e) {
5555
errors.push(
5656
`Command "${command} ${args.join(' ')}" did not run successfully. Please run this manually in your project.`,
5757
)

packages/cta-ui/package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
"scripts": {
77
"build": "tsc -p tsconfig.lib.json",
88
"dev": "tsc --watch -p tsconfig.lib.json",
9-
"dev:ui": "vinxi dev"
9+
"dev:ui": "vinxi dev",
10+
"test": "vitest run",
11+
"test:watch": "vitest",
12+
"test:coverage": "vitest run --coverage"
1013
},
1114
"dependencies": {
1215
"@codemirror/lang-css": "^6.3.1",
@@ -62,6 +65,7 @@
6265
"@types/node": "^22.14.1",
6366
"@types/react": "^19.0.8",
6467
"@types/react-dom": "^19.0.3",
68+
"@vitest/coverage-v8": "3.1.1",
6569
"@vitejs/plugin-react": "^4.3.4",
6670
"jsdom": "^26.0.0",
6771
"typescript": "^5.7.2",

packages/cta-ui/src/components/custom-add-on-dialog.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
DialogTitle,
1414
} from '@/components/ui/dialog'
1515

16-
import { customAddOns, projectOptions, selectedAddOns } from '@/store/project'
16+
import { customAddOns, projectOptions, toggleAddOn } from '@/store/project'
1717

1818
export default function CustomAddOnDialog() {
1919
const [url, setUrl] = useState('')
@@ -28,7 +28,7 @@ export default function CustomAddOnDialog() {
2828
if (!data.error) {
2929
customAddOns.setState((state) => [...state, data])
3030
if (data.modes.includes(mode)) {
31-
selectedAddOns.setState((state) => [...state, data])
31+
toggleAddOn(data.id)
3232
}
3333
setOpen(false)
3434
} else {

packages/cta-ui/src/components/sidebar-items/add-ons.tsx

+5-9
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { AddOnInfo } from '@/types'
77
import { Switch } from '@/components/ui/switch'
88
import { Label } from '@/components/ui/label'
99

10-
import { availableAddOns, selectedAddOns } from '@/store/project'
10+
import { addOnState, availableAddOns, toggleAddOn } from '@/store/project'
1111

1212
import ImportCustomAddOn from '@/components/custom-add-on-dialog'
1313
import AddOnInfoDialog from '@/components/add-on-info-dialog'
@@ -20,7 +20,7 @@ const addOnTypeLabels: Record<string, string> = {
2020

2121
export default function SelectedAddOns() {
2222
const addOns = useStore(availableAddOns)
23-
const selected = useStore(selectedAddOns)
23+
const addOnStatus = useStore(addOnState)
2424

2525
const sortedAddOns = useMemo(() => {
2626
return addOns.sort((a, b) => {
@@ -54,14 +54,10 @@ export default function SelectedAddOns() {
5454
<div className="p-1 flex flex-row items-center">
5555
<Switch
5656
id={addOn.id}
57-
checked={selected.some((a) => a.id === addOn.id)}
57+
checked={addOnStatus[addOn.id].selected}
58+
disabled={!addOnStatus[addOn.id].enabled}
5859
onCheckedChange={() => {
59-
selectedAddOns.setState((state) => {
60-
if (state.some((a) => a.id === addOn.id)) {
61-
return state.filter((a) => a.id !== addOn.id)
62-
}
63-
return [...state, addOn]
64-
})
60+
toggleAddOn(addOn.id)
6561
}}
6662
/>
6763
<Label

packages/cta-ui/src/components/sidebar-items/run-add-ons.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export default function RunAddOns() {
8585
disabled={currentlySelectedAddOns.length === 0 || isRunning}
8686
className="w-full"
8787
>
88-
Run Add-Ons
88+
Add These Add-Ons To Your App
8989
</Button>
9090
</div>
9191
</div>

packages/cta-ui/src/engine-handling/generate-initial-payload.ts

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export async function generateInitialPayload() {
6666
smallLogo: addOn.smallLogo,
6767
logo: addOn.logo,
6868
link: addOn.link,
69+
dependsOn: addOn.dependsOn,
6970
}))
7071

7172
const fileRouter = getAllAddOns(framework!, 'file-router').map((addOn) => ({
@@ -76,6 +77,7 @@ export async function generateInitialPayload() {
7677
smallLogo: addOn.smallLogo,
7778
logo: addOn.logo,
7879
link: addOn.link,
80+
dependsOn: addOn.dependsOn,
7981
}))
8082

8183
return {

packages/cta-ui/src/routes/api/load-starter.ts

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const APIRoute = createAPIFileRoute('/api/load-starter')({
3131
version: parsed.data.version,
3232
author: parsed.data.author,
3333
license: parsed.data.license,
34+
dependsOn: parsed.data.dependsOn,
3435

3536
mode: parsed.data.mode,
3637
typescript: parsed.data.typescript,

packages/cta-ui/src/store/add-ons.ts

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { AddOnInfo } from '@/types'
2+
3+
export function getAddOnStatus(
4+
availableAddOns: Array<AddOnInfo>,
5+
chosenAddOns: Array<string>,
6+
originalAddOns: Array<string>,
7+
) {
8+
const addOnMap = new Map<
9+
string,
10+
{
11+
enabled: boolean
12+
selected: boolean
13+
dependedUpon: boolean
14+
}
15+
>()
16+
17+
for (const addOn of availableAddOns) {
18+
addOnMap.set(addOn.id, {
19+
selected: false,
20+
enabled: true,
21+
dependedUpon: false,
22+
})
23+
}
24+
25+
// Guard against cycles in the dependency graph. The results won't be great. But it won't crash.
26+
function cycleGuardedSelectAndDisableDependsOn(startingAddOnId: string) {
27+
const visited = new Set<string>()
28+
function selectAndDisableDependsOn(addOnId: string) {
29+
if (visited.has(addOnId)) {
30+
return
31+
}
32+
visited.add(addOnId)
33+
const selectedAddOn = availableAddOns.find(
34+
(addOn) => addOn.id === addOnId,
35+
)
36+
if (selectedAddOn) {
37+
for (const dependsOnId of selectedAddOn.dependsOn || []) {
38+
const dependsOnAddOn = addOnMap.get(dependsOnId)
39+
if (dependsOnAddOn) {
40+
dependsOnAddOn.selected = true
41+
dependsOnAddOn.enabled = false
42+
dependsOnAddOn.dependedUpon = true
43+
selectAndDisableDependsOn(dependsOnId)
44+
}
45+
}
46+
const addOn = addOnMap.get(addOnId)
47+
if (addOn) {
48+
addOn.selected = true
49+
if (!addOn.dependedUpon) {
50+
addOn.enabled = true
51+
}
52+
}
53+
}
54+
}
55+
selectAndDisableDependsOn(startingAddOnId)
56+
}
57+
58+
for (const addOn of originalAddOns) {
59+
const addOnInfo = addOnMap.get(addOn)
60+
if (addOnInfo) {
61+
addOnInfo.selected = true
62+
addOnInfo.enabled = false
63+
addOnInfo.dependedUpon = true
64+
}
65+
cycleGuardedSelectAndDisableDependsOn(addOn)
66+
}
67+
68+
for (const addOnId of chosenAddOns) {
69+
cycleGuardedSelectAndDisableDependsOn(addOnId)
70+
}
71+
72+
return Object.fromEntries(
73+
Array.from(addOnMap.entries()).map(([v, addOn]) => [
74+
v,
75+
{
76+
enabled: addOn.enabled,
77+
selected: addOn.selected,
78+
},
79+
]),
80+
)
81+
}

packages/cta-ui/src/store/project.ts

+56-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Derived, Effect, Store } from '@tanstack/react-store'
22

3+
import { getAddOnStatus } from './add-ons'
4+
35
import type { Mode, SerializedOptions } from '@tanstack/cta-engine'
6+
47
import type { AddOnInfo, ProjectFiles, StarterInfo } from '@/types.js'
58

69
export const isInitialized = new Store<boolean>(false)
@@ -58,8 +61,6 @@ export const availableAddOns = new Derived<Array<AddOnInfo>>({
5861
})
5962
availableAddOns.mount()
6063

61-
export const selectedAddOns = new Store<Array<AddOnInfo>>([])
62-
6364
export const modeEditable = new Derived<boolean>({
6465
fn: () => {
6566
return projectStarter.state === undefined
@@ -87,6 +88,46 @@ export const tailwindEditable = new Derived<boolean>({
8788
})
8889
tailwindEditable.mount()
8990

91+
export const originalSelectedAddOns = new Store<Array<string>>([])
92+
export const userSelectedAddOns = new Store<Array<string>>([])
93+
94+
export const addOnState = new Derived<
95+
Record<
96+
string,
97+
{
98+
selected: boolean
99+
enabled: boolean
100+
}
101+
>
102+
>({
103+
fn: () => {
104+
const originalAddOns: Set<string> = new Set()
105+
for (const addOn of projectStarter.state?.dependsOn || []) {
106+
originalAddOns.add(addOn)
107+
}
108+
for (const addOn of originalSelectedAddOns.state) {
109+
originalAddOns.add(addOn)
110+
}
111+
112+
return getAddOnStatus(
113+
availableAddOns.state,
114+
userSelectedAddOns.state,
115+
originalSelectedAddOns.state,
116+
)
117+
},
118+
deps: [availableAddOns, userSelectedAddOns, originalSelectedAddOns],
119+
})
120+
addOnState.mount()
121+
122+
export const selectedAddOns = new Derived<Array<AddOnInfo>>({
123+
fn: () => {
124+
return availableAddOns.state.filter(
125+
(addOn) => addOnState.state[addOn.id].selected,
126+
)
127+
},
128+
deps: [availableAddOns, addOnState],
129+
})
130+
90131
const onProjectChange = new Effect({
91132
fn: async () => {
92133
if (applicationMode.state === 'setup') {
@@ -136,6 +177,18 @@ const onProjectChange = new Effect({
136177
})
137178
onProjectChange.mount()
138179

180+
export function toggleAddOn(addOnId: string) {
181+
if (addOnState.state[addOnId].enabled) {
182+
if (addOnState.state[addOnId].selected) {
183+
userSelectedAddOns.setState((state) =>
184+
state.filter((addOn) => addOn !== addOnId),
185+
)
186+
} else {
187+
userSelectedAddOns.setState((state) => [...state, addOnId])
188+
}
189+
}
190+
}
191+
139192
// Application setup
140193

141194
export const includeFiles = new Store<Array<string>>([
@@ -189,6 +242,7 @@ export async function loadInitialSetup() {
189242
output,
190243
}))
191244
projectOptions.setState(() => options)
245+
originalSelectedAddOns.setState(() => options.chosenAddOns)
192246
projectLocalFiles.setState(() => localFiles)
193247

194248
isInitialized.setState(() => true)

packages/cta-ui/src/types.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type StarterInfo = {
1212
typescript: boolean
1313
tailwind: boolean
1414
banner?: string
15+
dependsOn?: Array<string>
1516
}
1617

1718
// Files
@@ -43,6 +44,7 @@ export type AddOnInfo = {
4344
smallLogo?: string
4445
logo?: string
4546
link: string
47+
dependsOn?: Array<string>
4648
}
4749

4850
export type FileClass =

0 commit comments

Comments
 (0)