Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion actions/supabase/queries/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ export const fetchTemplatesExercise = async (userGroup: string) => {
const { data, error } = await supabase
.from("template")
.select("*")
.or(`user_group_id.eq.${userGroup},accessible_to_all.eq.true`);
.or(`user_group_id.eq.${userGroup},accessible_to_all.eq.true`)
.eq("archived", false);

if (error) {
console.error("Error fetching templates:", error);
Expand All @@ -203,6 +204,50 @@ export const fetchTemplatesExercise = async (userGroup: string) => {
return data;
};

// archive or delete a template
// can only do if from the right user_group (or if admin)
// no data is deleted (for PDFs and sessions)
export async function setTemplateArchived(
template_id: UUID,
archived: boolean,
requester_group_id: UUID,
is_admin: boolean,
): Promise<boolean> {
const supabase = await getSupabaseServerClient();

const { data: tmpl, error: fetchErr } = await supabase
.from("template")
.select("user_group_id, accessible_to_all")
.eq("template_id", template_id)
.single();

if (fetchErr || !tmpl) {
console.error("Error loading template for archive:", fetchErr);
return false;
}

const ownsTemplate = tmpl.user_group_id === requester_group_id;
const canArchive =
ownsTemplate || (is_admin && Boolean(tmpl.accessible_to_all));

if (!canArchive) {
console.error("Not authorized to archive template:", template_id);
return false;
}

const { error } = await supabase
.from("template")
.update({ archived })
.eq("template_id", template_id);

if (error) {
console.error("Error archiving template:", error);
return false;
}

return true;
}

export async function fetchFullTemplate(template_id: string) {
const supabase = await getSupabaseServerClient();

Expand Down
16 changes: 14 additions & 2 deletions app/facilitator/exercises/start/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
createSession,
fetchRoles,
} from "@/actions/supabase/queries/sessions";
import { fetchTemplatesExercise } from "@/actions/supabase/queries/templates";
import {
fetchTemplate,
fetchTemplatesExercise,
} from "@/actions/supabase/queries/templates";
import { fetchUserGroupMembers } from "@/actions/supabase/queries/user-groups";
import Play from "@/assets/images/play.svg";
import AccessError from "@/components/AccessError/AccessError";
Expand Down Expand Up @@ -81,13 +84,22 @@ export default function Page() {
setTemplatesLoading(true);
try {
const data = await fetchTemplatesExercise(profile.user_group_id as UUID);
setTemplates(data || []);
let list = data || [];

if (preselectedTemplateId) {
// The list is decluttered (archived templates dropped), but if we were
// sent here with a specific template, make sure it's an option so the
// dropdown populates instead of showing blank.
if (!list.some(t => t.template_id === preselectedTemplateId)) {
const preselected = await fetchTemplate(preselectedTemplateId);
if (preselected) list = [preselected, ...list];
}
setSelectedTemplateId(preselectedTemplateId);
const rolesData = await fetchRoles(preselectedTemplateId);
setRoles((rolesData as Role[]) || []);
}

setTemplates(list);
} finally {
setTemplatesLoading(false);
}
Expand Down
1 change: 1 addition & 0 deletions app/templates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const createInitialStore = (): LocalStore => {
template_id: templateID,
template_name: "New Template",
accessible_to_all: null,
archived: false,
user_group_id: null,
summary: "",
setting: "",
Expand Down
153 changes: 138 additions & 15 deletions components/TemplateList/TemplateList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import type { Template, UUID } from "@/types/schema";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { CircularProgress } from "@mui/material";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import {
Archive,
ArchiveRestore,
ArrowDown,
ArrowUp,
ArrowUpDown,
} from "lucide-react";
import {
assignTagToTemplate,
createTag,
Expand All @@ -15,6 +21,7 @@ import {
import {
copyTemplate,
fetchTemplatesWithTags,
setTemplateArchived,
} from "@/actions/supabase/queries/templates";
import TopNavBar from "@/components/FacilitatorNavBar/FacilitatorNavBar";
import { Tag } from "@/components/Tag/TagCreator";
Expand All @@ -35,6 +42,7 @@ import {
SortButton,
} from "../../app/facilitator/styles";
import {
ArchivedToggle,
AssociatedTags,
ContentWrapper,
DateColumn,
Expand Down Expand Up @@ -62,10 +70,11 @@ export default function TemplateListPage({
const router = useRouter();
const { profile } = useProfile();
const user_group_id = profile?.user_group_id as UUID;
const adminAccess = profile?.user_type === "Admin";
const loading = !user_group_id;
const [filterMode, setFilterMode] = useState<"All" | "Your" | "Browse">(
"All",
);
const [filterMode, setFilterMode] = useState<
"All" | "Your" | "Browse" | "Archived"
>("All");
const [searchInput, setSearchInput] = useState("");
const [templates, setTemplates] = useState<TemplateWithTags[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(true);
Expand All @@ -81,6 +90,11 @@ export default function TemplateListPage({
const [copyConfirm, setCopyConfirm] = useState<TemplateWithTags | null>(null);
const [copying, setCopying] = useState(false);

const [archiveConfirm, setArchiveConfirm] = useState<TemplateWithTags | null>(
null,
);
const [archiving, setArchiving] = useState(false);

useEffect(() => {
if (!user_group_id) return;

Expand All @@ -104,14 +118,25 @@ export default function TemplateListPage({
const filteredTemplates = useMemo(() => {
let updated = [...templates];

if (filterMode === "All") {
if (filterMode === "Archived") {
updated = updated.filter(
t => t.accessible_to_all || t.user_group_id === user_group_id,
t =>
t.archived &&
(t.user_group_id === user_group_id ||
(adminAccess && Boolean(t.accessible_to_all))),
);
} else if (filterMode === "Your") {
updated = updated.filter(t => t.user_group_id === user_group_id);
} else {
updated = updated.filter(t => t.accessible_to_all);
updated = updated.filter(t => !t.archived);

if (filterMode === "All") {
updated = updated.filter(
t => t.accessible_to_all || t.user_group_id === user_group_id,
);
} else if (filterMode === "Your") {
updated = updated.filter(t => t.user_group_id === user_group_id);
} else {
updated = updated.filter(t => t.accessible_to_all);
}
}

if (selectedTagIds) {
Expand Down Expand Up @@ -147,6 +172,7 @@ export default function TemplateListPage({
sortKey,
sortOrder,
user_group_id,
adminAccess,
]);

const toggleSort = (key: "name" | "date") => {
Expand All @@ -160,9 +186,25 @@ export default function TemplateListPage({

const isAdminTemplate = (t: TemplateWithTags) => Boolean(t.accessible_to_all);

// You manage a template if your group owns it, or you're an admin
const canManageTemplate = (t: TemplateWithTags) =>
t.user_group_id === user_group_id ||
(adminAccess && Boolean(t.accessible_to_all));

// A shared template you don't manage stays copy-to-edit only
const isLockedTemplate = (t: TemplateWithTags) =>
isAdminTemplate(t) && !canManageTemplate(t);

const handleOpenTemplate = (t: TemplateWithTags) => {
if (isAdminTemplate(t)) return;
router.push(`/templates?templateId=${t.template_id}&fromTemplateList=true`);
if (isLockedTemplate(t)) return;

const params = new URLSearchParams({
templateId: t.template_id,
fromTemplateList: "true",
});
if (t.accessible_to_all) params.set("isAdmin", "true");

router.push(`/templates?${params.toString()}`);
};

const handleCopyConfirm = async (action: WarningAction) => {
Expand All @@ -185,6 +227,40 @@ export default function TemplateListPage({
}
};

const handleArchiveConfirm = async (action: WarningAction) => {
if (action === "cancel" || !archiveConfirm) {
setArchiveConfirm(null);
return;
}

const target = archiveConfirm;
const nextArchived = !target.archived;

setArchiving(true);
try {
const ok = await setTemplateArchived(
target.template_id,
nextArchived,
user_group_id,
adminAccess,
);
if (ok) {
setTemplates(prev =>
prev.map(t =>
t.template_id === target.template_id
? { ...t, archived: nextArchived }
: t,
),
);
}
} catch (err) {
console.error("Archive failed", err);
} finally {
setArchiving(false);
setArchiveConfirm(null);
}
};

async function deleteTagComponent(tag_id: UUID, template_id?: UUID) {
if (template_id) {
const success = await removeTagFromTemplate(template_id, tag_id);
Expand Down Expand Up @@ -363,6 +439,21 @@ export default function TemplateListPage({
}
showBorder={true}
/>

{!showSidebar && (
<ArchivedToggle
$active={filterMode === "Archived"}
onClick={() =>
setFilterMode(prev =>
prev === "Archived" ? "All" : "Archived",
)
}
>
{filterMode === "Archived"
? "Active templates"
: "Archived"}
</ArchivedToggle>
)}
</FilterPlusSearch>
{templatesLoading ? (
<LoadingScreen>
Expand Down Expand Up @@ -406,15 +497,15 @@ export default function TemplateListPage({
)}

{filteredTemplates.map(t => {
const isAdmin = isAdminTemplate(t);
const isLocked = isLockedTemplate(t);

return (
<TemplateRow key={t.template_id} $disabled={isAdmin}>
<TemplateRow key={t.template_id} $disabled={isLocked}>
<NameColumn
onClick={() => handleOpenTemplate(t)}
title={
isAdmin
? "Shared template — make a copy to edit"
isLocked
? "You cannot edit this template. View content through downloading the PDF."
: undefined
}
>
Expand Down Expand Up @@ -536,6 +627,22 @@ export default function TemplateListPage({
</defs>
</svg>
</EditIconWrapper>

{canManageTemplate(t) && (
<EditIconWrapper
onClick={e => {
e.stopPropagation();
setArchiveConfirm(t);
}}
title={t.archived ? "Unarchive" : "Archive"}
>
{t.archived ? (
<ArchiveRestore size={18} />
) : (
<Archive size={18} />
)}
</EditIconWrapper>
)}
</RowActions>
</TemplateRow>
);
Expand All @@ -554,6 +661,22 @@ export default function TemplateListPage({
confirmLabel="Make a copy"
loading={copying}
/>

<WarningModal
open={archiveConfirm !== null}
onClose={handleArchiveConfirm}
title={
archiveConfirm?.archived ? "Unarchive template" : "Archive template"
}
caption={
archiveConfirm?.archived
? `Restore "${archiveConfirm?.template_name ?? "this template"}" to your active templates?`
: `Archive "${archiveConfirm?.template_name ?? "this template"}"? It will be hidden from your template lists. You can restore it from Archived.`
}
noCancel={false}
confirmLabel={archiveConfirm?.archived ? "Unarchive" : "Archive"}
loading={archiving}
/>
</>
);
}
10 changes: 8 additions & 2 deletions components/TemplateList/TemplateSideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import {
} from "./styles";

interface TemplateSideBarProps {
filterMode: "All" | "Your" | "Browse";
setFilterMode: (val: "All" | "Your" | "Browse") => void;
filterMode: "All" | "Your" | "Browse" | "Archived";
setFilterMode: (val: "All" | "Your" | "Browse" | "Archived") => void;
onDeleteConfirmed?: (tagId: UUID) => void;
user_group_id: UUID;
selectedTagIds: UUID[] | null;
Expand Down Expand Up @@ -93,6 +93,12 @@ export default function TemplateSideBar({
>
Browse Templates
</SideNavButton>
<SideNavButton
selected={filterMode === "Archived"}
onClick={() => setFilterMode("Archived")}
>
Archived
</SideNavButton>

<StyledAccordion>
<AccordionSummary
Expand Down
Loading
Loading