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
Binary file added Screenshot_20260812_101044.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@seamware/odrl-policy-editor": "^1.6.0",
"@seamware/odrl-policy-editor": "^1.7.0",
"oidc-client-ts": "^3.5.0",
"pinia": "^2.1.7",
"vue": "^3.4.21",
Expand Down
136 changes: 135 additions & 1 deletion src/components/OdrlPolicyEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ the License for the specific language governing permissions and * limitations un
:policy-id="policyId"
:theme="currentTheme"
:locale="currentLocale"
:hide-builder-tab="hideBuilderTab || undefined"
:hide-raw-tab="hideRawTab || undefined"
:hide-template-tab="hideTemplateTab || undefined"
:hide-template-create-tab="hideTemplateCreateTab || undefined"
/>
</template>

Expand All @@ -58,6 +62,12 @@ the License for the specific language governing permissions and * limitations un
* `CustomEvent` objects — not Vue component events — and Vue's template
* type system cannot infer their payload types.
*
* The editor exposes its features as tabs (policy builder, raw ODRL, template
* selection, template management). Individual tabs can be hidden via the
* `hide*Tab` props, and a specific tab can be activated on mount via
* `initialTab` — used, for example, to open the editor directly on template
* management for a dedicated "Create Template" flow.
*
* @example
* ```vue
* <OdrlPolicyEditor
Expand Down Expand Up @@ -94,14 +104,40 @@ const EVENT_POLICY_UPDATED = 'policy-updated'
/** Custom event name fired when the user cancels the editor. */
const EVENT_EDITOR_CANCELLED = 'editor-cancelled'

/** Custom event name fired when a new policy template is created. */
const EVENT_TEMPLATE_CREATED = 'template-created'

/** Custom event name fired when an existing policy template is updated. */
const EVENT_TEMPLATE_UPDATED = 'template-updated'

/**
* Identifier of an editor tab, matching the underlying custom element's
* internal tab keys. Used with `initialTab` to activate a tab on mount.
*
* - `builder` — visual policy builder
* - `odrl` — raw ODRL JSON editor
* - `template` — template selection (create mode, when templates exist)
* - `manage-templates` — template creation / management
*/
export type EditorTab = 'builder' | 'odrl' | 'template' | 'manage-templates'

/**
* Maximum number of polling attempts while waiting for the custom element's
* shadow DOM to render its tab bar before activating `initialTab`.
*/
const TAB_ACTIVATION_MAX_ATTEMPTS = 80

/** Delay in milliseconds between tab-activation polling attempts. */
const TAB_ACTIVATION_INTERVAL_MS = 25

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

/**
* Component props — passed through as HTML attributes to the custom element.
*/
withDefaults(
const props = withDefaults(
defineProps<{
/**
* Base URL for all PAP API calls made by the editor.
Expand All @@ -118,11 +154,29 @@ withDefaults(
* Ignored in create mode.
*/
policyId?: string | null
/** When `true`, hides the visual policy builder tab. */
hideBuilderTab?: boolean
/** When `true`, hides the raw ODRL JSON editor tab. */
hideRawTab?: boolean
/** When `true`, hides the template selection tab. */
hideTemplateTab?: boolean
/** When `true`, hides the template creation / management tab. */
hideTemplateCreateTab?: boolean
/**
* Tab to activate once the editor has mounted. When omitted, the editor
* uses its own default tab selection.
*/
initialTab?: EditorTab | null
}>(),
{
apiBaseUrl: DEFAULT_API_BASE_URL,
mode: DEFAULT_MODE,
policyId: null,
hideBuilderTab: false,
hideRawTab: false,
hideTemplateTab: false,
hideTemplateCreateTab: false,
initialTab: null,
},
)

Expand Down Expand Up @@ -155,6 +209,20 @@ const emit = defineEmits<{
* @param payload - Empty object (no detail data).
*/
'editor-cancelled': [payload: EmbeddedEventMap['editor-cancelled']]
/**
* Fired after the editor successfully creates a new policy template
* via the template management tab.
*
* @param payload - Contains the saved `template` object and its `id`.
*/
'template-created': [payload: EmbeddedEventMap['template-created']]
/**
* Fired after the editor successfully updates an existing policy template
* via the template management tab.
*
* @param payload - Contains the updated `template` object and its `id`.
*/
'template-updated': [payload: EmbeddedEventMap['template-updated']]
}>()

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -216,6 +284,63 @@ function onEditorCancelled(event: Event): void {
emit(EVENT_EDITOR_CANCELLED, detail)
}

/**
* Handle the `template-created` Custom Event from the web component.
* Unwraps `event.detail` and re-emits it as a Vue event.
*
* @param event - The native DOM event dispatched by the custom element.
*/
function onTemplateCreated(event: Event): void {
const detail = (event as CustomEvent<EmbeddedEventMap['template-created']>).detail
emit(EVENT_TEMPLATE_CREATED, detail)
}

/**
* Handle the `template-updated` Custom Event from the web component.
* Unwraps `event.detail` and re-emits it as a Vue event.
*
* @param event - The native DOM event dispatched by the custom element.
*/
function onTemplateUpdated(event: Event): void {
const detail = (event as CustomEvent<EmbeddedEventMap['template-updated']>).detail
emit(EVENT_TEMPLATE_UPDATED, detail)
}

// ---------------------------------------------------------------------------
// Tab activation
// ---------------------------------------------------------------------------

/** Pending tab-activation timer, cleared on unmount. */
let tabActivationTimer: ReturnType<typeof setTimeout> | null = null

/**
* Activate the editor tab named by `initialTab` once it appears in the
* custom element's (open) shadow DOM.
*
* The underlying element renders its tab bar asynchronously inside a shadow
* root, and exposes no public API to preselect a tab. Each tab is a button
* carrying a stable `data-rr-ui-event-key` attribute equal to the tab key,
* so we poll the shadow root until the target tab is present and then click
* it. Polling stops after {@link TAB_ACTIVATION_MAX_ATTEMPTS} attempts.
*
* @param tab - The tab key to activate.
* @param attempt - Current polling attempt (used internally for recursion).
*/
function activateTab(tab: EditorTab, attempt = 0): void {
const link = editorRef.value?.shadowRoot?.querySelector<HTMLElement>(
`[data-rr-ui-event-key="${tab}"]`,
)
if (link) {
link.click()
return
}
if (attempt >= TAB_ACTIVATION_MAX_ATTEMPTS) return
tabActivationTimer = setTimeout(
() => activateTab(tab, attempt + 1),
TAB_ACTIVATION_INTERVAL_MS,
)
}

// ---------------------------------------------------------------------------
// Lifecycle — attach / detach native event listeners
// ---------------------------------------------------------------------------
Expand All @@ -225,24 +350,33 @@ function onEditorCancelled(event: Event): void {
* mounted. This approach is used instead of Vue's `v-on` directive because
* the custom element dispatches native `CustomEvent` objects whose
* `detail` payloads must be unwrapped before re-emitting as Vue events.
*
* When `initialTab` is set, the requested tab is activated once the editor's
* shadow DOM has rendered.
*/
onMounted(() => {
const el = editorRef.value
if (!el) return
el.addEventListener(EVENT_POLICY_CREATED, onPolicyCreated)
el.addEventListener(EVENT_POLICY_UPDATED, onPolicyUpdated)
el.addEventListener(EVENT_EDITOR_CANCELLED, onEditorCancelled)
el.addEventListener(EVENT_TEMPLATE_CREATED, onTemplateCreated)
el.addEventListener(EVENT_TEMPLATE_UPDATED, onTemplateUpdated)
if (props.initialTab) activateTab(props.initialTab)
})

/**
* Remove native DOM event listeners before the component is unmounted
* to prevent memory leaks.
*/
onBeforeUnmount(() => {
if (tabActivationTimer !== null) clearTimeout(tabActivationTimer)
const el = editorRef.value
if (!el) return
el.removeEventListener(EVENT_POLICY_CREATED, onPolicyCreated)
el.removeEventListener(EVENT_POLICY_UPDATED, onPolicyUpdated)
el.removeEventListener(EVENT_EDITOR_CANCELLED, onEditorCancelled)
el.removeEventListener(EVENT_TEMPLATE_CREATED, onTemplateCreated)
el.removeEventListener(EVENT_TEMPLATE_UPDATED, onTemplateUpdated)
})
</script>
95 changes: 95 additions & 0 deletions src/components/__tests__/OdrlPolicyEditor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,75 @@ describe('OdrlPolicyEditor', () => {
})
})

describe('tab visibility', () => {
it('should not set any hide-*-tab attribute by default', () => {
const wrapper = mountComponent()
const el = findEditor(wrapper)
expect(el.hasAttribute('hide-builder-tab')).toBe(false)
expect(el.hasAttribute('hide-raw-tab')).toBe(false)
expect(el.hasAttribute('hide-template-tab')).toBe(false)
expect(el.hasAttribute('hide-template-create-tab')).toBe(false)
})

it('should set hide-*-tab attributes when the corresponding props are true', () => {
const wrapper = mountComponent({
hideBuilderTab: true,
hideRawTab: true,
hideTemplateTab: true,
})
const el = findEditor(wrapper)
expect(el.hasAttribute('hide-builder-tab')).toBe(true)
expect(el.hasAttribute('hide-raw-tab')).toBe(true)
expect(el.hasAttribute('hide-template-tab')).toBe(true)
// Not requested → remains absent so the template-management tab shows.
expect(el.hasAttribute('hide-template-create-tab')).toBe(false)
})
})

describe('initial tab activation', () => {
it('should click the matching shadow-DOM tab once it renders', async () => {
vi.useFakeTimers()
try {
const wrapper = mountComponent({ initialTab: 'manage-templates' })
const el = findEditor(wrapper) as HTMLElement

// Simulate the custom element rendering its tab bar into an open
// shadow root after mount (as the real web component does).
const shadow = el.attachShadow({ mode: 'open' })
const tabButton = document.createElement('button')
tabButton.setAttribute('data-rr-ui-event-key', 'manage-templates')
const clickSpy = vi.fn()
tabButton.addEventListener('click', clickSpy)
shadow.appendChild(tabButton)

// Advance past a polling interval so activateTab finds and clicks it.
vi.advanceTimersByTime(50)
expect(clickSpy).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})

it('should not attempt activation when initialTab is not set', async () => {
vi.useFakeTimers()
try {
const wrapper = mountComponent()
const el = findEditor(wrapper) as HTMLElement
const shadow = el.attachShadow({ mode: 'open' })
const tabButton = document.createElement('button')
tabButton.setAttribute('data-rr-ui-event-key', 'manage-templates')
const clickSpy = vi.fn()
tabButton.addEventListener('click', clickSpy)
shadow.appendChild(tabButton)

vi.advanceTimersByTime(200)
expect(clickSpy).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})

describe('dashboard state binding', () => {
it('should bind the auth token from useAuth composable', () => {
mockToken.value = 'my-bearer-token'
Expand Down Expand Up @@ -252,6 +321,32 @@ describe('OdrlPolicyEditor', () => {
expect(emitted).toBeTruthy()
expect(emitted![0]).toEqual([detail])
})

it('should re-emit template-created with unwrapped detail', async () => {
const wrapper = mountComponent()
const el = findEditor(wrapper)

const detail = { template: { name: 'DOME Access' }, id: 'template-1' }
const event = new CustomEvent('template-created', { detail, bubbles: true })
el.dispatchEvent(event)

const emitted = wrapper.emitted('template-created')
expect(emitted).toBeTruthy()
expect(emitted![0]).toEqual([detail])
})

it('should re-emit template-updated with unwrapped detail', async () => {
const wrapper = mountComponent()
const el = findEditor(wrapper)

const detail = { template: { name: 'DOME Access v2' }, id: 'template-1' }
const event = new CustomEvent('template-updated', { detail, bubbles: true })
el.dispatchEvent(event)

const emitted = wrapper.emitted('template-updated')
expect(emitted).toBeTruthy()
expect(emitted![0]).toEqual([detail])
})
})

describe('lifecycle cleanup', () => {
Expand Down
13 changes: 13 additions & 0 deletions src/custom-elements.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ export interface OdrlPolicyEditorAttributes {
locale?: string
/** JSON-LD `@context` for new policies (serialised as JSON string). */
'policy-context'?: string | null
/**
* Service ID for service-scoped policy operations. When set, the editor
* targets service-scoped API endpoints instead of root-level ones.
*/
'service-id'?: string | null
/** When present, hides the visual policy builder tab. */
'hide-builder-tab'?: boolean
/** When present, hides the raw ODRL JSON editor tab. */
'hide-raw-tab'?: boolean
/** When present, hides the template selection tab. */
'hide-template-tab'?: boolean
/** When present, hides the template creation/management tab. */
'hide-template-create-tab'?: boolean
}

declare module 'vue' {
Expand Down
4 changes: 4 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@
"detailTitle": "Policy Details",
"createTitle": "Create Policy",
"editTitle": "Edit Policy",
"createTemplate": "Create Template",
"templatesTitle": "Policy Templates",
"globalPolicies": "Global Policies",
"byService": "By Service",
"policyId": "Policy ID",
Expand Down Expand Up @@ -262,6 +264,8 @@
"rawJson": "Raw JSON",
"createSuccess": "Policy created successfully",
"updateSuccess": "Policy updated successfully",
"templateCreateSuccess": "Template created successfully",
"templateUpdateSuccess": "Template updated successfully",
"deleteSuccess": "Policy deleted successfully",
"deleteError": "Failed to delete policy",
"createServiceSuccess": "Service created successfully",
Expand Down
Loading
Loading