fix: refactored job form and permissions

This commit is contained in:
Jannat Patel
2026-02-19 15:58:44 +05:30
parent 44ca59c64a
commit 08373dc2ab
14 changed files with 170 additions and 207 deletions
@@ -107,7 +107,7 @@
<div class="flex flex-col gap-1 p-1"> <div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8"> <div class="text-base font-medium text-ink-gray-8">
{{ {{
option.value == option.label option.value == option.label && option.description
? option.description ? option.description
: option.label : option.label
}} }}
@@ -124,7 +124,7 @@
v-if="groups.length == 0" v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5" class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
> >
No results found {{ __('No results found') }}
</li> </li>
</ComboboxOptions> </ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5"> <div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
@@ -38,7 +38,7 @@
'border object-cover', 'border object-cover',
shape === 'circle' shape === 'circle'
? 'w-20 h-20 rounded-full' ? 'w-20 h-20 rounded-full'
: 'w-44 h-auto min-h-20 rounded-md', : 'w-44 h-auto min-h-20 max-h-32 rounded-md',
]" ]"
/> />
<video v-else controls class="border rounded-md w-44 h-auto"> <video v-else controls class="border rounded-md w-44 h-auto">
@@ -6,16 +6,18 @@
<div class="text-xl font-semibold leading-none text-ink-gray-9"> <div class="text-xl font-semibold leading-none text-ink-gray-9">
{{ __(label) }} {{ __(label) }}
</div> </div>
</div>
<div class="space-x-2">
<Badge <Badge
v-if="data.isDirty" v-if="data.isDirty"
:label="__('Not Saved')" :label="__('Not Saved')"
variant="subtle" variant="subtle"
theme="orange" theme="orange"
/> />
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div> </div>
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div> </div>
<div class="text-ink-gray-6 leading-5"> <div class="text-ink-gray-6 leading-5">
{{ __(description) }} {{ __(description) }}
@@ -219,6 +219,25 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
{
label: 'Jobs',
columns: [
{
fields: [
{
label: 'Allow Job Posting',
name: 'allow_job_posting',
type: 'checkbox',
description:
'If enabled, users can post job openings on the job board. Else only admins can post jobs.',
},
],
},
{
fields: [],
},
],
},
{ {
label: '', label: '',
columns: [ columns: [
+126 -107
View File
@@ -4,9 +4,14 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<Button variant="solid" @click="saveJob()"> <div class="space-x-2">
{{ __('Save') }} <Badge v-if="isDirty" theme="orange">
</Button> {{ __('Not Saved') }}
</Badge>
<Button variant="solid" @click="saveJob()">
{{ __('Save') }}
</Button>
</div>
</header> </header>
<div class="py-5"> <div class="py-5">
<div class="container border-b mb-4 pb-5"> <div class="container border-b mb-4 pb-5">
@@ -109,15 +114,25 @@
</template> </template>
<script setup> <script setup>
import { import {
Badge,
Breadcrumbs, Breadcrumbs,
call,
FormControl, FormControl,
createResource, createDocumentResource,
Button, Button,
TextEditor, TextEditor,
usePageMeta, usePageMeta,
toast, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, onMounted, reactive, inject } from 'vue' import {
computed,
inject,
onMounted,
onBeforeUnmount,
reactive,
ref,
watch,
} from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { escapeHTML, sanitizeHTML } from '@/utils' import { escapeHTML, sanitizeHTML } from '@/utils'
@@ -126,6 +141,8 @@ import Uploader from '@/components/Controls/Uploader.vue'
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
const { brand } = sessionStore() const { brand } = sessionStore()
const isDirty = ref(false)
const originalJobData = ref(null)
const props = defineProps({ const props = defineProps({
jobName: { jobName: {
@@ -134,67 +151,6 @@ const props = defineProps({
}, },
}) })
const newJob = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Job Opportunity',
company_logo: job.company_logo,
...job,
},
}
},
})
const updateJob = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Job Opportunity',
name: props.jobName,
fieldname: {
company_logo: job.company_logo,
...job,
},
}
},
})
const jobDetail = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'Job Opportunity',
name: props.jobName,
}
},
onSuccess(data) {
if (data.owner != user.data?.name && !user.data?.is_moderator) {
router.push({
name: 'Jobs',
})
}
Object.keys(data).forEach((key) => {
if (Object.hasOwn(job, key)) job[key] = data[key]
})
},
})
const job = reactive({
job_title: '',
location: '',
country: '',
type: 'Full Time',
work_mode: 'On-site',
status: 'Open',
company_name: '',
company_website: '',
company_logo: null,
description: '',
company_email_address: '',
})
onMounted(() => { onMounted(() => {
if (!user.data) { if (!user.data) {
router.push({ router.push({
@@ -202,22 +158,63 @@ onMounted(() => {
}) })
} }
if (props.jobName != 'new') jobDetail.reload() if (props.jobName != 'new') jobDetails.reload()
addKeyboardShortcuts() window.addEventListener('keydown', keyboardShortcut)
}) })
const addKeyboardShortcuts = () => { const job = reactive({
document.addEventListener('keydown', (e) => { job_title: '',
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { type: '',
e.preventDefault() work_mode: '',
saveJob() location: '',
country: '',
status: 'Open',
description: '',
company_name: '',
company_website: '',
company_email_address: '',
company_logo: '',
})
const jobDetails = createDocumentResource({
doctype: 'Job Opportunity',
name: props.jobName != 'new' ? props.jobName : undefined,
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
auto: props.jobName != 'new',
})
watch(
() => jobDetails?.doc,
() => {
if (jobDetails.doc.owner != user.data?.name && !user.data?.is_moderator) {
router.push({
name: 'Jobs',
})
} }
})
} if (jobDetails.doc) {
Object.assign(job, jobDetails.doc)
originalJobData.value = JSON.parse(JSON.stringify(jobDetails.doc))
}
}
)
watch(
job,
() => {
isDirty.value = Object.keys(job).some((key) => {
return job[key] != originalJobData.value?.[key]
})
},
{ deep: true }
)
const saveJob = () => { const saveJob = () => {
validateJobFields() validateJobFields()
if (jobDetail.data) { if (jobDetails?.doc) {
editJobDetails() editJobDetails()
} else { } else {
createNewJob() createNewJob()
@@ -225,38 +222,46 @@ const saveJob = () => {
} }
const createNewJob = () => { const createNewJob = () => {
newJob.submit( call('frappe.client.insert', {
{}, doc: {
{ doctype: 'Job Opportunity',
onSuccess(data) { company_logo: job.company_logo,
router.push({ ...job,
name: 'JobDetail', },
params: { })
job: data.name, .then((data) => {
}, router.push({
}) name: 'JobDetail',
}, params: {
onError(err) { job: data.name,
toast.error(err.messages?.[0] || err) },
}, })
} })
) .catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
} }
const editJobDetails = () => { const editJobDetails = () => {
updateJob.submit( jobDetails.setValue.submit(
{}, {
company_logo: job.company_logo,
...job,
},
{ {
onSuccess(data) { onSuccess(data) {
jobDetails.reload()
router.push({ router.push({
name: 'JobDetail', name: 'JobDetail',
params: { params: {
job: data.name, job: props.jobName,
}, },
}) })
}, },
onError(err) { onError(err) {
toast.error(err.messages?.[0] || err) toast.error(err.messages?.[0] || err)
console.error(err)
}, },
} }
) )
@@ -271,27 +276,38 @@ const validateJobFields = () => {
}) })
} }
const keyboardShortcut = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
saveJob()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const jobTypes = computed(() => { const jobTypes = computed(() => {
return [ return [
{ label: 'Full Time', value: 'Full Time' }, { label: __('Full Time'), value: 'Full Time' },
{ label: 'Part Time', value: 'Part Time' }, { label: __('Part Time'), value: 'Part Time' },
{ label: 'Contract', value: 'Contract' }, { label: __('Contract'), value: 'Contract' },
{ label: 'Freelance', value: 'Freelance' }, { label: __('Freelance'), value: 'Freelance' },
] ]
}) })
const workModes = computed(() => { const workModes = computed(() => {
return [ return [
{ label: 'On site', value: 'On-site' }, { label: __('On site'), value: 'On-site' },
{ label: 'Hybrid', value: 'Hybrid' }, { label: __('Hybrid'), value: 'Hybrid' },
{ label: 'Remote', value: 'Remote' }, { label: __('Remote'), value: 'Remote' },
] ]
}) })
const jobStatuses = computed(() => { const jobStatuses = computed(() => {
return [ return [
{ label: 'Open', value: 'Open' }, { label: __('Open'), value: 'Open' },
{ label: 'Closed', value: 'Closed' }, { label: __('Closed'), value: 'Closed' },
] ]
}) })
@@ -302,8 +318,11 @@ const breadcrumbs = computed(() => {
route: { name: 'Jobs' }, route: { name: 'Jobs' },
}, },
{ {
label: props.jobName == 'new' ? __('New Job') : __('Edit Job'), label: props.jobName == 'new' ? __('New Job') : jobDetails.doc?.job_title,
route: { name: 'JobForm' }, route:
props.jobName == 'new'
? {}
: { name: 'JobDetail', params: { job: props.jobName } },
}, },
] ]
return crumbs return crumbs
@@ -311,7 +330,7 @@ const breadcrumbs = computed(() => {
usePageMeta(() => { usePageMeta(() => {
return { return {
title: props.jobName == 'new' ? __('New Job') : jobDetail.data?.job_title, title: props.jobName == 'new' ? __('New Job') : jobDetails.doc?.job_title,
icon: brand.favicon, icon: brand.favicon,
} }
}) })
+7 -3
View File
@@ -8,7 +8,9 @@
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]" :items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
/> />
<router-link <router-link
v-if="user.data?.name" v-if="
user.data?.name && settings.data?.allow_job_posting && !readOnlyMode
"
:to="{ :to="{
name: 'JobForm', name: 'JobForm',
params: { params: {
@@ -16,7 +18,7 @@
}, },
}" }"
> >
<Button v-if="!readOnlyMode" variant="solid"> <Button variant="solid">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -123,7 +125,8 @@ import {
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, Search } from 'lucide-vue-next' import { Plus, Search } from 'lucide-vue-next'
import { sessionStore } from '../stores/session' import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
import { inject, computed, ref, onMounted, watch } from 'vue' import { inject, computed, ref, onMounted, watch } from 'vue'
import JobCard from '@/components/JobCard.vue' import JobCard from '@/components/JobCard.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
@@ -133,6 +136,7 @@ const user = inject('$user')
const jobType = ref(null) const jobType = ref(null)
const workMode = ref(null) const workMode = ref(null)
const { brand } = sessionStore() const { brand } = sessionStore()
const { settings } = useSettings()
const searchQuery = ref('') const searchQuery = ref('')
const country = ref(null) const country = ref(null)
const filters = ref({}) const filters = ref({})
@@ -130,7 +130,7 @@
} }
], ],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2025-12-02 16:58:49.903274", "modified": "2026-02-19 14:26:14.027340",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "Job", "module": "Job",
"name": "Job Opportunity", "name": "Job Opportunity",
@@ -149,24 +149,16 @@
"share": 1, "share": 1,
"write": 1 "write": 1
}, },
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"select": 1,
"share": 1
},
{ {
"create": 1, "create": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"if_owner": 1, "if_owner": 1,
"print": 1, "print": 1,
"read": 1,
"report": 1, "report": 1,
"role": "LMS Student", "role": "LMS Student",
"select": 1,
"share": 1, "share": 1,
"write": 1 "write": 1
} }
@@ -1,7 +0,0 @@
// Copyright (c) 2022, Frappe and contributors
// For license information, please see license.txt
frappe.ui.form.on("Job Settings", {
// refresh: function(frm) {
// }
});
@@ -1,54 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2022-02-07 12:01:41.422955",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"allow_posting",
"title",
"subtitle"
],
"fields": [
{
"default": "0",
"fieldname": "allow_posting",
"fieldtype": "Check",
"label": "Allow Job Posting From Website"
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Job Board Title"
},
{
"fieldname": "subtitle",
"fieldtype": "Data",
"label": "Job Board Subtitle"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-02-11 15:56:38.958317",
"modified_by": "Administrator",
"module": "Job",
"name": "Job Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -1,9 +0,0 @@
# Copyright (c) 2022, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class JobSettings(Document):
pass
@@ -1,9 +0,0 @@
# Copyright (c) 2022, Frappe and Contributors
# See license.txt
# import frappe
import unittest
class TestJobSettings(unittest.TestCase):
pass
+2 -1
View File
@@ -118,4 +118,5 @@ lms.patches.v2_0.open_to_opportunities
lms.patches.v2_0.open_to_work lms.patches.v2_0.open_to_work
lms.patches.v2_0.share_enrollment lms.patches.v2_0.share_enrollment
lms.patches.v2_0.give_user_list_permission #11-02-2026 lms.patches.v2_0.give_user_list_permission #11-02-2026
lms.patches.v2_0.rename_badge_assignment_event lms.patches.v2_0.rename_badge_assignment_event
lms.patches.v2_0.enable_allow_job_posting
@@ -0,0 +1,5 @@
import frappe
def execute():
frappe.db.set_value("LMS Settings", None, "allow_job_posting", 1)