feat(topic state and command) - add & delete topic
This commit is contained in:
@@ -4,4 +4,5 @@ export const ENV = {
|
||||
secretKey: import.meta.env.VITE_SIGN_SECRET_KEY,
|
||||
basicUsername: import.meta.env.VITE_BASIC_AUTH_USERNAME,
|
||||
basicPassword: import.meta.env.VITE_BASIC_AUTH_PASSWORD,
|
||||
credential: import.meta.env.VITE_CREDENTIAL,
|
||||
};
|
||||
|
||||
@@ -16,9 +16,13 @@ export default function SettingsFeature() {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
function isObjectValueDifferent(a: MerchantForm, b: MerchantForm): boolean {
|
||||
return (Object.keys(a) as (keyof MerchantForm)[]).some(
|
||||
(key) => a[key] !== b[key],
|
||||
);
|
||||
const keys: (keyof MerchantForm)[] = [
|
||||
"merchantName",
|
||||
"tower",
|
||||
"floor",
|
||||
"unit",
|
||||
];
|
||||
return keys.some((key) => a[key] !== b[key]);
|
||||
}
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
|
||||
24
src/features/topics/hooks/mutations.ts
Normal file
24
src/features/topics/hooks/mutations.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createTopic, deleteTopic } from "../../../repositories/device";
|
||||
|
||||
export function useCreateTopic() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: TopicPayload) => createTopic(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["topics/state"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["topics/command"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTopic() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: TopicPayload) => deleteTopic(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["topics/state"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["topics/command"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,100 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { cn } from "../../utils/classname";
|
||||
import StateSection from "./sections/state";
|
||||
import CommandSection from "./sections/command";
|
||||
import { useCreateTopic } from "./hooks/mutations";
|
||||
import { getMerchant } from "../../utils/storage";
|
||||
import { ENV } from "../../constants/env";
|
||||
|
||||
export default function TopicsFeature() {
|
||||
const [tab, setTab] = useState("state");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [form, setForm] = useState<TopicPayload>({
|
||||
topic: "",
|
||||
type: "state-reply",
|
||||
});
|
||||
const [error, setError] = useState("");
|
||||
const createTopic = useCreateTopic();
|
||||
const merchant = getMerchant();
|
||||
const expectedCredential = ENV.credential?.trim();
|
||||
const hasCredential = Boolean(merchant?.credential?.trim());
|
||||
const isCredentialMatch =
|
||||
Boolean(expectedCredential) &&
|
||||
merchant?.credential?.trim() === expectedCredential;
|
||||
const isMerchantSetup =
|
||||
Boolean(merchant?.merchantName?.trim()) &&
|
||||
Boolean(merchant?.tower?.trim()) &&
|
||||
Boolean(merchant?.floor?.trim()) &&
|
||||
Boolean(merchant?.unit?.trim());
|
||||
const isReady = isMerchantSetup && hasCredential && isCredentialMatch;
|
||||
|
||||
const handleOpen = () => {
|
||||
setError("");
|
||||
setForm({
|
||||
topic: "",
|
||||
type: tab === "state" ? "state-reply" : "commands",
|
||||
});
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (createTopic.isPending) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const topic = form.topic.trim();
|
||||
if (!topic) {
|
||||
setError("Topic wajib diisi.");
|
||||
return;
|
||||
}
|
||||
|
||||
createTopic.mutate(
|
||||
{ topic, type: form.type },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsOpen(false);
|
||||
setForm({
|
||||
topic: "",
|
||||
type: tab === "state" ? "state-reply" : "commands",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
setError("Gagal menambah topic.");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{!isReady && (
|
||||
<div className="mx-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
{!isMerchantSetup && (
|
||||
<span>
|
||||
Silakan setup merchant terlebih dahulu di halaman Settings sebelum
|
||||
mengakses Topics.
|
||||
</span>
|
||||
)}
|
||||
{isMerchantSetup && !hasCredential && (
|
||||
<span>
|
||||
Silakan isi credential terlebih dahulu di halaman Settings sebelum
|
||||
mengakses Topics.
|
||||
</span>
|
||||
)}
|
||||
{isMerchantSetup && hasCredential && !isCredentialMatch && (
|
||||
<span>Credential tidak sesuai.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 flex justify-end">
|
||||
<button className="shrink-0 px-4 py-2 rounded-md flex items-center justify-center transition-all bg-orange-500 text-white cursor-pointer">
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="shrink-0 px-4 py-2 rounded-md flex items-center justify-center transition-all bg-orange-500 text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!isReady}
|
||||
>
|
||||
<PlusIcon />
|
||||
Tambah Topic
|
||||
</button>
|
||||
@@ -36,6 +121,71 @@ export default function TopicsFeature() {
|
||||
</div>
|
||||
{tab === "state" && <StateSection />}
|
||||
{tab === "command" && <CommandSection />}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-md rounded-md bg-white p-4 shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Tambah Topic</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-sm text-neutral-500"
|
||||
type="button"
|
||||
>
|
||||
Tutup
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="mt-4 flex flex-col gap-3">
|
||||
<label className="text-sm font-medium text-neutral-700">
|
||||
Topic
|
||||
<input
|
||||
className="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm outline-none focus:border-amber-500"
|
||||
placeholder="Masukkan topic"
|
||||
value={form.topic}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, topic: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm font-medium text-neutral-700">
|
||||
Type
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm outline-none focus:border-amber-500"
|
||||
value={form.type}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
type: event.target.value as TopicPayload["type"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="state-reply">state-reply</option>
|
||||
<option value="commands">commands</option>
|
||||
</select>
|
||||
</label>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-md border border-neutral-300 px-4 py-2 text-sm"
|
||||
disabled={createTopic.isPending}
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-amber-500 px-4 py-2 text-sm text-white disabled:cursor-not-allowed disabled:opacity-70"
|
||||
disabled={createTopic.isPending}
|
||||
>
|
||||
{createTopic.isPending ? "Menyimpan..." : "Simpan"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { useTopicCommand } from "../hooks/queries";
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { useDeleteTopic } from "../hooks/mutations";
|
||||
|
||||
type PendingDelete = {
|
||||
open: boolean;
|
||||
topic: string;
|
||||
};
|
||||
|
||||
export default function CommandSection() {
|
||||
const { data } = useTopicCommand();
|
||||
const { data, isLoading } = useTopicCommand();
|
||||
const deleteTopic = useDeleteTopic();
|
||||
const [pending, setPending] = useState<PendingDelete>({
|
||||
open: false,
|
||||
topic: "",
|
||||
});
|
||||
|
||||
const handleDelete = (topic: string) => {
|
||||
if (deleteTopic.isPending) return;
|
||||
setPending({ open: true, topic });
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!pending.topic || deleteTopic.isPending) return;
|
||||
deleteTopic.mutate(
|
||||
{ topic: pending.topic, type: "commands" },
|
||||
{
|
||||
onSuccess: () => setPending({ open: false, topic: "" }),
|
||||
onError: () => setPending({ open: false, topic: "" }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (deleteTopic.isPending) return;
|
||||
setPending({ open: false, topic: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading &&
|
||||
Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={`loading-${index}`}
|
||||
className="bg-white border-b border-neutral-200 p-4 flex items-center justify-between"
|
||||
>
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-neutral-200" />
|
||||
<div className="flex gap-2">
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data?.map((item, key) => (
|
||||
<div
|
||||
key={key}
|
||||
@@ -12,11 +59,47 @@ export default function CommandSection() {
|
||||
>
|
||||
<h1>{item.topic}</h1>
|
||||
<div className="flex gap-2 ">
|
||||
<Edit className="text-blue-500 cursor-pointer" size={18} />
|
||||
<Trash2 className="text-red-500 cursor-pointer" size={18} />
|
||||
<Edit className="text-neutral-300" size={18} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(item.topic)}
|
||||
className="text-red-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={deleteTopic.isPending}
|
||||
aria-label={`Hapus topic ${item.topic}`}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{pending.open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-sm rounded-md bg-white p-4 shadow-lg transition-all duration-200 ease-out">
|
||||
<h3 className="text-base font-semibold">Hapus Topic</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
Yakin ingin menghapus topic "{pending.topic}"?
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-md border border-neutral-300 px-4 py-2 text-sm"
|
||||
disabled={deleteTopic.isPending}
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmDelete}
|
||||
className="rounded-md bg-red-500 px-4 py-2 text-sm text-white disabled:cursor-not-allowed disabled:opacity-70"
|
||||
disabled={deleteTopic.isPending}
|
||||
>
|
||||
{deleteTopic.isPending ? "Menghapus..." : "Hapus"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { useTopics } from "../hooks/queries";
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { useDeleteTopic } from "../hooks/mutations";
|
||||
|
||||
type PendingDelete = {
|
||||
open: boolean;
|
||||
topic: string;
|
||||
};
|
||||
|
||||
export default function StateSection() {
|
||||
const { data } = useTopics();
|
||||
const { data, isLoading } = useTopics();
|
||||
const deleteTopic = useDeleteTopic();
|
||||
const [pending, setPending] = useState<PendingDelete>({
|
||||
open: false,
|
||||
topic: "",
|
||||
});
|
||||
|
||||
const handleDelete = (topic: string) => {
|
||||
if (deleteTopic.isPending) return;
|
||||
setPending({ open: true, topic });
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!pending.topic || deleteTopic.isPending) return;
|
||||
deleteTopic.mutate(
|
||||
{ topic: pending.topic, type: "state-reply" },
|
||||
{
|
||||
onSuccess: () => setPending({ open: false, topic: "" }),
|
||||
onError: () => setPending({ open: false, topic: "" }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (deleteTopic.isPending) return;
|
||||
setPending({ open: false, topic: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading &&
|
||||
Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={`loading-${index}`}
|
||||
className="bg-white border-b border-neutral-200 p-4 flex items-center justify-between"
|
||||
>
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-neutral-200" />
|
||||
<div className="flex gap-2">
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data?.map((item, key) => (
|
||||
<div
|
||||
key={key}
|
||||
@@ -12,11 +59,47 @@ export default function StateSection() {
|
||||
>
|
||||
<h1>{item.topic}</h1>
|
||||
<div className="flex gap-2 ">
|
||||
<Edit className="text-blue-500 cursor-pointer" size={18} />
|
||||
<Trash2 className="text-red-500 cursor-pointer" size={18} />
|
||||
<Edit className="text-neutral-300" size={18} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(item.topic)}
|
||||
className="text-red-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={deleteTopic.isPending}
|
||||
aria-label={`Hapus topic ${item.topic}`}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{pending.open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-sm rounded-md bg-white p-4 shadow-lg transition-all duration-200 ease-out">
|
||||
<h3 className="text-base font-semibold">Hapus Topic</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
Yakin ingin menghapus topic "{pending.topic}"?
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-md border border-neutral-300 px-4 py-2 text-sm"
|
||||
disabled={deleteTopic.isPending}
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmDelete}
|
||||
className="rounded-md bg-red-500 px-4 py-2 text-sm text-white disabled:cursor-not-allowed disabled:opacity-70"
|
||||
disabled={deleteTopic.isPending}
|
||||
>
|
||||
{deleteTopic.isPending ? "Menghapus..." : "Hapus"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,3 +35,28 @@ export const getTopicsCommand = async (): Promise<TopicData> => {
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const createTopic = async (
|
||||
payload: TopicPayload,
|
||||
): Promise<TopicMutationResponse> => {
|
||||
const res = await api.post("/topics/v1/", payload, {
|
||||
headers: {
|
||||
Authorization: basicAuth(ENV.basicUsername, ENV.basicPassword),
|
||||
},
|
||||
});
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const deleteTopic = async (
|
||||
payload: TopicPayload,
|
||||
): Promise<TopicMutationResponse> => {
|
||||
const res = await api.delete("/topics/v1/", {
|
||||
data: payload,
|
||||
headers: {
|
||||
Authorization: basicAuth(ENV.basicUsername, ENV.basicPassword),
|
||||
},
|
||||
});
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
11
src/repositories/device/types.d.ts
vendored
11
src/repositories/device/types.d.ts
vendored
@@ -51,3 +51,14 @@ type TopicData = {
|
||||
message: string;
|
||||
data: { topic: string }[];
|
||||
};
|
||||
|
||||
type TopicPayload = {
|
||||
topic: string;
|
||||
type: "state-reply" | "commands";
|
||||
};
|
||||
|
||||
type TopicMutationResponse = {
|
||||
status: string;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import axios from "axios";
|
||||
import { generateSignature, parseQueryParamsManual } from "./signer";
|
||||
import { ENV } from "../constants/env";
|
||||
import { getMerchant } from "./storage";
|
||||
|
||||
import type { InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
@@ -17,6 +18,18 @@ const api = axios.create({
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config: SignedAxiosRequestConfig) => {
|
||||
const isTopicsRequest = (config.url ?? "").includes("/topics/");
|
||||
if (isTopicsRequest) {
|
||||
const merchant = getMerchant();
|
||||
const credential = merchant?.credential?.trim();
|
||||
const expectedCredential = ENV.credential?.trim();
|
||||
if (!credential || !expectedCredential || credential !== expectedCredential) {
|
||||
return Promise.reject(
|
||||
new Error("Credential tidak sesuai. Request dibatalkan."),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.signed === false) {
|
||||
return config;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user