Skip to content

Commit 924001a

Browse files
Feat: Installs frontend components with command.
1 parent 46c56c8 commit 924001a

File tree

8 files changed

+463
-10
lines changed

8 files changed

+463
-10
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ phpstan.neon
3030
testbench.yaml
3131
/docs
3232
/coverage
33-
/workbench
33+
/workbench
34+
/tests/Fixtures/Storage
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Deferred, router, usePage } from '@inertiajs/react';
2+
import { useEffect, useRef, useState } from 'react';
3+
4+
interface InfiniteScrollProps {
5+
children: React.ReactNode;
6+
data: string;
7+
whileLoading?: React.ReactNode;
8+
whileNoMoreData?: React.ReactNode;
9+
}
10+
11+
type PageProps = {
12+
type: 'cursor' | 'paged';
13+
perPage: number;
14+
hasMore: boolean;
15+
cursor?: string;
16+
page?: number;
17+
};
18+
19+
type Payload = {
20+
perPage: number;
21+
cursor?: string;
22+
page?: number;
23+
};
24+
25+
/**
26+
* Infinite scroll component.
27+
*
28+
* @param children - The children to render.
29+
* @param data - The data to load.
30+
* @param whileLoading - The component to render while loading.
31+
* @param whileNoMoreData - The component to render when there is no more data.
32+
*/
33+
function InfiniteScroll({ children, data, whileLoading, whileNoMoreData }: InfiniteScrollProps) {
34+
const page = usePage<PageProps>().props;
35+
const props: string[] = [data, 'type', 'perPage', 'hasMore'];
36+
const payload: Payload = { perPage: page.perPage };
37+
const [loading, setLoading] = useState<boolean>(false);
38+
39+
const loadData = () => {
40+
if (page.type === 'cursor') {
41+
props.push('cursor');
42+
payload.cursor = page.cursor;
43+
} else if (page.type === 'paged') {
44+
props.push('page');
45+
payload.page = page.page! + 1;
46+
}
47+
48+
router.reload({
49+
only: props,
50+
data: payload,
51+
preserveUrl: true,
52+
onStart: () => setLoading(true),
53+
onFinish: () => setLoading(false),
54+
onError: () => setLoading(false),
55+
});
56+
};
57+
58+
return (
59+
<>
60+
{children}
61+
<Deferred data={data} fallback={<>{whileLoading || <p className="text-center text-muted-foreground">Loading...</p>}</>}>
62+
{loading ? (
63+
<>{whileLoading || <p className="text-center text-muted-foreground">Loading...</p>}</>
64+
) : page.hasMore ? (
65+
<WhenVisible onVisible={loadData} />
66+
) : (
67+
<>{whileNoMoreData || <p className="text-center text-muted-foreground">No more data</p>}</>
68+
)}
69+
</Deferred>
70+
</>
71+
);
72+
}
73+
74+
/**
75+
* When visible component.
76+
*
77+
* @param onVisible - The callback to call when the component is visible.
78+
*/
79+
function WhenVisible({ onVisible }: { onVisible: () => void }) {
80+
const ref = useRef<HTMLDivElement>(null);
81+
82+
useEffect(() => {
83+
const observer = new IntersectionObserver(
84+
(entries) => {
85+
if (entries[0].isIntersecting) {
86+
onVisible();
87+
}
88+
},
89+
{ threshold: 1 },
90+
);
91+
92+
if (ref.current) {
93+
observer.observe(ref.current);
94+
}
95+
96+
return () => {
97+
if (ref.current) {
98+
observer.unobserve(ref.current);
99+
}
100+
};
101+
}, [ref, onVisible]);
102+
103+
return <div ref={ref} />;
104+
}
105+
106+
export { InfiniteScroll };

src/Console/Commands/InstallCommand.php

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Codelabmw\InfiniteScroll\Console\Commands;
66

