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
11 changes: 11 additions & 0 deletions client/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3000,6 +3000,17 @@ class API {
return res.data;
}

/**
* Writes the book's Lulu printing tag onto its coverpage. Pass an empty
* string to remove whatever tag is there.
*/
async setPublishLuluTag(projectID: string, tag: string) {
const res = await axios.put<
{ luluTag: string | null } & ConductorBaseResponse
>(`/project/${projectID}/publish/lulu-tag`, { tag });
return res.data;
}

async movePublishedBook(projectID: string, to: string) {
const res = await axios.post<{ path: string } & ConductorBaseResponse>(
`/project/${projectID}/publish/move`,
Expand Down
8 changes: 2 additions & 6 deletions client/src/components/projects/ProjectLinkButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,7 @@ const ProjectLinkButtons: React.FC<ProjectLinkButtonsProps> = ({
View Export Options
</DavisButton>
)}
{/* Hidden on purpose. The publish flow is merged but not yet released to
the publishing team; uncomment this to turn it on. Everything behind
it — `canPublish`, the drawer, the API — is live and unchanged, so
this is the only line that has to move. */}
{/* {canPublish && (
{canPublish && user.isSuperAdmin && (
<DavisButton
variant="primary"
size="sm"
Expand All @@ -195,7 +191,7 @@ const ProjectLinkButtons: React.FC<ProjectLinkButtonsProps> = ({
>
Publish Book (Admin Visible Only)
</DavisButton>
)} */}
)}
{projectVisibility === "public" && (
<Popup
content="This link will take you to the project's page on the Commons."
Expand Down
112 changes: 112 additions & 0 deletions client/src/components/projects/PublishBook/LuluTagStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useEffect, useId, useRef, useState } from "react";
import { Input, Text } from "@libretexts/davis-react";
import { IconTag } from "@tabler/icons-react";
import PublishStep from "./PublishStep";

/** Prefix the server enforces. Mirrors `LULU_TAG_PREFIX` on the publish service. */
const TAG_PREFIX = "lulu@";

interface LuluTagStepProps {
index: number;
/** The tag currently on the coverpage, read live by the status endpoint. */
luluTag: string | null;
/** Called with the trimmed value; an empty string removes the tag. */
onSave: (tag: string) => void;
isSaving: boolean;
}

/**
* The optional Lulu printing tag row.
*
* Wraps {@link PublishStep} so the row's numbering, badge, and button stay
* identical to every other step, while the input's state stays local — the
* drawer has no business holding a half-typed tag.
*
* Unlike the real steps there is nothing persisted on the project: the status
* shown here is derived from whether the coverpage currently carries a `lulu@`
* tag, so the row never renders `failed`. A failed save surfaces through the
* notification the hook pushes instead.
*/
const LuluTagStep: React.FC<LuluTagStepProps> = ({
index,
luluTag,
onSave,
isSaving,
}) => {
const fieldId = useId();
const messageId = useId();
const [value, setValue] = useState(luluTag ?? "");

// The drawer re-reads publishing status every few seconds while a step is
// running. Syncing on the *server* value changing, rather than on every
// render, is what keeps a poll from stomping what the user is typing.
const lastServerTag = useRef(luluTag);
useEffect(() => {
if (lastServerTag.current === luluTag) return;
lastServerTag.current = luluTag;
setValue(luluTag ?? "");
}, [luluTag]);

const trimmed = value.trim();
const invalid =
trimmed.length > 0 && !trimmed.toLowerCase().startsWith(TAG_PREFIX);
const unchanged = trimmed === (luluTag ?? "");
const clearing = trimmed.length === 0 && !!luluTag;

return (
<PublishStep
index={index}
title="Add Lulu printing tag (optional)"
description={
<span>
Override cover page values for Lulu printing. Format is{" "}
<code>
{
'lulu@<cover_title>@<author_name>@<institution>@<spine_title>'
}
</code>.{" "}
To use the field value as-is (no override) use an empty space in
the corresponding segment.
</span>
}
status={luluTag ? "succeeded" : "not-started"}
// facts={[luluTag ? `Currently ${luluTag}` : undefined]}
actionLabel={clearing ? "Remove tag" : "Save tag"}
actionIcon={<IconTag size={16} />}
onRun={() => onSave(trimmed)}
isSubmitting={isSaving}
actionDisabled={invalid || unchanged}
>
<div className="max-w-md">
<Input
id={fieldId}
name="luluTag"
label="Lulu Tag"
placeholder="lulu@Introductory Chemistry@Delmar Larsen, PhD@UC Davis@Intro Chem"
value={value}
onChange={(e) => setValue(e.target.value)}
error={invalid}
aria-invalid={invalid}
aria-describedby={messageId}
disabled={isSaving}
/>
{/* Rendered here rather than through Davis's own `errorMessage` /
`helperText` so the text carries an id the input can point
`aria-describedby` at. Davis does not yet wire those props to the
input — pending a Davis fix. */}
<Text
id={messageId}
size="sm"
color={invalid ? "danger" : "muted"}
className="!mt-1 !mb-0"
>
{invalid
? 'The tag must start with "lulu@".'
: 'Must start with "lulu@". Clear the field and save to remove the tag.'}
</Text>
</div>
</PublishStep>
);
};

export default LuluTagStep;
23 changes: 19 additions & 4 deletions client/src/components/projects/PublishBook/PublishBookDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
import { Alert, Drawer, Spinner, Text } from "@libretexts/davis-react";
import usePublishBook from "../../../hooks/usePublishBook";
import PublishStep from "./PublishStep";
import LuluTagStep from "./LuluTagStep";
import MoveDestinationPicker from "./MoveDestinationPicker";
import { IconBolt, IconEye, IconHandMove, IconPackage } from "@tabler/icons-react";

Expand Down Expand Up @@ -30,6 +31,9 @@ const PublishBookDrawer: React.FC<PublishBookDrawerProps> = ({
runMove,
runVisibility,
runCompile,
luluTag,
runSetLuluTag,
isSavingLuluTag,
} = usePublishBook({ projectID, enabled: open });

const [destination, setDestination] = useState<string | null>(null);
Expand Down Expand Up @@ -114,8 +118,15 @@ const PublishBookDrawer: React.FC<PublishBookDrawerProps> = ({
isSubmitting={isSubmitting("security")}
/>

<PublishStep
<LuluTagStep
index={3}
luluTag={luluTag}
onSave={runSetLuluTag}
isSaving={isSavingLuluTag}
/>

<PublishStep
index={4}
title="Move book to its final location"
description="Relocates the cover page and every page beneath it on the library."
status={steps.move.status}
Expand All @@ -124,7 +135,11 @@ const PublishBookDrawer: React.FC<PublishBookDrawerProps> = ({
// The live path is the honest answer to "where is this book now",
// and it can differ from what a past move recorded.
status?.currentPath && status.currentPath !== steps.move.detail
? ` Currently at ${status.currentPath}`
? <span>Currently at {" "}
<code>
{status.currentPath}
</code>.
</span>
: undefined,
]}
finishedAt={steps.move.finishedAt}
Expand All @@ -147,7 +162,7 @@ const PublishBookDrawer: React.FC<PublishBookDrawerProps> = ({
</PublishStep>

<PublishStep
index={4}
index={5}
title="Set project visibility to public"
description="Lists the project on the Commons catalog. Separate from the library permissions in step 2."
status={steps.visibility.status}
Expand All @@ -163,7 +178,7 @@ const PublishBookDrawer: React.FC<PublishBookDrawerProps> = ({
/>

<PublishStep
index={5}
index={6}
title="Compile the book"
description="Submits a Shapeshift job to generate the PDF, LMS, and print exports."
status={steps.compile.status}
Expand Down
15 changes: 10 additions & 5 deletions client/src/components/projects/PublishBook/PublishStep.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useId } from "react";
import { Fragment, useId } from "react";
import { Badge, Button, Progress, Text } from "@libretexts/davis-react";
import { format as formatDate } from "date-fns";
import type { PublishStepStatus } from "../../../types/Publish";
Expand All @@ -19,10 +19,10 @@ const DATE_FORMAT = "MM/dd/yyyy h:mm aaa";
interface PublishStepProps {
index: number;
title: string;
description: string;
description: string | React.ReactNode;
status: PublishStepStatus;
/** Short facts the step recorded — a job ID, the path it wrote. */
facts?: (string | undefined)[];
facts?: (string | React.ReactNode | undefined)[];
finishedAt?: string;
errorMessage?: string;
/** Progress of an in-flight run, 0-100. Omit for an indeterminate step. */
Expand Down Expand Up @@ -64,7 +64,7 @@ const PublishStep: React.FC<PublishStepProps> = ({
const badge = STATUS_BADGE[status];
const running = status === "running" || isSubmitting;

const shownFacts = facts.filter((f): f is string => !!f);
const shownFacts = facts.filter((f): f is React.ReactNode => !!f);
if (status === "succeeded" && finishedAt) {
shownFacts.push(formatDate(new Date(finishedAt), DATE_FORMAT));
}
Expand Down Expand Up @@ -93,7 +93,12 @@ const PublishStep: React.FC<PublishStepProps> = ({

{shownFacts.length > 0 && (
<Text size="sm" color="muted" className="!mt-2 !mb-0 break-all">
{shownFacts.join(" · ")}
{shownFacts.map((fact, i) => (
<Fragment key={i}>
{i > 0 && " · "}
{fact}
</Fragment>
))}
</Text>
)}

Expand Down
39 changes: 39 additions & 0 deletions client/src/hooks/usePublishBook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ export interface UsePublishBookResult {
runMove: (to: string) => void;
runVisibility: () => void;
runCompile: () => void;
/**
* The coverpage's `lulu@` printing tag, and the save for it. Kept apart from
* the steps because the tag is optional and nothing about it is persisted on
* the project — see `PublishService.setLuluTag`.
*/
luluTag: string | null;
/** Pass an empty string to remove the tag. */
runSetLuluTag: (tag: string) => void;
isSavingLuluTag: boolean;
refetch: () => void;
}

Expand Down Expand Up @@ -137,6 +146,25 @@ export default function usePublishBook({
...stepMutationOptions,
});

/**
* The tag save is synchronous, so it announces itself here rather than
* through the step-transition effect below — that effect walks
* `PUBLISH_STEP_ORDER`, and the printing tag is deliberately not in it.
*/
const luluTagMutation = useMutation({
mutationFn: async (tag: string) =>
api.setPublishLuluTag(projectID as string, tag),
onSuccess: (data) => {
addNotification({
type: "success",
message: data.luluTag
? "Lulu printing tag saved."
: "Lulu printing tag removed.",
});
},
...stepMutationOptions,
});

/**
* Announce each step once, on its transition out of `running`.
*
Expand Down Expand Up @@ -219,6 +247,14 @@ export default function usePublishBook({
compileMutation.mutate();
}, [projectID, compileMutation]);

const runSetLuluTag = useCallback(
(tag: string) => {
if (!projectID) return;
luluTagMutation.mutate(tag);
},
[projectID, luluTagMutation]
);

return {
status,
steps,
Expand All @@ -231,6 +267,9 @@ export default function usePublishBook({
runMove,
runVisibility,
runCompile,
luluTag: status?.luluTag ?? null,
runSetLuluTag,
isSavingLuluTag: luluTagMutation.isPending,
refetch: invalidate,
};
}
7 changes: 7 additions & 0 deletions client/src/types/Publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export type PublishStatus = {
currentPath: string | null;
/** The coverpage's current MindTouch restriction, read live. */
restriction: string | null;
/**
* The coverpage's current `lulu@` printing tag, read live from the library.
*
* Optional, and not one of the steps: it is absent from `PublishStepKey` and
* `PUBLISH_STEP_ORDER` on purpose, so it can never affect `isPublished`.
*/
luluTag: string | null;
visibility: "public" | "private";
steps: Record<PublishStepKey, PublishStepState>;
preprocessPercentage?: number;
Expand Down
10 changes: 10 additions & 0 deletions server/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -3459,6 +3459,16 @@ router.route('/project/:projectID/publish/security').post(
catchInternal((req, res) => publishAPI.setPublishBookSecurity(req, res)),
);

// PUT rather than POST: this replaces one value on the coverpage and is
// idempotent, mirroring the CXOne tags call underneath it.
router.route('/project/:projectID/publish/lulu-tag').put(
authAPI.verifyRequest,
authAPI.getUserAttributes,
authAPI.checkHasRoleMiddleware("libretexts", "superadmin"),
middleware.validateZod(PublishValidators.LuluTagValidator),
catchInternal((req, res) => publishAPI.setPublishLuluTag(req, res)),
);

router.route('/project/:projectID/publish/move').post(
authAPI.verifyRequest,
authAPI.getUserAttributes,
Expand Down
Loading
Loading