Feature: Create, list & edit job advertisements

This commit is contained in:
Aarni Halinen
2020-11-07 19:42:48 +00:00
parent a89e69c47a
commit 22c0ed0d1c
20 changed files with 550 additions and 70 deletions
-2
View File
@@ -1,2 +0,0 @@
import AdminHeader from "./AdminHeader";
export default AdminHeader;
+33
View File
@@ -0,0 +1,33 @@
import React from "react";
import styled from "styled-components";
import AdminSidebarLink from "./AdminSidebarLink";
interface AdminSidebarProps {
path: string;
}
const SideBar = styled.nav`
display: flex;
flex-flow: column nowrap;
align-self: stretch;
margin-right: 1rem;
@media screen and (max-width: 800px - 1px) {
margin-right: 0;
margin-bottom: 1rem;
}
`;
const AdminSidebar: React.FC<AdminSidebarProps> = ({ path }) => (
<SideBar>
<AdminSidebarLink to="/admin" path={path}>Home</AdminSidebarLink>
<AdminSidebarLink to="/admin/events" path={path}>Events</AdminSidebarLink>
<AdminSidebarLink to="/admin/feed" path={path}>Feed</AdminSidebarLink>
<AdminSidebarLink to="/admin/signups" path={path}>Signup forms</AdminSidebarLink>
<AdminSidebarLink to="/admin/jobads" path={path}>Job advertisements</AdminSidebarLink>
<AdminSidebarLink to="https://static.sika.sik.party/admin" path={path}>Files</AdminSidebarLink>
<AdminSidebarLink id="admin-sidebar-logout" to="/admin/logout" path={path}>Logout</AdminSidebarLink>
</SideBar>
);
export default AdminSidebar;
@@ -1,13 +0,0 @@
@import "../../assets/scss/globals";
.admin-sidebar {
display: flex;
flex-flow: column nowrap;
align-self: stretch;
margin-right: 1rem;
@media screen and (max-width: 800px - 1px) {
margin-right: 0;
margin-bottom: 1rem;
}
}
@@ -1,26 +0,0 @@
import React from "react";
import "./AdminSidebar.scss";
import AdminSidebarLink from "../AdminSidebarLink";
export interface AdminSidebarProps {
path: string;
}
export interface AdminSidebarState {}
class AdminSidebar extends React.Component<AdminSidebarProps, AdminSidebarState> {
render() {
const { path } = this.props;
return (
<div className="admin-sidebar">
<AdminSidebarLink to="/admin" path={path}>Home</AdminSidebarLink>
<AdminSidebarLink to="/admin/events" path={path}>Events</AdminSidebarLink>
<AdminSidebarLink to="/admin/feed" path={path}>Feed</AdminSidebarLink>
<AdminSidebarLink to="/admin/signups" path={path}>Signup forms</AdminSidebarLink>
<AdminSidebarLink to="https://static.sika.sik.party/admin" path={path}>Files</AdminSidebarLink>
<AdminSidebarLink id="admin-sidebar-logout" to="/admin/logout" path={path}>Logout</AdminSidebarLink>
</div>
);
}
}
export default AdminSidebar;
-2
View File
@@ -1,2 +0,0 @@
import AdminSidebar from "./AdminSidebar";
export default AdminSidebar;
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
import { JobAd, getJobAd } from "@models/JobAd";
const useFetchJobAd = (id: number) => {
const [jobAd, setJobAd] = useState<JobAd>(null);
useEffect(() => {
getJobAd(id)
.then(res => setJobAd(res))
}, []);
return jobAd;
}
export default useFetchJobAd;
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
import { JobAd, getJobAds, GetJobAdsOptions } from "@models/JobAd";
const useFetchJobAds = (options: GetJobAdsOptions = {}) => {
const [jobAds, setJobAds] = useState<JobAd[]>([]);
useEffect(() => {
getJobAds(options)
.then(res => setJobAds(res))
}, []);
return jobAds;
}
export default useFetchJobAds;
+83
View File
@@ -0,0 +1,83 @@
import axios from "axios";
import qs from "query-string";
import { getAuthHeader } from "@utils/auth";
const url = `${process.env.API_URL}/jobads/`;
export interface JobAd {
id: number;
title_fi: string;
title_en: string;
description_fi: string;
description_en: string;
content_fi: string;
content_en: string;
autohide_at: Date;
autohide_enabled: boolean;
}
export interface GetJobAdsOptions {
onlyNonPast?: boolean;
limit?: number;
auth?: boolean;
}
export const getJobAds = async (options: GetJobAdsOptions = {}): Promise<JobAd[]> => {
const { onlyNonPast, limit, auth } = options;
try {
const params = {
since: onlyNonPast ? (new Date()).toISOString() : undefined,
limit,
};
const search = qs.stringify(params);
const headers = auth ? { "Authorization": getAuthHeader() } : null;
const resp = await axios.get(`${url}?${search}`, {
headers
});
return resp.data["results"];
} catch (err) {
console.error(err);
throw err;
}
}
export const getJobAd = async (id: number, auth = false): Promise<JobAd> => {
try {
const headers = auth ? { "Authorization": getAuthHeader() } : null;
const resp = await axios.get(`${url}${id}/`, {
headers
});
return resp.data;
} catch (err) {
console.error(err);
throw err;
}
}
export const createJobAd = async (data: any): Promise<JobAd> => {
try {
const resp = await axios.post(url, data, {
headers: {
"Authorization": getAuthHeader(),
},
});
return resp.data;
} catch (err) {
console.error(err);
throw err;
}
}
export const updateJobAd = async (data: any): Promise<JobAd> => {
try {
const putUrl = `${url}${data.id}/`;
const resp = await axios.put(putUrl, data, {
headers: {
"Authorization": getAuthHeader(),
},
});
return resp.data;
} catch (err) {
console.error(err);
throw err;
}
}
+4 -7
View File
@@ -1,21 +1,18 @@
import React from "react";
import { Helmet } from "react-helmet";
import CorporatePageView from "@views/CorporatePage/CorporatePageView";
import useFetchJobAds from "@hooks/useFetchJobAds";
export interface CorporatePageProps {}
export interface CorporatePageState {}
class CorporatePage extends React.Component<CorporatePageProps, CorporatePageState> {
render() {
const CorporatePage: React.FC = () => {
const jobAds = useFetchJobAds();
return (
<>
<Helmet>
<link rel="canonical" href="https://sik.ayy.fi/yritysyhteistyo" />
</Helmet>
<CorporatePageView />
<CorporatePageView jobAds={jobAds} />
</>
);
}
}
export default CorporatePage;
-1
View File
@@ -4,7 +4,6 @@ import Anchor from "@components/Anchor";
import "./AdminFeedPage.scss";
import { StaticContext } from "@server/StaticContext";
import { Post, getFeed } from "@models/Feed";
import { getEvents } from "@models/Event";
import { formatRelative } from "date-fns";
import { th } from "date-fns/esm/locale";
import AddIcon from "@assets/img/add-icon.png";
+1
View File
@@ -16,6 +16,7 @@ class AdminFrontPage extends React.Component<AdminFrontPageProps, AdminFrontPage
<h1>SIK Admin</h1>
<Anchor to="/admin/events">Events</Anchor>
<Anchor to="/admin/feed">Feed</Anchor>
<Anchor to="/admin/jobads">Job advertisements</Anchor>
<Anchor to="https:https://static.sika.sik.party/admin">Files</Anchor>
</div>
);
+63
View File
@@ -0,0 +1,63 @@
import React from "react";
import { Helmet } from "react-helmet";
import { formatRelative } from "date-fns";
import useFetchJobAds from "@hooks/useFetchJobAds";
import { JobAd } from "@models/JobAd";
import Anchor from "@components/Anchor";
import AddIcon from "@assets/img/add-icon.png";
const URL = "/admin/jobads"
const renderAddLink = () => (
<Anchor className="add-link" to={`${URL}/create`}>
<img src={AddIcon} /> Create Ad
</Anchor>
)
const renderData = (jobAds: JobAd[]) => {
if (!jobAds || jobAds.length === 0) {
return <div>No advertisements.</div>;
}
return (
<table>
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>Autohide</th>
</tr>
</thead>
<tbody>
{jobAds.map(ad => (
<tr key={ad.id}>
<td><Anchor to={`${URL}/${ad.id}`}>{ad.title_fi}</Anchor></td>
<td>{ad.description_fi}</td>
<td>
{ad.autohide_enabled ?
formatRelative(new Date(ad.autohide_at), new Date())
: "Disabled"}
</td>
</tr>
))}
</tbody>
</table>
);
}
const AdminJobAdPage: React.FC = () => {
const jobAds = useFetchJobAds({ auth: true });
return (
<div>
<Helmet>
<link rel="canonical" href={`https://sik.ayy.fi/${URL}`} />
</Helmet>
<h1>Job advertisements</h1>
{renderAddLink()}
{renderData(jobAds)}
</div>
)
}
export default AdminJobAdPage;
+7 -11
View File
@@ -1,8 +1,8 @@
import React from "react";
import { Helmet } from "react-helmet";
import "./FeedCreatePage.scss";
import { isAuthenticated } from "@utils/auth";
import Form from "react-jsonschema-form";
import { RouteComponentProps } from "react-router-dom";
import "./FeedCreatePage.scss";
import { Tag, getTags } from "@models/Tag";
import { createPost, getPost, updatePost } from "@models/Feed";
import DatetimeWidget from "@components/Widgets/DatetimeWidget/DatetimeWidget";
@@ -11,16 +11,12 @@ const widgets = {
datetime: DatetimeWidget,
};
export interface FeedCreatePageProps {
history: {
push: (to: string) => void;
};
match: {
params: {
id?: number;
};
};
interface MatchParams {
id?: string;
}
type FeedCreatePageProps = RouteComponentProps<MatchParams>;
export interface FeedCreatePageState {
tags: Tag[];
error?: string;
+183
View File
@@ -0,0 +1,183 @@
import React, { useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import Form from "react-jsonschema-form";
import { RouteComponentProps } from "react-router-dom";
import { JobAd, getJobAd, createJobAd, updateJobAd } from "@models/JobAd";
import DatetimeWidget from "@components/Widgets/DatetimeWidget/DatetimeWidget";
import SectionDividerWidget from "@components/Widgets/SectionDividerWidget/SectionDividerWidget";
import MarkdownEditorWidget from "@components/Widgets/MarkdownEditorWidget";
const widgets = {
datetime: DatetimeWidget,
section_divider: SectionDividerWidget,
markdownEditor: MarkdownEditorWidget
};
const buildSchema = (title: string) => {
const date = new Date();
const monthFromNow = new Date().setDate(date.getDate() + 30)
const schema = {
title,
type: "object",
required: ["title_fi", "title_en", "description_fi", "description_en", "content_fi", "content_en", "autohide_at", "autohide_enabled", "visible"],
properties: {
visible: {
type: "boolean",
title: "Visible",
default: true,
},
autohide_enabled: {
type: "boolean",
title: "Autohide enabled",
default: false,
},
autohide_at: {
type: "string",
title: "Autohide time",
default: monthFromNow,
},
finnish_section_divider: {
title: "Finnish",
type: "string",
},
title_fi: {
type: "string",
title: "Title",
default: ""
},
description_fi: {
type: "string",
title: "Description",
default: "",
},
content_fi: {
type: "string",
title: "Content",
default: "",
},
english_section_divider: {
title: "English",
type: "string",
},
title_en: {
type: "string",
title: "Title",
default: ""
},
description_en: {
type: "string",
title: "Description",
default: "",
},
content_en: {
type: "string",
title: "Content",
default: "",
},
}
};
return schema;
}
const buildUISchema = (formData: JobAd) => ({
content_fi: {
"ui:widget": "markdownEditor",
},
content_en: {
"ui:widget": "markdownEditor",
},
autohide_at: {
"ui:widget": formData && formData.autohide_enabled ? "datetime" : "hidden",
},
finnish_section_divider: {
"ui:widget": "section_divider",
"ui:options": {
label: false
},
},
english_section_divider: {
"ui:widget": "section_divider",
"ui:options": {
label: false
},
},
});
interface MatchParams {
id?: string;
}
type JobAdCreatePageProps = RouteComponentProps<MatchParams>;
const JobAdCreatePage: React.FC<JobAdCreatePageProps> = ({ match: { params: { id } } }) => {
const [formData, setFormData] = useState<JobAd>(null);
useEffect(() => {
const jobId = Number(id);
if (jobId !== undefined) {
getJobAd(jobId, true)
.then(res => setFormData(res))
}
}, [id])
const [error, setError] = useState<string>(null);
const [statusMessage, setStatusMessage] = useState<string>(null);
const onSubmit = async (data) => {
try {
const payload = data.formData;
if (payload.id === undefined) {
const resp = await createJobAd(payload);
setStatusMessage("Post created successfully");
setFormData(resp);
} else {
const resp = await updateJobAd(payload);
setStatusMessage("Post updated successfully");
setFormData(resp);
}
} catch (err) {
setError(err);
}
}
const onError = (data) => {
console.error("error, data:");
console.log(data);
}
const onChange = (data) => setFormData(data.formData);
const onFocus = () => setStatusMessage(null);
const title = formData?.id
? `Edit Ad "${formData.title_fi}"`
: "Create Ad";
return (
<div className="post-create-page">
<Helmet>
<link rel="canonical" href="https://sik.ayy.fi/admin/jobads/create" />
</Helmet>
<h1>{title}</h1>
{statusMessage && <div className="success">{statusMessage}</div>}
<Form
schema={buildSchema(formData?.id ? formData.title_fi : "New Post") as any}
uiSchema={buildUISchema(formData)}
formData={formData}
idPrefix="rjsf"
widgets={widgets as any}
onChange={onChange}
onSubmit={onSubmit}
onError={onError}
onFocus={onFocus} />
{error && <div className="error">{error}</div>}
</div>
);
}
export default JobAdCreatePage;
+7
View File
@@ -25,6 +25,9 @@ import StudiesPage from "./pages/StudiesPage";
import CorporatePage from "./pages/CorporatePage";
import InEnglishPage from "./pages/InEnglishPage";
import EventPage from "./pages/EventPage";
import JobAd from "@views/CorporatePage/JobAdView";
import AdminJobAdPage from "@pages/admin/AdminJobAdPage";
import JobAdCreatePage from "@pages/admin/JobAdCreatePage";
const renderPage = (Page) => (props): JSX.Element => {
return <CommonPage page={Page} {...props} />;
@@ -49,6 +52,7 @@ const commonRoutes = [
{ path: "/opinnot_ja_ura", page: StudiesPage },
{ path: "/yritysyhteistyo", page: CorporatePage },
{ path: "/yhteystiedot", page: ContactsPage },
{ path: "/jobads/:id", page: JobAd },
{ path: "/in_english", page: InEnglishPage },
];
@@ -66,6 +70,9 @@ const adminRoutes = [
{ path: "/admin/signups", page: AdminSignupPage },
{ path: "/admin/signups/create", page: SignupCreatePage },
{ path: "/admin/signups/:id", page: SignupCreatePage },
{ path: "/admin/jobads", page: AdminJobAdPage },
{ path: "/admin/jobads/create", page: JobAdCreatePage },
{ path: "/admin/jobads/:id", page: JobAdCreatePage },
{ path: "/admin/logout", page: AdminLogoutPage },
{ path: "/admin", page: AdminFrontPage },
];
@@ -1,8 +1,14 @@
import React from "react";
import CorporatePageHero from "./CorporatePageHero";
import { CTASection, TextSection, PageLink } from "@components/index";
import { JobAd } from "@models/JobAd";
import JobAdList from "./JobAdList";
const CorporatePageView: React.FC = () => (
interface CorporatePageViewProps {
jobAds: JobAd[];
}
const CorporatePageView: React.FC<CorporatePageViewProps> = ({ jobAds }) => (
<>
<CorporatePageHero />
<TextSection>
@@ -66,6 +72,7 @@ const CorporatePageView: React.FC = () => (
<TextSection>
<h3 id="tyopaikat">Työpaikkailmoitukset</h3>
<div>
<JobAdList jobAds={jobAds} />
<p>Voit saada yrityksesi työpaikkailmoituksen listalle lähettämällä sen osoitteeseen <a style={{color: "black"}} href="mailto:sik-yritys@list.ayy.fi">sik-yritys@list.ayy.fi</a> </p>
</div>
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
import styled from "styled-components";
import { JobAd } from "@models/JobAd";
import Anchor from "@components/Anchor";
interface JobAdListProps {
jobAds: JobAd[];
}
const List = styled.ul`
li {
margin: 1rem 0;
}
`;
const JobAdList: React.FC<JobAdListProps> = ({ jobAds }) => (
<List>
{jobAds.map((ad) => (
<li key={ad.id}>
<Anchor to={`/jobads/${ad.id}`}>
{ad.title_fi}
</Anchor>
</li>
))}
</List>
)
export default JobAdList;
+92
View File
@@ -0,0 +1,92 @@
import React from "react";
import styled from "styled-components";
import { Helmet } from "react-helmet";
import { RouteComponentProps } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { colors } from "@theme/colors";
import useFetchJobAd from "@hooks/useFetchJobAd";
const StyledSection = styled.section`
margin: 2rem auto;
max-width: 1000px;
align-items: center;
& > h1 {
color: ${colors.darkBlue};
}
& > div > img {
height: auto;
width: 100%;
}
& > p {
color: ${colors.orange1};
}
`;
const Content = styled.div`
margin-top: 24px;
p {
color: ${colors.black};
}
h1, h3 {
color: ${colors.orange2};
}
a {
color: ${colors.blue1};
&:hover {
color: ${colors.lightBlue};
}
}
table {
tr {
vertical-align: top;
td {
word-break: break-word;
padding: 8px;
}
td:first-of-type {
word-break: unset;
padding-left: 0;
}
}
}
`;
interface MatchParams {
id: string;
}
type JobAdProps = RouteComponentProps<MatchParams>
const JobAdView: React.FC<JobAdProps> = ({ match: { params: { id } } }) => {
const jobAd = useFetchJobAd(Number(id));
if (!jobAd) return <div>Loading</div>
return (
<>
<Helmet>
<link rel="canonical" href="https://sik.ayy.fi/INSERT_PATH_HERE!" />
</Helmet>
<StyledSection>
<h1>{jobAd.title_fi}</h1>
<p>{jobAd.description_fi}</p>
<Content>
<ReactMarkdown source={jobAd.content_fi} escapeHtml={false} />
</Content>
</StyledSection>
</>
);
}
export default JobAdView;
+3
View File
@@ -30,6 +30,9 @@
"@components/*": [
"src/components/*"
],
"@hooks/*": [
"src/hooks/*"
],
"@models/*": [
"src/models/*"
],