77
use Codelabmw\InfiniteScroll\Contracts\Stack;
8+
use Codelabmw\InfiniteScroll\Support\FileSystem;
89
use Codelabmw\InfiniteScroll\SupportedStacks;
910
use Illuminate\Console\Command;
1011
use Illuminate\Support\Collection;
@@ -63,13 +64,14 @@ public function handle(): ?int
6364
/** @var Stack */
6465
$stack = App::make((string) $this->supportedStacks->get($stack));
6566

67+
// @phpstan-ignore-next-line
6668
$installationPath = text(
6769
label: 'Where do you want to install components?',
6870
placeholder: $stack->getDefaultInstallationPath(),
69-
required: true,
70-
);
71+
required: false,
72+
) ?? $stack->getDefaultInstallationPath();
7173

72-
$error = spin(fn (): ?string => $this->install($stack->getStubs()), 'Installing infinite scroll components for '.$stack->getLabel().' in '.$installationPath.'.');
74+
$error = spin(fn (): ?string => $this->install($stack->getStubs(), $installationPath), 'Installing infinite scroll components for '.$stack->getLabel().' in '.$installationPath.'.');
7375

7476
if ($error) {
7577
error('Error occurred while installing components. '.$error);
@@ -89,21 +91,38 @@ public function handle(): ?int
8991
*
9092
* @param Collection<int, string> $stubs
9193
*/
92-
private function install(Collection $stubs): ?string
94+
private function install(Collection $stubs, string $destination): ?string
9395
{
9496
if ($stubs->isEmpty()) {
9597
return 'Installation files were not found.';
9698
}
9799

98100
$error = null;
101+
99102
$stubs->each(function (string $file) use (&$error): void {
100-
if (! file_exists($file)) {
103+
if (! FileSystem::exists($file)) {
101104
$error = 'The file: '.$file.' does not exists.';
102105

103106
return;
104107
}
105108
});
106109

107-
return $error;
110+
if ($error !== null) {
111+
return $error;
112+
}
113+
114+
FileSystem::ensureDirectoryExists($destination);
115+
116+
$stubs->each(function (string $file) use ($destination): void {
117+
$destinationFile = $destination.'/'.basename($file);
118+
119+
if (FileSystem::exists($destinationFile)) {
120+
return;
121+
}
122+
123+
FileSystem::copy($file, $destinationFile);
124+
});
125+
126+
return null;
108127
}
109128
}

src/Stacks/React.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Codelabmw\InfiniteScroll\Stacks;
66

77
use Codelabmw\InfiniteScroll\Contracts\Stack;
8+
use Codelabmw\InfiniteScroll\Support\FileSystem;
89
use Illuminate\Support\Collection;
910

1011
final class React implements Stack
@@ -32,6 +33,8 @@ public function getDefaultInstallationPath(): string
3233
*/
3334
public function getStubs(): Collection
3435
{
35-
return Collection::make();
36+
return Collection::make([
37+
FileSystem::stubs('components/react/ts/infinite-scroll.tsx'),
38+
]);
3639
}
3740
}

src/Support/FileSystem.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Codelabmw\InfiniteScroll\Support;
6+
7+
final class FileSystem
8+
{
9+
/**
10+
* Checks if a file or directory exists.
11+
*/
12+
public static function exists(string $path): bool
13+
{
14+
return file_exists($path);
15+
}
16+
17+
/**
18+
* Deletes a file.
19+
*/
20+
public static function delete(string $path): bool
21+
{
22+
return unlink($path);
23+
}
24+
25+
/**
26+
* Ensures a directory exists.
27+
*/
28+
public static function ensureDirectoryExists(string $path): bool
29+
{
30+
if (! self::exists($path)) {
31+
mkdir($path, 0777, true);
32+
}
33+
34+
return true;
35+
}
36+
37+
/**
38+
* Copies a file.
39+
*/
40+
public static function copy(string $source, string $destination): bool
41+
{
42+
return copy($source, $destination);
43+
}
44+
45+
/**
46+
* Returns the path to the stubs directory.
47+
*/
48+
public static function stubs(?string $path = null): string
49+
{
50+
if ($path === null) {
51+
return __DIR__.'/../../stubs';
52+
}
53+
54+
return __DIR__.'/../../stubs/'.$path;
55+
}
56+
57+
/**
58+
* Returns the path to the tests directory.
59+
*/
60+
public static function tests(?string $path = null): string
61+
{
62+
if ($path === null) {
63+
return __DIR__.'/../../tests';
64+
}
65+
66+
return __DIR__.'/../../tests/'.$path;
67+
}
68+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Deferred, router, usePage } from '@inertiajs/react';
2+
import { useEffect, useRef, useState } from 'react';
3+
4+
interface InfiniteScrollProps {
5+
children: React.ReactNode;
6+
data: string;
7+
whileLoading?: React.ReactNode;
8+
whileNoMoreData?: React.ReactNode;
9+
}
10+
11+
type PageProps = {
12+
type: 'cursor' | 'paged';
13+
perPage: number;
14+
hasMore: boolean;
15+
cursor?: string;
16+
page?: number;
17+
};
18+
19+
type Payload = {
20+
perPage: number;
21+
cursor?: string;
22+
page?: number;
23+
};
24+
25+
/**
26+
* Infinite scroll component.
27+
*
28+
* @param children - The children to render.
29+
* @param data - The data to load.
30+
* @param whileLoading - The component to render while loading.
31+
* @param whileNoMoreData - The component to render when there is no more data.
32+
*/
33+
function InfiniteScroll({ children, data, whileLoading, whileNoMoreData }: InfiniteScrollProps) {
34+
const page = usePage<PageProps>().props;
35+
const props: string[] = [data, 'type', 'perPage', 'hasMore'];
36+
const payload: Payload = { perPage: page.perPage };
37+
const [loading, setLoading] = useState<boolean>(false);
38+
39+
const loadData = () => {
40+
if (page.type === 'cursor') {
41+
props.push('cursor');
42+
payload.cursor = page.cursor;
43+
} else if (page.type === 'paged') {
44+
props.push('page');
45+
payload.page = page.page! + 1;
46+
}
47+
48+
router.reload({
49+
only: props,
50+
data: payload,
51+
preserveUrl: true,
52+
onStart: () => setLoading(true),
53+
onFinish: () => setLoading(false),
54+
onError: () => setLoading(false),
55+
});
56+
};
57+
58+
return (
59+
<>
60+
{children}
61+
<Deferred data={data} fallback={<>{whileLoading || <p className="text-center text-muted-foreground">Loading...</p>}</>}>
62+
{loading ? (
63+
<>{whileLoading || <p className="text-center text-muted-foreground">Loading...</p>}</>
64+
) : page.hasMore ? (
65+
<WhenVisible onVisible={loadData} />
66+
) : (
67+
<>{whileNoMoreData || <p className="text-center text-muted-foreground">No more data</p>}</>
68+
)}
69+
</Deferred>
70+
</>
71+
);
72+
}
73+
74+
/**
75+
* When visible component.
76+
*
77+
* @param onVisible - The callback to call when the component is visible.
78+
*/
79+
function WhenVisible({ onVisible }: { onVisible: () => void }) {
80+
const ref = useRef<HTMLDivElement>(null);
81+
82+
useEffect(() => {
83+
const observer = new IntersectionObserver(
84+
(entries) => {
85+
if (entries[0].isIntersecting) {
86+
onVisible();
87+
}
88+
},
89+
{ threshold: 1 },
90+
);
91+
92+
if (ref.current) {
93+
observer.observe(ref.current);
94+
}
95+
96+
return () => {
97+
if (ref.current) {
98+
observer.unobserve(ref.current);
99+
}
100+
};
101+
}, [ref, onVisible]);
102+
103+
return <div ref={ref} />;
104+
}
105+
106+
export { InfiniteScroll };

0 commit comments

Comments
 (0)